startRestore method

Future<void> startRestore(
  1. List<String> mnemonicWords, {
  2. DateTime? approximateCreationTime,
})

Restore a wallet from mnemonicWords (the recovery phrase) and — since the user demonstrably HOLDS the backup by typing it — go straight to deposit-ready OnboardingActive, not the forced-backup state a fresh create lands in. approximateCreationTime optionally speeds the scan (the adapter floors the birthday from it; null ⇒ a full, money-safe scan).

THE LOWERCASE CHOKEPOINT (a HARD money-reliability contract): every word is normalized (trim + lowercase, drop empties) HERE before it reaches the SDK — the single place that guarantees the host-UI contract regardless of caller (the audited bip39 parser does NOT case-fold, so a correct backup typed with a soft-keyboard's autocapitalization would otherwise be rejected with a word index). The restore screen normalizes too (for the live word count); re-applying the idempotent normalizer here is the belt-and-suspenders that makes the contract structural.

A FIXABLE input fault (a mistyped word, a too-recent birthday) returns to OnboardingRestoreInput with an inline message and the typed phrase intact (the screen stays mounted across the round-trip); a provisioning failure (device locked, no vault, disk full …) surfaces on the generic OnboardingFailed screen. No rethrow — the state IS the surface.

Implementation

Future<void> startRestore(
  List<String> mnemonicWords, {
  DateTime? approximateCreationTime,
}) async {
  if (state is! OnboardingRestoreInput) return; // only from the restore form
  // OnboardingRestoreInput is reachable only via beginRestore from Welcome,
  // which implies a non-null provisioner+store — but a release build strips
  // `assert`, so a bare `return` would drop the user's typed phrase with no
  // feedback (invariant 10). Surface it honestly instead, as `retry()` does.
  final provisioner = _provisioner;
  final store = _store;
  if (provisioner == null || store == null) {
    _set(const OnboardingUnavailable());
    return;
  }

  // The normalize chokepoint — the hard host-UI contract enforced once, for
  // every caller (see the doc above).
  final words = mnemonicWords
      .map(normalizeMnemonicWord)
      .where((word) => word.isNotEmpty)
      .toList(growable: false);

  // SOLE-GUARD the empty case: an all-whitespace/empty phrase normalizes to []
  // and must never reach the SDK (no gate-close, no wasted round-trip). The
  // screen also gates word count, but the chokepoint owns the contract for
  // EVERY caller — surface it as the fixable invalid-phrase fault, staying on
  // the form. (Synchronous, before any await, so re-entrancy is unaffected.)
  if (words.isEmpty) {
    _set(const OnboardingRestoreInput(fault: RestoreInputFault.invalidWord));
    return;
  }

  // RE-ENTRANCY INTERLOCK (as in startCreate/confirmBackup): this synchronous
  // transition BEFORE the first await is the double-tap guard — a second
  // startRestore sees `state != RestoreInput` and no-ops, so a wallet is
  // restored AT MOST once.
  final generation = _beginMutation();
  if (generation == null) return;
  _set(const OnboardingRestoring());
  try {
    // CRASH-SAFE ORDERING (mirrors startCreate): durably CLOSE the backup gate
    // BEFORE the wallet is written. A crash anywhere before the confirm below
    // then re-forces a backup on next boot (safe — the user re-confirms with
    // the phrase they entered) rather than leaking an unconfirmed wallet past a
    // stale `true`. SAFE because `walletExists()` is false when startRestore runs
    // — either we entered from Welcome (no wallet was ever created) or from
    // `recoverByRestore` (the unreadable remnant was just wiped) — so the false
    // write touches a not-yet-existing wallet's gate, exactly as startCreate's does.
    await store.setBackupConfirmed(confirmed: false);
    if (_stale(generation)) return;
    final session = await provisioner.restore(
      words,
      approximateCreationTime: approximateCreationTime,
    );
    if (_stale(generation)) return;
    // The user typed the phrase ⇒ the backup is already held. Persist the
    // confirmation durably, THEN open the gate. On a persist failure the gate
    // stays CLOSED and we hold at AwaitingBackup (the wallet IS restored), so
    // the user re-confirms with the phrase — never deposit-ready on a
    // confirmation that didn't survive. Mirrors confirmBackup's revert.
    try {
      await store.setBackupConfirmed(confirmed: true);
      if (_stale(generation)) return;
      // #390: a RESTORED wallet may hold swap deposits/refunds past the
      // restore sweep's ceiling (the #387 residual) — arm the one-time
      // post-restore note (shown once the first catch-up completes). AWAITED
      // BEFORE the gate opens so the `_set` below (which rebuilds
      // deepScanRestoreNoteProvider) cannot race the write and read a stale
      // note. The setter NEVER throws, so it cannot fail the restore;
      // a silently-lost write just leaves the note absent (fail-safe).
      await store.setDeepScanRestoreNoteState(
        DeepScanRestoreNoteState.pending,
      );
      if (_stale(generation)) return;
      // BELT (security LOW — symmetry with confirmBackup): only open
      // the gate over OUR restoring transition; a build() re-key mid-persist
      // must never be clobbered with a stale-cycle Active. AFTER both
      // persists so it guards the flip against a re-key during either await.
      if (state is! OnboardingRestoring) return;
      // First activation of a just-restored wallet — a new life. A restore
      // supplies a seed, so it is spend-capable (#397 P1a).
      _set(
        OnboardingActive(
          session,
          identityEpoch: _nextIdentityEpoch(),
          isWatchOnly: false,
        ),
      );
    } catch (_) {
      if (_stale(generation)) return;
      if (state is! OnboardingRestoring) return;
      _set(OnboardingAwaitingBackup(session));
    }
  } catch (error) {
    if (_stale(generation)) return;
    final inputFault = classifyRestoreInputFault(error);
    if (inputFault != null) {
      _set(
        OnboardingRestoreInput(
          fault: inputFault.fault,
          invalidWordIndex: inputFault.wordIndex,
        ),
      );
    } else {
      _set(OnboardingFailed(classifyOnboardingFailure(error)));
    }
  } finally {
    _endMutation(generation);
  }
}