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