From 1b3c4c66bfaad01205f3c206733c6a82c0c298fa Mon Sep 17 00:00:00 2001 From: epicexcelsior Date: Wed, 10 Jun 2026 10:11:12 -0800 Subject: [PATCH] fix(send): human-readable failure copy, demoted raw detail, honest sent-vs-not semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device repro: an MWA approval timeout rendered 'java.util.concurrent.TimeoutException: Timed out waiting for response with id=2' as the inline failure headline, plus a dev-client toast of the tagged JSON console.error dump. Developer goo in a consumer surface. - sendErrorMessages: new describeSubmitFailure(err, mode) classifier for submission-stage throws (wallet-timeout, wallet-declined, insufficient-funds, mesh-unreachable, network, unknown). Every class states the user's actual situation; all throws reaching ReviewCard's catch are pre-broadcast (submitSignedTransaction returns the signature for ambiguous submit errors), so 'nothing was sent' is honest there. Platform-goo headlines are demoted to a detail field; our own deliberately human throws pass through unchanged. - confirmation-poll timeout copy now tells the truth about post-submit ambiguity: 'submitted, confirmation unknown — check activity before retrying' (mesh variant prefixed 'submitted over the mesh'). - ReviewCard: inline panel shows the human headline with the raw error as a small selectable monospace sub-line; failure toast is the human message; the two failure console.error calls become console.warn so the dev client stops toasting JSON over the failure UI. No retry/state-machine changes: slider reset, double-send guard, silent user-cancel (LESSON 2026-05-13), and FailureCard timeout behavior are untouched. Classifier smoke-tested against 15 real error shapes incl. the device-reproduced MWA string. --- mobile_app/components/send/ReviewCard.tsx | 42 +++++- mobile_app/src/services/sendErrorMessages.ts | 148 ++++++++++++++++++- 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/mobile_app/components/send/ReviewCard.tsx b/mobile_app/components/send/ReviewCard.tsx index db444724..38accfea 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