queueOffline method

Future<void> queueOffline({
  1. required String address,
  2. required String amountText,
  3. String? memo,
  4. 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);
  }
}