From 4d37cf0b57897ae6193331332c1b40c686b2de6a Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:46:28 -0300 Subject: [PATCH 1/3] feat(rn_cli_wallet): add NFC tap-to-pay support for iOS and Android Add NFC reader integration with foreground dispatch on Android for seamless tag detection while app is visible. iOS uses native CoreNFC with universal links. Add NFC button to header on iOS, Android App Links configuration, and iOS App Site Association for deep linking from NFC tags. - Add react-native-nfc-manager dependency - Implement useNfc hook with foreground dispatch for Android - Add NFC icon (NfcTap.tsx) with customizable fill color - Add NFC button to Header on iOS (hidden on Android due to foreground dispatch) - Update Android manifest with NFC permissions, app links, and autoVerify - Update iOS entitlements with NDEF reading and universal links - Bump Android versionCode to 60 - Add logging for debugging NFC scan events Co-Authored-By: Claude Haiku 4.5 --- .../rn_cli_wallet/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 9 + wallets/rn_cli_wallet/ios/Podfile.lock | 6 + .../RNWeb3Wallet.xcodeproj/project.pbxproj | 34 ++- .../xcshareddata/xcschemes/RNWallet.xcscheme | 2 +- .../RNWeb3Wallet/RNWeb3Wallet.entitlements | 4 + .../RNWeb3WalletDebug.entitlements | 4 + wallets/rn_cli_wallet/package.json | 1 + wallets/rn_cli_wallet/src/assets/NfcTap.tsx | 11 + .../rn_cli_wallet/src/components/Header.tsx | 60 ++++- wallets/rn_cli_wallet/src/hooks/useNfc.ts | 245 ++++++++++++++++++ wallets/rn_cli_wallet/src/screens/App.tsx | 19 +- wallets/rn_cli_wallet/yarn.lock | 13 + 13 files changed, 391 insertions(+), 19 deletions(-) create mode 100644 wallets/rn_cli_wallet/src/assets/NfcTap.tsx create mode 100644 wallets/rn_cli_wallet/src/hooks/useNfc.ts diff --git a/wallets/rn_cli_wallet/android/app/build.gradle b/wallets/rn_cli_wallet/android/app/build.gradle index 7c7e2966..6ca3fa9a 100644 --- a/wallets/rn_cli_wallet/android/app/build.gradle +++ b/wallets/rn_cli_wallet/android/app/build.gradle @@ -85,7 +85,7 @@ android { applicationId "com.walletconnect.web3wallet.rnsample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 59 + versionCode 60 versionName "1.0" resValue "string", "build_config_package", "com.walletconnect.web3wallet.rnsample" } diff --git a/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml b/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml index 41dff387..b4978da5 100644 --- a/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml +++ b/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + + + diff --git a/wallets/rn_cli_wallet/ios/Podfile.lock b/wallets/rn_cli_wallet/ios/Podfile.lock index 3ec67777..167127b5 100644 --- a/wallets/rn_cli_wallet/ios/Podfile.lock +++ b/wallets/rn_cli_wallet/ios/Podfile.lock @@ -1933,6 +1933,8 @@ PODS: - Yoga - react-native-netinfo (11.4.1): - React-Core + - react-native-nfc-manager (3.17.2): + - React-Core - react-native-quick-base64 (2.2.2): - React-Core - react-native-quick-crypto (0.7.17): @@ -3120,6 +3122,7 @@ DEPENDENCIES: - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-mmkv (from `../node_modules/react-native-mmkv`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" + - react-native-nfc-manager (from `../node_modules/react-native-nfc-manager`) - react-native-quick-base64 (from `../node_modules/react-native-quick-base64`) - react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) @@ -3273,6 +3276,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-mmkv" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" + react-native-nfc-manager: + :path: "../node_modules/react-native-nfc-manager" react-native-quick-base64: :path: "../node_modules/react-native-quick-base64" react-native-quick-crypto: @@ -3419,6 +3424,7 @@ SPEC CHECKSUMS: react-native-keyboard-controller: f2ed31d12d9d8cb8ad2f9110c18fa5df499b66a3 react-native-mmkv: ac7507625cd74bac0eb5333604a7cd7b08fe9e3e react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83 react-native-quick-base64: 6568199bb2ac8e72ecdfdc73a230fbc5c1d3aac4 react-native-quick-crypto: dac9db2adb0a61b4881909b6db7c51eaaada66a8 react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616 diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj index d7c4a6ab..25a95de6 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj @@ -33,7 +33,7 @@ 13B07F961A680F5B00A75B9A /* RNWallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RNWallet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RNWeb3Wallet/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNWeb3Wallet/Info.plist; sourceTree = ""; }; - 18BB39FF004742E9BA98CBB1 /* KHTeka-Light.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Light.otf"; path = "../assets/fonts/KHTeka-Light.otf"; sourceTree = ""; }; + 18BB39FF004742E9BA98CBB1 /* KHTeka-Light.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Light.otf"; path = "../assets/fonts/KHTeka-Light.otf"; sourceTree = ""; }; 1DE6EA8716545F6CED9BF875 /* Pods-App-RNWallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-RNWallet.debug.xcconfig"; path = "Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet.debug.xcconfig"; sourceTree = ""; }; 1FCDA2A22B9B8E6200E0BF0C /* RNWallet Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RNWallet Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 1FCDA2BA2B9B8E6800E0BF0C /* RNWallet Internal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RNWallet Internal.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -44,13 +44,13 @@ 2C50482F22BB4C56ACE24EA8 /* BootSplash.storyboard */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = BootSplash.storyboard; path = RNWeb3Wallet/BootSplash.storyboard; sourceTree = ""; }; 3616329FA2535E44622E8399 /* libPods-App-RNWallet Debug.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-App-RNWallet Debug.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3F16776DD2AD1459C80F97BA /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = RNWeb3Wallet/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 4FBAB7122BF343DBAF0BB7F7 /* KHTeka-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Medium.otf"; path = "../assets/fonts/KHTeka-Medium.otf"; sourceTree = ""; }; + 4FBAB7122BF343DBAF0BB7F7 /* KHTeka-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Medium.otf"; path = "../assets/fonts/KHTeka-Medium.otf"; sourceTree = ""; }; 540B49B18EE9B4B3211A85F1 /* Pods-App-RNWallet Debug.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-RNWallet Debug.debug.xcconfig"; path = "Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug.debug.xcconfig"; sourceTree = ""; }; - 7AE83AB305C04F529AD8275C /* Colors.xcassets */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Colors.xcassets; path = RNWeb3Wallet/Colors.xcassets; sourceTree = ""; }; + 7AE83AB305C04F529AD8275C /* Colors.xcassets */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Colors.xcassets; path = RNWeb3Wallet/Colors.xcassets; sourceTree = ""; }; 7F0ADF9FBB4DFB43D9B42D14 /* libPods-App-RNWallet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-App-RNWallet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 8987A5832B077AEB1770930B /* Pods-App-RNWallet Internal.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-RNWallet Internal.debug.xcconfig"; path = "Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal.debug.xcconfig"; sourceTree = ""; }; 936277491F1F3293104F8AD2 /* libPods-App-RNWallet Internal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-App-RNWallet Internal.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - ABEB231DA62E483992083356 /* KHTeka-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Regular.otf"; path = "../assets/fonts/KHTeka-Regular.otf"; sourceTree = ""; }; + ABEB231DA62E483992083356 /* KHTeka-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Regular.otf"; path = "../assets/fonts/KHTeka-Regular.otf"; sourceTree = ""; }; BC0E53642B7E95F80075EFC3 /* RNWeb3Wallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = RNWeb3Wallet.entitlements; path = RNWeb3Wallet/RNWeb3Wallet.entitlements; sourceTree = ""; }; BC0E53652B7EAFF70075EFC3 /* RNWeb3WalletDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = RNWeb3WalletDebug.entitlements; path = RNWeb3Wallet/RNWeb3WalletDebug.entitlements; sourceTree = ""; }; C3A29D35A12B15C6680651C1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = RNWeb3Wallet/PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -346,10 +346,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-resources.sh\"\n"; @@ -505,10 +509,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-frameworks.sh\"\n"; @@ -522,10 +530,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-frameworks.sh\"\n"; @@ -539,10 +551,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-resources.sh\"\n"; @@ -600,10 +616,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-resources.sh\"\n"; @@ -617,10 +637,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-frameworks.sh\"\n"; @@ -756,7 +780,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.walletconnect.web3wallet.rnsample; PRODUCT_NAME = RNWallet; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.walletconnect.web3wallet.rnsample 1757428941"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.walletconnect.web3wallet.rnsample"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme index f744d6b0..d836b013 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements index fde45392..246e4c52 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements @@ -7,9 +7,13 @@ com.apple.developer.associated-domains applinks:lab.reown.com + applinks:pay.walletconnect.com + applinks:staging.pay.walletconnect.com + applinks:dev.pay.walletconnect.com com.apple.developer.nfc.readersession.formats + NDEF TAG diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements index fde45392..246e4c52 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements @@ -7,9 +7,13 @@ com.apple.developer.associated-domains applinks:lab.reown.com + applinks:pay.walletconnect.com + applinks:staging.pay.walletconnect.com + applinks:dev.pay.walletconnect.com com.apple.developer.nfc.readersession.formats + NDEF TAG diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 4467dce5..6babe651 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -59,6 +59,7 @@ "react-native-keyboard-controller": "^1.20.6", "react-native-mmkv": "3.3.3", "react-native-modal": "14.0.0-rc.1", + "react-native-nfc-manager": "^3.15.1", "react-native-quick-base64": "2.2.2", "react-native-quick-crypto": "0.7.17", "react-native-reanimated": "^4.2.1", diff --git a/wallets/rn_cli_wallet/src/assets/NfcTap.tsx b/wallets/rn_cli_wallet/src/assets/NfcTap.tsx new file mode 100644 index 00000000..c7b2ea5a --- /dev/null +++ b/wallets/rn_cli_wallet/src/assets/NfcTap.tsx @@ -0,0 +1,11 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; + +const SvgNfcTap = (props: SvgProps) => ( + + + +); +export default SvgNfcTap; diff --git a/wallets/rn_cli_wallet/src/components/Header.tsx b/wallets/rn_cli_wallet/src/components/Header.tsx index ccc133c6..0eec1da9 100644 --- a/wallets/rn_cli_wallet/src/components/Header.tsx +++ b/wallets/rn_cli_wallet/src/components/Header.tsx @@ -1,21 +1,46 @@ import { View, StyleSheet, Image, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Toast from 'react-native-toast-message'; import { useTheme } from '@/hooks/useTheme'; import { BorderRadius, Spacing } from '@/utils/ThemeUtil'; +import { useNfc } from '@/hooks/useNfc'; +import { usePairing } from '@/hooks/usePairing'; import ModalStore from '@/store/ModalStore'; import BarcodeSvg from '@/assets/Barcode'; +import NfcTapSvg from '@/assets/NfcTap'; import { Button } from '@/components/Button'; export function Header() { const { top } = useSafeAreaInsets(); const Theme = useTheme(); + const { isNfcSupported, scanNfcTag } = useNfc(); + const { handleUriOrPaymentLink } = usePairing(); const onScannerPress = () => { ModalStore.open('ScannerOptionsModal', {}); }; + const onNfcPress = async () => { + try { + const uri = await scanNfcTag(); + if (uri) { + handleUriOrPaymentLink(uri); + } else { + Toast.show({ + type: 'info', + text1: 'No data found on NFC tag', + }); + } + } catch { + Toast.show({ + type: 'error', + text1: 'NFC scan failed', + }); + } + }; + return ( - + + {Platform.OS === 'ios' && isNfcSupported && ( + + )} + + ); } @@ -67,13 +106,20 @@ const styles = StyleSheet.create({ width: 28, height: 18, }, - scanButton: { + actions: { + flexDirection: 'row', + gap: Spacing[2], + }, + actionButton: { height: 38, width: 38, borderRadius: BorderRadius[3], alignItems: 'center', justifyContent: 'center', }, + nfcButton: { + borderWidth: 1, + }, scanIcon: { height: 20, width: 20, diff --git a/wallets/rn_cli_wallet/src/hooks/useNfc.ts b/wallets/rn_cli_wallet/src/hooks/useNfc.ts new file mode 100644 index 00000000..1a752bb8 --- /dev/null +++ b/wallets/rn_cli_wallet/src/hooks/useNfc.ts @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, Platform } from 'react-native'; +import NfcManager, { + NfcTech, + NfcEvents, + NfcAdapter, + Ndef, +} from 'react-native-nfc-manager'; + +import LogStore from '@/store/LogStore'; + +let foregroundDispatchPaused = false; +let resumeForegroundDispatch: (() => void) | null = null; + +export function pauseForegroundDispatch() { + foregroundDispatchPaused = true; +} + +export function unpauseForegroundDispatch() { + foregroundDispatchPaused = false; + resumeForegroundDispatch?.(); +} + +export function useNfc() { + const [isNfcSupported, setIsNfcSupported] = useState(false); + + useEffect(() => { + NfcManager.start() + .then(() => NfcManager.isSupported()) + .then(supported => { + setIsNfcSupported(supported); + LogStore.log(`NFC supported: ${supported}`, 'useNfc', 'init'); + }) + .catch(() => { + setIsNfcSupported(false); + }); + }, []); + + const scanNfcTag = useCallback(async (): Promise => { + // Pause foreground dispatch on Android so requestTechnology doesn't conflict + pauseForegroundDispatch(); + try { + // Cancel any stale session from a previous scan attempt (e.g. user + // tapped the button twice on Android where there's no native dialog). + await NfcManager.cancelTechnologyRequest().catch(() => {}); + await NfcManager.unregisterTagEvent().catch(() => {}); + await NfcManager.requestTechnology(NfcTech.Ndef); + const tag = await NfcManager.getTag(); + + if (!tag?.ndefMessage?.length) { + LogStore.log('No NDEF message on tag', 'useNfc', 'scanNfcTag'); + return null; + } + + for (const record of tag.ndefMessage) { + const uri = extractUri(record); + if (uri) { + LogStore.log(`NFC URI found: ${uri}`, 'useNfc', 'scanNfcTag'); + return uri; + } + } + + LogStore.log('No URI found in NDEF records', 'useNfc', 'scanNfcTag'); + return null; + } catch (error: any) { + // User cancelled — not an error + if (error?.message?.includes('cancelled')) { + return null; + } + LogStore.log(`NFC scan error: ${error?.message}`, 'useNfc', 'scanNfcTag'); + throw error; + } finally { + NfcManager.cancelTechnologyRequest().catch(() => {}); + unpauseForegroundDispatch(); + } + }, []); + + return { + isNfcSupported, + scanNfcTag, + }; +} + +/** + * Android-only: registers NFC foreground dispatch so tags are automatically + * intercepted while the app is visible. Calls `onUri` with the parsed URI. + */ +export function useNfcForegroundDispatch(onUri: (uri: string) => void) { + const onUriRef = useRef(onUri); + onUriRef.current = onUri; + + useEffect(() => { + if (Platform.OS !== 'android') { + return; + } + + let registered = false; + + const register = async () => { + if (registered || foregroundDispatchPaused) { + return; + } + try { + await NfcManager.start(); + NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: any) => { + LogStore.log( + `Foreground dispatch: tag discovered`, + 'useNfc', + 'foregroundDispatch', + ); + + if (!tag?.ndefMessage?.length) { + LogStore.log( + 'Foreground dispatch: no NDEF message', + 'useNfc', + 'foregroundDispatch', + ); + return; + } + + for (const record of tag.ndefMessage) { + const uri = extractUri(record); + if (uri) { + LogStore.log( + `Foreground dispatch URI: ${uri}`, + 'useNfc', + 'foregroundDispatch', + ); + onUriRef.current(uri); + return; + } + } + + LogStore.log( + 'Foreground dispatch: no URI in NDEF records', + 'useNfc', + 'foregroundDispatch', + ); + }); + + /* eslint-disable no-bitwise */ + const readerModeFlags = + NfcAdapter.FLAG_READER_NFC_A | + NfcAdapter.FLAG_READER_NFC_B | + NfcAdapter.FLAG_READER_NFC_V; + /* eslint-enable no-bitwise */ + await NfcManager.registerTagEvent({ + isReaderModeEnabled: true, + readerModeFlags, + }); + registered = true; + LogStore.log( + 'NFC reader mode registered', + 'useNfc', + 'foregroundDispatch', + ); + } catch (error: any) { + LogStore.log( + `Failed to register foreground dispatch: ${error?.message}`, + 'useNfc', + 'foregroundDispatch', + ); + } + }; + + const unregister = async () => { + if (!registered) { + return; + } + try { + await NfcManager.unregisterTagEvent(); + NfcManager.setEventListener(NfcEvents.DiscoverTag, null); + registered = false; + } catch { + // ignore + } + }; + + // Register immediately + register(); + + // Allow manual scan to re-register foreground dispatch after it finishes + resumeForegroundDispatch = () => register(); + + // Re-register when app comes back to foreground + const subscription = AppState.addEventListener('change', state => { + if (state === 'active') { + register(); + } else { + unregister(); + } + }); + + return () => { + subscription.remove(); + resumeForegroundDispatch = null; + unregister(); + }; + }, []); +} + +function extractUri(record: { + tnf: number; + type: number[]; + payload: number[]; +}): string | null { + // TNF 3 = Absolute URI — the type field IS the URI + if (record.tnf === 3 && record.type?.length) { + return bytesToString(record.type); + } + + // TNF 1 = Well-known type + if (record.tnf === 1) { + const typeStr = bytesToString(record.type); + + // RTD_URI (type = "U" / 0x55) + if (typeStr === 'U' && record.payload?.length > 1) { + return Ndef.uri.decodePayload(new Uint8Array(record.payload) as any); + } + + // RTD_SMART_POSTER (type = "Sp") — contains nested NDEF with URI + if (typeStr === 'Sp' && record.payload?.length) { + try { + const innerBytes = new Uint8Array(record.payload); + const innerRecords = Ndef.decodeMessage(innerBytes as any); + if (innerRecords) { + for (const inner of innerRecords) { + const uri = extractUri(inner as any); + if (uri) { + return uri; + } + } + } + } catch { + // Failed to parse smart poster, skip + } + } + } + + return null; +} + +function bytesToString(bytes: number[]): string { + return String.fromCharCode(...bytes); +} diff --git a/wallets/rn_cli_wallet/src/screens/App.tsx b/wallets/rn_cli_wallet/src/screens/App.tsx index ae93edee..6ea95ca5 100644 --- a/wallets/rn_cli_wallet/src/screens/App.tsx +++ b/wallets/rn_cli_wallet/src/screens/App.tsx @@ -16,6 +16,7 @@ import { RootStackNavigator } from '@/navigators/RootStackNavigator'; import useInitializeWalletKit from '@/hooks/useInitializeWalletKit'; import useWalletKitEventsManager from '@/hooks/useWalletKitEventsManager'; import { usePairing } from '@/hooks/usePairing'; +import { useNfcForegroundDispatch } from '@/hooks/useNfc'; import { walletKit } from '@/utils/WalletKitUtil'; import SettingsStore from '@/store/SettingsStore'; import ModalStore from '@/store/ModalStore'; @@ -63,6 +64,9 @@ const App = () => { // Get centralized URI/payment link handler const { handleUriOrPaymentLink } = usePairing(); + // Android: automatically intercept NFC tags while app is in foreground + useNfcForegroundDispatch(handleUriOrPaymentLink); + // Hide splash screen once wallets are initialized, addresses are loaded and theme mode is set useEffect(() => { if (initialized && eip155Address && themeMode) { @@ -88,7 +92,7 @@ const App = () => { const deeplinkHandler = useCallback( ({ url }: { url: string }) => { LogStore.log('Deep link received', 'App', 'deeplinkHandler', { - url + url, }); // 1. Link mode (wc_ev) - SDK handles it, just set the flag @@ -98,7 +102,13 @@ const App = () => { return; } - // 2. Redirection from app with encoded URI (wc?uri=) + // 2. Payment link from NFC tag or App Link (pay.walletconnect.com) + if (url.includes('pay.walletconnect.com')) { + handleUriOrPaymentLink(url); + return; + } + + // 3. Redirection from app with encoded URI (wc?uri=) if (url.includes('wc?uri=')) { const encodedUri = url.split('wc?uri=')[1]; if (!encodedUri) { @@ -116,7 +126,7 @@ const App = () => { return; } - // 3. Direct WC protocol URI (wc:) + // 4. Direct WC protocol URI (wc:) // Extract from wc: onwards to remove app scheme prefix if (url.includes('wc:')) { const wcIndex = url.indexOf('wc:'); @@ -125,14 +135,13 @@ const App = () => { return; } - // 4. Request for already paired session (wc?) + // 5. Request for already paired session (wc?) if (url.includes('wc?')) { ModalStore.open('LoadingModal', { loadingMessage: 'Loading request...', }); return; } - }, [handleUriOrPaymentLink], ); diff --git a/wallets/rn_cli_wallet/yarn.lock b/wallets/rn_cli_wallet/yarn.lock index 3a91516b..cd6650b8 100644 --- a/wallets/rn_cli_wallet/yarn.lock +++ b/wallets/rn_cli_wallet/yarn.lock @@ -4490,6 +4490,7 @@ __metadata: react-native-keyboard-controller: ^1.20.6 react-native-mmkv: 3.3.3 react-native-modal: 14.0.0-rc.1 + react-native-nfc-manager: ^3.15.1 react-native-quick-base64: 2.2.2 react-native-quick-crypto: 0.7.17 react-native-reanimated: ^4.2.1 @@ -10548,6 +10549,18 @@ __metadata: languageName: node linkType: hard +"react-native-nfc-manager@npm:^3.15.1": + version: 3.17.2 + resolution: "react-native-nfc-manager@npm:3.17.2" + peerDependencies: + "@expo/config-plugins": "*" + peerDependenciesMeta: + "@expo/config-plugins": + optional: true + checksum: 7435840e0ddd190b442dbe4972e318da2423c953648f917ba515d99a92aba27a477d738fb0f75db89bc64fc52201b9e6880d74e5c7f800d75195c77f4c20cde3 + languageName: node + linkType: hard + "react-native-quick-base64@npm:2.2.2, react-native-quick-base64@npm:^2.2.2": version: 2.2.2 resolution: "react-native-quick-base64@npm:2.2.2" From 15dc4c4fa11566ee920aa7af7a1fe84224e171be Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:04:44 -0300 Subject: [PATCH 2/3] fix(rn_cli_wallet): address NFC PR review feedback - Validate NFC URIs against allowlist before handling - Parse payment link URLs strictly instead of substring match - Fix foreground dispatch: deduplicate start(), add tag debounce, share registration flag across hooks, remove stale unregisterTagEvent - Move autoVerify to a separate VIEW intent filter (not NDEF_DISCOVERED) - Revert accidental xcscheme version downgrade - Fix bytesToString stack overflow on large payloads Co-Authored-By: Claude Opus 4.6 --- .../android/app/src/main/AndroidManifest.xml | 10 ++- .../xcshareddata/xcschemes/RNWallet.xcscheme | 2 +- .../rn_cli_wallet/src/components/Header.tsx | 9 ++- wallets/rn_cli_wallet/src/hooks/useNfc.ts | 76 ++++++++++++++----- wallets/rn_cli_wallet/src/screens/App.tsx | 28 +++++-- 5 files changed, 98 insertions(+), 27 deletions(-) diff --git a/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml b/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml index b4978da5..7d9d31da 100644 --- a/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml +++ b/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml @@ -47,13 +47,21 @@ - + + + + + + + + + diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme index d836b013..f744d6b0 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/xcshareddata/xcschemes/RNWallet.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> diff --git a/wallets/rn_cli_wallet/src/components/Header.tsx b/wallets/rn_cli_wallet/src/components/Header.tsx index 0eec1da9..cb1b130e 100644 --- a/wallets/rn_cli_wallet/src/components/Header.tsx +++ b/wallets/rn_cli_wallet/src/components/Header.tsx @@ -4,7 +4,7 @@ import Toast from 'react-native-toast-message'; import { useTheme } from '@/hooks/useTheme'; import { BorderRadius, Spacing } from '@/utils/ThemeUtil'; -import { useNfc } from '@/hooks/useNfc'; +import { useNfc, isAllowedNfcUri } from '@/hooks/useNfc'; import { usePairing } from '@/hooks/usePairing'; import ModalStore from '@/store/ModalStore'; @@ -26,6 +26,13 @@ export function Header() { try { const uri = await scanNfcTag(); if (uri) { + if (!isAllowedNfcUri(uri)) { + Toast.show({ + type: 'error', + text1: 'Unrecognized NFC tag', + }); + return; + } handleUriOrPaymentLink(uri); } else { Toast.show({ diff --git a/wallets/rn_cli_wallet/src/hooks/useNfc.ts b/wallets/rn_cli_wallet/src/hooks/useNfc.ts index 1a752bb8..3ced5cb1 100644 --- a/wallets/rn_cli_wallet/src/hooks/useNfc.ts +++ b/wallets/rn_cli_wallet/src/hooks/useNfc.ts @@ -9,15 +9,43 @@ import NfcManager, { import LogStore from '@/store/LogStore'; +const ALLOWED_NFC_HOSTS = [ + 'pay.walletconnect.com', + 'staging.pay.walletconnect.com', + 'dev.pay.walletconnect.com', +]; + +export function isAllowedNfcUri(uri: string): boolean { + try { + const { protocol, hostname } = new URL(uri); + return ( + protocol === 'wc:' || + (protocol === 'https:' && ALLOWED_NFC_HOSTS.includes(hostname)) + ); + } catch { + return false; + } +} + +let nfcStarted = false; let foregroundDispatchPaused = false; +let foregroundRegistered = false; let resumeForegroundDispatch: (() => void) | null = null; -export function pauseForegroundDispatch() { +async function ensureNfcStarted() { + if (!nfcStarted) { + await NfcManager.start(); + nfcStarted = true; + } +} + +function pauseForegroundDispatch() { foregroundDispatchPaused = true; } -export function unpauseForegroundDispatch() { +function unpauseForegroundDispatch() { foregroundDispatchPaused = false; + foregroundRegistered = false; resumeForegroundDispatch?.(); } @@ -25,7 +53,7 @@ export function useNfc() { const [isNfcSupported, setIsNfcSupported] = useState(false); useEffect(() => { - NfcManager.start() + ensureNfcStarted() .then(() => NfcManager.isSupported()) .then(supported => { setIsNfcSupported(supported); @@ -37,13 +65,9 @@ export function useNfc() { }, []); const scanNfcTag = useCallback(async (): Promise => { - // Pause foreground dispatch on Android so requestTechnology doesn't conflict pauseForegroundDispatch(); try { - // Cancel any stale session from a previous scan attempt (e.g. user - // tapped the button twice on Android where there's no native dialog). await NfcManager.cancelTechnologyRequest().catch(() => {}); - await NfcManager.unregisterTagEvent().catch(() => {}); await NfcManager.requestTechnology(NfcTech.Ndef); const tag = await NfcManager.getTag(); @@ -63,7 +87,6 @@ export function useNfc() { LogStore.log('No URI found in NDEF records', 'useNfc', 'scanNfcTag'); return null; } catch (error: any) { - // User cancelled — not an error if (error?.message?.includes('cancelled')) { return null; } @@ -94,17 +117,18 @@ export function useNfcForegroundDispatch(onUri: (uri: string) => void) { return; } - let registered = false; + let lastHandledUri = ''; + let lastHandledTime = 0; const register = async () => { - if (registered || foregroundDispatchPaused) { + if (foregroundRegistered || foregroundDispatchPaused) { return; } try { - await NfcManager.start(); + await ensureNfcStarted(); NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: any) => { LogStore.log( - `Foreground dispatch: tag discovered`, + 'Foreground dispatch: tag discovered', 'useNfc', 'foregroundDispatch', ); @@ -121,6 +145,19 @@ export function useNfcForegroundDispatch(onUri: (uri: string) => void) { for (const record of tag.ndefMessage) { const uri = extractUri(record); if (uri) { + // Debounce: skip duplicate tag reads within 3 seconds + const now = Date.now(); + if (uri === lastHandledUri && now - lastHandledTime < 3000) { + LogStore.log( + 'Foreground dispatch: duplicate tag ignored', + 'useNfc', + 'foregroundDispatch', + ); + return; + } + lastHandledUri = uri; + lastHandledTime = now; + LogStore.log( `Foreground dispatch URI: ${uri}`, 'useNfc', @@ -148,7 +185,7 @@ export function useNfcForegroundDispatch(onUri: (uri: string) => void) { isReaderModeEnabled: true, readerModeFlags, }); - registered = true; + foregroundRegistered = true; LogStore.log( 'NFC reader mode registered', 'useNfc', @@ -164,25 +201,22 @@ export function useNfcForegroundDispatch(onUri: (uri: string) => void) { }; const unregister = async () => { - if (!registered) { + if (!foregroundRegistered) { return; } try { await NfcManager.unregisterTagEvent(); NfcManager.setEventListener(NfcEvents.DiscoverTag, null); - registered = false; + foregroundRegistered = false; } catch { // ignore } }; - // Register immediately register(); - // Allow manual scan to re-register foreground dispatch after it finishes resumeForegroundDispatch = () => register(); - // Re-register when app comes back to foreground const subscription = AppState.addEventListener('change', state => { if (state === 'active') { register(); @@ -241,5 +275,9 @@ function extractUri(record: { } function bytesToString(bytes: number[]): string { - return String.fromCharCode(...bytes); + let result = ''; + for (const byte of bytes) { + result += String.fromCharCode(byte); + } + return result; } diff --git a/wallets/rn_cli_wallet/src/screens/App.tsx b/wallets/rn_cli_wallet/src/screens/App.tsx index 6ea95ca5..8d43712a 100644 --- a/wallets/rn_cli_wallet/src/screens/App.tsx +++ b/wallets/rn_cli_wallet/src/screens/App.tsx @@ -16,7 +16,7 @@ import { RootStackNavigator } from '@/navigators/RootStackNavigator'; import useInitializeWalletKit from '@/hooks/useInitializeWalletKit'; import useWalletKitEventsManager from '@/hooks/useWalletKitEventsManager'; import { usePairing } from '@/hooks/usePairing'; -import { useNfcForegroundDispatch } from '@/hooks/useNfc'; +import { useNfcForegroundDispatch, isAllowedNfcUri } from '@/hooks/useNfc'; import { walletKit } from '@/utils/WalletKitUtil'; import SettingsStore from '@/store/SettingsStore'; import ModalStore from '@/store/ModalStore'; @@ -65,7 +65,17 @@ const App = () => { const { handleUriOrPaymentLink } = usePairing(); // Android: automatically intercept NFC tags while app is in foreground - useNfcForegroundDispatch(handleUriOrPaymentLink); + const handleNfcUri = useCallback( + (uri: string) => { + if (isAllowedNfcUri(uri)) { + handleUriOrPaymentLink(uri); + } else { + LogStore.log(`NFC URI rejected: ${uri}`, 'App', 'handleNfcUri'); + } + }, + [handleUriOrPaymentLink], + ); + useNfcForegroundDispatch(handleNfcUri); // Hide splash screen once wallets are initialized, addresses are loaded and theme mode is set useEffect(() => { @@ -103,9 +113,17 @@ const App = () => { } // 2. Payment link from NFC tag or App Link (pay.walletconnect.com) - if (url.includes('pay.walletconnect.com')) { - handleUriOrPaymentLink(url); - return; + try { + const { hostname } = new URL(url); + if ( + hostname.endsWith('.pay.walletconnect.com') || + hostname === 'pay.walletconnect.com' + ) { + handleUriOrPaymentLink(url); + return; + } + } catch { + // Not a valid URL, continue to other handlers } // 3. Redirection from app with encoded URI (wc?uri=) From d6a3e075a2af0e266688a61e51ae34a8bf83b0e0 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:06:58 -0300 Subject: [PATCH 3/3] fix(rn_cli_wallet): remove NDEF from NFC entitlements for Xcode 26 compatibility Xcode 26 / SDK 26.2 rejects NDEF in readersession.formats. TAG alone is sufficient for CoreNFC NDEF reading on newer SDKs. Co-Authored-By: Claude Opus 4.6 --- wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements | 1 - .../ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements | 1 - 2 files changed, 2 deletions(-) diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements index 246e4c52..748ab0f4 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements @@ -13,7 +13,6 @@ com.apple.developer.nfc.readersession.formats - NDEF TAG diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements index 246e4c52..748ab0f4 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements @@ -13,7 +13,6 @@ com.apple.developer.nfc.readersession.formats - NDEF TAG