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