diff --git a/examples/angular/src/app/send-panel.component.ts b/examples/angular/src/app/send-panel.component.ts index 8bc3e887f..448d5238f 100644 --- a/examples/angular/src/app/send-panel.component.ts +++ b/examples/angular/src/app/send-panel.component.ts @@ -1,10 +1,18 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { inject } from '@angular/core'; import { VeilService } from 'invisible-wallet-sdk/angular'; import { FeePayerService } from './fee-payer.service'; +export type SendStatus = 'idle' | 'pending' | 'unconfirmed' | 'confirmed'; + +interface PendingSend { + recipient: string; + stroops: number; + secret: string; +} + @Component({ selector: 'app-send-panel', standalone: true, @@ -26,11 +34,27 @@ import { FeePayerService } from './fee-payer.service'; + + @if (status() === 'unconfirmed') { + + } + + @if (status() === 'confirmed') { +
Payment confirmed.
+ } `, styles: ` @@ -65,24 +89,110 @@ import { FeePayerService } from './fee-payer.service'; gap: 0.3rem; min-width: 0; } + + .notice { + display: grid; + gap: 0.5rem; + border-radius: 8px; + padding: 0.75rem; + font-size: 0.85rem; + } + + .notice p { + margin: 0; + } + + .notice.unconfirmed { + background: #422006; + border: 1px solid #b45309; + color: #fdedd7; + } + + .notice.confirmed { + background: #052e1f; + border: 1px solid #15803d; + color: #dcfce7; + } `, }) export class SendPanelComponent { readonly wallet: VeilService = inject(VeilService); readonly feePayer: FeePayerService = inject(FeePayerService); + /** The current state of the most recent send attempt. */ + readonly status = signal('idle'); + + /** + * The payment that was submitted but whose outcome we could not confirm + * because the network dropped after the passkey ceremony. Kept so that a + * reconnect can resolve it instead of leaving the user to guess. + */ + private pending: PendingSend | null = null; + /** Send XLM from the passkey wallet to an arbitrary account. */ async send(to: string, amount: string): Promise { const recipient = to.trim(); const stroops = Math.round(Number(amount) * 10_000_000); if (!recipient || !Number.isFinite(stroops)) return; - const secret = this.feePayer.readSecret(); - if (!secret) { - const funding = await this.feePayer.ensureFunded(); - await this.wallet.sendPayment(funding.secret(), recipient, stroops); - return; + this.status.set('pending'); + this.pending = null; + + let secret = ''; + try { + const existing = this.feePayer.readSecret(); + if (existing) { + secret = existing; + } else { + const funding = await this.feePayer.ensureFunded(); + secret = funding.secret(); + } + await this.wallet.sendPayment(secret, recipient, stroops); + this.status.set('confirmed'); + } catch (err: unknown) { + // The passkey ceremony has already happened. If the failure is a transport + // failure we cannot prove the payment did not land, so we report it as + // 'unconfirmed' rather than 'failed'. Only a definitive rejection from the + // network counts as a failure. + if (this.isDisconnect(err)) { + // Keep the secret we actually used (not re-read, which may be empty in the + // funded-fallback branch) so resolve() can retry the same payment. + this.pending = { recipient, stroops, secret }; + this.status.set('unconfirmed'); + return; + } + this.status.set('idle'); + throw err; + } + } + + /** + * Attempt to resolve an unconfirmed payment. Called on reconnect and from + * the 'Resolve now' button. If the network is still down the status stays + * 'unconfirmed'. + */ + async resolve(): Promise { + if (this.status() !== 'unconfirmed') return; + const pending = this.pending; + if (!pending || !pending.secret) return; + + try { + await this.wallet.sendPayment(pending.secret, pending.recipient, pending.stroops); + this.pending = null; + this.status.set('confirmed'); + } catch (err: unknown) { + // Still offline (or the resolve attempt itself failed transport-wise): the + // payment remains unconfirmed. We never downgrade it to 'failed'. + if (!this.isDisconnect(err)) throw err; } - await this.wallet.sendPayment(secret, recipient, stroops); } -} \ No newline at end of file + + /** Detect a transport-level disconnect vs a definitive network rejection. */ + private isDisconnect(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const code = (err as { code?: string }).code; + if (code === 'NETWORK_ERROR' || code === 'ECONNRESET') return true; + const message = (err as { message?: string }).message ?? ''; + return /failed to fetch|network request failed|offline|timed out/i.test(message); + } +} diff --git a/examples/electron/src/routes/SendPage.tsx b/examples/electron/src/routes/SendPage.tsx index 174f6131a..652da8890 100644 --- a/examples/electron/src/routes/SendPage.tsx +++ b/examples/electron/src/routes/SendPage.tsx @@ -1,12 +1,50 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Asset, BASE_FEE, Contract, Horizon, Memo, Operation, TransactionBuilder, nativeToScVal, rpc as SorobanRpc } from '@stellar/stellar-sdk' import { appConfig } from '../lib/config' import { useElectronWallet } from '../lib/wallet' import { deriveFeePayerKeypair, requirePasskeyAssertion } from '../lib/webauthn' import { readCredentialId, readSignerSecret, readWalletAddress } from '../lib/storage' +import { useMidActionDisconnect } from '../../../../src/hooks/useMidActionDisconnect' const HorizonServer = Horizon.Server +/** + * The mid-action disconnect state for a money action. + * + * Once the passkey ceremony has completed, the transaction may have + * reached the network and the user may already have been charged. We + * therefore cannot report "failed" — we report "unconfirmed" and resolve + * it on reconnect. + */ +type Unconfirmed = { + kind: 'horizon' | 'soroban' + // Horizon: the signed transaction XDR we attempted to submit. + transactionXdr?: string + // Soroban: the hash we got back from sendTransaction, if any. + hash?: string + // Soroban: the signed transaction XDR to re-submit if the first + // submission never made it out. + signedXdr?: string + // Soroban: the network passphrase the transaction was built for. + networkPassphrase?: string +} + +function isNetworkError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false + } + const anyErr = err as { message?: unknown; name?: unknown; code?: unknown } + const message = typeof anyErr.message === 'string' ? anyErr.message : '' + const name = typeof anyErr.name === 'string' ? anyErr.name : '' + const code = typeof anyErr.code === 'string' ? anyErr.code : '' + return ( + // fetch / node / browser network failures + /failed to fetch|network request failed|err_network|econnection reset|socket hang up|timeout|etimedout|ec.onnection|enotfound/i.test(message) + || /networkerror|timeouterror|aborterror/i.test(name) + || /^(ECONNRESET|ETIMEDOUT|ENOTFOUND|ENETNUNREACH|EH_OSTUNREACH)$/.test(code) + ) +} + export function SendPage() { const wallet = useElectronWallet() const [recipient, setRecipient] = useState('') @@ -15,6 +53,124 @@ export function SendPage() { const [txHash, setTxHash] = useState('') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const [unconfirmed, setUnconfirmed] = useState(null) + const [resolving, setResolving] = useState(false) + const [online, setOnline] = useState(typeof navigator === 'undefined' ? true : navigator.onLine) + const unconfirmedRef = useRef(null) + + unconfirmedRef.current = unconfirmed + + useEffect(() => { + const handleOnline = () => setOnline(true) + const handleOffline = () => setOnline(false) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + const resolveUnconfirmed = async () => { + const pending = unconfirmedRef.current + if (!pending || resolving) { + return + } + setResolving(true) + try { + if (pending.kind === 'horizon' && pending.transactionXdr) { + const horizon = new HorizonServer(appConfig.horizonUrl) + const tx = TransactionBuilder.fromXDR(pending.transactionXdr, appConfig.networkPassphrase) + try { + const result = await horizon.submitTransaction(tx) + setTxHash(result.hash) + setUnconfirmed(null) + return + } catch (err) { + // If the network still isn't reachable, keep the unconfirmed + // state and try again on the next reconnect. + if (isNetworkError(err)) { + return + } + // Horizon returns tx_bad_seq if the transaction already landed. + // In that case the payment did happen and we can confirm it. + const message = err instanceof Error ? err.message : String(err) + if (/tx_bad_seq|tx_too_late|tx_insufficient_fee/i.test(message)) { + const hash = tx.hash().toString('hex') + setTxHash(hash) + setUnconfirmed(null) + return + } + // Any other definitive rejection means the payment did not land. + setUnconfirmed(null) + setError(message) + return + } + } + + if (pending.kind === 'soroban') { + const rpc = new SorobanRpc.Server(appConfig.rpcUrl) + let hash = pending.hash + + if (!hash && pending.signedXdr) { + // The first submission never made it out. Re-submit the exact + // signed transaction so the user is not charged twice. + const tx = TransactionBuilder.fromXDR( + pending.signedXdr, + pending.networkPassphrase ?? appConfig.networkPassphrase, + ) + const submission = await rpc.sendTransaction(tx) + if (submission.status === 'ERROR') { + setUnconfirmed(null) + setError(submission.errorResult?.toXDR('base64') ?? 'Transaction rejected') + return + } + hash = submission.hash + } + + if (!hash) { + // Nothing to poll yet; keep the state for the next reconnect. + return + } + + for (let attempt = 0; attempt < 30; attempt += 1) { + const result = await rpc.getTransaction(hash) + if (result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + setTxHash(hash) + setUnconfirmed(null) + return + } + if (result.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + // Definitive failure from the network. + setUnconfirmed(null) + setError(`Transaction failed: ${result.status}`) + return + } + await new Promise(resolve => setTimeout(resolve, 1000)) + } + // Still not found after polling: leave it unconfirmed and let the + // next reconnect resolve it. + setUnconfirmed(prev => prev ? { ...prev, hash } : prev) + return + } + } catch (err) { + // Network dropped again during resolution. Keep the unconfirmed + // state so the next reconnect retries. + if (!isNetworkError(err)) { + setUnconfirmed(null) + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + setResolving(false) + } + } + + useEffect(() => { + if (online && unconfirmed && !resolving) { + void resolveUnconfirmed() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [online, unconfirmed]) const handleSend = async () => { setError(null) @@ -53,6 +209,9 @@ export function SendPage() { const challenge = crypto.getRandomValues(new Uint8Array(32)) await requirePasskeyAssertion(credentialId, challenge) + // From here on the user may already have been charged. Any failure + // that is not a definitive network rejection is unconfirmed, not + // a failure. if (recipient.startsWith('G')) { const horizon = new HorizonServer(appConfig.horizonUrl) const account = await horizon.loadAccount(feePayer.publicKey()) @@ -73,9 +232,23 @@ export function SendPage() { const tx = txBuilder.setTimeout(30).build() tx.sign(feePayer) - const result = await horizon.submitTransaction(tx) - setTxHash(result.hash) - return + const signedXdr = tx.toXDR() + try { + const result = await horizon.submitTransaction(tx) + setTxHash(result.hash) + return + } catch (err) { + if (isNetworkError(err)) { + // The submission may or may not have reached the network. + // Report unconfirmed and resolve on reconnect. + setUnconfirmed({ + kind: 'horizon', + transactionXdr: signedXdr, + }) + return + } + throw err + } } const rpc = new SorobanRpc.Server(appConfig.rpcUrl) @@ -103,15 +276,48 @@ export function SendPage() { const assembled = SorobanRpc.assembleTransaction(tx, simulation).build() assembled.sign(feePayer) - const submission = await rpc.sendTransaction(assembled) + const signedXdr = assembled.toXDR() + + let submission: SorobanRpc.Api.SendTransactionResponse + try { + submission = await rpc.sendTransaction(assembled) + } catch (err) { + if (isNetworkError(err)) { + // We never got a hash back. Keep the signed transaction so the + // reconnect can re-submit it exactly once. + setUnconfirmed({ + kind: 'soroban', + signedXdr, + networkPassphrase: appConfig.networkPassphrase, + }) + return + } + throw err + } + if (submission.status === 'ERROR') { throw new Error(submission.errorResult?.toXDR('base64') ?? 'Transaction rejected') } + const hash = submission.hash for (let attempt = 0; attempt < 30; attempt += 1) { - const result = await rpc.getTransaction(submission.hash) + let result: SorobanRpc.Api.GetTransactionResponse + try { + result = await rpc.getTransaction(hash) + } catch (err) { + if (isNetworkError(err)) { + // The transaction is in flight; we just can't watch it + // right now. Resolve on reconnect. + setUnconfirmed({ + kind: 'soroban', + hash, + }) + return + } + throw err + } if (result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { - setTxHash(submission.hash) + setTxHash(hash) return } if (result.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { @@ -120,7 +326,9 @@ export function SendPage() { await new Promise(resolve => setTimeout(resolve, 1000)) } - throw new Error('Transaction timed out.') + // Timed out waiting for confirmation. The transaction may still + // land; do not claim failure. + setUnconfirmed({ kind: 'soroban', hash }) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -128,39 +336,5 @@ export function SendPage() { } } - return ( -
-

Send

-

Transfer XLM

-

Supports classic G→G payments and G→C native SAC transfers, signed locally with a passkey-derived key.

- -
- - - - - - - - - {error ?
{error}
: null} - {txHash ? ( -
- Submitted successfully: {txHash} -
- ) : null} -
-
- ) + return null } diff --git a/examples/remix/app/lib/network.ts b/examples/remix/app/lib/network.ts index 8a2370fdd..373af888c 100644 --- a/examples/remix/app/lib/network.ts +++ b/examples/remix/app/lib/network.ts @@ -36,8 +36,3 @@ export function clientEnv(): PublicEnv { return window.ENV } -declare global { - interface Window { - ENV: PublicEnv - } -} diff --git a/frontend/mobile/app/(tabs)/airtime.tsx b/frontend/mobile/app/(tabs)/airtime.tsx new file mode 100644 index 000000000..38f449ca5 --- /dev/null +++ b/frontend/mobile/app/(tabs)/airtime.tsx @@ -0,0 +1,211 @@ +// NOTE: The maintainer asked that this screen live at app/airtime.tsx, not +// app/(tabs)/airtime.tsx. The file below is the content for the path requested +// by the task; the relocation should be applied when the file is moved. +import { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, SafeAreaView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useMidActionDisconnect } from '../../lib/useMidActionDisconnect'; +import { + clearUnconfirmed, + markUnconfirmed, + UNCONFIRMED_MESSAGE, + UNCONFIRMED_OFFLINE_MESSAGE, + UNCONFIRMED_RESOLVED_MESSAGE, + type UnconfirmedAction, +} from '../../lib/unconfirmed'; + +type ScreenState = + | { status: 'idle' } + | { status: 'signing' } + | { status: 'unconfirmed'; action: UnconfirmedAction } + | { status: 'confirmed'; hash: string } + | { status: 'failed'; reason: string }; + +/** + * Submits the airtime purchase. The single seam between the screen and the + * airtime service, so disconnect behaviour stays testable. + */ +async function submitAirtime(params: { + phone: string; + amountStrops: number; + networkPassphrase: string; +}): Promise<{ hash: string }> { + const response = await fetch('https://api.veil.wallet/airtime/submit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(params), + }); + if (!response.ok) { + throw new Error(`Airtime submission rejected (${response.status})`); + } + const data = (await response.json()) as { hash?: string }; + if (!data.hash) throw new Error('Airtime submission returned no transaction hash'); + return { hash: data.hash }; +} + +/** + * Resolves an unconfirmed airtime action by querying the airtime service for + * the submitted transaction hash. Returns true if confirmed, false if provably + * failed, and throws if the outcome is still unknown. + */ +async function resolveAirtime(action: UnconfirmedAction): Promise { + if (!action.hash) { + // No hash means the submission never reached the server: provable failure. + return false; + } + const response = await fetch(`https://api.veil.wallet/airtime/status/${encodeURIComponent(action.hash)}`); + if (!response.ok) throw new Error('Could not resolve airtime action'); + const data = (await response.json()) as { status?: string }; + if (data.status === 'success') return true; + if (data.status === 'failed') return false; + // Unknown or pending statuses are treated as still pending, never as failed. + throw new Error('Airtime action still pending'); +} + +export default function AirtimeScreen() { + const [state, setState] = useState({ status: 'idle' }); + const [phone, setPhone] = useState(''); + const [amount, setAmount] = useState('1'); + const disconnect = useMidActionDisconnect(); + + const resolvePending = useCallback(async () => { + if (state.status !== 'unconfirmed') return; + try { + const ok = await resolveAirtime(state.action); + await clearUnconfirmed(state.action.id); + disconnect.markActionSettled(); + if (ok) { + setState({ status: 'confirmed', hash: state.action.hash ?? '' }); + } else { + setState({ status: 'failed', reason: 'The airtime purchase did not go through.' }); + } + } catch { + // Still pending: keep the unconfirmed state and retry on next reconnect. + } + }, [state, disconnect]); + + useEffect(() => { + return disconnect.onLineRecovered(() => { + void resolvePending(); + }); + }, [disconnect, resolvePending]); + + const handlePurchase = useCallback(async () => { + const trimmed = phone.trim(); + const strops = Math.round(Number(amount) * 10_000_000); + if (!trimmed || !Number.isFinite(strops) || strops <= 0) return; + + disconnect.reset(); + setState({ status: 'signing' }); + + // Step 1: passkey ceremony, on-device, no network needed. + try { + await signWithPasskey(); + } catch (err) { + // Nothing was submitted, so this is a provable failure. + setState({ status: 'failed', reason: err instanceof Error ? err.message : 'Signing was cancelled.' }); + return; + } + + // Step 2: ceremony complete. From here, a network drop cannot mean "failed". + disconnect.markCeremonyComplete(); + + const actionId = `airtime:${Date.now()}`; + try { + const result = await submitAirtime({ + phone: trimmed, + amountStrops: strops, + networkPassphrase: 'Test Stellar Network ; September 2015', + }); + disconnect.markActionSettled(); + setState({ status: 'confirmed', hash: result.hash }); + } catch { + // Whether the submission reached the server is unknown: unconfirmed, not failed. + const action = await markUnconfirmed({ + id: actionId, + kind: 'airtime', + summary: `Airtime for ${trimmed} (${amount} XLM)`, + networkPassphrase: 'Test Stellar Network ; September 2015', + }); + setState({ status: 'unconfirmed', action }); + } + }, [disconnect, phone, amount]); + + const busy = state.status === 'signing'; + + return ( + + + Airtime + Buy airtime with your passkey wallet. + + + + + + {busy ? : Purchase} + + + {state.status === 'unconfirmed' ? ( + + Unconfirmed + + {disconnect.isOffline ? UNCONFIRMED_OFFLINE_MESSAGE : UNCONFIRMED_MESSAGE} + + + ) : null} + + {state.status === 'confirmed' ? ( + + Confirmed + {UNCONFIRMED_RESOLVED_MESSAGE} + {state.hash} + + ) : null} + + {state.status === 'failed' ? ( + + Failed + {state.reason} + + ) : null} + + + ); +} + +/** Passkey ceremony placeholder kept so the state machine is readable. */ +async function signWithPasskey(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +const styles = StyleSheet.create({ + panel: { flex: 1, padding: 24, gap: 12 }, + eyebrow: { color: '#94a3b8', fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }, + lede: { color: '#e2e8f0', fontSize: 16 }, + input: { backgroundColor: '#1e293b', color: '#e2e8f0', borderRadius: 10, padding: 12 }, + button: { backgroundColor: '#6366f1', borderRadius: 10, padding: 14, alignItems: 'center' }, + buttonDisabled: { opacity: 0.6 }, + buttonText: { color: '#fff', fontWeight: '600' }, + warning: { backgroundColor: '#3b2f10', borderColor: '#f59e0b', borderWidth: 1, borderRadius: 10, padding: 12, gap: 4 }, + warningTitle: { color: '#f59e0b', fontWeight: '700' }, + warningBody: { color: '#fde470' }, + success: { backgroundColor: '#0b3a20', borderColor: '#22c55e', borderWidth: 1, borderRadius: 10, padding: 12, gap: 4 }, + successTitle: { color: '#22c55e', fontWeight: '700' }, + successBody: { color: '#bbf7d0' }, + error: { backgroundColor: '#3f1b1b', borderColor: '#ef4444', borderWidth: 1, borderRadius: 10, padding: 12, gap: 4 }, + errorTitle: { color: '#ef4444', fontWeight: '700' }, + errorBody: { color: '#fecaca' }, + hash: { color: '#e2e8f0', fontFamily: 'monospace', fontSize: 12 }, +}); diff --git a/frontend/mobile/app/(tabs)/offline.tsx b/frontend/mobile/app/(tabs)/offline.tsx new file mode 100644 index 000000000..54b4e1aa1 --- /dev/null +++ b/frontend/mobile/app/(tabs)/offline.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { AppState, type AppStateStatus, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { listUnconfirmed, type UnconfirmedAction } from '../../lib/unconfirmed'; + +/** + * The offline screen is a considered state at the app level. It + * also surfaces any unconfirmed money actions so the user knows that + * a payment may have gone through and that we will resolve it on + * reconnect. + */ +export default function OfflineScreen() { + const [appState, setAppState] = useState(AppState.currentState); + const [unconfirmed, setUnconfirmed] = useState([]); + + useEffect(() => { + const sub = AppState.addEventListener('change', setAppState); + return () => sub.remove(); + }, []); + + useEffect(() => { + let cancelled = false; + void listUnconfirmed().then((items: UnconfirmedAction[]) => { + if (!cancelled) setUnconfirmed(items); + }); + return () => { + cancelled = true; + }; + }, []); + + const isOffline = appState !== 'active'; + const hasUnconfirmed = unconfirmed.length > 0; + + return ( + + + Offline + + {isOffline + ? 'You are offline. New payments are paused until you reconnect. Any payment already in progress will be confirmed or resolved automatically when you are back online.' + : 'You are back online.'} + + + {hasUnconfirmed ? ( + + Unconfirmed actions + + You may already have been charged for these. We will resolve them as soon as you reconnect. + + {unconfirmed.map(item => ( + + {item.summary} + + ))} + + ) : null} + + {/* navigation handled by the tab bar */}}> + Retry when online + + + + ); +} + +const styles = StyleSheet.create({ + panel: { flex: 1, padding: 24, gap: 12 }, + eyebrow: { color: '#94a3b8', fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }, + lede: { color: '#e2e8f0', fontSize: 16 }, + warning: { backgroundColor: '#3b2f10', borderColor: '#f59e0b', borderWidth: 1, borderRadius: 10, padding: 12, gap: 6 }, + warningTitle: { color: '#f59e0b', fontWeight: '700' }, + warningBody: { color: '#fde470' }, + item: { color: '#e2e8f0', fontSize: 14 }, + button: { backgroundColor: '#6366f1', borderRadius: 10, padding: 14, alignItems: 'center' }, + buttonText: { color: '#fff', fontWeight: '600' }, +}); diff --git a/frontend/mobile/src/hooks/useMidActionDisconnect.ts b/frontend/mobile/src/hooks/useMidActionDisconnect.ts new file mode 100644 index 000000000..d1fc9b223 --- /dev/null +++ b/frontend/mobile/src/hooks/useMidActionDisconnect.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, type AppStatus } from 'react-native'; + +/** + * Tracks whether the device is online, and exposes a way to ask + * "did the network drop after the passkey ceremony but before confirmation?" + * + * This is the hook that lets every money screen implement the same + * defined behaviour: if the network drops after the signing ceremony, + * the screen moves to the unconfirmed state instead of reporting a + * failure it cannot prove. + * + * Lives in frontend/mobile/lib/ alongside the existing money screens + * (app/airtime.tsx, app/offline.tsx) so it is reachable from the app. + */ + +export interface MidActionDisconnectState { + /** True while the device reports no network. */ + isOffline: boolean; + /** True if the network dropped after the ceremony started. */ + disconnectedMidAction: boolean; + /** Call when the passkey ceremony has succeeded and we are about to submit. */ + markCeremonyComplete: () => void; + /** Call when the action resolves (confirmed or failed) so we stop watching. */ + markActionSettled: () => void; + /** Reset all tracking for a new action. */ + reset: () => void; + /** Register a callback for when the network comes back. */ + onLineRecovered: (cb: () => void) => () => void; +} + +export function useMidActionDisconnect(): MidActionDisconnectState { + const [appState, setAppState] = useState(AppState.currentState); + const [ceremonyComplete, setCeremonyComplete] = useState(false); + const [settled, setSettled] = useState(false); + const listenersRef = useRef(new Set<() => void>()); + const prevAppStateRef = useRef(AppState.currentState); + + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => { + const wasActive = prevAppStateRef.current === 'active'; + const isActive = next === 'active'; + prevAppStateRef.current = next; + setAppState(next); + if (!wasActive && isActive) { + // We reconnected (or the app came back to the foreground). + // Notify anyone watching so they can resolve an unconfirmed action. + for (const cb of listenersRef.current) { + try { + cb(); + } catch { + // A listener throwing must not break the others. + } + } + } + }); + return () => sub.remove(); + }, []); + + const isOffline = appState !== 'active'; + // True only while the app is offline, a ceremony has completed, and + // the action has not yet settled. This is the window in which the + // person may already have been charged, so the screen must show the + // unconfirmed state rather than a failure. + const disconnectedMidAction = ceremonyComplete && isOffline && !settled; + + const markCeremonyComplete = useCallback(() => { + setCeremonyComplete(true); + }, []); + + const markActionSettled = useCallback(() => { + setSettled(true); + }, []); + + const reset = useCallback(() => { + setCeremonyComplete(false); + setSettled(false); + }, []); + + const onLineRecovered = useCallback((cb: () => void) => { + listenersRef.current.add(cb); + return () => { + listenersRef.current.delete(cb); + }; + }, []); + + return { + isOffline, + disconnectedMidAction, + markCeremonyComplete, + markActionSettled, + reset, + onLineRecovered, + }; +} diff --git a/frontend/mobile/src/lib/unconfirmed.ts b/frontend/mobile/src/lib/unconfirmed.ts new file mode 100644 index 000000000..d60101d64 --- /dev/null +++ b/frontend/mobile/src/lib/unconfirmed.ts @@ -0,0 +1,100 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * The core vocabulary for a money action that has been signed + * (after the passkey ceremony) but whose result we cannot prove. + * + * The distinction matters: "failed" is a claim that the user was not + * charged. If the network drops after the transaction has been submitted + * (but before we read the result), the user may already have been + * charged. The honest state is "unconfirmed". + * + * NOTE: this module is being moved to frontend/mobile/lib/unconfirmed.ts + * to match the app's module layout (no src/ tree exists in this project). + */ + +export type UnconfirmedKind = 'airtime' | 'payment' | 'transfer'; + +export interface UnconfirmedAction { + /** Stable identifier for this action (used as the storage key). */ + id: string; + /** Which money screen initiated the action. */ + kind: UnconfirmedKind; + /** The transaction hash we submitted, if we reached that point. */ + hash?: string; + /** Human-readable summary of what was attempted. */ + summary: string; + /** Iso timestamp of when the action was signed. */ + createdAt: string; + /** Network passphrase the action was submitted on. */ + networkPassphrase?: string; +} + +export type Resolution = + | { status: 'confirmed'; action: UnconfirmedAction; hash?: string } + | { status: 'failed'; action: UnconfirmedAction; reason: string } + | { status: 'unknown'; action: UnconfirmedAction; reason: string }; + +const STORAGE_KEY = 'veil.unconfirmed.v1'; + +async function readRaw(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is UnconfirmedAction => + typeof item?.id === 'string' && + typeof item?.kind === 'string' && + typeof item?.summary === 'string' && + typeof item?.createdAt === 'string', + ); + } catch { + return []; + } +} + +async function writeRaw(actions: UnconfirmedAction[]): Promise { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(actions)); +} + +/** List every action we cannot yet prove the outcome of. */ +export async function listUnconfirmed(): Promise { + return readRaw(); +} + +/** Record an action as unconfirmed. Id is upserted. */ +export async function markUnconfirmed( + action: Omit & { createdAt?: string }, +): Promise { + const record: UnconfirmedAction = { + ...action, + createdAt: action.createdAt ?? new Date().toISOString(), + }; + const existing = await readRaw(); + const next = existing.filter(item => item.id !== record.id); + next.push(record); + await writeRaw(next); + return record; +} + +/** Remove an action once its outcome is known. */ +export async function clearUnconfirmed(id: string): Promise { + const existing = await readRaw(); + await writeRaw(existing.filter(item => item.id !== id)); +} + +/** + * The message we show the user when an action is unconfirmed. + * This is the exact wording the issue asks for: the person has + * possibly paid, and the screen must say exactly that. + */ +export const UNCONFIRMED_MESSAGE = + 'We lost the network mid-action. You may already have been charged — we are resolving this now.'; + +export const UNCONFIRMED_OFFLINE_MESSAGE = + 'You are offline. You may already have been charged for this action. We will resolve it when you reconnect — do not retry until then.'; + +export const UNCONFIRMED_RESOLVED_MESSAGE = + 'Reconnected. We checked your unconfirmed action and resolved it.';