diff --git a/README.md b/README.md index 31255ec..104a2e6 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ See [`user-controlled-wallets-social/README.md`](./user-controlled-wallets-socia Run `npm run server` and `npm run dev` in separate terminals. +### [`modular-wallets-mobile-expo-starter`](./modular-wallets-mobile-expo-starter) + +A single-screen Expo testnet wallet for iOS and Android: create or reconnect a native passkey, unlock with Face ID / Touch ID / Android biometrics, receive test USDC, and review and approve a transfer. Includes the official Circle native bridge, app identity setup, and runnable checks; signed physical-device acceptance remains pending. + ## License Apache 2.0 — see [LICENSE](./LICENSE). diff --git a/modular-wallets-mobile-expo-starter/.env.example b/modular-wallets-mobile-expo-starter/.env.example new file mode 100644 index 0000000..cacfe9e --- /dev/null +++ b/modular-wallets-mobile-expo-starter/.env.example @@ -0,0 +1,2 @@ +EXPO_PUBLIC_CIRCLE_CLIENT_KEY=YOUR_RESTRICTED_TESTNET_CLIENT_KEY +EXPO_PUBLIC_WEBAUTHN_RP_ID=wallet.example.com diff --git a/modular-wallets-mobile-expo-starter/.gitignore b/modular-wallets-mobile-expo-starter/.gitignore new file mode 100644 index 0000000..7357edc --- /dev/null +++ b/modular-wallets-mobile-expo-starter/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +.env* +!.env.example +.expo/ +build/ +.gradle/ +Pods/ +*.keystore +*.mobileprovision +.DS_Store +dist/ +ios/ +android/ +!modules/**/ios/ +!modules/**/android/ diff --git a/modular-wallets-mobile-expo-starter/App.tsx b/modular-wallets-mobile-expo-starter/App.tsx new file mode 100644 index 0000000..a5c53db --- /dev/null +++ b/modular-wallets-mobile-expo-starter/App.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, AppState, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, StatusBar, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import * as LocalAuthentication from 'expo-local-authentication'; +import * as SecureStore from 'expo-secure-store'; +import { isAddress, type Address, type Hex } from 'viem'; +import { resolveCircleModularWalletsNativeModule, type CircleModularWalletsNativeModule } from './modules/circle-modular-wallets'; +import { registerOfficialCircleNativeCredential, loginOfficialCircleNativeCredential, reconnectOfficialCircleNativeCredential, type OfficialCircleNativeCredential } from './src/native-passkey'; +import { createWalletSession, network, walletConfig, type WalletSession } from './src/wallet'; +import { normalizePublicKey, parseTransfer } from './src/wallet-input'; + +function isBackgrounded() { return AppState.currentState === 'background'; } +const storageKey = 'passkey-wallet-v1'; +type SavedWallet = { credential: OfficialCircleNativeCredential; address?: Address; pending?: Hex }; +const colors = { purple: '#6951ce', ink: '#352575', pale: '#f6f2ff', line: '#e4d8ff', mint: '#aeffea' }; + +function Button({ title, onPress, disabled = false, secondary = false }: { title: string; onPress: () => void; disabled?: boolean; secondary?: boolean }) { + return [styles.button, secondary && styles.secondary, (disabled || pressed) && styles.dim]}> + {title} + ; +} + +function WalletScreen() { + const [saved, setSaved] = useState(null); + const savedRef = useRef(null); + const [loaded, setLoaded] = useState(false); + const [session, setSession] = useState(null); + const [locked, setLocked] = useState(true); + const lockedRef = useRef(true); + const [covered, setCovered] = useState(false); + const [busy, setBusy] = useState(''); + const busyRef = useRef(false); + const nativePrompt = useRef(false); + const epoch = useRef(0); + const [authLabel, setAuthLabel] = useState('Biometrics'); + const [balance, setBalance] = useState('—'); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [notice, setNotice] = useState(''); + const [error, setError] = useState(''); + const [transaction, setTransaction] = useState(); + + function lock() { + epoch.current += 1; + lockedRef.current = true; + setLocked(true); setSession(null); setBalance('—'); + setRecipient(''); setAmount(''); setNotice(''); setTransaction(undefined); setError(''); + } + + useEffect(() => { + async function load() { + try { + const encoded = await SecureStore.getItemAsync(storageKey); + if (encoded) { + const record = JSON.parse(encoded) as SavedWallet; + if (!record.credential?.id || !record.credential.rpId || (record.address && !isAddress(record.address)) + || (record.pending && !/^0x[0-9a-f]{64}$/i.test(record.pending))) throw new Error('Saved wallet data is invalid. Do not reset it; check your device backup.'); + record.credential.publicKey = normalizePublicKey(record.credential.publicKey); + savedRef.current = record; setSaved(record); + } + setLoaded(true); + const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); + setAuthLabel(Platform.OS === 'ios' + ? types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION) ? 'Face ID' : 'Touch ID' + : 'Biometrics'); + } catch { setError('Unable to load your saved wallet. Close and reopen the app; your passkey has not been removed.'); } + } + void load(); + const listener = AppState.addEventListener('change', state => { + // Hide immediately during app switching, including the OS passkey prompt. + setCovered(state !== 'active'); + if (state === 'background' && !nativePrompt.current) lock(); + }); + return () => listener.remove(); + }, []); + + async function save(record: SavedWallet) { + await SecureStore.setItemAsync(storageKey, JSON.stringify(record), { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + savedRef.current = record; setSaved(record); + } + + async function prompt(work: () => Promise) { + if (AppState.currentState !== 'active') throw new Error('Return to the app to approve.'); + nativePrompt.current = true; + try { return await work(); } + finally { nativePrompt.current = false; if (isBackgrounded()) lock(); } + } + + function moduleWithPrompt(): CircleModularWalletsNativeModule { + const native = resolveCircleModularWalletsNativeModule(); + if (!native) throw new Error('This requires a native development build. Expo Go is not supported.'); + return { + register: (...args) => prompt(() => native.register(...args)), + login: (...args) => prompt(() => native.login(...args)), + sign: (...args) => { + if (lockedRef.current) throw new Error('Unlock your wallet before approving.'); + return prompt(() => native.sign(...args)); + }, + rpcHeaders: () => native.rpcHeaders(), + }; + } + + async function run(label: string, work: () => Promise) { + if (busyRef.current) return; + busyRef.current = true; setBusy(label); setError(''); + try { await work(); } + catch (cause) { + if (lockedRef.current && label !== 'Connecting your passkey…' && label !== 'Reconnecting…') return; + const key = process.env.EXPO_PUBLIC_CIRCLE_CLIENT_KEY || ''; + const message = cause instanceof Error ? cause.message : 'Something went wrong. Please retry.'; + setError(key ? message.split(key).join('[redacted]') : message); + } finally { busyRef.current = false; setBusy(''); } + } + + async function connect(create: boolean) { + const config = walletConfig(); + const native = moduleWithPrompt(); + const currentEpoch = epoch.current; + if (create && savedRef.current) throw new Error('Reconnect your existing wallet instead.'); + if (!await LocalAuthentication.hasHardwareAsync() || !await LocalAuthentication.isEnrolledAsync()) { + throw new Error('Set up Face ID, Touch ID, or fingerprint unlock in your device settings first.'); + } + const authentication = await prompt(() => LocalAuthentication.authenticateAsync({ + promptMessage: 'Unlock your wallet', cancelLabel: 'Cancel', disableDeviceFallback: false, biometricsSecurityLevel: 'strong', + })); + if (!authentication.success) throw new Error('Unlock canceled. Your wallet stays locked.'); + if (epoch.current !== currentEpoch) return; + const stored = savedRef.current; + const credential = create + ? await registerOfficialCircleNativeCredential({ ...config, userName: 'My Wallet' }, native) + : stored + ? await reconnectOfficialCircleNativeCredential({ ...config, expectedCredentialId: stored.credential.id }, native) + : await loginOfficialCircleNativeCredential(config, native); + if (stored && normalizePublicKey(stored.credential.publicKey) !== credential.publicKey) throw new Error('The selected passkey public key does not match your saved wallet.'); + // Retain the new credential even if address creation or the RPC fails. Retry reconnects it. + if (!stored) await save({ credential: { id: credential.id, publicKey: credential.publicKey, rpId: credential.rpId } }); + if (epoch.current !== currentEpoch) return; + const next = await createWalletSession(credential, stored?.address, native); + if (stored?.address && next.account.address.toLowerCase() !== stored.address.toLowerCase()) throw new Error('The connected passkey belongs to a different wallet.'); + await save({ credential: { id: credential.id, publicKey: credential.publicKey, rpId: credential.rpId }, address: next.account.address, ...(stored?.pending ? { pending: stored.pending } : {}) }); + if (epoch.current !== currentEpoch || AppState.currentState !== 'active') return; + lockedRef.current = false; setLocked(false); setSession(next); + setNotice(stored?.pending ? 'A transfer is awaiting confirmation. Check its status before sending again.' : 'Your passkey is connected.'); + const initialBalance = await next.balance(); + if (!lockedRef.current && epoch.current === currentEpoch) setBalance(initialBalance); + } + + async function checkTransfer(next: WalletSession, record: SavedWallet) { + if (!record.pending) return; + const receiptEpoch = epoch.current; + const receipt = await next.receipt(record.pending); + await save({ credential: record.credential, address: record.address }); + if (!receipt.success) throw new Error('The transfer failed onchain. No successful transfer was recorded; you can review and retry.'); + if (!lockedRef.current && epoch.current === receiptEpoch) { + setTransaction(receipt.hash); setNotice('Transfer confirmed.'); + const refreshed = await next.balance(); + if (!lockedRef.current && epoch.current === receiptEpoch) setBalance(refreshed); + } + } + + function review() { + try { + const transfer = parseTransfer(recipient, amount); + Alert.alert('Review transfer', `Send ${amount.trim()} test USDC\n\nTo ${transfer.to}\n\nAvalanche Fuji • test funds only`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Approve with passkey', onPress: () => void run('Waiting for your approval…', async () => { + if (lockedRef.current || !session || !savedRef.current || savedRef.current.pending) throw new Error('Unlock the wallet and check any pending transfer first.'); + const sendingEpoch = epoch.current; + const activeSession = session; + const hash = await activeSession.send(recipient, amount, async hash => { + if (lockedRef.current || epoch.current !== sendingEpoch) throw new Error('Wallet locked. Review the transfer again.'); + const record = { ...savedRef.current!, pending: hash }; + await save(record); + if (lockedRef.current || epoch.current !== sendingEpoch) { + await save({ credential: record.credential, address: record.address }); + throw new Error('Wallet locked before submission. Review the transfer again.'); + } + setNotice('Operation prepared. Waiting for submission and confirmation…'); + }, async hash => { + const record = savedRef.current; + if (record?.pending === hash) await save({ credential: record.credential, address: record.address }); + if (!lockedRef.current) setNotice('Transfer rejected before submission. Review the error and retry.'); + }); + if (!lockedRef.current) setNotice('Transfer submitted. Waiting for confirmation…'); + await checkTransfer(activeSession, { ...savedRef.current!, pending: hash }); + setAmount(''); setRecipient(''); + }) }, + ]); + } catch (cause) { setError(cause instanceof Error ? cause.message : 'Check the recipient and amount.'); } + } + + return + + + + Passkey WalletTESTNET + {locked ? + ◎{saved ? 'Welcome back.' : 'Your wallet. Your passkey.'} + Unlock with {authLabel}. Approve transfers with your device’s secure passkey prompt. +