+ 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.
+
+
+
+ }
+
+ @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.';