prepare method

Future<void> prepare({
  1. required String address,
  2. required String amountText,
  3. String? memo,
  4. WalletMachineMemo? machineMemo,
})

Compose the ZIP-321 URI from the form and propose (the FIRST half). On success → SendReview with the confirmable numbers; on a typed failure → back to SendForm with an honest inline fault. The amount is parsed host-side first (integer-exact, never via double).

Implementation

Future<void> prepare({
  required String address,
  required String amountText,
  String? memo,
  WalletMachineMemo? machineMemo,
}) async {
  if (_inFlight) return; // re-entrancy: ignore taps while a step runs
  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 host's policy ceiling (e.g. an alpha cap) — refused BEFORE any
  // bridge call, like the parse gate above (`null` = no ceiling).
  final ceiling = ref.read(walletSendCeilingZatProvider);
  if (ceiling != null && zat > ceiling) {
    _set(SendForm(fault: SendOverCeiling(ceiling)));
    return;
  }
  final recipient = address.trim();

  // Synchronous transient transition BEFORE the first await — the double-tap
  // interlock (a second prepare sees `_inFlight` and no-ops). Deliberately
  // NON-const: the post-await guards match on INSTANCE IDENTITY (#330) — a
  // canonicalized const would alias a different build cycle's transient, so
  // a session flip + fresh prepare could let the DEAD cycle's continuation
  // write its stale proposal over the live one.
  // ignore: prefer_const_constructors
  final preparing = SendPreparing();
  _set(preparing);

  final String uri;
  try {
    // Compose through the port (the bridge crossing lives in the adapter); a
    // bad address / un-sendable memo throws a typed error here, synchronously.
    uri = session.composePaymentUri(
      recipient: recipient,
      amountZat: zat,
      memoText: memo,
      // FR-28: the host's opaque bytes ride EVERY compose, not just the
      // first. The form re-composes from its own fields on each Review tap,
      // which is exactly where the bytes used to be dropped silently.
      memoBytes: machineMemo?.bytes,
    );
  } catch (error) {
    _set(SendForm(fault: classifyProposeFailure(error)));
    return;
  }

  try {
    // The same honest-degradation timeout the shield prepare uses (
    // wrap review): propose is a LOCAL, no-network call, so a hang means
    // the FFI boundary is WEDGED — and with the entry resets now in-flight
    // no-ops (re-attachment, review F1) a wedged Preparing would otherwise
    // be a permanently stuck money screen. TimeoutException classifies to
    // couldNotPrepareTransient: a wallet still scanning, not bad input.
    final proposal = await session
        .propose(uri)
        .timeout(walletFfiWedgeTimeout);
    if (_disposed || !identical(state, preparing)) return;
    _set(
      SendReview(
        proposal: proposal,
        recipient: recipient,
        // FR-28: the purpose travels to the REVIEW so the disclosure and the
        // host's authorization prompt read the same sentence from one place.
        machineMemoPurpose: machineMemo?.purpose,
      ),
    );
  } catch (error) {
    if (_disposed || !identical(state, preparing)) return;
    _set(SendForm(fault: classifyProposeFailure(error)));
  }
}