Files
minibits_wallet/__tests__/orphanedSeed.test.ts
T
minibits-cashandClaude Opus 4.8 a3051d327a Ask before resuming a seed that outlived its wallet
Deleting an app on iOS removes its container but NOT its keychain items, so
a reinstall comes back with the seed intact and every derivation counter
gone with the database. Onboarding treated that as a happy path — keys
found, reuse them, carry on — which resumes a used seed at counter 0 and
re-derives blinded secrets the mint has already signed. That is the
duplicate _B that SeedRecoveryScreen advances the counter to avoid; the mint
then either rejects the outputs or returns proofs that are already spent.

Not a regression: counters died with an iOS reinstall when they lived in
MMKV too. But a native release is exactly what prompts people to delete and
reinstall, so it is worth closing first.

Only ONE of the onboarding cases is unsafe, and the discriminator is the
schema, not the keys. Keys with an intact database mean a replayed
onboarding or terms re-agreement — the wallet and its counters are fine and
the user should not be interrupted. Keys with NO database mean the container
was wiped and the keychain survived it. Android reaches that state with no
keys at all (the keystore dies with the uid, allowBackup="false" stops a
Backup restore) and needs no handling, as the tests pin.

The fact is knowable exactly once, inside _createOrUpdateSchema, and is gone
immediately after: by the time any screen renders, setupRootStore has long
since called getInstance(). So instance.ts records it and setupRootStore
snapshots the pair at startup.

The snapshot is the subtle part. A live "keys exist AND schema is new" check
triggers on ITSELF: on a fresh install the schema IS new, so the moment
onboarding saves its keys the condition turns true, and anyone who backs out
and returns is offered the chance to reset a seed thirty seconds old. The
question is not "are there keys now" but "were there keys before we ran".

What the user is offered, per their own seed:
- RECOVER — keep it. Recovery walks the derivation space, moves each counter
  past what the mint has seen, restores the ecash, and recovers the profile
  from the seedHash so the minibits.cash address survives.
- START FRESH — discard it. Safe by construction, but it abandons whatever
  the old seed holds AND changes their identity: the Nostr keypair is
  NIP-06-derived from the mnemonic and walletId is regenerated, so the
  address changes and contacts can no longer reach them. Hence the mnemonic
  is shown and copyable before this is offered, and it takes a confirmation.

SeedRecoveryScreen now sets isOnboarded, because it has become an exit from
onboarding and was previously only ever reached from a wallet that was
already past it. A no-op for that caller.

Also drops torService from the services barrel: the file is zero bytes, so
it is not a module, and the export was a standing tsc error. Nothing imports
it. Net tsc errors 91 -> 90; no new ones.

NOT device-tested — the iOS reinstall path needs a real device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 14:46:58 +02:00

156 lines
6.2 KiB
TypeScript

/**
* @jest-environment node
*/
/**
* The onboarding cases, and which of them may touch the seed.
*
* A seed is ORPHANED when the keychain still holds one but this launch had to build
* the database from nothing — the container was wiped and the keychain survived it.
* That is the only case where resuming would derive from a zeroed counter against a
* seed the mint has already seen, and so the only one worth interrupting a user for.
*
* The schema half runs for real: instance.ts is driven through the op-sqlite mock, so
* "was the database built this launch" is decided by the production code path rather
* than by a stub of it. Only the keychain is mocked, because there isn't one under
* jest.
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
const mockHasWalletKeys = jest.fn()
jest.mock('../src/services/keyChain', () => ({
KeyChain: {hasWalletKeys: mockHasWalletKeys},
}))
/**
* A database that already exists. Only the version row matters — it is the single
* fact instance.ts branches on, and stamping it at the current version means no
* migration runs, which is what a launch on an up-to-date wallet actually does.
*/
const seedExistingDatabase = (db: any) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {_dbVersion} = require('../src/services/db/migrations')
db.exec(`CREATE TABLE dbversion (id INTEGER PRIMARY KEY NOT NULL, version INTEGER, createdAt TEXT)`)
db.exec(`INSERT INTO dbversion (id, version, createdAt) VALUES (1, ${_dbVersion}, '2026-01-01')`)
}
type Launch = {databaseExists: boolean; keysInKeychain: boolean}
/** One app launch, up to the point setupRootStore captures the answer. */
const launch = async ({databaseExists, keysInKeychain}: Launch) => {
jest.resetModules()
mockHasWalletKeys.mockReset()
mockHasWalletKeys.mockResolvedValue(keysInKeychain)
if (databaseExists) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('@op-engineering/op-sqlite').__seedNextDatabase(seedExistingDatabase)
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {Database} = require('../src/services/db')
Database.getInstance() // what setupRootStore does before capturing
// eslint-disable-next-line @typescript-eslint/no-var-requires
const orphanedSeed = require('../src/services/orphanedSeed')
await orphanedSeed.captureOrphanedSeed()
return orphanedSeed
}
describe('orphaned seed detection — the onboarding cases', () => {
test('a fresh first install: no keys, no database', async () => {
const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: false})
expect(hasOrphanedSeed()).toBe(false)
})
test('an Android reinstall looks exactly like a first install', async () => {
// Not a separate code path, and that IS the finding: the keystore dies with the
// app's uid and allowBackup="false" stops Google Backup restoring a container, so
// Android arrives with neither keys nor schema and needs no special handling.
const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: false})
expect(hasOrphanedSeed()).toBe(false)
})
test('an iOS reinstall: keys survived, the database did not', async () => {
// The one case worth prompting on. iOS keeps keychain items across app deletion.
const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: true})
expect(hasOrphanedSeed()).toBe(true)
})
test('a TOS re-onboard leaves the seed alone', async () => {
// isOnboarded is flipped back to false to re-show the terms. The wallet, its
// database and its counters are all intact — there is nothing to decide.
const {hasOrphanedSeed} = await launch({databaseExists: true, keysInKeychain: true})
expect(hasOrphanedSeed()).toBe(false)
})
test('replaying onboarding from the developer screen leaves the seed alone', async () => {
const {hasOrphanedSeed} = await launch({databaseExists: true, keysInKeychain: true})
expect(hasOrphanedSeed()).toBe(false)
})
test('a factory reset leaves the seed alone, because it takes the keys with it', async () => {
// DeveloperScreen calls removeWalletKeys() alongside cleanAll(), so the next
// launch finds neither — a first install, not an orphan.
const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: false})
expect(hasOrphanedSeed()).toBe(false)
})
})
describe('the snapshot', () => {
test('keys created BY onboarding do not make the wallet look orphaned', async () => {
// The trap this module exists to avoid. On a fresh install the schema IS new, so
// a live check would turn true the instant onboarding saved its keys — offering
// to reset a seed thirty seconds old to anyone who backed out and came back.
const {hasOrphanedSeed, captureOrphanedSeed} = await launch({
databaseExists: false,
keysInKeychain: false,
})
// Onboarding generates and saves keys; the keychain now has some.
mockHasWalletKeys.mockResolvedValue(true)
await captureOrphanedSeed()
expect(hasOrphanedSeed()).toBe(false)
})
test('resolving it clears it, so a fresh start is not re-offered', async () => {
const {hasOrphanedSeed, resolveOrphanedSeed} = await launch({
databaseExists: false,
keysInKeychain: true,
})
expect(hasOrphanedSeed()).toBe(true)
resolveOrphanedSeed()
expect(hasOrphanedSeed()).toBe(false)
})
test('an unreadable keychain does not fail the launch', async () => {
jest.resetModules()
mockHasWalletKeys.mockReset()
mockHasWalletKeys.mockRejectedValue(new Error('keychain unavailable'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {Database} = require('../src/services/db')
Database.getInstance()
// eslint-disable-next-line @typescript-eslint/no-var-requires
const orphanedSeed = require('../src/services/orphanedSeed')
await expect(orphanedSeed.captureOrphanedSeed()).resolves.toBeUndefined()
// Falls back to the behaviour every release until now shipped: resume the seed.
expect(orphanedSeed.hasOrphanedSeed()).toBe(false)
})
})