execute method

Future<void> execute()

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