classifyProposeFailure function
Map a propose/encodePaymentUri failure to a SendFormFault. Reads the
typed WalletApiError.kind ONLY (never a payload — §5.4); a non-FRB error is
the generic SendFaultReason.couldNotPrepare. Pure + total, so it is
unit-tested at its boundary without a device.
Implementation
SendFormFault classifyProposeFailure(Object error) {
if (error is WalletApiError) {
return switch (error.kind) {
WalletErrorKind_InsufficientFunds(
:final availableZat,
:final requiredZat,
:final pendingIncomingZat,
) =>
SendInsufficientFunds(
availableZat: availableZat,
requiredZat: requiredZat,
pendingIncomingZat: pendingIncomingZat,
),
WalletErrorKind_AddressInvalid() => const SendCategoricalFault(
SendFaultReason.addressInvalid,
),
WalletErrorKind_MemoRequiresShieldedRecipient() =>
const SendCategoricalFault(SendFaultReason.memoToTransparent),
WalletErrorKind_MemoTooLong() => const SendCategoricalFault(
SendFaultReason.memoTooLong,
),
WalletErrorKind_ReservedMemoNotSendable() ||
WalletErrorKind_MemoInvalid() => const SendCategoricalFault(
SendFaultReason.memoNotSendable,
),
// Its OWN arm, not folded into memoNotSendable: a both-memos refusal is
// a caller bug and its copy must not blame the user's memo.
WalletErrorKind_MemoConflict() => const SendCategoricalFault(
SendFaultReason.memoConflict,
),
WalletErrorKind_AmountOutOfRange() => const SendCategoricalFault(
SendFaultReason.amountOutOfRange,
),
WalletErrorKind_NetworkMismatch() => const SendCategoricalFault(
SendFaultReason.networkMismatch,
),
// A WATCH-ONLY wallet refuses every spend at the SDK entry (RW-VIEW-001).
// Defense-in-depth (security-N2): the chrome hides Send and the send
// screen re-gates on the watch-only kind, so this is unreachable on the
// normal path — but a deep-link / race that DID reach propose gets the
// honest "this wallet can't send", never the generic couldNotPrepare.
WalletErrorKind_WatchOnly() => const SendCategoricalFault(
SendFaultReason.watchOnly,
),
WalletErrorKind_PaymentUriInvalid() => const SendCategoricalFault(
SendFaultReason.uriInvalid,
),
// A not-yet-anchorable wallet (TTL/sync) is the offline-first fork's
// trigger — the host offers "queue to send later".
WalletErrorKind_ProposalStale() => const SendCategoricalFault(
SendFaultReason.notSyncedYet,
),
// Ironwood/NU6.3: the network runs consensus rules this version cannot
// build for. Its OWN arm — folding it into notSyncedYet would offer
// "queue to send later" over a send that cannot go on this version at
// all, which is worse than an error: it invites the user to wait.
WalletErrorKind_NetworkUpgradeUnsupported() => const SendCategoricalFault(
SendFaultReason.networkUpgradeUnsupported,
),
// GRACE-1 (§4p G-1/G-6): the server will not say which network it is on
// and the grace ran out — its OWN fault, with the SDK's reason, so the
// copy says "switch servers" (or "check the device's date and time"),
// never the update-the-app sentence above.
WalletErrorKind_ConsensusGraceExpired(
:final by,
:final blocksSinceLastCurrent,
) =>
SendServerSilentFault(
by: by,
blocksSinceLastCurrent: blocksSinceLastCurrent,
),
// And "never checked" is the not-synced-yet story (§4p item 2): the first
// completed pass resolves it, and the queue is a fine answer meanwhile.
WalletErrorKind_ConsensusNotEvaluated() => const SendCategoricalFault(
SendFaultReason.notSyncedYet,
),
// storeBusy joins the lifecycle-busy arm (W-swap-4-a-4): both are honest
// "try again in a moment" transients — the store one means a write lost
// its race to a sync commit even past the SDK's in-Rust bounded retry;
// nothing was written, so retrying IS the remedy (never the recovery
// journey a storeCorrupt would earn).
WalletErrorKind_WalletBusy() ||
WalletErrorKind_InvalidState() ||
WalletErrorKind_StoreBusy() => const SendCategoricalFault(
SendFaultReason.walletBusy,
),
// Out of disk persisting the compose (#373): the honest "free up space",
// not a generic "couldn't prepare" that retries into a deterministic
// re-fail on a tight disk. Nothing was written — funds untouched.
WalletErrorKind_DiskFull() => const SendCategoricalFault(
SendFaultReason.storageFull,
),
// A zero-valued transparent output is structurally unreachable from the form
// (the host blocks a non-positive amount before compose). Map it EXPLICITLY
// to the honest generic rather than silently to the wildcard — a future
// QR/URI path that composed one would still get a true "couldn't prepare",
// never a wrong "larger than total supply". (A dedicated reason can land
// with the QR path.)
WalletErrorKind_ZeroValuedTransparentOutput() =>
const SendCategoricalFault(SendFaultReason.couldNotPrepare),
// INC-018 (b), phase-2 P2-2: the retryable class the SDK now types apart —
// its OWN arm, so the copy says "try again in a moment" and never sends the
// user to correct details that are correct. Its
// sibling `proposeFailed` stays on the generic arm below: that class is
// deterministic on the input, and "check the details" is its honest copy.
WalletErrorKind_ProposeTransient() => const SendCategoricalFault(
SendFaultReason.couldNotPrepareTransient,
),
// sendAmountRequired never reaches here (the form always sets an amount);
// proposeFailed / anything else → generic.
_ => const SendCategoricalFault(SendFaultReason.couldNotPrepare),
};
}
// The controller's own bound ran out (`walletFfiWedgeTimeout`). Propose is
// local work that queues behind the scan's db lock, so on a wallet catching
// up this is the WAIT, not the input (the Seeker walk saw a 12–14 s
// first address behind the same lock). "Check the details" would send the
// user to correct details that are correct — the INC-018 (b) mistake.
if (error is TimeoutException) {
return const SendCategoricalFault(SendFaultReason.couldNotPrepareTransient);
}
return const SendCategoricalFault(SendFaultReason.couldNotPrepare);
}