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>
This commit is contained in:
minibits-cash
2026-08-04 14:46:58 +02:00
co-authored by Claude Opus 4.8
parent 9ff5e1f39c
commit a3051d327a
11 changed files with 575 additions and 6 deletions
+155
View File
@@ -0,0 +1,155 @@
/**
* @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)
})
})
+11
View File
@@ -768,6 +768,17 @@
"welcomeScreen_terms_agreeTerms": "Terms",
"welcomeScreen_terms_agreeConjunction": "and",
"welcomeScreen_terms_agreePrivacy": "Privacy Policy",
"orphanedSeed_title": "Wallet keys found",
"orphanedSeed_explainTitle": "Your wallet data is gone, your keys are not",
"orphanedSeed_explainDescription": "This device still holds the keys to a previous Minibits wallet, but none of its data. Removing the app deletes its ecash, history and mints, while iOS keeps its keys. The seed phrase below is the only way to reach any funds that wallet still holds.",
"orphanedSeed_copied": "Seed phrase copied to the clipboard.",
"orphanedSeed_recoverTitle": "Recover this wallet",
"orphanedSeed_recoverDescription": "Restore the ecash, the mints and your minibits.cash address from this seed phrase. Choose this if the previous wallet might still hold funds.",
"orphanedSeed_resetTitle": "Start a new wallet",
"orphanedSeed_resetDescription": "Discard the keys above and create a new wallet. Save the seed phrase first if you may need those funds later.",
"orphanedSeed_resetConfirmTitle": "Discard these keys?",
"orphanedSeed_resetConfirmDescription": "Without the seed phrase above, any ecash the previous wallet holds becomes unreachable. You will also get a new minibits.cash address, and contacts will no longer reach you at the old one.",
"orphanedSeed_resetConfirmButton": "Discard and start fresh",
"mintSelector_noTopupSupport": "Top up not supported for this currency",
"mintSelector_noPayoutSupport": "Payouts not supported for this currency",
"mintSelector_noOnchainPayoutSupport": "This mint does not support Bitcoin payouts",
+7 -1
View File
@@ -17,7 +17,7 @@ import {
} from 'mobx-state-tree'
import * as Sentry from '@sentry/react-native'
import type { RootStore } from '../RootStore'
import { Database, MMKVStorage } from '../../services'
import { captureOrphanedSeed, Database, MMKVStorage } from '../../services'
import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db'
import type { Mint } from '../Mint'
import { log } from '../../services/logService'
@@ -93,6 +93,12 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO
// hydrateMintsFromDatabase for why that must not be gated on a version.
mintsStore.hydrateMintsFromDatabase()
// Did the keychain outlive the wallet? Answered HERE because both halves are
// only true here: the database has just been opened (so "was it built this
// launch" is meaningful), and no screen has rendered yet (so onboarding cannot
// have generated the very keys we are asking about). See services/orphanedSeed.
await captureOrphanedSeed()
if(walletProfileStore.walletId) {
Sentry.setUser({ id: walletProfileStore.walletId })
}
+3
View File
@@ -10,6 +10,7 @@ import React from "react"
import Config from "../config"
import {
WelcomeScreen,
OrphanedSeedScreen,
SeedRecoveryScreen,
MintsScreen,
SeedRecoveryOptionsScreen,
@@ -43,6 +44,8 @@ const RootStack = createNativeStackNavigator({
},
screens: {
Welcome: WelcomeScreen,
// Only ever reached from Welcome, when the keychain outlived the wallet.
OrphanedSeed: OrphanedSeedScreen,
SeedRecovery: SeedRecoveryScreen,
ImportBackup: ImportBackupScreen,
+269
View File
@@ -0,0 +1,269 @@
import React, {useEffect, useState} from 'react'
import {FlatList, TextStyle, View, ViewStyle} from 'react-native'
import {scale} from '@gocodingnow/rn-size-matters'
import Clipboard from '@react-native-clipboard/clipboard'
import {StaticScreenProps, useNavigation} from '@react-navigation/native'
import {
$sizeStyles,
BottomModal,
Button,
Card,
ErrorModal,
InfoModal,
ListItem,
Loading,
Screen,
Text,
} from '../components'
import {translate} from '../i18n'
import {KeyChain, log, resolveOrphanedSeed} from '../services'
import {colors, spacing, useThemeColor} from '../theme'
import AppError from '../utils/AppError'
type Props = StaticScreenProps<undefined>
/**
* The keychain outlived the wallet — offer the user the choice before anything derives.
*
* Reached from WelcomeScreen when `hasOrphanedSeed()` is true, which in practice means
* an iOS reinstall: the container (database, MMKV) went with the app, the keychain did
* not. The seed is therefore intact while every derivation counter is gone, and simply
* resuming would re-derive blinded secrets the mint has already signed.
*
* Two ways out, both safe, and the screen is careful not to make the choice for them:
*
* RECOVER — keep the seed and run the standard recovery, which walks the derivation
* space, moves each counter past what the mint has already seen, and brings the ecash
* back. It also recovers the profile from the seedHash, so the user keeps their
* @minibits.cash address.
*
* START FRESH — discard the seed and generate a new one. Safe by construction (a new
* seed has no history, so counter 0 is correct), but it abandons whatever the old
* seed still holds AND changes the user's identity: the Nostr keypair is derived from
* the mnemonic via NIP-06 and the walletId is regenerated, so the address changes and
* contacts can no longer reach them. That is why the mnemonic is shown and copyable
* BEFORE this is offered, and why it takes a confirmation.
*/
export const OrphanedSeedScreen = function ({route}: Props) {
const navigation = useNavigation()
const headerBg = useThemeColor('header')
const headerTitle = useThemeColor('headerTitle')
const [mnemonic, setMnemonic] = useState<string>()
const [mnemonicArray, setMnemonicArray] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isResetConfirmVisible, setIsResetConfirmVisible] = useState(false)
const [info, setInfo] = useState('')
const [error, setError] = useState<AppError | undefined>()
useEffect(() => {
const loadMnemonic = async () => {
try {
const keys = await KeyChain.getWalletKeys()
// Defensive: the screen is only reachable when keys were found at startup, so
// this means they vanished underneath us. Nothing to decide — let onboarding
// carry on and generate a fresh set.
if (!keys) {
log.warn('[OrphanedSeedScreen]', 'No wallet keys found, returning to onboarding')
resolveOrphanedSeed()
navigation.goBack()
return
}
setMnemonic(keys.SEED.mnemonic)
setMnemonicArray(keys.SEED.mnemonic.split(/\s+/))
setIsLoading(false)
} catch (e: any) {
setIsLoading(false)
setError(e)
}
}
loadMnemonic()
}, [])
const onCopy = function () {
try {
if (!mnemonic) return
Clipboard.setString(mnemonic)
setInfo(translate('orphanedSeed_copied'))
} catch (e: any) {
setInfo(translate('commonCopyFailParam', {param: e.message}))
}
}
/** Keep the seed. Recovery restores the funds AND advances the counters. */
const onRecover = function () {
// Deliberately NOT resolved: recovery may be abandoned half way, and a user who
// backs out has decided nothing. Asking again is correct.
navigation.navigate('SeedRecovery' as never)
}
/** Discard the seed. Onboarding then generates a fresh one and derives from 0 safely. */
const onStartFresh = async function () {
try {
setIsResetConfirmVisible(false)
setIsLoading(true)
await KeyChain.removeWalletKeys()
// Clear the startup snapshot, or the freshly generated keys look orphaned too and
// onboarding offers to reset a seed that is seconds old.
resolveOrphanedSeed()
log.info('[OrphanedSeedScreen]', 'Wallet keys discarded on user confirmation')
navigation.goBack()
} catch (e: any) {
setIsLoading(false)
setError(e)
}
}
return (
<Screen contentContainerStyle={$screen} preset="auto">
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text preset="heading" tx="orphanedSeed_title" style={{color: headerTitle}} />
</View>
<View style={$contentContainer}>
<Card
style={$card}
ContentComponent={
<ListItem
tx="orphanedSeed_explainTitle"
subTx="orphanedSeed_explainDescription"
leftIcon="faTriangleExclamation"
leftIconColor={colors.palette.accent400}
leftIconInverse={true}
style={$item}
/>
}
/>
<Card
style={$card}
ContentComponent={
<>
{isLoading && <Loading />}
<FlatList
data={mnemonicArray}
numColumns={2}
renderItem={({item, index}) => (
<Button
key={index}
preset={'secondary'}
onPress={() => false}
text={`${index + 1}. ${item}`}
style={{minWidth: scale(150), margin: spacing.tiny, minHeight: scale(25)}}
textStyle={[$sizeStyles.xs, {padding: 0, margin: 0, lineHeight: 16}]}
/>
)}
keyExtractor={item => item}
style={{flexGrow: 0}}
contentContainerStyle={{alignItems: 'center'}}
/>
</>
}
FooterComponent={
<View style={$buttonContainer}>
<Button
preset="default"
style={{margin: spacing.small}}
tx="commonCopy"
onPress={onCopy}
/>
</View>
}
/>
<Card
style={$card}
ContentComponent={
<>
<ListItem
tx="orphanedSeed_recoverTitle"
subTx="orphanedSeed_recoverDescription"
leftIcon="faRotate"
leftIconColor={colors.palette.success200}
leftIconInverse={true}
style={$item}
bottomSeparator={true}
onPress={onRecover}
/>
<ListItem
tx="orphanedSeed_resetTitle"
subTx="orphanedSeed_resetDescription"
leftIcon="faXmark"
leftIconColor={colors.palette.neutral400}
leftIconInverse={true}
style={$item}
onPress={() => setIsResetConfirmVisible(true)}
/>
</>
}
/>
</View>
<BottomModal
isVisible={isResetConfirmVisible}
ContentComponent={
<View style={$modalContainer}>
<Text preset="subheading" tx="orphanedSeed_resetConfirmTitle" />
<Text
style={{marginVertical: spacing.small, textAlign: 'center'}}
tx="orphanedSeed_resetConfirmDescription"
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx="commonCancel"
style={{marginRight: spacing.small}}
onPress={() => setIsResetConfirmVisible(false)}
/>
<Button
preset="default"
tx="orphanedSeed_resetConfirmButton"
onPress={onStartFresh}
/>
</View>
</View>
}
onBackButtonPress={() => setIsResetConfirmVisible(false)}
onBackdropPress={() => setIsResetConfirmVisible(false)}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</Screen>
)
}
const $screen: ViewStyle = {}
const $headerContainer: TextStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
height: spacing.screenHeight * 0.15,
}
const $contentContainer: TextStyle = {
marginTop: -spacing.extraLarge * 2,
padding: spacing.extraSmall,
}
const $card: ViewStyle = {
marginBottom: spacing.small,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}
const $modalContainer: ViewStyle = {
alignItems: 'center',
paddingVertical: spacing.large,
paddingHorizontal: spacing.small,
}
const $item: ViewStyle = {
paddingHorizontal: spacing.small,
paddingLeft: 0,
}
+7
View File
@@ -507,6 +507,13 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
setStatusMessage(translate('recovery_completed'))
// Recovery is now also an EXIT from onboarding: OrphanedSeedScreen sends a
// user here who has agreed to the terms but never reached the end of
// WelcomeScreen, the only other place this is set. Without it they would
// land in the wallet and then be shown the terms again on the next cold
// start. A no-op for the usual caller, who is onboarded already.
userSettingsStore.setIsOnboarded(true)
// go directly to the wallet (profile has been rehydrated from the one with the seed)
//@ts-ignore
navigation.navigate('Tabs')
+16 -2
View File
@@ -25,7 +25,7 @@ import {translate} from '../i18n'
import AppError from '../utils/AppError'
import { MINIBITS_MINT_URL } from '@env'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { KeyChain, log } from '../services'
import { hasOrphanedSeed, KeyChain, log } from '../services'
import { delay } from '../utils/utils'
import { htmlToBlocks, Block, InlineSegment } from '../utils/htmlToBlocks'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
@@ -79,9 +79,23 @@ export const WelcomeScreen = function ({ route }: Props) {
setIsLoading(true)
setStatusMessage(translate('welcomeScreen_creatingKeys'))
// check if keys already exist (if onboarding is repeated or if iOS did not wipe keys?)
// Keys can already exist here for benign reasons — onboarding replayed from
// the developer screen, or isOnboarded flipped back to re-show updated terms.
// In both the wallet behind them is intact, so they are simply reused.
let keys = await KeyChain.getWalletKeys()
// The one case that is NOT benign: keys with no wallet behind them, which is
// an iOS reinstall (the container goes, the keychain stays). Every derivation
// counter died with the database, so resuming this seed would re-derive
// blinded secrets the mint has already signed. Hand the decision to the user
// rather than deriving from a zeroed counter. See services/orphanedSeed.
if(keys && hasOrphanedSeed()) {
setIsLoading(false)
setStatusMessage('')
navigation.navigate('OrphanedSeed')
return
}
if(!keys) {
const newKeys = KeyChain.generateWalletKeys()
+1
View File
@@ -1,5 +1,6 @@
// AppStack
export * from './WelcomeScreen'
export * from './OrphanedSeedScreen'
export * from './RecoveryOptionsScreen'
export * from './SeedRecoveryOptionsScreen'
export * from './SeedRecoveryScreen'
+19
View File
@@ -6,6 +6,24 @@ import {log} from '../logService'
let _db: DbConnection
let _schemaCreatedThisLaunch = false
/**
* True when THIS launch built the database from nothing, rather than opening or
* migrating an existing one.
*
* The fact is knowable exactly once — inside _createOrUpdateSchema, at the moment
* the version row is found missing — and is destroyed immediately afterwards, since
* from then on the schema simply exists. Nothing else can reconstruct it: by the time
* any screen renders, setupRootStore has long since called getInstance().
*
* It matters because, combined with a seed in the keychain, it identifies a wallet
* whose keys outlived its data. On iOS that is a reinstall: deleting an app removes
* its container (this database, and MMKV with it) but NOT its keychain items, so the
* seed comes back while every derivation counter is gone. See services/orphanedSeed.
*/
export const wasSchemaCreatedThisLaunch = () => _schemaCreatedThisLaunch
export const getInstance = function () {
if (!_db) {
// 1. creates database
@@ -56,6 +74,7 @@ const _createOrUpdateSchema = function (db: DbConnection) {
// that produced that shape are correctly skipped.
db.executeBatch(createSchemaQueries)
seedDatabaseVersion(db)
_schemaCreatedThisLaunch = true
log.info('[_createOrUpdateSchema]', `New database created at version ${_dbVersion}`)
return
}
+1 -1
View File
@@ -1,11 +1,11 @@
export * from "./mmkvStorage"
export * from "./sqlite"
export * from "./keyChain"
export * from "./orphanedSeed"
export * from "./walletService"
export * from "./minibitsService"
export * from "./nostrService"
export * from "./logService"
export * from "./torService"
export * from "./syncQueueService"
export * from "./notificationService"
+84
View File
@@ -0,0 +1,84 @@
import {wasSchemaCreatedThisLaunch} from './db/instance'
import {KeyChain} from './keyChain'
import {log} from './logService'
/**
* An ORPHANED SEED is a wallet key that outlived the wallet: the keychain still holds
* a seed, but this launch had to build the database from nothing.
*
* In practice that means an iOS reinstall. Deleting an app removes its container —
* this database, and MMKV with it — but iOS deliberately does NOT remove the app's
* keychain items. (Apple briefly changed that in an iOS 10.3 beta and reverted before
* release; it has never been contractually guaranteed either way, so this is behavior
* to detect, not to depend on.) Android has no equivalent: the keystore dies with the
* app's uid, and `android:allowBackup="false"` keeps Google Backup from restoring a
* container, so a reinstall there arrives with neither keys nor schema.
*
* Why it is worth detecting: the seed comes back, but every derivation counter is
* gone with the database. Resuming that seed at counter 0 re-derives blinded secrets
* the mint has ALREADY signed — the duplicate _B that SeedRecoveryScreen advances the
* counter to avoid. The mint then either rejects the outputs or hands back proofs that
* are already spent. Recovery is the only safe way to resume such a seed: it walks the
* derivation space and moves the counter past whatever the mint has seen.
*
* WHY A SNAPSHOT, AND NOT A LIVE CHECK. The naive condition — "keys exist AND the
* schema is new" — triggers on itself. On a genuinely fresh install the schema IS new,
* so the moment onboarding generates and saves keys the condition becomes true, and a
* user who backs out of onboarding and returns is offered the chance to reset a seed
* that is thirty seconds old. The question is not "are there keys now" but "were there
* keys before we ran", so it is answered once, at startup, before onboarding can
* create anything.
*/
let _hasOrphanedSeed = false
let _isCaptured = false
/**
* Answer the question once, at startup. Must run BEFORE onboarding can save keys
* (see setupRootStore) and AFTER the database has been opened, which is what makes
* wasSchemaCreatedThisLaunch meaningful.
*
* Never throws: a keychain that cannot be read is not a reason to fail a launch. It
* only means we cannot offer the choice, and the pre-existing behaviour (resume the
* seed) applies — which is what shipped for every release until now.
*/
export const captureOrphanedSeed = async function () {
if (_isCaptured) return
try {
_hasOrphanedSeed = wasSchemaCreatedThisLaunch() && (await KeyChain.hasWalletKeys())
if (_hasOrphanedSeed) {
log.info(
'[captureOrphanedSeed]',
'Wallet keys found but no database — the container was wiped and the keychain survived',
)
}
} catch (e: any) {
log.error('[captureOrphanedSeed]', 'Could not determine seed provenance', {message: e.message})
_hasOrphanedSeed = false
} finally {
_isCaptured = true
}
}
/** True when this launch found a seed with no wallet behind it. */
export const hasOrphanedSeed = () => _hasOrphanedSeed
/**
* Clear the flag once the user has decided what to do with the seed.
*
* Needed because the snapshot outlives the decision: after a user chooses to start
* fresh, onboarding generates NEW keys, and without this a back-out-and-return would
* offer to reset those instead. Recovery deliberately does NOT clear it — a user who
* abandons recovery half way has not resolved anything, and should be asked again.
*/
export const resolveOrphanedSeed = function () {
_hasOrphanedSeed = false
}
/** Test seam: restore the module to its pre-capture state. */
export const _resetOrphanedSeedForTests = function () {
_hasOrphanedSeed = false
_isCaptured = false
}