classifySendFailure function

SendState classifySendFailure(
  1. Object error
)

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());
}