Legacy account recovery (spec 062)¶
The Recovery section (formerly "Backup & Security") lets a member bring an older account into FairWins from a raw EOA private key or a BIP-39 word list, store it encrypted on-device, and optionally move its funds to a smart account. It is a frontend-only feature — no contracts, no gateway — that composes existing subsystems.
Where it lives¶
| Concern | Module |
|---|---|
| Classify / encrypt / decrypt / vault / sweep | frontend/src/lib/recovery/legacyKeys.js |
| Backup-synced encrypted store (ciphertext only) | frontend/src/lib/recovery/legacyRecoveredKeysStore.js |
| BIP-39 word suggestions (typo help) | frontend/src/lib/recovery/bip39Suggest.js |
| Audit ledger record (no secrets) | frontend/src/data/ledger/sources/legacyRecoverySource.js |
| UI (guided bottom sheets) | frontend/src/components/account/LegacyKeyRecoveryPanel.jsx |
| Backup domain registration | frontend/src/lib/backup/syncedObjects.js (legacyRecoveredKeys) |
Rules to keep¶
- Secrets are encrypted at rest, never in the clear, never transmitted, never logged. The raw
private key / mnemonic is wrapped with AES-GCM under a PBKDF2-SHA256 (650k) key stretched from a
member-chosen passphrase. A wrong passphrase fails the GCM tag — never fall through to substitute
material. Only the ciphertext blob (
{ v, kind, address, salt, iv, ct, iterations, importedAt }) is persisted or backed up. - The vault is per-account.
legacyKeyVault(account)is a CRUD facade overlegacyRecoveredKeysStore(userStorage keylegacy_recovered_keys). The store owns the key + shape; do not read/write that key from anywhere else. - Moving funds is OPTIONAL. Storing the encrypted secret completes recovery (the SAVED screen).
The sweep (
sweepAllAssets) moves all supported fungible assets — native + every supported ERC-20 fromgetPortfolioRegistry(chainId)— ERC-20s first, native last (leaving a gas reserve). It returns a per-asset outcome array; one asset failing never aborts the rest, and nothing is silently dropped. NFTs/collectibles are out of scope and disclosed as such. - Recovered accounts are first-class. Saving to the address book uses
useAddressBook()(findByAddress→addContact/updateContact) so the account is usable on every picker. The encrypted records ride the spec-032 backup via thelegacyRecoveredKeyssynced object (not network-scoped — a legacy EOA address is the same across EVM chains). - Audit without leakage.
captureLegacyRecovery(account, chainId, { recoveredAddress, source })appends one client-ledger record (kind: 'legacy_account_recovered',refs= address + type only) with a stable entryId, so it is idempotent and never carries key material.
End-to-end coverage¶
Two specs cover this feature, split by the tiering rule that a flow validatable without a chain must not sit in the full tier (the tiering policy):
frontend/cypress/e2e/fast/28-legacy-recovery.cy.js— the import, asserted against the STORAGE rather than the screen, plus spec 063's cross-chain scan.frontend/cypress/e2e/full/28-legacy-recovery-sweep.cy.js— the sweep, judged by chain state.
Status and depth per flow live in the coverage matrix; it is generated,
so record changes in frontend/cypress/coverage/matrix.json and run npm run e2e:matrix.
Notes for anyone extending these:
- The sweep is the reason
cy.mockWeb3ProvidergrewrealBalances. The default mock answers a fixed 100 ETH for every address, which is harmless while a spec only reads the connected account and a fabrication the moment it reads one it does not control — such as a recovered legacy key. - Fixtures come from the
legacyFixtureCypress task (frontend/cypress.config.js), which mints a fresh EOA per test. A fixed key accumulates balances across runs, and "what moved" stops being a property of the code. - The per-asset failure has to be forced at the moment of TRANSFER.
sweepAllAssetsre-reads balances itself, so draining a token before "Transfer all" just drops it from the run and proves nothing.makeTokenRefuseswaps inReentrantToken's code (contracts/mocks) over the token the app scans and arms it to refuse one transfer — a token that holds the balance and declines to move it, which is what a blocklisting stablecoin does. Balances are untouched by the swap, and the per-spec chain checkpoint restores the original code. - Writing these tests found two real defects in
sweepAllAssets, both fixed and both invisible to the unit suite because a stubbed provider's nonce and balance never move: the coin leg reused a token leg's nonce, and its value was sized from a balance taken before the token transfers had paid their gas. With any ERC-20 to move first, the member's coin never left. - Moving funds is optional, so both tiers assert that too: storing the key completes recovery, and on chain nothing moves until the member asks.
Testing note¶
Under vitest+jsdom, Node's Buffer leaks in and ethers' default sha256 returns a Buffer its own
hexlify rejects, breaking BIP-39 parsing. Any suite that exercises mnemonics calls
registerEthersCrypto() (frontend/src/test/recovery/registerEthersCrypto.js) to register
@noble/hashes. Real browsers have no Buffer and use the pure-JS path — production is unaffected.
See specs/062-legacy-account-recovery/ for the spec, plan, and task breakdown.