diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx
index db44472..38accfe 100644
--- a/mobile_app/components/send/ReviewCard.tsx
+++ b/mobile_app/components/send/ReviewCard.tsx
@@ -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,
@@ -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;
@@ -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,
@@ -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,
@@ -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);
@@ -578,6 +598,11 @@ export function ReviewCard({ to, amount, symbol, mintAddress, decimals, programI
{error.message}
+ {error.kind === "send" && error.detail ? (
+
+ {error.detail}
+
+ ) : null}
{error.kind === "approval" || error.kind === "send" || error.kind === "route" ? (
;
+// 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
@@ -20,17 +24,19 @@ type FailedResult = Extract;
* 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',
};
}
@@ -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',
@@ -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