confirm method

Future<void> confirm()

Sign + broadcast the reviewed proposal (the SECOND half) — consumes the one-shot token by id. A per-tx broadcast failure is DATA, not a throw, so it lands on the result screen as "saved for retry"; a thrown typed error routes per classifySendFailure (already-submitted / sign-failed / stale → form).

Implementation

Future<void> confirm() async {
  final current = state;
  if (current is! SendReview) return; // only from the confirm screen
  // Stage S8 `deadline`: a request the host already holds a final "no
  // transaction" for cannot pay — refused HERE, before the session is even
  // asked for and before the authorizer below is read, so no host bracket
  // opens for a spend that will not happen. Lands on the expired state, not
  // back on Review: a review with a Confirm that can never go through is a
  // money surface lying about what the tap does.
  if (_requestRevoked) {
    _set(const SendRequestExpired());
    return;
  }
  // S13 H1: an abandoned flow's spend would refuse inside the closure —
  // refuse it HERE instead, so the host's prompt never opens for a spend
  // that cannot happen (the fold review's LOW). Reached from a screen
  // that re-attached to a Review another screen left.
  if (_abandonedFlows.contains(_flowId)) {
    _restartAbandoned();
    return;
  }
  final session = _requireSession();
  if (session == null) return;
  final proposal = current.proposal;

  // The host-authorization seam (#327) — read before the transition so a
  // disposed ref can't be touched later. The transient state below also
  // covers the host's prompt (it renders modally over the screen).
  final authorizer = ref.read(walletSendAuthorizerProvider);

  // Synchronous transient transition before the await — the double-tap
  // interlock (a second confirm sees `state != SendReview` and no-ops, so the
  // token is consumed at most once; the SDK's own one-shot guard is the
  // backstop, surfacing as SendAlreadySubmitted 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, so a session flip (and
  // even a whole fresh confirm) can happen mid-flight — a type check alone
  // would pass for the NEW cycle's transient.
  final submitting = SendSubmitting(proposal);
  _set(submitting);
  // FR-26: the flow this confirm belongs to, and whether the spend was ever
  // ENTERED. The flag is the difference between "no money moved" and "we lost
  // the answer": everything below the `session.send` line can throw AFTER the
  // transaction is signed, persisted and broadcast — the host's own
  // `authorizeSpend` wraps the closure, so its bookkeeping throwing lands in
  // the same `catch` as a refused sign. Reserving the hard "nothing was
  // created" claim for the not-entered case is the whole point.
  final flowId = _flowId;
  // The started-spend bookkeeping (R13 §4.2, [runStartedSpend]): whether the
  // closure reached `session.send`, the SDK's OWN record of what it returned,
  // and whether a throw is `send`'s own. `authorizeSpend` is HOST code
  // wrapping our closure, and its return value is whatever the host chooses
  // to hand back — a host that runs the closure and discards the result, or
  // never runs it, could otherwise return a fabricated
  // `List<TxSubmitResult>` and make the wallet's own result screen say "Sent"
  // over invented txids. Everything below reads the verdict, never the
  // authorizer's return. Only a kind the core provably raises BEFORE anything
  // is persisted ([sendErrorPrecedesPersistence]) keeps its classification
  // once the spend started; everything else past that point — a store fault
  // the core can raise after the transaction is persisted, the host's code
  // throwing, declining or reporting a session change after the closure ran,
  // an untyped throw — is an answer this layer lost, and lands on
  // [SendOutcomeUnknown].
  final run = await runStartedSpend<List<TxSubmitResult>>(
    precedesPersistence: sendErrorPrecedesPersistence,
    // Run AT MOST ONCE. `send_authorization.dart` states this as a contract
    // clause; a clause with no predicate behind it is a comment. A second run
    // here would consume a second proposal token, and on the queue twin it is
    // a straight double-pay at drain.
    atMostOnceMessage:
        'WalletSendAuthorizer ran the spend action twice — the contract '
        'is at most once (send_authorization.dart)',
    onEntered: () => _enteredFlows.add(flowId),
    authorize: (start) => authorizer.authorizeSpend(
      WalletSpendIntent(
        kind: WalletSpendKind.send,
        amountZat: proposal.totalZat,
        // #383 R3 display-facts: bind the host's prompt to WHAT is signed.
        // The elided form of the review's recipient (render-never-log).
        // `selfSend` is safe to forward as the whole-spend self claim ONLY
        // because this flow composes a SINGLE-payment URI (composePaymentUri
        // above) — the core's selfSend is ANY-leg, so with exactly one
        // payment leg "any leg is self" ≡ "the recipient is self" (
        // see the coupling note on [WalletSpendIntent.recipientIsSelf]). A
        // future multi-payment propose path must derive its own value.
        recipientAbbrev: abbreviateWalletAddress(current.recipient),
        recipientIsSelf: proposal.selfSend,
        feeZat: proposal.feeZat,
        // FR-17 (#396): the proposal's spend-binding nonce — a host-custody
        // supplier records it at stage time so the sign-time native seed
        // pull can only serve THIS reviewed proposal.
        bindingToken: proposal.binding,
        // FR-28: a per-spend-credential host's prompt IS the authorization
        // moment for its user, so it gets the same sentence the review shows.
        machineMemoPurpose: current.machineMemoPurpose,
      ),
      () {
        // The identity-switch fence: an approval landing after a
        // session flip must never spend from the DEAD identity's wallet —
        // no surface could show the outcome (see the seam contract). The
        // `ref.mounted` leg maps whole-scope teardown to the same
        // documented type instead of leaking riverpod's internal
        // unmounted-ref throw through the host's authorizer.
        if (!ref.mounted ||
            !identical(ref.read(walletSessionProvider), session)) {
          throw const WalletSpendSessionChanged();
        }
        // `start` checks at most once, then — S13 §1a H1 — refuses an
        // abandoned flow before entering (the screen that ran it is gone and
        // has told its host "no transaction", so it stays true), then marks
        // the spend started and records what `send` returns or throws.
        return start(
          () => session.send(proposal.proposalId),
          beforeEnter: () {
            if (_abandonedFlows.contains(flowId)) {
              throw const _FlowAbandoned();
            }
          },
        );
      },
    ),
  );
  switch (run) {
    case SpendLanded(:final value, :final errorAfter):
      await _landSent(
        session,
        proposal,
        submitting,
        flowId,
        value,
        // A throw after `send` returned (the host's code, a delivery read):
        // the landing stands, read with no delivery state (R13 §4.2).
        readDelivery: errorAfter == null,
      );
    case SpendNotStarted(error: null):
      // The authorizer returned without the closure ever running: nothing
      // was signed. Honest refusal rather than a report built on nothing.
      _publishFlow(SendFlowNothingCreated(flowId));
      if (_disposed || !identical(state, submitting)) return;
      _set(current);
    case SpendNotStarted(error: WalletSpendAuthorizationDenied()):
      // The user (or a host policy) declined the host's authorization prompt
      // BEFORE any bridge call — the proposal token is unconsumed, so land
      // back on review, ready for another confirm. The host's own prompt was
      // the communication; no additional fault banner (see the seam
      // contract). The transient guard (review H2, tightened to IDENTITY in
      // #330): restore ONLY over our own Submitting instance — if the machine
      // moved on mid-prompt (a session swap re-ran build, possibly all the way
      // into a NEW confirm's own Submitting), writing the old review back
      // would resurrect a dead session's proposal.
      //
      // FR-26 — and the correction the review forced. A denial returns the
      // user to a LIVE Review with a working Confirm, so the flow is not
      // over: publishing a terminal here consumed the one-shot channel, and
      // the very next tap paid while the host held "nothing was created" and
      // the payee held the money. A nothing-created terminal must only be
      // published for a flow that can no longer spend.
      if (_disposed || !identical(state, submitting)) return;
      _set(current);
    case SpendNotStarted(error: WalletSpendSessionChanged()):
      // The SDK's own identity-switch fence fired — NOTHING was attempted
      // (the fence throws before `session.send`). Explicit arm: the generic
      // classifier below would shape this as a "payment failed" lie, kept
      // off-screen today only by the identity guard — never rely on that
      // incidentally. The host is owed the honest "nothing was created" —
      // structurally, not by inference.
      _publishFlow(SendFlowNothingCreated(flowId));
    case SpendNotStarted(error: _FlowAbandoned()):
      // S13 §1a H1: nothing was signed — the refusal is ahead of `entered`.
      _publishFlow(SendFlowNothingCreated(flowId));
      if (_disposed || !identical(state, submitting)) return;
      _restartAbandoned();
    case SpendNotStarted(:final error?):
      // Not entered: nothing was created, structurally.
      final classified = classifySendFailure(error);
      if (classified is SendSent) {
        _publishFlow(
          SendFlowSent(
            flowId,
            outcome: classified.outcome,
            txids: classified.txids,
            isTwoStepTex: proposal.isTwoStepTex,
            singleRecipientZat: proposal.singleRecipientZat,
          ),
        );
      }
      // Not entered and routed back to the FORM (a stale anchor, a busy
      // store): the flow is still ALIVE and still spendable, so nothing is
      // published — closing the one-shot channel there is what let a retry
      // pay while the host held "nothing was created".
      if (_disposed || !identical(state, submitting)) return;
      _set(classified);
    case SpendRefusedBeforePersist(:final error):
      // `session.send`'s own typed refusal of a kind raised before anything
      // is persisted (S7 U1): its classification is still the truth.
      final classified = classifySendFailure(error);
      _publishAfterEntered(classified, flowId, proposal);
      if (_disposed || !identical(state, submitting)) return;
      _set(classified);
    case SpendAnswerLost():
      // ENTERED DOMINATES THE CLASSIFIER, in EVERY arm — a decline, a
      // session-changed report, or any throw from a host that ran the spend
      // first (R13 §4.2): the money it moved does not un-move because of
      // what the prompt returned, and a live Review there would pay a
      // second time. No classification survives here: the SDK's OWN
      // `ProposalAlreadyUsed` is on the precedes-persistence list and never
      // reaches this arm, so one that does was substituted by the host —
      // and "already submitted" is a claim that money moved, which only the
      // SDK may make.
      // The host is told we lost the answer — published BEFORE the identity
      // guard, so it hears it even when the fence fired and `build()` moved
      // the state — and so is the screen (S7 U1): no "nothing was sent", no
      // Try again, no error text — "check Activity first".
      _publishFlow(SendFlowIndeterminate(flowId));
      _landOutcomeUnknown(submitting, queued: false);
  }
}