diff --git a/frontend/mobile/app/(tabs)/send.tsx b/frontend/mobile/app/(tabs)/send.tsx new file mode 100644 index 00000000..25fa5c39 --- /dev/null +++ b/frontend/mobile/app/(tabs)/send.tsx @@ -0,0 +1,721 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + StyleSheet, + ActivityIndicator, + Platform, + KeyboardAvoidingView, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { + Keypair, + TransactionBuilder, + BASE_FEE, + Asset, + Operation, + Contract, + rpc as SorobanRpc, + nativeToScVal, + Horizon, +} from '@stellar/stellar-sdk'; +import { + parseSep7QrValue, +} from 'invisible-wallet-sdk'; +import { getNetwork, getNativeAssetContractId } from '../../lib/network'; +import { getItem } from '../../lib/storage'; +import { QrScanner } from '../../components/QrScanner'; + +const network = getNetwork(); +const Server = Horizon.Server; + +type Step = 'form' | 'confirm' | 'signing' | 'done' | 'error'; + +interface WalletAsset { + code: string; + issuer: string | null; + contractId: string | null; +} + +export default function SendScreen() { + const router = useRouter(); + + const [step, setStep] = useState('form'); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [memo, setMemo] = useState(''); + const [txHash, setTxHash] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [showScanner, setShowScanner] = useState(false); + + const [assets, setAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + + // Load assets on mount + useEffect(() => { + const loadAssets = async () => { + const xlm: WalletAsset = { + code: 'XLM', + issuer: null, + contractId: getNativeAssetContractId(), + }; + + try { + const secret = await getItem('veil_signer_secret'); + const signerPublicKey = secret + ? Keypair.fromSecret(secret).publicKey() + : await getItem('veil_signer_public_key'); + + if (!signerPublicKey || !signerPublicKey.startsWith('G')) { + setAssets([xlm]); + setSelectedAsset(xlm); + return; + } + + const server = new Server(network.horizonUrl); + const account = await server.loadAccount(signerPublicKey); + const list: WalletAsset[] = account.balances.map((b: any) => { + if (b.asset_type === 'native') { + return { + code: 'XLM', + issuer: null, + contractId: getNativeAssetContractId(), + }; + } + const issued = b as { asset_code: string; asset_issuer: string }; + const asset = new Asset(issued.asset_code, issued.asset_issuer); + return { + code: issued.asset_code, + issuer: issued.asset_issuer, + contractId: asset.contractId(network.networkPassphrase), + }; + }); + setAssets(list); + if (list.length > 0) setSelectedAsset(list[0]); + } catch { + setAssets([xlm]); + setSelectedAsset(xlm); + } + }; + + loadAssets(); + }, []); + + function validateForm(): boolean { + const validAddress = + (recipient.startsWith('G') || recipient.startsWith('C')) && + recipient.length === 56; + if (!validAddress) return false; + if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) return false; + if (!selectedAsset) return false; + return true; + } + + const handleSend = useCallback(async () => { + setStep('signing'); + setErrorMsg(null); + + try { + const signerSecret = await getItem('veil_signer_secret'); + if (!signerSecret) { + setErrorMsg( + 'Signing key not found. Return to dashboard and set up a fee-payer.' + ); + setStep('error'); + return; + } + const feePayerKp = Keypair.fromSecret(signerSecret); + + const keyId = await getItem('invisible_wallet_key_id'); + if (!keyId) { + setErrorMsg('No passkey found. Please register the wallet first.'); + setStep('error'); + return; + } + + const horizonServer = new Server(network.horizonUrl); + + if (recipient.startsWith('G') && recipient.length === 56) { + const account = await horizonServer.loadAccount(feePayerKp.publicKey()); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: network.networkPassphrase, + }) + .addOperation( + Operation.payment({ + destination: recipient, + asset: Asset.native(), + amount, + }) + ) + .setTimeout(30) + .build(); + tx.sign(feePayerKp); + const result = await horizonServer.submitTransaction(tx); + setTxHash(result.hash); + } else { + const rpcServer = new SorobanRpc.Server(network.rpcUrl); + const feePayerAcct = await rpcServer.getAccount(feePayerKp.publicKey()); + const sacContract = new Contract(getNativeAssetContractId()); + const amountStroops = BigInt( + Math.round(parseFloat(amount) * 10_000_000) + ); + + const tx = new TransactionBuilder(feePayerAcct, { + fee: BASE_FEE, + networkPassphrase: network.networkPassphrase, + }) + .addOperation( + sacContract.call( + 'transfer', + nativeToScVal(feePayerKp.publicKey(), { type: 'address' }), + nativeToScVal(recipient, { type: 'address' }), + nativeToScVal(amountStroops, { type: 'i128' }) + ) + ) + .setTimeout(30) + .build(); + + const sim = await rpcServer.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${sim.error}`); + } + const assembled = SorobanRpc.assembleTransaction(tx, sim).build(); + assembled.sign(feePayerKp); + + const sendResult = await rpcServer.sendTransaction(assembled); + if (sendResult.status === 'ERROR') { + throw new Error( + `Transaction rejected: ${ + sendResult.errorResult?.toXDR('base64') ?? 'unknown' + }` + ); + } + for (let i = 0; i < 30; i++) { + const result = await rpcServer.getTransaction(sendResult.hash); + if ( + result.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND + ) { + if ( + result.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS + ) { + throw new Error(`Transaction failed: ${result.status}`); + } + break; + } + await new Promise((r) => setTimeout(r, 1_000)); + } + setTxHash(sendResult.hash); + } + + setStep('done'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setErrorMsg( + msg.includes('NotAllowedError') || msg.includes('not allowed') + ? 'Biometric verification was cancelled. Please try again.' + : msg + ); + setStep('error'); + } + }, [recipient, amount, selectedAsset, network]); + + const handleQrScan = useCallback( + (value: string) => { + try { + const parsed = parseSep7QrValue(value); + if (parsed.destination) setRecipient(parsed.destination); + if ('amount' in parsed && parsed.amount) setAmount(parsed.amount); + if ('memo' in parsed && parsed.memo) setMemo(parsed.memo); + } catch { + // If SEP-7 parsing fails, it might be a bare address — already handled in QrScanner + } + setShowScanner(false); + }, + [] + ); + + return ( + + {/* Nav */} + + router.back()} + style={styles.backButton} + > + {'< Back'} + + V + + + + + Send + + {/* ── Form Step ─────────────────────────────────────────────── */} + {step === 'form' && ( + + {/* Asset selector */} + {assets.length > 1 && ( + + ASSET + + {assets.map((a) => ( + setSelectedAsset(a)} + > + + {a.code} + + + ))} + + + )} + + {/* Recipient */} + + RECIPIENT ADDRESS + + + setShowScanner(true)} + style={styles.iconBtn} + aria-label="Scan QR code" + > + QR + + + + + {/* Amount */} + + + AMOUNT{selectedAsset ? ` (${selectedAsset.code})` : ''} + + + + + {/* Memo */} + + MEMO (OPTIONAL) + + + + setStep('confirm')} + disabled={!validateForm()} + > + + Review + + + + )} + + {/* ── Confirm Step ──────────────────────────────────────────── */} + {step === 'confirm' && ( + + + + + {memo ? : null} + + + + + Confirm & sign + + + setStep('form')} + > + Edit + + + )} + + {/* ── Signing Step ──────────────────────────────────────────── */} + {step === 'signing' && ( + + + Waiting for passkey... + + Approve the prompt to authorise the transfer + + + )} + + {/* ── Done Step ─────────────────────────────────────────────── */} + {step === 'done' && ( + + + + + Sent successfully + {txHash && ( + + {txHash.slice(0, 20)}... + + )} + router.replace('/')} + > + Done + + + )} + + {/* ── Error Step ────────────────────────────────────────────── */} + {step === 'error' && ( + + + + + Transaction failed + {errorMsg} + setStep('form')} + > + Try again + + + )} + + + {/* QR Scanner Modal */} + setShowScanner(false)} + /> + + ); +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +function Row({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( + + {label} + {value} + + ); +} + +// ── Styles ────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0B0B0F', + }, + nav: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: Platform.OS === 'ios' ? 60 : 20, + paddingBottom: 12, + }, + backButton: { + paddingVertical: 4, + paddingRight: 12, + }, + backText: { + color: '#F6F7F8', + fontSize: 14, + fontFamily: 'Inter, sans-serif', + }, + navLogo: { + fontSize: 22, + color: '#D4AF37', + }, + navSpacer: { + width: 40, + }, + scrollArea: { + flex: 1, + }, + scrollContent: { + padding: 20, + paddingBottom: 40, + }, + pageTitle: { + fontFamily: 'Lora, Georgia, serif', + fontWeight: '600', + fontStyle: 'italic', + fontSize: 28, + color: '#F6F7F8', + marginBottom: 24, + }, + formSection: { + gap: 16, + }, + fieldGroup: { + gap: 6, + }, + label: { + fontSize: 11, + color: 'rgba(246,247,248,0.4)', + fontFamily: 'Anton, Impact, sans-serif', + letterSpacing: 0.6, + }, + input: { + backgroundColor: 'rgba(246,247,248,0.06)', + borderWidth: 1, + borderColor: 'rgba(246,247,248,0.15)', + borderRadius: 8, + color: '#F6F7F8', + fontSize: 14, + padding: 14, + }, + monoInput: { + fontFamily: 'Inconsolata, monospace', + flex: 1, + }, + amountInput: { + fontFamily: 'Inconsolata, monospace', + fontSize: 20, + }, + recipientRow: { + flexDirection: 'row', + gap: 8, + alignItems: 'stretch', + }, + iconBtn: { + width: 48, + backgroundColor: 'rgba(246,247,248,0.08)', + borderWidth: 1, + borderColor: 'rgba(246,247,248,0.15)', + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + iconBtnText: { + color: '#D4AF37', + fontSize: 14, + fontWeight: '700', + }, + pickerRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + assetChip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: 'rgba(246,247,248,0.06)', + borderWidth: 1, + borderColor: 'rgba(246,247,248,0.1)', + }, + assetChipActive: { + backgroundColor: 'rgba(212,175,55,0.15)', + borderColor: 'rgba(212,175,55,0.4)', + }, + assetChipText: { + color: 'rgba(246,247,248,0.5)', + fontSize: 13, + fontFamily: 'Inconsolata, monospace', + }, + assetChipTextActive: { + color: '#D4AF37', + }, + primaryButton: { + backgroundColor: '#D4AF37', + borderRadius: 8, + paddingVertical: 14, + alignItems: 'center', + marginTop: 8, + }, + primaryButtonDisabled: { + opacity: 0.3, + }, + primaryButtonText: { + color: '#0B0B0F', + fontWeight: '700', + fontSize: 15, + }, + primaryButtonTextDisabled: { + color: 'rgba(11,11,15,0.5)', + }, + ghostButton: { + paddingVertical: 14, + alignItems: 'center', + }, + ghostButtonText: { + color: 'rgba(246,247,248,0.5)', + fontSize: 14, + }, + card: { + backgroundColor: '#141519', + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(246,247,248,0.08)', + padding: 16, + gap: 16, + }, + centeredCard: { + alignItems: 'center', + gap: 16, + paddingVertical: 32, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: 12, + }, + rowLabel: { + fontSize: 13, + color: 'rgba(246,247,248,0.4)', + flexShrink: 0, + }, + rowValue: { + fontSize: 14, + color: '#F6F7F8', + textAlign: 'right', + flexShrink: 1, + }, + monoValue: { + fontFamily: 'Inconsolata, monospace', + }, + signingTitle: { + color: '#F6F7F8', + fontSize: 16, + fontWeight: '500', + }, + signingSubtitle: { + color: 'rgba(246,247,248,0.4)', + fontSize: 13, + textAlign: 'center', + }, + successIcon: { + width: 48, + height: 48, + borderRadius: 24, + borderWidth: 2, + borderColor: '#35D4A0', + alignItems: 'center', + justifyContent: 'center', + }, + successIconText: { + color: '#35D4A0', + fontSize: 24, + fontWeight: '700', + }, + doneTitle: { + fontFamily: 'Lora, Georgia, serif', + fontWeight: '600', + fontStyle: 'italic', + fontSize: 20, + color: '#F6F7F8', + }, + txHash: { + fontSize: 12, + color: 'rgba(246,247,248,0.35)', + fontFamily: 'Inconsolata, monospace', + }, + errorIcon: { + width: 48, + height: 48, + borderRadius: 24, + borderWidth: 2, + borderColor: '#35D4A0', + opacity: 0.5, + alignItems: 'center', + justifyContent: 'center', + }, + errorIconText: { + color: '#35D4A0', + fontSize: 24, + fontWeight: '700', + }, + errorTitle: { + fontWeight: '500', + fontSize: 16, + color: '#F6F7F8', + }, + errorDetail: { + fontSize: 13, + color: 'rgba(246,247,248,0.4)', + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/frontend/mobile/lib/storage.ts b/frontend/mobile/lib/storage.ts new file mode 100644 index 00000000..d7449aa3 --- /dev/null +++ b/frontend/mobile/lib/storage.ts @@ -0,0 +1,25 @@ +/** + * Storage abstraction for the Veil mobile app. + * + * In production this should be backed by expo-secure-store for secrets. + * For now we use a simple in-memory map so the app can be developed + * and tested without native module linking. + */ + +const store = new Map(); + +export async function getItem(key: string): Promise { + return store.get(key) ?? null; +} + +export async function setItem(key: string, value: string): Promise { + store.set(key, value); +} + +export async function removeItem(key: string): Promise { + store.delete(key); +} + +export async function clear(): Promise { + store.clear(); +}