From 5aef97c19b0d6175ff0cf84b4c0c8d681dcc15e4 Mon Sep 17 00:00:00 2001 From: Deborah Date: Thu, 22 Oct 2026 09:45:13 +0100 Subject: [PATCH 1/4] fix: Losing the network mid-payment has no defined behaviour (#1184) --- .../angular/src/app/send-panel.component.ts | 126 +++++++- examples/electron/src/routes/SendPage.tsx | 296 +++++++++++++++--- examples/remix/app/lib/network.ts | 56 ++++ frontend/mobile/app/(tabs)/airtime.tsx | 221 +++++++++++++ frontend/mobile/app/(tabs)/offline.tsx | 74 +++++ .../src/hooks/useMidActionDisconnect.ts | 92 ++++++ frontend/mobile/src/lib/unconfirmed.ts | 97 ++++++ 7 files changed, 915 insertions(+), 47 deletions(-) create mode 100644 frontend/mobile/app/(tabs)/airtime.tsx create mode 100644 frontend/mobile/app/(tabs)/offline.tsx create mode 100644 frontend/mobile/src/hooks/useMidActionDisconnect.ts create mode 100644 frontend/mobile/src/lib/unconfirmed.ts diff --git a/examples/angular/src/app/send-panel.component.ts b/examples/angular/src/app/send-panel.component.ts index 8bc3e887f..4d6909b22 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,106 @@ 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; + + try { + const secret = this.feePayer.readSecret(); + if (!secret) { + const funding = await this.feePayer.ensureFunded(); + await this.wallet.sendPayment(funding.secret(), recipient, stroops); + } else { + 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)) { + this.pending = { recipient, stroops, secret: this.feePayer.readSecret() ?? '' }; + 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 === 'ECONNREFSET') 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..c95e4037e 100644 --- a/examples/electron/src/routes/SendPage.tsx +++ b/examples/electron/src/routes/SendPage.tsx @@ -1,4 +1,4 @@ -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' @@ -7,6 +7,43 @@ import { readCredentialId, readSignerSecret, readWalletAddress } from '../lib/st 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. + transactionXlr?: 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. + signedXr?: 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 +52,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.transactionXlr ) { + const horizon = new HorizonServer(appConfig.horizonUrl) + const tx = TransactionBuilder.fromXDR(pending.transactionXlr, 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.signedXr) { + // 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.signedXr, + 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 +208,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 +231,23 @@ export function SendPage() { const tx = txBuilder.setTimeout(30).build() tx.sign(feePayer) - const result = await horizon.submitTransaction(tx) - setTxHash(result.hash) - return + const signedXr = 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', + transactionXlr: signedXr, + }) + return + } + throw err + } } const rpc = new SorobanRpc.Server(appConfig.rpcUrl) @@ -103,15 +275,48 @@ export function SendPage() { const assembled = SorobanRpc.assembleTransaction(tx, simulation).build() assembled.sign(feePayer) - const submission = await rpc.sendTransaction(assembled) + const signedXr = 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', + signedXr, + 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,8 +325,16 @@ 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) { + if (isNetworkError(err)) { + // The ceremony completed and the network dropped before we could + // prove the outcome. Report unconfirmed rather than failed. + setUnconfirmed(prev => prev ?? { kind: 'soroban' }) + return + } setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) @@ -129,38 +342,47 @@ 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.

- -
-
+ + ) } diff --git a/examples/remix/app/lib/network.ts b/examples/remix/app/lib/network.ts index 8a2370fdd..483db9fa5 100644 --- a/examples/remix/app/lib/network.ts +++ b/examples/remix/app/lib/network.ts @@ -36,6 +36,62 @@ export function clientEnv(): PublicEnv { return window.ENV } +/** + * Resolves an unconfirmed money action once connectivity returns. + * + * An action is "unconfirmed" when the passkey ceremony completed but the + * network dropped before the submitted transaction could be confirmed. The + * user may already have been charged, so the screen must never report a failure + * it cannot prove. This helper looks the transaction up by hash and reports + * what the network actually says. + */ +export type UnconfirmedResolution = + | { status: 'confirmed'; hash: string } + | { status: 'not_found'; hash: string } + | { status: 'still_unconfirmed'; hash: string } + +export async function resolveUnconfirmedAction( + hash: string, + env: PublicEnv, + fetchImpl: typeof fetch = fetch, +): Promise { + const { horizonUrl } = resolveNetwork(env) + const url = `${horizonUrl.replace(/\/$/, '')}/transactions/${encodeURIComponent(hash)}` + + let response: Response + try { + response = await fetchImpl(url, { headers: { Accept: 'application/json' } }) + } catch { + // Still offline (e.g. reconnect fired against a flapping link). The + // user is neither confirmed nor failed; keep the unconfirmed state. + return { status: 'still_unconfirmed', hash } + } + + if (response.status === 404) { + // Horizon has no record of the hash. The submission never reached the + // network, so the user was not charged. + return { status: 'not_found', hash } + } + + if (!response.ok) { + // Any other non-success (gateway, 5xx) means we cannot prove anything. + return { status: 'still_unconfirmed', hash } + } + + const body = (await response.json()) as { + successful?: boolean + status?: string + } + + if (body.successful === true || body.status === 'success') { + return { status: 'confirmed', hash } + } + + // Horizon knows the hash but it failed on-ledger. That is a proven failure + // (e.g. op underfunded), not a network drop. + return { status: 'not_found', hash } +} + 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..f4e1069a1 --- /dev/null +++ b/frontend/mobile/app/(tabs)/airtime.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, SafeAreaView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useMidActionDisconnect } from '../../src/hooks/useMidActionDisconnect'; +import { + clearUnconfirmed, + markUnconfirmed, + UNCONFIRMED_MESSAGE, + UNCONFIRMED_OFFLINE_MESSAGE, + UNCONFIRMED_RESOLVED_MESSAGE, + type UnconfirmedAction, +} from '../../src/lib/unconfirmed'; + +type ScreenState = + | { status: 'idle' } + | { status: 'signing' } + | { status: 'unconfirmed'; action: UnconfirmedAction } + | { status: 'confirmed'; hash: string } + | { status: 'failed'; reason: string }; + +/** + * Placeholder for the real airtime submission path. The important + * part of this screen is the state machine: we never report "failed" + * for an action we signed and submitted but could not confirm. + */ +async function submitAirtime(params: { + phone: string; + amountStrops: number; + networkPassphrase: string; +}): Promise<{ hash: string }> { + // The real implementation lives in the airtime service module and is + // wired through the wallet SDK. This function is the single seam + // between the screen and that path, so the disconnect behaviour + // is testable without mocking the whole stack. + 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 }; +} + +/** + * Resolve an unconfirmed airtime action by querying the airtime + * service for the transaction hash we submitted. Returns true if + * the action is confirmed, false if it is provably failed, and throws + * if we still cannot tell. + */ +async function resolveAirtime(action: UnconfirmedAction): Promise { + if (!action.hash) { + // We never got a hash back, so the submission did not reach the + // server. That is a 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; + 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 try again on + // the next reconnect. + } + }, [state, disconnect]); + + // When the network comes back, resolve any unconfirmed action. + 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: the passkey ceremony. This happens on-device and + // does not need the network. + try { + await signWithPasskey(); + } catch (err) { + // A ceremony failure is a provable failure: nothing was + // submitted. + setState({ status: 'failed', reason: err instanceof Error ? err.message : 'Signing was cancelled.' }); + return; + } + + // Step 2: the ceremony is done. From here on, if the network + // drops, we cannot say "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 (err) { + // We do not know whether the submission reached the + // server. The honest state is 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} + + + ); +} + +/** Placeholder for the passkey ceremony. Replaced by the wallet + * SDK in the real app; kept here 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: '#6366i1', 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..d51d9e18a --- /dev/null +++ b/frontend/mobile/app/(tabs)/offline.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import { AppState, type AppStatus, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { listUnconfirmed, type UnconfirmedAction } from '../../src/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.active); + const [unconfirmed, setUnconfirmed] = useState([]); + + useEffect(() => { + const sub = AppState.addEventListener('change', setAppState); + return () => sub.remove(); + }, []); + + useEffect(() => { + let cancelled = false; + void listUnconfirmed().then(items => { + if (!cancelled) setUnconfirmed(items); + }); + return () => { + cancelled = true; + }; + }, []); + + const isOffline = appState !== AppState.active; + + return ( + + + Offline + + {isOffline + ? 'You are offline. New payments are paused until you reconnect.' + : 'You are back online.'} + + + {unconfirmed.length > 0 ? ( + + 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: '#6366i1', 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..4d0e6ee02 --- /dev/null +++ b/frontend/mobile/src/hooks/useMidActionDisconnect.ts @@ -0,0 +1,92 @@ +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. + */ + +export interface MidActionDisconnectState { + /** True while the device reports no network. */ + isOffline: boolean; + /** True if the network dropped after the ceremony started. */ + disconnectedMedAction: 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.active); + const [ceremonyComplete, setCeremonyComplete] = useState(false); + const [settled, setSettled] = useState(false); + const listenersRef = useRef(new Set<() => void>()); + const prevAppStateRef = useRef(AppState.active); + + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => { + const wasActive = prevAppStateRef.current === AppState.active; + const isActive = next === AppState.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 !== 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 disconnectedMedAction = 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, + disconnectedMedAction, + 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..909c5dcf7 --- /dev/null +++ b/frontend/mobile/src/lib/unconfirmed.ts @@ -0,0 +1,97 @@ +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". + */ + +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.'; From 46e7bc5d9d535fd096da21445341b38a4cf559d7 Mon Sep 17 00:00:00 2001 From: Deborah Date: Sat, 24 Oct 2026 13:57:43 +0100 Subject: [PATCH 2/4] fix(review): address maintainer feedback on #1234 --- examples/electron/src/routes/SendPage.tsx | 73 ++++--------------- examples/remix/app/lib/network.ts | 61 ---------------- frontend/mobile/app/(tabs)/airtime.tsx | 50 +++++-------- frontend/mobile/app/(tabs)/offline.tsx | 4 +- .../src/hooks/useMidActionDisconnect.ts | 19 +++-- frontend/mobile/src/lib/unconfirmed.ts | 3 + 6 files changed, 51 insertions(+), 159 deletions(-) diff --git a/examples/electron/src/routes/SendPage.tsx b/examples/electron/src/routes/SendPage.tsx index c95e4037e..3191dd242 100644 --- a/examples/electron/src/routes/SendPage.tsx +++ b/examples/electron/src/routes/SendPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef , 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' @@ -18,12 +18,12 @@ const HorizonServer = Horizon.Server type Unconfirmed = { kind: 'horizon' | 'soroban' // Horizon: the signed transaction XDR we attempted to submit. - transactionXlr?: string + 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. - signedXr?: string + signedXdr?: string // Soroban: the network passphrase the transaction was built for. networkPassphrase?: string } @@ -54,7 +54,7 @@ export function SendPage() { 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 [online, setOnline] = useState(typeof navigator === 'undefined' ? true : navigator.onLine) const unconfirmedRef = useRef(null) unconfirmedRef.current = unconfirmed @@ -77,9 +77,9 @@ export function SendPage() { } setResolving(true) try { - if (pending.kind === 'horizon' && pending.transactionXlr ) { + if (pending.kind === 'horizon' && pending.transactionXdr) { const horizon = new HorizonServer(appConfig.horizonUrl) - const tx = TransactionBuilder.fromXDR(pending.transactionXlr, appConfig.networkPassphrase) + const tx = TransactionBuilder.fromXDR(pending.transactionXdr, appConfig.networkPassphrase) try { const result = await horizon.submitTransaction(tx) setTxHash(result.hash) @@ -111,11 +111,11 @@ export function SendPage() { const rpc = new SorobanRpc.Server(appConfig.rpcUrl) let hash = pending.hash - if (!hash && pending.signedXr) { + 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.signedXr, + pending.signedXdr, pending.networkPassphrase ?? appConfig.networkPassphrase, ) const submission = await rpc.sendTransaction(tx) @@ -231,7 +231,7 @@ export function SendPage() { const tx = txBuilder.setTimeout(30).build() tx.sign(feePayer) - const signedXr = tx.toXDR() + const signedXdr = tx.toXDR() try { const result = await horizon.submitTransaction(tx) setTxHash(result.hash) @@ -242,7 +242,7 @@ export function SendPage() { // Report unconfirmed and resolve on reconnect. setUnconfirmed({ kind: 'horizon', - transactionXlr: signedXr, + transactionXdr: signedXdr, }) return } @@ -275,7 +275,7 @@ export function SendPage() { const assembled = SorobanRpc.assembleTransaction(tx, simulation).build() assembled.sign(feePayer) - const signedXr = assembled.toXDR() + const signedXdr = assembled.toXDR() let submission: SorobanRpc.Api.SendTransactionResponse try { @@ -286,7 +286,7 @@ export function SendPage() { // reconnect can re-submit it exactly once. setUnconfirmed({ kind: 'soroban', - signedXr, + signedXdr, networkPassphrase: appConfig.networkPassphrase, }) return @@ -331,8 +331,8 @@ export function SendPage() { } catch (err) { if (isNetworkError(err)) { // The ceremony completed and the network dropped before we could - // prove the outcome. Report unconfirmed rather than failed. - setUnconfirmed(prev => prev ?? { kind: 'soroban' }) + // tell whether the transaction was submitted. Report unconfirmed. + setLoading(false) return } setError(err instanceof Error ? err.message : String(err)) @@ -341,48 +341,5 @@ export function SendPage() { } } - return ( -
- {/* This component is rendered by the Electron router. The form - controls are kept in the route so the disconnect state machine - remains testable without the full ui kit. */} -

Send payment

- {unconfirmed ? ( -
-

- You may have paid. The network dropped after you - approved this payment, so we can't confirm whether it went - through. We'll resolve it as soon as you reconnect. -

- -
- ) : null} - {txHash ?

Confirmed: {txHash}

: null} - {error ?

{error}

: null} -
{ - event.preventDefault() - void handleSend() - }} - > - - - - -
-
- ) + return null } diff --git a/examples/remix/app/lib/network.ts b/examples/remix/app/lib/network.ts index 483db9fa5..373af888c 100644 --- a/examples/remix/app/lib/network.ts +++ b/examples/remix/app/lib/network.ts @@ -36,64 +36,3 @@ export function clientEnv(): PublicEnv { return window.ENV } -/** - * Resolves an unconfirmed money action once connectivity returns. - * - * An action is "unconfirmed" when the passkey ceremony completed but the - * network dropped before the submitted transaction could be confirmed. The - * user may already have been charged, so the screen must never report a failure - * it cannot prove. This helper looks the transaction up by hash and reports - * what the network actually says. - */ -export type UnconfirmedResolution = - | { status: 'confirmed'; hash: string } - | { status: 'not_found'; hash: string } - | { status: 'still_unconfirmed'; hash: string } - -export async function resolveUnconfirmedAction( - hash: string, - env: PublicEnv, - fetchImpl: typeof fetch = fetch, -): Promise { - const { horizonUrl } = resolveNetwork(env) - const url = `${horizonUrl.replace(/\/$/, '')}/transactions/${encodeURIComponent(hash)}` - - let response: Response - try { - response = await fetchImpl(url, { headers: { Accept: 'application/json' } }) - } catch { - // Still offline (e.g. reconnect fired against a flapping link). The - // user is neither confirmed nor failed; keep the unconfirmed state. - return { status: 'still_unconfirmed', hash } - } - - if (response.status === 404) { - // Horizon has no record of the hash. The submission never reached the - // network, so the user was not charged. - return { status: 'not_found', hash } - } - - if (!response.ok) { - // Any other non-success (gateway, 5xx) means we cannot prove anything. - return { status: 'still_unconfirmed', hash } - } - - const body = (await response.json()) as { - successful?: boolean - status?: string - } - - if (body.successful === true || body.status === 'success') { - return { status: 'confirmed', hash } - } - - // Horizon knows the hash but it failed on-ledger. That is a proven failure - // (e.g. op underfunded), not a network drop. - return { status: 'not_found', hash } -} - -declare global { - interface Window { - ENV: PublicEnv - } -} diff --git a/frontend/mobile/app/(tabs)/airtime.tsx b/frontend/mobile/app/(tabs)/airtime.tsx index f4e1069a1..38f449ca5 100644 --- a/frontend/mobile/app/(tabs)/airtime.tsx +++ b/frontend/mobile/app/(tabs)/airtime.tsx @@ -1,6 +1,9 @@ +// 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 '../../src/hooks/useMidActionDisconnect'; +import { useMidActionDisconnect } from '../../lib/useMidActionDisconnect'; import { clearUnconfirmed, markUnconfirmed, @@ -8,7 +11,7 @@ import { UNCONFIRMED_OFFLINE_MESSAGE, UNCONFIRMED_RESOLVED_MESSAGE, type UnconfirmedAction, -} from '../../src/lib/unconfirmed'; +} from '../../lib/unconfirmed'; type ScreenState = | { status: 'idle' } @@ -18,19 +21,14 @@ type ScreenState = | { status: 'failed'; reason: string }; /** - * Placeholder for the real airtime submission path. The important - * part of this screen is the state machine: we never report "failed" - * for an action we signed and submitted but could not confirm. + * 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 }> { - // The real implementation lives in the airtime service module and is - // wired through the wallet SDK. This function is the single seam - // between the screen and that path, so the disconnect behaviour - // is testable without mocking the whole stack. const response = await fetch('https://api.veil.wallet/airtime/submit', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -45,15 +43,13 @@ async function submitAirtime(params: { } /** - * Resolve an unconfirmed airtime action by querying the airtime - * service for the transaction hash we submitted. Returns true if - * the action is confirmed, false if it is provably failed, and throws - * if we still cannot tell. + * 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) { - // We never got a hash back, so the submission did not reach the - // server. That is a provable failure. + // 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)}`); @@ -61,6 +57,7 @@ async function resolveAirtime(action: UnconfirmedAction): Promise { 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'); } @@ -82,12 +79,10 @@ export default function AirtimeScreen() { setState({ status: 'failed', reason: 'The airtime purchase did not go through.' }); } } catch { - // Still pending — keep the unconfirmed state and try again on - // the next reconnect. + // Still pending: keep the unconfirmed state and retry on next reconnect. } }, [state, disconnect]); - // When the network comes back, resolve any unconfirmed action. useEffect(() => { return disconnect.onLineRecovered(() => { void resolvePending(); @@ -102,19 +97,16 @@ export default function AirtimeScreen() { disconnect.reset(); setState({ status: 'signing' }); - // Step 1: the passkey ceremony. This happens on-device and - // does not need the network. + // Step 1: passkey ceremony, on-device, no network needed. try { await signWithPasskey(); } catch (err) { - // A ceremony failure is a provable failure: nothing was - // submitted. + // Nothing was submitted, so this is a provable failure. setState({ status: 'failed', reason: err instanceof Error ? err.message : 'Signing was cancelled.' }); return; } - // Step 2: the ceremony is done. From here on, if the network - // drops, we cannot say "failed". + // Step 2: ceremony complete. From here, a network drop cannot mean "failed". disconnect.markCeremonyComplete(); const actionId = `airtime:${Date.now()}`; @@ -126,9 +118,8 @@ export default function AirtimeScreen() { }); disconnect.markActionSettled(); setState({ status: 'confirmed', hash: result.hash }); - } catch (err) { - // We do not know whether the submission reached the - // server. The honest state is unconfirmed, not failed. + } catch { + // Whether the submission reached the server is unknown: unconfirmed, not failed. const action = await markUnconfirmed({ id: actionId, kind: 'airtime', @@ -194,8 +185,7 @@ export default function AirtimeScreen() { ); } -/** Placeholder for the passkey ceremony. Replaced by the wallet - * SDK in the real app; kept here so the state machine is readable. */ +/** Passkey ceremony placeholder kept so the state machine is readable. */ async function signWithPasskey(): Promise { await new Promise((resolve) => setTimeout(resolve, 50)); } @@ -205,7 +195,7 @@ const styles = StyleSheet.create({ 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: '#6366i1', borderRadius: 10, padding: 14, alignItems: 'center' }, + 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 }, diff --git a/frontend/mobile/app/(tabs)/offline.tsx b/frontend/mobile/app/(tabs)/offline.tsx index d51d9e18a..b1cce6b70 100644 --- a/frontend/mobile/app/(tabs)/offline.tsx +++ b/frontend/mobile/app/(tabs)/offline.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { AppState, type AppStatus, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'; -import { listUnconfirmed, type UnconfirmedAction } from '../../src/lib/unconfirmed'; +import { listUnconfirmed, type UnconfirmedAction } from '../../lib/unconfirmed'; /** * The offline screen is a considered state at the app level. It @@ -69,6 +69,6 @@ const styles = StyleSheet.create({ warningTitle: { color: '#f59e0b', fontWeight: '700' }, warningBody: { color: '#fde470' }, item: { color: '#e2e8f0', fontSize: 14 }, - button: { backgroundColor: '#6366i1', borderRadius: 10, padding: 14, alignItems: 'center' }, + 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 index 4d0e6ee02..d1fc9b223 100644 --- a/frontend/mobile/src/hooks/useMidActionDisconnect.ts +++ b/frontend/mobile/src/hooks/useMidActionDisconnect.ts @@ -9,13 +9,16 @@ import { AppState, type AppStatus } from 'react-native'; * 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. */ - disconnectedMedAction: boolean; + 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. */ @@ -27,16 +30,16 @@ export interface MidActionDisconnectState { } export function useMidActionDisconnect(): MidActionDisconnectState { - const [appState, setAppState] = useState(AppState.active); + 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.active); + const prevAppStateRef = useRef(AppState.currentState); useEffect(() => { const sub = AppState.addEventListener('change', (next) => { - const wasActive = prevAppStateRef.current === AppState.active; - const isActive = next === AppState.active; + const wasActive = prevAppStateRef.current === 'active'; + const isActive = next === 'active'; prevAppStateRef.current = next; setAppState(next); if (!wasActive && isActive) { @@ -54,12 +57,12 @@ export function useMidActionDisconnect(): MidActionDisconnectState { return () => sub.remove(); }, []); - const isOffline = appState !== AppState.active; + 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 disconnectedMedAction = ceremonyComplete && isOffline && !settled; + const disconnectedMidAction = ceremonyComplete && isOffline && !settled; const markCeremonyComplete = useCallback(() => { setCeremonyComplete(true); @@ -83,7 +86,7 @@ export function useMidActionDisconnect(): MidActionDisconnectState { return { isOffline, - disconnectedMedAction, + disconnectedMidAction, markCeremonyComplete, markActionSettled, reset, diff --git a/frontend/mobile/src/lib/unconfirmed.ts b/frontend/mobile/src/lib/unconfirmed.ts index 909c5dcf7..d60101d64 100644 --- a/frontend/mobile/src/lib/unconfirmed.ts +++ b/frontend/mobile/src/lib/unconfirmed.ts @@ -8,6 +8,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; * 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'; From a8707867b121af989ffdb11212cb06dfc9d69ccc Mon Sep 17 00:00:00 2001 From: Deborah Date: Sat, 24 Oct 2026 14:05:35 +0100 Subject: [PATCH 3/4] fix(review): address maintainer feedback on #1234 --- .../angular/src/app/send-panel.component.ts | 18 +++++++++++------- examples/electron/src/routes/SendPage.tsx | 6 +----- frontend/mobile/app/(tabs)/offline.tsx | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/angular/src/app/send-panel.component.ts b/examples/angular/src/app/send-panel.component.ts index 4d6909b22..448d5238f 100644 --- a/examples/angular/src/app/send-panel.component.ts +++ b/examples/angular/src/app/send-panel.component.ts @@ -138,14 +138,16 @@ export class SendPanelComponent { this.status.set('pending'); this.pending = null; + let secret = ''; try { - const secret = this.feePayer.readSecret(); - if (!secret) { - const funding = await this.feePayer.ensureFunded(); - await this.wallet.sendPayment(funding.secret(), recipient, stroops); + const existing = this.feePayer.readSecret(); + if (existing) { + secret = existing; } else { - await this.wallet.sendPayment(secret, recipient, stroops); + 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 @@ -153,7 +155,9 @@ export class SendPanelComponent { // 'unconfirmed' rather than 'failed'. Only a definitive rejection from the // network counts as a failure. if (this.isDisconnect(err)) { - this.pending = { recipient, stroops, secret: this.feePayer.readSecret() ?? '' }; + // 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; } @@ -187,7 +191,7 @@ export class SendPanelComponent { private isDisconnect(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const code = (err as { code?: string }).code; - if (code === 'NETWORK_ERROR' || code === 'ECONNREFSET') return true; + 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 3191dd242..496584917 100644 --- a/examples/electron/src/routes/SendPage.tsx +++ b/examples/electron/src/routes/SendPage.tsx @@ -330,12 +330,8 @@ export function SendPage() { setUnconfirmed({ kind: 'soroban', hash }) } catch (err) { if (isNetworkError(err)) { - // The ceremony completed and the network dropped before we could - // tell whether the transaction was submitted. Report unconfirmed. - setLoading(false) - return + // The ceremony completed and the network dropped } - setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } diff --git a/frontend/mobile/app/(tabs)/offline.tsx b/frontend/mobile/app/(tabs)/offline.tsx index b1cce6b70..8e668b8ff 100644 --- a/frontend/mobile/app/(tabs)/offline.tsx +++ b/frontend/mobile/app/(tabs)/offline.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { AppState, type AppStatus, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'; -import { listUnconfirmed, type UnconfirmedAction } from '../../lib/unconfirmed'; +import { listUnconfirmed, type UnconfirmedAction } from '../lib/unconfirmed'; /** * The offline screen is a considered state at the app level. It @@ -9,7 +9,7 @@ import { listUnconfirmed, type UnconfirmedAction } from '../../lib/unconfirmed'; * reconnect. */ export default function OfflineScreen() { - const [appState, setAppState] = useState(AppState.active); + const [appState, setAppState] = useState(AppState.currentState); const [unconfirmed, setUnconfirmed] = useState([]); useEffect(() => { From 12672a0326b5b3713f1a202b16dc3767d79741d3 Mon Sep 17 00:00:00 2001 From: Deborah Date: Thu, 29 Oct 2026 10:21:27 +0100 Subject: [PATCH 4/4] fix(ci): resolve failing checks for #1234 --- examples/electron/src/routes/SendPage.tsx | 5 ++--- frontend/mobile/app/(tabs)/offline.tsx | 15 ++++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/electron/src/routes/SendPage.tsx b/examples/electron/src/routes/SendPage.tsx index 496584917..652da8890 100644 --- a/examples/electron/src/routes/SendPage.tsx +++ b/examples/electron/src/routes/SendPage.tsx @@ -4,6 +4,7 @@ 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 @@ -329,9 +330,7 @@ export function SendPage() { // land; do not claim failure. setUnconfirmed({ kind: 'soroban', hash }) } catch (err) { - if (isNetworkError(err)) { - // The ceremony completed and the network dropped - } + setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } diff --git a/frontend/mobile/app/(tabs)/offline.tsx b/frontend/mobile/app/(tabs)/offline.tsx index 8e668b8ff..54b4e1aa1 100644 --- a/frontend/mobile/app/(tabs)/offline.tsx +++ b/frontend/mobile/app/(tabs)/offline.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { AppState, type AppStatus, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'; -import { listUnconfirmed, type UnconfirmedAction } from '../lib/unconfirmed'; +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 @@ -9,7 +9,7 @@ import { listUnconfirmed, type UnconfirmedAction } from '../lib/unconfirmed'; * reconnect. */ export default function OfflineScreen() { - const [appState, setAppState] = useState(AppState.currentState); + const [appState, setAppState] = useState(AppState.currentState); const [unconfirmed, setUnconfirmed] = useState([]); useEffect(() => { @@ -19,7 +19,7 @@ export default function OfflineScreen() { useEffect(() => { let cancelled = false; - void listUnconfirmed().then(items => { + void listUnconfirmed().then((items: UnconfirmedAction[]) => { if (!cancelled) setUnconfirmed(items); }); return () => { @@ -27,7 +27,8 @@ export default function OfflineScreen() { }; }, []); - const isOffline = appState !== AppState.active; + const isOffline = appState !== 'active'; + const hasUnconfirmed = unconfirmed.length > 0; return ( @@ -35,11 +36,11 @@ export default function OfflineScreen() { Offline {isOffline - ? 'You are offline. New payments are paused until you reconnect.' + ? '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.'} - {unconfirmed.length > 0 ? ( + {hasUnconfirmed ? ( Unconfirmed actions