classifySendFailure function
Map a send (sign+broadcast) failure to the next SendState. Distinct from
the form mappers: a send failure is terminal-ish, so it routes to either the
result screen (already-submitted / sign-failed) or back to the form
(stale ⇒ re-propose, busy ⇒ retry). Reads kind only (§5.4).
Implementation
SendState classifySendFailure(Object error) {
if (error is WalletApiError) {
return switch (error.kind) {
// The token was consumed (double-tap / re-entry): the notes are never
// broadcast twice. Honest "already submitted", not an error.
WalletErrorKind_ProposalAlreadyUsed() => const SendSent(
SendAlreadySubmitted(),
),
// The anchor went stale between confirm and send (a long pause — the
// large-amount dialog widens it): the reviewed NUMBERS expired, so the
// host re-proposes for fresh figures, never retries the same token. Honest
// "amounts expired, review again" — NOT the propose-path "not synced" (the
// wallet IS synced here; only the proposal's anchor aged out).
WalletErrorKind_ProposalStale() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.amountsExpired),
),
// The consensus verdict is re-evaluated on EVERY sync pass, so it can flip
// Current → Unsupported between propose (which passed) and the user
// tapping Confirm (which signs). Without this arm that lands in the
// wildcard below as a generic `SendSignFailed` — "couldn't complete this
// payment", with a Retry that cannot work — instead of the honest "this
// version needs an update". Nothing was signed or broadcast, so it
// returns to the FORM with the same fault the propose path uses
// (code reviewer).
WalletErrorKind_NetworkUpgradeUnsupported() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.networkUpgradeUnsupported),
),
// GRACE-1: the grace can run out between propose and Confirm exactly as
// the verdict above can flip — same return to the FORM, its own fault.
WalletErrorKind_ConsensusGraceExpired(
:final by,
:final blocksSinceLastCurrent,
) =>
SendForm(
fault: SendServerSilentFault(
by: by,
blocksSinceLastCurrent: blocksSinceLastCurrent,
),
),
WalletErrorKind_ConsensusNotEvaluated() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.notSyncedYet),
),
// storeBusy joins this arm: the interactive two-step
// enrol writes the aux outbox inside the send bracket, and a store write
// that lost its race to a sync commit (past the SDK's in-Rust bounded
// retry) wrote NOTHING — the orange "busy, try again in a moment" back to
// the form is honest; the red "couldn't complete" dead-end is not.
// (Structurally near-unreachable today — the enrol holds both the db and
// aux guards — but the taxonomy must not depend on that invariant.)
WalletErrorKind_WalletBusy() ||
WalletErrorKind_InvalidState() ||
WalletErrorKind_StoreBusy() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.walletBusy),
),
// A watch-only wallet can never sign (RW-VIEW-001): defense-in-depth back
// to the form with the honest permanent fact (the chrome + screen gate
// make this unreachable on the normal path). Routed to the form (not the
// result screen) so the inline fault states why, matching the propose arm.
WalletErrorKind_WatchOnly() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.watchOnly),
),
// The durable outbox is full when an interactive multi-step (TEX) send tries
// to enrol its forwarding leg: honest "outbox full — let it drain, then
// retry", routed BACK TO THE FORM (orange-transient), NEVER the red
// "couldn't complete; nothing was sent" dead-end (a re-tap loop, most
// reachable on an unstable mobile link where the drain is stalled — the
// outbox is fullest exactly then). The SEND path reaches this via the
// two-step enrol, LIVE since gate-removal (2e-2b-v-5a). (The QUEUE path's
// `classifyQueueFailure` already maps this kind to the same reason.)
WalletErrorKind_QueuedSendsFull() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.queueFull),
),
// The one-time-address ceiling (dual-natured, #315): a mined forward frees
// its slot, but a slot used by a send that never confirmed does NOT free by
// waiting — the routed copy holds both ("some may free up as transfers
// confirm, but this may not clear on its own; your funds are safe"), back to
// the form as orange, never the red dead-end. LIVE since gate-removal
// (2e-2b-v-5a): the create/sign path reserves an ephemeral for every
// two-step TEX now that the gates are off.
WalletErrorKind_TexSendLimitReached() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.oneTimeAddressLimit),
),
// Out of disk persisting the created tx before it escaped (#373): nothing
// was broadcast (§6.3 — the notes are re-proposable), so route BACK TO THE
// FORM with the honest "free up space" transient, never the terminal
// "couldn't complete" that would loop on a full disk.
WalletErrorKind_DiskFull() => const SendForm(
fault: SendCategoricalFault(SendFaultReason.storageFull),
),
// signFailed (or anything else): no money moved; the consumed token can't
// be re-sent, so the result offers "try again" → a fresh form.
_ => const SendSent(SendSignFailed()),
};
}
return const SendSent(SendSignFailed());
}