execute method
Execute the reviewed quote — claim the durable single-flight (by id), register
the swap intent, and (OutOfZec only) queue the §4.4 ZEC deposit. On success:
OutOfZec → SwapExecuted (the wallet sent the deposit; track it); IntoZec →
SwapAwaitingDeposit (the D7 "send your funds" screen — the USER deposits
externally, §3.3b L7). Every TYPED failure routes back to the FORM with a
re-quote fault: the core claims the single-flight FIRST, so a failed execute
may have consumed the quote — re-executing would be QuoteExpired (#367);
re-quoting is the only safe path (never a double-deposit). The ONE exception
is an OutOfZec TIMEOUT (W-swap-4-a-2): a .timeout abandons — not cancels —
the Rust future, so the deposit may be signing/in motion; that arm goes to
TRACKING by quote id, never to a re-quotable form (a timeout is not a
failure). The SDK's SwapAlreadyInFlight guard backstops any host that
re-quotes anyway.
Implementation
Future<void> execute() async {
final current = state;
if (current is! SwapReview) return; // only from the review screen
final session = _requireSession();
if (session == null) return;
final quote = current.quote;
final direction = current.direction;
final counter = current.counter;
final payoutAddress = current.payoutAddress;
// The host-authorization seam (#327): ONLY the OutOfZec direction commits
// wallet funds (the §4.4 ZEC deposit — signed at execute INSIDE this bracket,
// FR-23-a), so only it is authorized. An IntoZec execute registers the swap
// and mints a receive address — no wallet funds move, and prompting for a
// spend that isn't one would be dishonest.
final authorizer = ref.read(walletSendAuthorizerProvider);
// Synchronous transient transition before the await — the double-tap
// interlock (a second execute sees `state != SwapReview` and no-ops, so the
// quote is executed at most once here; the SDK's durable single-flight is the
// backstop, refusing a re-claim typed if a tap still slips through).
// Captured for the INSTANCE-IDENTITY guards below (#330) — the authorizer
// await spans user think-time at the host's prompt.
final executing = SwapExecuting(quote);
_set(executing);
try {
// Same host-side timeout backstop — a stalled execute can't hang the
// "Starting…" spinner; any failure routes back to the form to re-quote
// (the SDK's durable single-flight is the money-safety backstop). The
// timeout sits INSIDE the authorized action so it bounds only the
// network round-trip, never the user's time at the host's prompt.
final swapId = switch (direction) {
SwapFlowDirection.outOfZec => await authorizer.authorizeSpend(
WalletSpendIntent(
kind: WalletSpendKind.swapDeposit,
amountZat: quote.zecSideZat,
// #383 R3: bind the prompt to the provider deposit address the
// execute will pay (elided, render-never-log). NOT a self-transfer
// (the deposit leaves the wallet); the network fee is discovered
// at the execute-time propose, so feeZat stays null here.
recipientAbbrev: abbreviateWalletAddress(quote.depositAddress),
// N03: the user's own foreign payout address, kept DISTINCT from
// the provider deposit address above (the review showed it in
// full; a host prompt may show it again).
payoutAbbrev: payoutAddress == null
? null
: abbreviateWalletAddress(payoutAddress),
// FR-17 (#396): the quote's spend-binding nonce — the deposit's
// sign-time seed pull presents it, so a host-custody supplier
// fail-closes anything but THIS reviewed quote.
bindingToken: quote.binding,
),
() {
// The identity-switch fence: an approval landing after
// a session flip must never commit the DEAD identity's deposit
// (see the seam contract). `ref.mounted` maps whole-scope
// teardown to the same documented type.
if (!ref.mounted ||
!identical(ref.read(walletSessionProvider), session)) {
throw const WalletSpendSessionChanged();
}
return _executeBounded(session, quote);
},
),
SwapFlowDirection.intoZec => await _executeBounded(session, quote),
};
if (_disposed) return;
// An OutOfZec execute just COMMITTED the §4.4 deposit (signed at execute,
// FR-23-a) — refresh the cold snapshot ABOVE the identity guard (
// wrap review: the screen's own listen dies with the widget, so an
// away-at-landing deposit otherwise refreshed nothing; shield/move
// parity). IntoZec moves no wallet funds — nothing to refresh.
if (direction == SwapFlowDirection.outOfZec) {
ref.invalidate(walletSnapshotReadProvider);
}
// The durable swap HOME refreshes on the SAME away-at-landing edge for
// BOTH directions (arch review M-A3): the record committed at execute,
// and the screen's own listener (the usual invalidation site) dies with
// the widget — without this, a pop-mid-execute left the fresh swap off
// the home until a sync edge or resume, transiently recreating the
// "in progress, invisible" state (and starving the guard-fault's
// "View swap" of its record).
ref.invalidate(walletInFlightSwapsReadProvider);
if (!identical(state, executing)) return;
_set(switch (direction) {
SwapFlowDirection.outOfZec => SwapExecuted(
swapId,
SwapFlowDirection.outOfZec,
),
SwapFlowDirection.intoZec => SwapAwaitingDeposit(
swapId: swapId,
quote: quote,
counter: counter,
),
});
} on WalletSpendAuthorizationDenied {
// Declined at the host's prompt BEFORE the execute call — the durable
// single-flight was never claimed, so the quote stays executable; back
// to the review, no fault (the seam contract — a stale busy banner from
// an earlier attempt must not survive the denial, review fold). Identity
// guard (review H2, tightened in #330): never restore over a state
// the machine moved past — a bare type check would pass for a NEW
// cycle's own Executing.
if (_disposed || !identical(state, executing)) return;
_set(
SwapReview(
quote: quote,
direction: direction,
counter: counter,
payoutAddress: payoutAddress,
),
);
} on WalletSpendSessionChanged {
// The SDK's identity-switch fence — nothing was attempted; explicit arm
// so the classifier can never shape it as a failure.
return;
} on _SwapExecuteTimedOut {
// The SDK execute call ITSELF timed out (W-swap-4-a-2 — the timeout→track
// defense; the sentinel is thrown only around `session.swapExecute`, so a
// host authorizer's own TimeoutException can never land here — review MED:
// that one falls to the generic classifier below, honest because nothing
// was executed and the quote is unconsumed). Dart's `.timeout` ABANDONS
// the Rust future, it cannot cancel it: by now the durable deposit enqueue
// has long committed (it precedes the unbounded ZK-proving sign that
// overran the clock), so for OutOfZec money may be in motion. Routing to
// the re-quotable form here is exactly the double-deposit door — a
// re-quote mints a NEW quote id and defeats the per-quote single-flight
// (the SDK's in-flight guard would refuse the second deposit, but the
// honest UI never invites it). TRACK instead: the quote id is the SDK's
// own execution identity (S8) — the same value execute returns and the
// durable home row is keyed by, so it IS the tracking id — and the
// tracking view renders the live status from "sending your deposit"
// through the terminal outcome. The snapshot refresh sits ABOVE the
// identity guard (parity with the success arm — an away-at-timeout
// deposit must still refresh the balance).
if (_disposed) return;
if (direction == SwapFlowDirection.outOfZec) {
ref.invalidate(walletSnapshotReadProvider);
}
// Home refresh above the identity guard, timeout parity with the
// success arm (arch review M-A3): by the timeout the durable enqueue —
// and record-first, the home row — has long committed.
ref.invalidate(walletInFlightSwapsReadProvider);
if (!identical(state, executing)) return;
if (direction == SwapFlowDirection.outOfZec) {
_set(SwapExecuted(quote.id, SwapFlowDirection.outOfZec));
} else {
// IntoZec moved no wallet funds and registered nothing new at execute
// (registration happened at quote) — the re-quote form is the honest
// arm. The HEDGED timeout reason (#367, F5): a ~30 s local store
// stall can consume most of the window, so the copy must not firmly
// blame the user's connection the way the quote-timeout copy does.
_set(
const SwapFormState(
fault: SwapCategoricalFault(SwapFaultReason.executeTimedOut),
),
);
}
} catch (error) {
if (_disposed || !identical(state, executing)) return;
final fault = classifySwapFailure(error);
// #367: a BUSY execute claim consumed NOTHING — the quote is still
// executable, so restore the review with the retryable fault inline
// instead of bouncing to the form (a forced re-quote would needlessly
// burn a valid quote). Every other failure keeps the re-quote form.
if (fault is SwapCategoricalFault &&
fault.reason == SwapFaultReason.storeBusy) {
_set(
SwapReview(
quote: quote,
direction: direction,
counter: counter,
payoutAddress: payoutAddress,
fault: fault,
),
);
return;
}
_set(SwapFormState(fault: fault));
}
}