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