queueOffline method
Future<void>
queueOffline({
- required String address,
- required String amountText,
- String? memo,
- WalletMachineMemo? machineMemo,
Queue the send for OFFLINE-first delivery — durably persist the intent (no network/signing/money movement) and land on SendQueued. The offline fork: proposing is deferred to send-time so a long-queued send never carries a stale anchor. A typed failure (e.g. queue full) returns to the form.
Implementation
Future<void> queueOffline({
required String address,
required String amountText,
String? memo,
WalletMachineMemo? machineMemo,
}) async {
if (_inFlight) return;
// Stage S8 `deadline`: the queue commits a future spend, so it is the
// second seam the revoked request is refused at — same gate as confirm(),
// same place: before anything below (the parse, the ceiling, the compose,
// the authorizer) runs for it.
if (_requestRevoked) {
_set(const SendRequestExpired());
return;
}
// S13 H1: as in confirm() — no prompt for a spend that cannot happen.
if (_abandonedFlows.contains(_flowId)) {
_restartAbandoned();
return;
}
// The entry state — restored VERBATIM on an authorization denial so a
// fault that OFFERED the queue (notSyncedYet while online) survives the
// cancelled prompt and the affordance doesn't vanish.
final entry = state;
final session = _requireSession();
if (session == null) return;
final parsed = parseZecAmount(amountText);
if (parsed is ZecAmountInvalid) {
_set(SendForm(fault: SendAmountFault(parsed.fault)));
return;
}
final zat = (parsed as ZecAmountValid).zat;
// The same host policy ceiling as prepare() — the offline queue is a send
// too; a cap that only guarded the online path would be a bypass.
final ceiling = ref.read(walletSendCeilingZatProvider);
if (ceiling != null && zat > ceiling) {
_set(SendForm(fault: SendOverCeiling(ceiling)));
return;
}
// The host-authorization seam (#327): queuing COMMITS a future spend, so
// it is authorized like one — even though the signing itself happens at
// background drain (the [walletOfflineQueueSupportedProvider] caveat).
final authorizer = ref.read(walletSendAuthorizerProvider);
// NON-const for the identity guards below, like confirm()'s transient
// (#330) — the authorizer await spans the host's prompt.
// ignore: prefer_const_constructors
final queuing = SendQueuing();
_set(queuing);
// FR-26: same discipline as confirm() — the flow's id; whether the enqueue
// was ever entered, what it returned and what it threw are the shared
// started-spend bookkeeping ([runStartedSpend]).
final flowId = _flowId;
final String uri;
try {
uri = session.composePaymentUri(
recipient: address.trim(),
amountZat: zat,
memoText: memo,
// FR-28: the offline queue composes the same URI, so it carries the
// same bytes — a queued payment that lost the host's reference would
// drain later as an unattributable one.
memoBytes: machineMemo?.bytes,
);
} catch (error) {
_set(SendForm(fault: classifyQueueFailure(error)));
return;
}
final run = await runStartedSpend<String>(
// S7 U1: past `entered`, only `queueSend`'s own typed refusal of a kind
// raised before the intent is written ([queueErrorPrecedesPersistence]:
// a full queue, a watch-only or closing wallet) keeps its retryable form
// fault.
precedesPersistence: queueErrorPrecedesPersistence,
// At most once — and here the contract has teeth it lacked: a queued
// send has NO one-shot token, so a second run enqueues a SECOND payment
// and both drain (`send_authorization.dart` says so; it was a comment,
// not a predicate — security review).
atMostOnceMessage:
'WalletSendAuthorizer ran the queue action twice — a queued send '
'has no one-shot token, so this is a double pay at drain',
onEntered: () => _enteredFlows.add(flowId),
authorize: (start) => authorizer.authorizeSpend(
WalletSpendIntent(
kind: WalletSpendKind.queuedSend,
amountZat: zat,
// #383 R3: what is known at QUEUE time — the typed recipient. No
// proposal exists yet (signing happens at drain), so no fee and no
// self-send detection; false here means "unknown", per the doc.
recipientAbbrev: abbreviateWalletAddress(address),
// FR-17: no bindingToken — the binding is minted at enqueue and
// surfaced on the parked-send row for re-stage.
// FR-28: the queue commits the same memo, so it discloses the same.
machineMemoPurpose: machineMemo?.purpose,
),
() {
// The identity-switch fence — see confirm(); a queuedSend
// has NO one-shot token, so a fenceless late approval would commit
// a spend the dead identity's user can never see or cancel.
if (!ref.mounted ||
!identical(ref.read(walletSessionProvider), session)) {
throw const WalletSpendSessionChanged();
}
// At most once (see `atMostOnceMessage`), then — S13 §1a H1, as in
// confirm() — a gone screen's flow never commits.
return start(
() => session.queueSend(uri),
beforeEnter: () {
if (_abandonedFlows.contains(flowId)) {
throw const _FlowAbandoned();
}
},
);
},
),
);
switch (run) {
case SpendLanded(:final value):
// The SDK's own id, never the authorizer's return value (see
// confirm()) — and it stands even when the host threw, declined or
// reported a session change after `queueSend` returned it (R13 §4.2):
// the answer was not lost. FR-26: published before the guards, same
// reason as
// confirm(). The id is the core's own `ParkedSend.id` — the ONLY
// handle a host has onto a committed payment that has no txid yet,
// and it was being discarded.
_publishFlow(SendFlowQueued(flowId, queuedSendId: value));
if (_disposed) return;
// Refresh the parked "saved & pending" surface AT OUTCOME-LANDING (#309
// holistic H2): queueing happens OFFLINE by design, so no sync edge
// will fire — without this a queued send stays invisible on the wallet
// screen until app resume, and a worried user re-queues (both drain
// when connectivity returns — a double pay). ABOVE the identity guard
// (review F1), same rationale as confirm()'s in-flight invalidation.
ref.invalidate(walletParkedSendsReadProvider);
if (!identical(state, queuing)) return;
_set(SendQueued(queuedSendId: value));
case SpendNotStarted(error: null):
// The authorizer returned without ever running the closure: nothing was
// committed, and the form is still live.
if (_disposed || !identical(state, queuing)) return;
_set(entry is SendForm ? entry : const SendForm());
case SpendNotStarted(error: WalletSpendAuthorizationDenied()):
// Declined at the host's prompt — nothing was queued; back to the
// (still-populated) form EXACTLY as entered, prior fault included (a
// notSynced fault is what OFFERS the queue button — dropping it would
// hide the affordance the user just used). No NEW banner (the seam
// contract). Same identity guard as confirm (review H2 / #330).
//
// Nothing published when the closure never ran: the form is still
// live and the queue button still works, so this flow can still commit
// a payment — closing its one-shot channel here would leave the next
// one unreportable.
if (_disposed || !identical(state, queuing)) return;
_set(entry is SendForm ? entry : const SendForm());
case SpendNotStarted(error: WalletSpendSessionChanged()):
// The SDK's identity-switch fence — nothing was queued; explicit arm
// so the classifier can never shape it as a failure. The fence throws
// before `queueSend`, so nothing was committed.
_publishFlow(SendFlowNothingCreated(flowId));
case SpendNotStarted(error: _FlowAbandoned()):
// S13 §1a H1: nothing was queued — the refusal is ahead of `entered`.
_publishFlow(SendFlowNothingCreated(flowId));
if (_disposed || !identical(state, queuing)) return;
_restartAbandoned();
case SpendNotStarted(:final error?):
// Not entered ⇒ the flow is alive and nothing is published.
if (_disposed || !identical(state, queuing)) return;
_set(SendForm(fault: classifyQueueFailure(error)));
case SpendRefusedBeforePersist(:final error):
// Entered, and `queueSend`'s own refusal raised before the intent is
// written: the host still hears that this layer cannot vouch for the
// flow, and the form keeps its retryable fault.
_publishFlow(SendFlowIndeterminate(flowId));
if (_disposed || !identical(state, queuing)) return;
_set(SendForm(fault: classifyQueueFailure(error)));
case SpendAnswerLost():
// Entered and no id came back ⇒ the intent may be durably committed —
// `queueSend` threw a kind that can follow the write, or the host
// swallowed its result or what it threw (S7 U1; R13 §4.2: EVERY arm,
// a decline and a session change included, checks it).
// The honest answer is that this layer cannot tell — published BEFORE
// the identity guard — and the screen lands on "check your pending
// payments first", never a form whose Queue button would commit a
// second payment.
_publishFlow(SendFlowIndeterminate(flowId));
_landOutcomeUnknown(queuing, queued: true);
}
}