diff --git a/frontend/mobile/app/buy.tsx b/frontend/mobile/app/buy.tsx index 4f63209..b629d27 100644 --- a/frontend/mobile/app/buy.tsx +++ b/frontend/mobile/app/buy.tsx @@ -1,24 +1,421 @@ -import { ScreenScaffold, ComingSoonBadge, NavRow } from '@/components/ScreenScaffold'; -import { View, StyleSheet } from 'react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { Keypair } from '@stellar/stellar-sdk'; + +import { + discoverAnchorInfo, + getSep10Jwt, + getTransactionStatus, + initiateDeposit, + isSep24Complete, + signSep10Challenge, + type Sep24TransactionStatus, +} from '../lib/sep24'; +import { getSignerSecret, getWalletAddress } from '../lib/walletStore'; + +// Falls back to env so the screen can be exercised against a testnet anchor +// before a wallet exists on the device. +const FALLBACK_ADDRESS = process.env['EXPO_PUBLIC_FEE_PAYER_ADDRESS']?.trim() || ''; +const DEFAULT_ANCHOR_DOMAIN = process.env['EXPO_PUBLIC_SEP24_ANCHOR_DOMAIN']?.trim() || 'testanchor.stellar.org'; + +/** + * Authenticate with the anchor if it advertises a SEP-10 endpoint. + * + * Most anchors reject `deposit/interactive` without a JWT. Returns `undefined` + * when the anchor publishes no `WEB_AUTH_ENDPOINT`, in which case the deposit is + * attempted unauthenticated and the anchor's own error is surfaced. + */ +async function authenticate( + webAuthEndpoint: string, + account: string, + networkPassphrase: string, +): Promise { + if (!webAuthEndpoint) return undefined; + + const secret = await getSignerSecret(); + if (!secret) { + throw new Error('No signing key on this device — create or restore a wallet first.'); + } + const signerKeypair = Keypair.fromSecret(secret); + + return getSep10Jwt(webAuthEndpoint, account, networkPassphrase, async (params) => + signSep10Challenge(params.challengeXdr, params.networkPassphrase, signerKeypair), + ); +} + +type Step = 'form' | 'connecting' | 'pending' | 'success' | 'error'; + +const POLL_INTERVAL_MS = 5_000; + +export default function BuyScreen() { + const [anchorDomain, setAnchorDomain] = useState(DEFAULT_ANCHOR_DOMAIN); + const [assetCode, setAssetCode] = useState('XLM'); + const [amount, setAmount] = useState(''); + const [step, setStep] = useState('form'); + const [error, setError] = useState(null); + + const [transferServerUrl, setTransferServerUrl] = useState(null); + const [txnId, setTxnId] = useState(null); + const [txnStatus, setTxnStatus] = useState(null); + const [account, setAccount] = useState(FALLBACK_ADDRESS); + + useEffect(() => { + let cancelled = false; + getWalletAddress() + .then((address) => { + if (!cancelled && address) setAccount(address); + }) + .catch(() => { + // Leave the env fallback in place; handleBuy surfaces the missing account. + }); + return () => { + cancelled = true; + }; + }, []); + + const pollRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + const startPolling = useCallback( + (server: string, id: string) => { + stopPolling(); + pollRef.current = setInterval(async () => { + try { + const status = await getTransactionStatus(server, id); + setTxnStatus(status); + if (status.status === 'completed') { + stopPolling(); + setStep('success'); + } else if (isSep24Complete(status.status)) { + stopPolling(); + } + } catch { + // Polling errors are soft — keep trying until the user backs out. + } + }, POLL_INTERVAL_MS); + }, + [stopPolling] + ); + + useEffect(() => () => stopPolling(), [stopPolling]); + + const handleBuy = async () => { + if (!anchorDomain.trim()) { + setError('Enter the anchor domain.'); + setStep('error'); + return; + } + + setStep('connecting'); + setError(null); + try { + const resolved = (await getWalletAddress()) || FALLBACK_ADDRESS; + if (!resolved) { + throw new Error('Spending wallet not set up yet. Fund your wallet first.'); + } + + const { + transferServerUrl: server, + webAuthEndpoint, + networkPassphrase, + } = await discoverAnchorInfo(anchorDomain.trim()); + setTransferServerUrl(server); + + const jwt = await authenticate(webAuthEndpoint, resolved, networkPassphrase); + + const deposit = await initiateDeposit( + server, + { + assetCode: assetCode.trim() || 'XLM', + account: resolved, + amount: amount.trim() || undefined, + }, + jwt, + ); + setTxnId(deposit.id); + + // Launch the anchor's interactive flow in an in-app browser session and + // return to the app once the user finishes (or dismisses) it. + const redirectUrl = Linking.createURL('buy'); + await WebBrowser.openAuthSessionAsync(deposit.url, redirectUrl); + + setStep('pending'); + startPolling(server, deposit.id); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Could not connect to anchor.'); + setStep('error'); + } + }; + + const checkStatusNow = async () => { + if (!transferServerUrl || !txnId) return; + try { + const status = await getTransactionStatus(transferServerUrl, txnId); + setTxnStatus(status); + if (status.status === 'completed') { + stopPolling(); + setStep('success'); + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Could not fetch transaction status.'); + } + }; + + const reset = () => { + stopPolling(); + setStep('form'); + setError(null); + setTxnId(null); + setTxnStatus(null); + setTransferServerUrl(null); + }; + + if (step === 'success') { + return ( + + Deposit confirmed + + Your funds are on their way. It may take a few minutes for the balance to appear. + + {txnStatus?.amount_in && ( + + +{txnStatus.amount_in} {txnStatus.amount_in_asset ?? assetCode} + + )} + + Buy again + + + ); + } + + if (step === 'pending') { + return ( + + Waiting for the anchor + + Complete the deposit in the browser window. Come back here when you're done. + + {txnStatus && ( + + Status + {txnStatus.status} + + )} + + + Check status + + + Cancel + + + ); + } -export default function BuyRoute() { return ( - - - - + + Buy + Bring fiat into your wallet through a SEP-24 anchor. + + {!account && ( + + + Spending wallet not set up yet. Fund your wallet first so the anchor knows where to + send your deposit. + + + )} + + + ANCHOR DOMAIN + + + + + AMOUNT (OPTIONAL) + + + + ASSET + + + + + + {step === 'connecting' ? ( + + ) : ( + Connect to anchor + )} + - - + + {step === 'error' && error && {error}} + ); } const styles = StyleSheet.create({ - grid: { gap: 8 }, + container: { + flexGrow: 1, + backgroundColor: '#0B0B0F', + padding: 24, + gap: 16, + }, + title: { + color: '#FFFFFF', + fontSize: 28, + fontWeight: '700', + }, + subtitle: { + color: '#9BA1A6', + fontSize: 15, + }, + notice: { + backgroundColor: '#1e293b', + borderRadius: 10, + borderWidth: 1, + borderColor: '#334155', + padding: 14, + }, + noticeText: { + color: '#94a3b8', + fontSize: 13, + lineHeight: 18, + }, + form: { + gap: 10, + }, + label: { + color: '#64748b', + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 1, + }, + row: { + flexDirection: 'row', + gap: 10, + }, + rowFieldFlex: { + flex: 2, + gap: 6, + }, + rowFieldAsset: { + flex: 1, + gap: 6, + }, + input: { + backgroundColor: '#1e293b', + borderRadius: 10, + padding: 14, + color: '#f1f5f9', + fontSize: 16, + borderWidth: 1, + borderColor: '#334155', + }, + btn: { + borderRadius: 10, + paddingVertical: 14, + alignItems: 'center', + }, + btnPrimary: { + backgroundColor: '#6366f1', + }, + btnSecondary: { + backgroundColor: '#334155', + }, + btnDisabled: { + opacity: 0.4, + }, + btnText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + linkBtn: { + alignItems: 'center', + paddingVertical: 8, + }, + linkText: { + color: '#94a3b8', + fontSize: 14, + }, + card: { + backgroundColor: '#1e293b', + borderRadius: 10, + padding: 14, + gap: 4, + }, + rowLabel: { + color: '#64748b', + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 1, + }, + rowValue: { + color: '#f1f5f9', + fontSize: 14, + fontWeight: '600', + }, + spinner: { + marginVertical: 4, + }, + amount: { + color: '#a5b4fc', + fontFamily: 'monospace', + fontSize: 20, + fontWeight: '700', + }, + error: { + color: '#f87171', + fontSize: 13, + backgroundColor: '#450a0a', + borderRadius: 8, + padding: 10, + }, }); diff --git a/frontend/mobile/lib/sep24.ts b/frontend/mobile/lib/sep24.ts new file mode 100644 index 0000000..11b23f4 --- /dev/null +++ b/frontend/mobile/lib/sep24.ts @@ -0,0 +1,281 @@ +/** + * SEP-24 Hosted Deposit utility (mobile). + * https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md + * + * Ported from frontend/wallet/lib/sep24.ts. Only the deposit (on-ramp) path is + * implemented for now, matching the mobile buy screen's scope. + * + * Pure / testable — no browser-only APIs (no `window`, `document`, + * `localStorage`, `sessionStorage`, WebAuthn). Where the web version signs + * the SEP-10 challenge with a passkey stored in browser storage, this module + * takes an injectable `signChallenge` async function instead — same pattern + * as `executeBulkPayout(rows, submitBatch)` in `lib/bulkPayout.ts`. Mobile has + * no signing infra ported yet, so screens pass a stub today. + */ + +import { Networks, Transaction, TransactionBuilder, type Keypair } from '@stellar/stellar-sdk'; + +// ── Anchor config ──────────────────────────────────────────────────────────── + +export interface AnchorInfo { + transferServerUrl: string; + webAuthEndpoint: string; + networkPassphrase: string; +} + +// ── TOML discovery ─────────────────────────────────────────────────────────── + +/** Fetch and parse `/.well-known/stellar.toml` for SEP-24 endpoints. */ +export async function discoverAnchorInfo(anchorDomain: string): Promise { + const res = await fetch(`https://${anchorDomain}/.well-known/stellar.toml`, { + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + throw new Error(`Could not fetch stellar.toml from ${anchorDomain} (HTTP ${res.status})`); + } + + const text = await res.text(); + + const transferMatch = text.match(/TRANSFER_SERVER_SEP0024\s*=\s*"([^"]+)"/); + if (!transferMatch) { + throw new Error(`TRANSFER_SERVER_SEP0024 not found in ${anchorDomain}/.well-known/stellar.toml`); + } + + const webAuthMatch = text.match(/WEB_AUTH_ENDPOINT\s*=\s*"([^"]+)"/); + if (!webAuthMatch) { + throw new Error(`WEB_AUTH_ENDPOINT not found in ${anchorDomain}/.well-known/stellar.toml`); + } + + const networkMatch = text.match(/NETWORK_PASSPHRASE\s*=\s*"([^"]+)"/); + const networkPassphrase = networkMatch ? networkMatch[1] : Networks.TESTNET; + + return { + transferServerUrl: transferMatch[1].replace(/\/$/, ''), + webAuthEndpoint: webAuthMatch[1].replace(/\/$/, ''), + networkPassphrase, + }; +} + +// ── SEP-10 challenge signing (pure / testable) ────────────────────────────── + +/** Error codes for typed SEP-10 validation failures. */ +export type Sep10ErrorCode = 'MISSING_MANAGE_DATA' | 'INVALID_HOME_DOMAIN' | 'EXPIRED' | 'MALFORMED'; + +/** + * Typed error thrown by {@link signSep10Challenge} when a challenge is invalid. + * Consumers can narrow on `error.code` for programmatic handling. + */ +export class Sep10ChallengeError extends Error { + readonly code: Sep10ErrorCode; + constructor(message: string, code: Sep10ErrorCode) { + super(message); + this.name = 'Sep10ChallengeError'; + this.code = code; + } +} + +/** + * Validate and sign a SEP-10 challenge transaction with a classic Stellar keypair. + * + * SEP-10 rules enforced: + * - The transaction MUST contain at least one `manage_data` operation. + * - The `manage_data` key MUST end with " auth" (the standard anchor suffix). + * - The transaction MUST have `timeBounds` set (minTime / maxTime). + * - `maxTime` MUST be in the future (challenge not expired). + * + * @throws {Sep10ChallengeError} if any SEP-10 validation rule is violated. + */ +export function signSep10Challenge( + challengeXdr: string, + networkPassphrase: string, + signerKeypair: Keypair +): string { + let tx: Transaction; + try { + tx = new Transaction(challengeXdr, networkPassphrase); + } catch (err) { + throw new Sep10ChallengeError(`Failed to parse challenge XDR: ${(err as Error).message}`, 'MALFORMED'); + } + + const manageDataOps = tx.operations.filter((op) => op.type === 'manageData'); + if (manageDataOps.length === 0) { + throw new Sep10ChallengeError( + 'SEP-10 challenge must contain at least one manage_data operation', + 'MISSING_MANAGE_DATA' + ); + } + + const firstKey = (manageDataOps[0] as { name: string }).name; + if (!firstKey.endsWith(' auth')) { + throw new Sep10ChallengeError( + `manage_data key "${firstKey}" does not follow the " auth" SEP-10 convention`, + 'INVALID_HOME_DOMAIN' + ); + } + + if (!tx.timeBounds) { + throw new Sep10ChallengeError('SEP-10 challenge must have timeBounds set', 'MALFORMED'); + } + + const nowSec = Math.floor(Date.now() / 1000); + const maxTime = Number(tx.timeBounds.maxTime); + if (maxTime > 0 && nowSec > maxTime) { + throw new Sep10ChallengeError( + `SEP-10 challenge has expired (maxTime=${maxTime}, now=${nowSec})`, + 'EXPIRED' + ); + } + + const rebuilt = TransactionBuilder.cloneFrom(tx).build(); + rebuilt.sign(signerKeypair); + return rebuilt.toXDR(); +} + +// ── SEP-10 Web Auth ────────────────────────────────────────────────────────── + +/** + * Injectable signer for the SEP-10 challenge. The web app signs with a + * browser passkey; mobile has no signing infra ported yet, so callers pass a + * stub (or, once wired up, a function backed by the mobile wallet's signer). + * Returns the signed (or otherwise anchor-acceptable) transaction XDR. + */ +export type Sep10ChallengeSigner = (params: { + challengeXdr: string; + networkPassphrase: string; + account: string; +}) => Promise; + +/** + * Obtain a SEP-10 JWT by: + * 1. Fetching the challenge transaction from the anchor's WEB_AUTH_ENDPOINT + * 2. Signing it via the injected `signChallenge` function + * 3. Posting the signed transaction back to get a JWT + */ +export async function getSep10Jwt( + webAuthEndpoint: string, + account: string, + networkPassphrase: string, + signChallenge: Sep10ChallengeSigner +): Promise { + const challengeRes = await fetch(`${webAuthEndpoint}?account=${encodeURIComponent(account)}`, { + signal: AbortSignal.timeout(10_000), + }); + if (!challengeRes.ok) { + const errText = await challengeRes.text().catch(() => challengeRes.statusText); + throw new Error(`SEP-10 challenge fetch failed (HTTP ${challengeRes.status}): ${errText}`); + } + const { transaction: challengeXdr, network_passphrase } = (await challengeRes.json()) as { + transaction: string; + network_passphrase?: string; + }; + + const effectivePassphrase = network_passphrase ?? networkPassphrase; + + const signedXdr = await signChallenge({ + challengeXdr, + networkPassphrase: effectivePassphrase, + account, + }); + + const tokenRes = await fetch(webAuthEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction: signedXdr }), + signal: AbortSignal.timeout(15_000), + }); + if (!tokenRes.ok) { + const errText = await tokenRes.text().catch(() => tokenRes.statusText); + throw new Error(`SEP-10 token exchange failed (HTTP ${tokenRes.status}): ${errText}`); + } + + const { token } = (await tokenRes.json()) as { token?: string }; + if (!token) throw new Error('Anchor did not return a JWT token.'); + return token; +} + +// ── Shared types ───────────────────────────────────────────────────────────── + +export interface Sep24InteractiveResult { + /** URL to open in the anchor's interactive (KYC / payment) flow. */ + url: string; + /** Anchor-assigned transaction ID — use this to poll status. */ + id: string; +} + +export interface Sep24TransactionStatus { + id: string; + /** SEP-24 status: pending_user_transfer_start | pending_anchor | completed | error | … */ + status: string; + stellar_transaction_id?: string; + message?: string; + amount_in?: string; + amount_in_asset?: string; + amount_out?: string; + amount_out_asset?: string; +} + +// ── Deposit ────────────────────────────────────────────────────────────────── + +export interface InitiateDepositParams { + assetCode: string; + account: string; + amount?: string; + lang?: string; +} + +/** Start a SEP-24 interactive deposit. Returns the interactive URL and txn id to poll. */ +export async function initiateDeposit( + transferServerUrl: string, + params: InitiateDepositParams, + jwt?: string +): Promise { + const body = new URLSearchParams({ + asset_code: params.assetCode, + account: params.account, + lang: params.lang ?? 'en', + ...(params.amount ? { amount: params.amount } : {}), + }); + + const res = await fetch(`${transferServerUrl}/transactions/deposit/interactive`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + body: body.toString(), + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => res.statusText); + throw new Error(`Deposit initiation failed (HTTP ${res.status}): ${errText}`); + } + + const data = (await res.json()) as { url?: string; id?: string }; + if (!data.url || !data.id) throw new Error('Anchor returned an invalid response (missing url or id)'); + return { url: data.url, id: data.id }; +} + +// ── Status polling ─────────────────────────────────────────────────────────── + +/** Fetch the current status of a SEP-24 transaction from the anchor. */ +export async function getTransactionStatus( + transferServerUrl: string, + txnId: string, + jwt?: string +): Promise { + const res = await fetch(`${transferServerUrl}/transaction?id=${encodeURIComponent(txnId)}`, { + headers: jwt ? { Authorization: `Bearer ${jwt}` } : {}, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) throw new Error(`Failed to fetch transaction status (HTTP ${res.status})`); + + const data = (await res.json()) as { transaction?: Sep24TransactionStatus }; + if (!data.transaction) throw new Error('Anchor response missing transaction object'); + return data.transaction; +} + +/** Returns true once a SEP-24 status no longer requires polling. */ +export function isSep24Complete(status: string): boolean { + return ['completed', 'error', 'refunded', 'expired'].includes(status); +} diff --git a/frontend/mobile/package-lock.json b/frontend/mobile/package-lock.json index 3893fe1..3332867 100644 --- a/frontend/mobile/package-lock.json +++ b/frontend/mobile/package-lock.json @@ -39,6 +39,7 @@ "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.1", + "expo-web-browser": "~57.0.2", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", @@ -9420,6 +9421,16 @@ "expo": "*" } }, + "node_modules/expo-web-browser": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.2.tgz", + "integrity": "sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "57.0.10", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.10.tgz", diff --git a/frontend/mobile/package.json b/frontend/mobile/package.json index 2d39abe..9122691 100644 --- a/frontend/mobile/package.json +++ b/frontend/mobile/package.json @@ -44,6 +44,7 @@ "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.1", + "expo-web-browser": "~57.0.2", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0",