confirm method

Future<void> confirm()

Sign + broadcast the shield (the SECOND half) — consumes the one-shot token by id, the SAME send path a payment uses. A per-tx broadcast failure is DATA, not a throw, so it lands as "saved for retry"; a thrown typed error routes per classifyShieldSendFailure — once the spend started, only a kind singleStepSendErrorPrecedesPersistence accepts; anything else lands on ShieldOutcomeUnknown (R13). On a money-moving outcome the cold balance is invalidated so the transparent line updates promptly.

Implementation

Future<void> confirm() async {
  final current = state;
  if (current is! ShieldReady) return; // only from the confirm sheet
  final session = _requireSession();
  if (session == null) return;
  final proposal = current.proposal;

  // The host-authorization seam (#327) — a shield consumes its proposal
  // through the SAME signing path a payment does, so it is authorized the
  // same way.
  final authorizer = ref.read(walletSendAuthorizerProvider);

  // Synchronous transient transition before the await — the double-tap interlock
  // (a second confirm sees `state != ShieldReady` and no-ops, so the token is
  // consumed at most once; the SDK's own one-shot guard is the backstop,
  // surfacing as already-submitted 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 submitting = ShieldSubmitting(proposal);
  _set(submitting);
  // The started-spend bookkeeping send uses (R13 §4.2, [runStartedSpend]):
  // the outcome is read from what the closure recorded, never from the
  // authorizer's return, and once the spend started only `send`'s own
  // refusal of a kind raised before persistence
  // ([singleStepSendErrorPrecedesPersistence]) keeps its classification.
  final run = await runStartedSpend<List<TxSubmitResult>>(
    precedesPersistence: singleStepSendErrorPrecedesPersistence,
    atMostOnceMessage:
        'WalletSendAuthorizer ran the shield action twice — the contract '
        'is at most once (send_authorization.dart)',
    authorize: (start) => authorizer.authorizeSpend(
      WalletSpendIntent(
        kind: WalletSpendKind.shield,
        amountZat: proposal.totalZat,
        // #383 R3: a shield is a self-transfer by construction; the proposal
        // knows the network fee — both ride the prompt's display-facts.
        recipientIsSelf: true,
        feeZat: proposal.feeZat,
        // FR-17 (#396): the proposal's spend-binding nonce — the shield
        // consumes its proposal through the same signing path a payment
        // does, so it binds the same way.
        bindingToken: proposal.binding,
      ),
      () {
        // The identity-switch fence: an approval landing after a
        // session flip must never spend from the DEAD identity's wallet
        // (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 start(() => session.send(proposal.proposalId));
      },
    ),
  );
  switch (run) {
    case SpendLanded(:final value, :final errorAfter):
      // The core's delivery reading for what did not go out (stage S8
      // `obligation`, row 10) — read from the session that sent, BEFORE the
      // guards below, so the outcome is one pure reduction of what landed.
      // A throw after `send` returned keeps the landing, read with no
      // delivery state (R13 §4.2).
      final delivery = errorAfter == null
          ? await landedDeliveryStates(session, value)
          : const <String, DeliveryState?>{};
      if (_disposed) return;
      // The transparent balance just changed (funds are leaving the
      // transparent pool); refresh the cold snapshot so the balance card
      // reflects it without waiting for the next sync tick. ABOVE the
      // identity guard (review F1): the balance must reflect a LANDED
      // outcome even when the state write below is rightly swallowed.
      ref.invalidate(walletSnapshotReadProvider);
      if (!identical(state, submitting)) return;
      // A t→z shield has no TEX destination ⇒ `isTwoStepTex` is always false
      // here; threaded for the shared summarizer's SSOT discipline (never a
      // [SendTexInMotion]).
      _set(
        ShieldDone(
          summarizeSendOutcome(
            value,
            isTwoStepTex: proposal.isTwoStepTex,
            delivery: delivery,
          ),
        ),
      );
    case SpendNotStarted(error: null || WalletSpendAuthorizationDenied()):
      // Declined at the host's prompt BEFORE any bridge call, or the
      // authorizer returned without running the spend — the token is
      // unconsumed; back to the confirm sheet, no fault (the seam contract).
      // Identity guard (review H2, tightened in #330): never restore
      // over a state the machine already moved past mid-prompt — a bare type
      // check would pass for a NEW cycle's own Submitting.
      if (_disposed || !identical(state, submitting)) return;
      _set(current);
    case SpendNotStarted(error: WalletSpendSessionChanged()):
      // The SDK's identity-switch fence — nothing was attempted; explicit arm
      // so the classifier can never shape it as a failure.
      return;
    case SpendNotStarted(:final error?) ||
        SpendRefusedBeforePersist(:final error):
      // Nothing started, or `send`'s own refusal raised before anything was
      // saved: today's classifier is still the truth.
      if (_disposed || !identical(state, submitting)) return;
      _set(classifyShieldSendFailure(error));
    case SpendAnswerLost():
      // No classification survives a lost answer: the SDK's OWN
      // `ProposalAlreadyUsed` is on the single-step list and lands above, so
      // one here was substituted by the host — and "already submitted"
      // claims money moved, which only the SDK may say.
      _landOutcomeUnknown(submitting);
  }
}