Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions mobile_app/components/send/ReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import { PigeonLoader } from "@/components/ui/PigeonLoader";
import { useWallet } from "@/context/WalletContext";
import * as haptics from "@/src/design-system/haptics";
import { useNetworkMode } from "@/src/hooks/useNetworkMode";
import { showToast } from "@/components/ui/Toast";
import { saveAddressBookRecipient, useAddressBook } from "@/src/services/addressBook";
import { describeSendFailure, formatRawError } from "@/src/services/sendErrorMessages";
import {
describeSendFailure,
describeSubmitFailure,
formatRawError,
} from "@/src/services/sendErrorMessages";
import {
confirmTransaction,
estimateSplTransferFeeLamports,
Expand Down Expand Up @@ -54,7 +59,9 @@ type ReviewError =
| { kind: "approval"; message: string }
| { kind: "unsupported"; message: string }
| { kind: "route"; message: string }
| { kind: "send"; message: string };
// `detail` carries the raw technical error text, rendered as a demoted
// monospace sub-line under the human headline — never as the headline.
| { kind: "send"; message: string; detail?: string };

interface ReviewCardProps {
readonly to: string;
Expand Down Expand Up @@ -310,7 +317,9 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI
// conf.kind === "failed"
const { subtitle, pillLabel } = describeSendFailure(conf, rpcAdapter.mode);
const rawError = formatRawError(conf.err);
console.error("[send/ReviewCard] confirmation failed", {
// console.warn, not console.error — the dev client toasts console.error
// over the FailureCard that is about to present this same information.
console.warn("[send/ReviewCard] confirmation failed", {
reason: conf.reason,
signature: conf.signature,
rawError,
Expand Down Expand Up @@ -341,8 +350,16 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI
} catch (err: unknown) {
const summary = summarizeError(err, "Transaction failed before the wallet returned a reason");
const isUserCancel = err instanceof TransactionNotApprovedError;
const logFn = isUserCancel ? console.warn : console.error;
logFn("[send/ReviewCard] transfer failed", {
// Every throw reaching this catch happened before the tx was broadcast
// (submitSignedTransaction returns the signature instead of throwing for
// ambiguous submit errors), so "nothing was sent" copy is honest here.
const failure = describeSubmitFailure(err, rpcAdapter.mode);
// console.warn, not console.error: the dev client renders console.error
// as an on-screen toast, which is how the raw JSON dump ended up in a
// consumer surface. The failure is fully surfaced in UI below; this log
// is diagnostics for Metro/logcat.
console.warn("[send/ReviewCard] transfer failed", {
kind: failure.kind,
message: summary.message,
name: summary.name ?? null,
code: summary.code ?? null,
Expand All @@ -357,7 +374,10 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI
// 2026-05-13) — the wallet popup is the consent surface, an inline banner
// double-prompts and reads like an error.
if (!isUserCancel) {
setError({ kind: "send", message: summary.message });
setError({ kind: "send", message: failure.message, detail: failure.detail });
// The inline panel sits at the bottom of a scroll view and can be off
// screen; the toast guarantees immediate, human-readable feedback.
showToast(failure.message);
}
setSliderResetKey((k) => k + 1);
setIsConfirming(false);
Expand Down Expand Up @@ -578,6 +598,11 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI
</Text>
</View>
<Text style={[S.errorText, { color: colors.textSecondary }]}>{error.message}</Text>
{error.kind === "send" && error.detail ? (
<Text selectable style={[S.errorDetail, { color: colors.textTertiary }]}>
{error.detail}
</Text>
) : null}
{error.kind === "approval" || error.kind === "send" || error.kind === "route" ? (
<Pressable
accessibilityLabel="Try transaction again"
Expand Down Expand Up @@ -836,6 +861,11 @@ const S = StyleSheet.create({
fontSize: fontSize.sm,
lineHeight: 17,
},
errorDetail: {
fontFamily: FF.mono,
fontSize: fontSize.xs,
lineHeight: 15,
},
retryButton: {
alignItems: "center",
alignSelf: "stretch",
Expand Down
148 changes: 144 additions & 4 deletions mobile_app/src/services/sendErrorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface FailureCopy {

type FailedResult = Extract<ConfirmResult, { kind: 'failed' }>;

// Shared between the confirmation-stage and submission-stage classifiers.
const INSUFFICIENT_FUNDS_RE =
/insufficient ?funds|insufficient ?lamports|insufficient ?fee|found no record of a prior credit/i;

/**
* Map a confirmation-stage failure to user-facing copy. Pattern-matches
* common Solana error shapes against a stringified view of the error so we
Expand All @@ -20,17 +24,19 @@ type FailedResult = Extract<ConfirmResult, { kind: 'failed' }>;
* Pure — no React, safe to unit test.
*/
export function describeSendFailure(result: FailedResult, mode: NetworkMode): FailureCopy {
// Confirmation-poll budget expired AFTER the signed tx was handed to the
// network/relay. The broadcast outcome is genuinely unknown here, so the
// copy must never claim "nothing was sent" — it may have landed.
if (result.reason === 'timeout') {
if (mode === 'mesh') {
return {
subtitle:
"Mesh route didn't respond in time. Your tx may still settle — check the explorer below.",
'submitted over the mesh, confirmation unknown — check activity before retrying',
pillLabel: 'Mesh timeout',
};
}
return {
subtitle:
"Network didn't respond in time. Your tx may still settle — check the explorer below.",
subtitle: 'submitted, confirmation unknown — check activity before retrying',
pillLabel: 'Network timeout',
};
}
Expand All @@ -44,7 +50,7 @@ export function describeSendFailure(result: FailedResult, mode: NetworkMode): Fa
pillLabel: 'Blockhash expired',
};
}
if (/insufficient ?funds|insufficient ?lamports|insufficient ?fee/i.test(haystack)) {
if (INSUFFICIENT_FUNDS_RE.test(haystack)) {
return {
subtitle: 'Not enough SOL to cover the amount plus network fees.',
pillLabel: 'Insufficient funds',
Expand All @@ -69,6 +75,140 @@ export function describeSendFailure(result: FailedResult, mode: NetworkMode): Fa
};
}

// ── Submission-stage failures ─────────────────────────────────────────────────
//
// Errors thrown from sendSolTransfer / sendSplTransfer before confirmation
// polling starts. Every throw that can reach ReviewCard's catch happens BEFORE
// the transaction was broadcast (build/blockhash/signing failures, wallet
// declines, and node-side preflight rejections — submitSignedTransaction
// returns the signature instead of throwing for any ambiguous submit error),
// so "nothing was sent" is honest for every class below. Post-submit ambiguity
// is the confirmation path's job — see describeSendFailure's timeout branch.

export type SubmitFailureKind =
| 'wallet-timeout'
| 'wallet-declined'
| 'insufficient-funds'
| 'mesh-unreachable'
| 'network'
| 'unknown';

export interface SubmitFailureCopy {
readonly kind: SubmitFailureKind;
/**
* Human headline for the inline "Transfer not sent" panel and the failure
* toast. Always states the user's actual situation (sent vs not sent).
*/
readonly message: string;
/**
* Raw technical detail (e.g. the platform exception text), demoted to a
* small monospace sub-line. Omitted when the headline already carries the
* full information.
*/
readonly detail?: string;
}

// MWA wallet-approval timeout surfaces as the Android session-layer exception,
// e.g. "java.util.concurrent.TimeoutException: Timed out waiting for response
// with id=2" (device-verified 2026-06-10).
const WALLET_TIMEOUT_RE = /timed ?out waiting for response|java\.util\.concurrent\.TimeoutException/i;

// Transport-level failures: our own withTimeout/TimeoutError text, fetch-layer
// failures from the direct RPC path, and mesh broadcast failures ("No beacons
// discovered yet" from beaconBroadcastRpc, Promise.any's AggregateError, and
// malformed relay responses).
const TRANSPORT_FAILURE_RE =
/timed out after \d+ms|network request failed|failed to fetch|fetch failed|networkerror|econnrefused|enotfound|etimedout|socket|connection (?:refused|reset|closed)|no beacons discovered|all promises were rejected|malformed mesh rpc response/i;

/**
* True when a message reads like developer goo (JVM class paths, serialized
* JSON, oversized dumps) rather than copy a human should see as a headline.
* Our own deliberate throws ("Invalid recipient address", the MWA account
* mismatch guidance, …) pass through untouched.
*/
function looksLikeDeveloperGoo(message: string): boolean {
if (/(?:[A-Za-z_$][\w$]*\.){2,}[A-Z][\w$]*(?:Exception|Error)\b/.test(message)) return true;
if (/^\s*[[{"]/.test(message)) return true;
return message.length > 160;
}

function compactDetail(err: unknown): string {
const summary = summarizeError(err, 'Unknown error');
if (summary.name && summary.name !== 'Error' && !summary.message.includes(summary.name)) {
return `${summary.name}: ${summary.message}`;
}
return summary.message;
}

/**
* Map a submission-stage throw to user-facing copy. Classification only —
* retry/state-machine behavior is owned by the caller.
*
* Pure — no React, safe to unit test.
*/
export function describeSubmitFailure(err: unknown, mode: NetworkMode): SubmitFailureCopy {
const summary = summarizeError(err, 'Transaction failed before the wallet returned a reason');
const haystack = serializeForMatch(err);
const detail = compactDetail(err);

// User backed out in the wallet UI. Callers may keep this silent (the wallet
// popup is the consent surface — LESSON 2026-05-13) but the class still
// needs honest copy for any surface that does render it.
if (summary.name === 'TransactionNotApprovedError') {
return {
kind: 'wallet-declined',
message: 'you declined in your wallet — nothing was sent',
};
}

if (WALLET_TIMEOUT_RE.test(haystack)) {
return {
kind: 'wallet-timeout',
message: "the wallet didn't respond — nothing was sent",
detail,
};
}

if (INSUFFICIENT_FUNDS_RE.test(haystack)) {
return {
kind: 'insufficient-funds',
message: 'not enough SOL to cover the amount plus network fees — nothing was sent',
detail,
};
}

if (summary.name === 'TimeoutError' || summary.name === 'MeshResponseParseError' || TRANSPORT_FAILURE_RE.test(haystack)) {
if (mode === 'mesh') {
return {
kind: 'mesh-unreachable',
message: "couldn't reach a relay — your funds didn't move",
detail,
};
}
return {
kind: 'network',
message: "couldn't reach the network — nothing was sent",
detail,
};
}

// Unrecognized. Our own throws are already written for humans — let them
// through. Anything that smells like a platform exception gets a neutral
// headline with the goo demoted to the detail line.
if (looksLikeDeveloperGoo(summary.message)) {
return {
kind: 'unknown',
message: 'something went wrong — nothing was sent',
detail,
};
}
return {
kind: 'unknown',
message: summary.message,
detail: detail === summary.message ? undefined : detail,
};
}

/**
* Multi-line pretty-print of an error for the FailureCard's collapsible
* "View error details" section. Plugs `summarizeError` so we get the same
Expand Down
Loading