feat(identity): list and start identities from both stores, and fix the other entrance

Phase 3 of docs/nsec-sign-in.md, and the submodule bump that brings Phase 2's
NostrKeyManager in (lightning-kmp-app 01489b8 -> 59c11ed, branch
claude/nostr-key-store). Nothing can create an nsec identity yet -- that is
Phase 4 -- but from here one that exists is listed, selected, started and
routed like any wallet.

StoredIdentity is what the startup screen now reads: a sealed type over
Mnemonic(userWallet) and NostrSecret(id, privateKey), both carrying the nostr
public key, because that is the one thing the two kinds share and the one
thing a duplicate check has to compare. StoredIdentity.merge folds seed.dat
and nostr-keys.dat into one map keyed by WalletId; a wallet's pubkey is one
more LocalKeyManager over words that SeedManager has already derived once.

SovereignWalletViewModel.listAvailableWallets becomes listIdentities. It
reads both files and surfaces a failure in either rather than skipping it: a
corrupt nostr-keys.dat would otherwise drop every imported identity from the
list without a word, and the seed file has always been handled this way.
Metadata registration is unchanged -- it keys on WalletId and does not care
what is behind one.

SovereignWalletStartupScreen picks a StoredIdentity, and LoadWallet, the
screen-lock gate, takes the identity rather than the wallet: it only ever read
the id, to look up the lock preferences, and it now sits outside the branch on
kind so a future lock is not something to remember to add twice. The Mnemonic
branch is the old path -- startupNode, setActiveWallet. The NostrSecret branch
builds the Identity directly with business = null and sets it; no
platformStartupLogic, no schedulePlatformLogic, no StartupViewState. Setting
it recomposes into the existing `activeIdentity != null` branch, which is what
reports the startup, so the nsec path does not double-report as the mnemonic
path does. WalletsSelector shows the npub on the second line for both kinds;
it used to show the node id, which a bare key does not have.

The other entrance. NavigationViewModel.loadNostrProfile(startupRoute) read

    getLocalAccounts().firstOrNull()?.profile?.publicKey

which is wrong twice for this plan. It takes the first kind-0 account on the
device whichever identity is active -- harmless with one wallet, wrong half
the time with two, and an imported key is how a device gets its second. And
it keys off the Profile row, which only the notary's *signing* writes, so a
placeholder account planted by signInToProfile (or a profile created moments
ago and not yet signed) read as null and was answered with Landing, while
observeProfile, reading the same rows, answered the sync screen: two writers
to one state in whichever order the coroutines ran, and Landing pops the back
stack. It now selects the account by the active identity's pubkey, matched on
the unsigned event's pubKey, and passes it straight to processLocalAccount
rather than fetching it a second time.

The seed writer, hoisted. platformWriteSeed was an expect with three
byte-identical actuals -- android, jvm, ios -- using nothing but commonMain.
It is one function now, IdentityWriter.writeMnemonic, and the second writer,
IdentityWriter.writeNostrKey, sits next to it rather than being triplicated
in turn. Both refuse a duplicate by nostr public key as well as by id: a
wallet and an imported key can be the same npub under different ids, and the
database is keyed by pubkey. The three platform files lose the copy and the
imports that only it used; the jvm file's header, which described the copy,
now describes what is left.

Tests. NavigationRoutingTest gains the placeholder fixture -- kind 0 at
GENESIS_AT, no Profile, no sync request -- and asserts it routes to
UnqueuedProfileSynchronization and never Landing. The jvm routing test gains
a device with two accounts and asserts the startup entrance reads the active
identity's. StoredIdentityJvmTest pins the merge against NIP-06's own vector:
the words "leader monkey parrot ..." derive 17162c92...cd917, the same key an
nsec import of that secret produces, which is the whole basis of the
duplicate check; that a wallet and its own nostr key as a bare secret land
under different ids (why the writers dedupe by pubkey); and that a key filed
under a pubkey it does not derive is dropped.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (870
tests) and :composeApp:m3Audit. The submodule commit is local to the branch
named above and has to be pushed with this one.

Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 59c11ed9, the pin this commit compiles against (git had fast-forwarded it to the newer one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@366b017787
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-13 12:00:19 +02:00
1 parent bfba7e186f
commit 24ca50ca48
14 files changed
+644 -381

No files matched your search

@@ -1,37 +1,22 @@
// The ios actuals with one import changed. That file uses no ios API -- SeedManager,
// DataStoreManager, EncryptedSeed and LocalKeyManager are all commonMain -- so the only
// thing that had to move was BusinessManager, which is now ported to the library's jvmMain.
// The ios actuals with one import changed: BusinessManager, ported to the library's jvmMain.
// The seed writer that used to be the bulk of this file was byte-identical on all three
// platforms and now lives once, in commonMain, as IdentityWriter.writeMnemonic.
//
// Kept as a copy for the same reason the ported BusinessManager is: sharing it means an
// intermediate source set between iosMain and jvmMain, which is a change to how the module
// is wired rather than to what it does.
// What is left is kept as a copy for the same reason the ported BusinessManager is: sharing
// it means an intermediate source set between iosMain and jvmMain, which is a change to how
// the module is wired rather than to what it does.
package press.mantra.compose.ui.view.model
import press.mantra.compose.AppVersion
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.Chain
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.DecryptSeedResult
import fr.acinq.phoenix.data.ElectrumConfig
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.jvm.BusinessManager
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.security.EncryptedSeed
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
actual fun updateBusinessActiveInUI(walletId: WalletId) {
@@ -78,80 +63,3 @@ actual suspend fun saveAvailableWalletMeta(
isHidden = isHidden
)
}
actual fun platformWriteSeed(
log: Logger,
phoenixGlobal: PhoenixGlobal,
globalPrefs: GlobalPrefs,
writingState: WritingSeedState,
viewModelScope: CoroutineScope,
mnemonics: List<String>,
onWritingSeedError: (WritingSeedState.Error) -> Unit,
onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit,
isRestoringWallet: Boolean,
isTorEnabled: Boolean,
customElectrumServer: ElectrumConfig.Custom?,
onSeedWritten: (WalletId) -> Unit
) {
if (writingState !is WritingSeedState.Init) return
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
log.e("failed to write mnemonics to disk: ${e.message}")
onWritingSeedError.invoke(
WritingSeedState.Error.Generic(e)
)
}) {
log.d("writing mnemonics to disk...")
onWritingSeedStateWriting.invoke(
WritingSeedState.Writing(mnemonics)
)
val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map {
it.key to it.value.words
}?.toMap()
val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector()
val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub)
val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey)
when {
existingSeeds == null -> {
log.e("could not load the existing seed map, aborting...")
onWritingSeedError.invoke(
WritingSeedState.Error.CannotLoadSeedMap
)
return@launch
}
existingSeeds.containsKey(newWalletId) -> {
log.i("attempting to import a seed that already exists, aborting...")
onWritingSeedError.invoke(
WritingSeedState.Error.SeedAlreadyExists
)
return@launch
}
else -> {
val newSeedMap = existingSeeds + (newWalletId to mnemonics)
val encrypted = EncryptedSeed.V2.encrypt(newSeedMap)
SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true)
if (isRestoringWallet) {
log.i("successfully restored wallet=$newWalletId")
} else {
log.i("successfully created wallet=$newWalletId")
}
}
}
globalPrefs.saveLastUsedAppCode(AppVersion.versionCode)
val dataStoreManager = DataStoreManager(
phoenixGlobal.ctx,
chain = NodeParamsManager.chain,
)
val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId)
userPrefs.saveIsTorEnabled(isTorEnabled)
userPrefs.saveElectrumServer(customElectrumServer)
viewModelScope.launch(Dispatchers.Main) {
delay(1000)
onSeedWritten(newWalletId)
}
}
}