From 24ca50ca48cb708e9670deb0053ca8c247b12cac Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 11:56:25 +0200 Subject: [PATCH] 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 Pulled-From: curated/curated@366b017787377a0af20b3f10f0dd1a312c885aba --- .../view/model/NavigationViewModel.android.kt | 93 -------- .../mantra/compose/identity/IdentityWriter.kt | 209 ++++++++++++++++++ .../mantra/compose/identity/StoredIdentity.kt | 98 ++++++++ .../SovereignWalletStartupScreen.kt | 86 ++++--- .../ui/composable/navigation/MantraNavHost.kt | 2 +- .../widgets/wallet/WalletsSelector.kt | 30 ++- .../ui/view/model/NavigationViewModel.kt | 27 ++- .../ui/view/model/SovereignWalletViewModel.kt | 121 ++++++---- .../ui/view/model/NavigationRoutingTest.kt | 47 ++++ .../ui/view/model/NavigationViewModel.ios.kt | 92 -------- .../ui/view/model/NavigationViewModel.jvm.kt | 104 +-------- .../compose/identity/StoredIdentityJvmTest.kt | 87 ++++++++ .../model/NavigationIdentityRoutingJvmTest.kt | 27 +++ lightning-kmp-app | 2 +- 14 files changed, 644 insertions(+), 381 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt index eaf692c1..3a90eb28 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt @@ -1,28 +1,13 @@ package press.mantra.compose.ui.view.model import press.mantra.android.MantraApplication -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger -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.android.BusinessManager import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId -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.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) @@ -59,81 +44,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - 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) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt new file mode 100644 index 00000000..a6b2a484 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -0,0 +1,209 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrKeys +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import press.mantra.compose.AppVersion +import press.mantra.compose.ui.view.model.WritingSeedState + +/** + * Puts a new identity's secret on disk, one function per kind, and prepares the + * per-identity preferences both kinds read. + * + * [writeMnemonic] was an `expect` with three byte-identical actuals -- android, jvm, ios -- + * using nothing but commonMain: SeedManager, EncryptedSeed, LocalKeyManager, + * DataStoreManager, AppVersion. It was an expect because something once needed to + * differ and nothing does now, so it is one function here, and the second writer sits + * next to it rather than being triplicated in turn. + * + * Both refuse a duplicate **by nostr public key**, not only by id. A wallet and an + * imported key can be the same npub under different ids -- one is hash160 of the node + * key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two + * identities for one pubkey would share every row and disagree about which is active. + */ +object IdentityWriter { + + /** The nostr public keys of every identity already on this device, or null if a store could not be read. */ + private fun knownNostrPublicKeys(phoenixGlobal: PhoenixGlobal): Set? { + val wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null + val nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null + return StoredIdentity.merge(wallets, nostrKeys).values.map { it.nostrPublicKey }.toSet() + } + + /** + * Creates the preference files a new id needs and records the choices made before + * the id was known. Tor and a custom Electrum server mean nothing to an identity + * without a wallet; they are saved anyway, because `loadUserPrefsForWallet` is what + * creates the file and the recovery screens read it. + */ + private suspend fun prepareIdentity( + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + id: WalletId, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ) { + globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) + val dataStoreManager = DataStoreManager( + phoenixGlobal.ctx, + chain = NodeParamsManager.chain, + ) + val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = id) + userPrefs.saveIsTorEnabled(isTorEnabled) + userPrefs.saveElectrumServer(customElectrumServer) + } + + /** + * Adds [mnemonics] to `seed.dat`. Callback-shaped, driving [WritingSeedState], because + * that is what the create flow and its view model were built around. + */ + fun writeMnemonic( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + writingState: WritingSeedState, + viewModelScope: CoroutineScope, + mnemonics: List, + 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() + // Null when nostr-keys.dat exists and cannot be read; an empty map when it is absent. + val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + + val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() + val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) + val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val newNostrPublicKey = keyManager.nostrPrivateKey().nostrPublicKeyHex() + + when { + existingSeeds == null || existingNostrKeys == 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 + } + existingNostrKeys.containsKey(newNostrPublicKey) -> { + // The same npub is already here as a bare key. The id check above cannot + // see that -- different hash, different key -- so it is asked by pubkey. + log.i("attempting to import a seed whose nostr key is already here, 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") + } + } + } + + prepareIdentity(phoenixGlobal, globalPrefs, newWalletId, isTorEnabled, customElectrumServer) + + viewModelScope.launch(Dispatchers.Main) { + delay(1000) + onSeedWritten(newWalletId) + } + } + } + + sealed class WriteNostrKeyResult { + data class Written(val id: WalletId) : WriteNostrKeyResult() + + /** An identity with this nostr public key is already on the device, as a wallet or as a key. */ + data object AlreadyExists : WriteNostrKeyResult() + + /** One of the two stores exists and could not be read; nothing was written. */ + data object CannotLoadKeys : WriteNostrKeyResult() + } + + /** + * Adds [privateKey] to `nostr-keys.dat` and prepares its preferences. Suspending and + * result-shaped rather than callback-shaped: nothing was built around a state machine + * for it, and the caller is a coroutine already. Throws on an I/O or key store failure + * during the write, as [writeMnemonic]'s handler would have caught. + */ + suspend fun writeNostrKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + privateKey: PrivateKey, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ): WriteNostrKeyResult { + log.d("writing nostr key to disk...") + val stored = StoredIdentity.nostrSecret(privateKey) + + val known = knownNostrPublicKeys(phoenixGlobal) + val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + if (known == null || existingNostrKeys == null) { + log.e("could not load the existing keys, aborting...") + return WriteNostrKeyResult.CannotLoadKeys + } + if (stored.nostrPublicKey in known) { + log.i("attempting to import a nostr key that is already here, aborting...") + return WriteNostrKeyResult.AlreadyExists + } + + val encrypted = EncryptedNostrKeys.encrypt(existingNostrKeys + (stored.nostrPublicKey to privateKey)) + NostrKeyManager.writeToDisk(phoenixGlobal, encrypted) + log.i("successfully imported nostr key for identity=${stored.id}") + + prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) + + return WriteNostrKeyResult.Written(stored.id) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt new file mode 100644 index 00000000..6ca37701 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt @@ -0,0 +1,98 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex + +/** + * An identity this device holds the secret for, as listed before any of them is started. + * + * Two stores feed this -- `seed.dat` for wallets, `nostr-keys.dat` for bare keys -- and + * the startup screen wants one list. The nostr public key is on both kinds because it + * is the one thing they have in common and the one thing a duplicate check has to + * compare: a wallet and an imported key can be the *same* npub under different ids. + * + * On what this holds: `SovereignWalletViewModel.availableWallets` has always carried + * decrypted words for the view model's life, because starting the node needs them. + * [NostrSecret] carries its key for the same reason and is no worse; decrypting at + * activation instead would be an improvement for both kinds and belongs to both. + */ +sealed interface StoredIdentity { + val id: WalletId + val kind: IdentityKind + val nostrPublicKey: HexKey + + data class Mnemonic( + val userWallet: UserWallet, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val id: WalletId get() = userWallet.walletId + override val kind: IdentityKind get() = IdentityKind.Mnemonic + override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey)" + } + + data class NostrSecret( + override val id: WalletId, + override val nostrPublicKey: HexKey, + val privateKey: PrivateKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrSecret + override fun toString(): String = "StoredIdentity.NostrSecret(id=$id, npub=$nostrPublicKey, key=)" + } + + companion object { + /** + * The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has + * already built exactly this key manager to learn the node id, so this is the second + * derivation of it, not a new cost. + */ + fun nostrPublicKeyOf(words: List): HexKey = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ).nostrPrivateKey().nostrPublicKeyHex() + + fun mnemonic(userWallet: UserWallet): Mnemonic = + Mnemonic(userWallet, nostrPublicKeyOf(userWallet.words)) + + fun nostrSecret(privateKey: PrivateKey): NostrSecret { + val xOnly = privateKey.publicKey().xOnly() + return NostrSecret( + id = xOnly.toWalletId(), + nostrPublicKey = xOnly.value.toHex(), + privateKey = privateKey, + ) + } + + /** + * One map from the two stores. [nostrKeys] is keyed by x-only public key hex, as + * `nostr-keys.dat` is; the id of each is derived from that key, so a stored key + * whose map key disagrees with its own public key is dropped here too, though + * `EncryptedNostrKeys` refuses such a file before it gets this far. + */ + fun merge( + wallets: Map, + nostrKeys: Map, + ): Map { + val merged = LinkedHashMap() + wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) } + nostrKeys.forEach { (publicKeyHex, privateKey) -> + val stored = nostrSecret(privateKey) + if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + } + return merged + } + } +} + +/** The x-only key as bitcoin-kmp types it, from the hex nostr carries it as. */ +fun HexKey.toXonlyPublicKey(): XonlyPublicKey = XonlyPublicKey(ByteVector32(this)) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 66bacd42..b2a030e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import fr.acinq.phoenix.data.ListWalletState -import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager @@ -26,6 +25,9 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import kotlinx.coroutines.flow.first +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -74,7 +76,7 @@ fun SovereignWalletStartupScreen( is ListWalletState.Success -> { val globalPrefs = sovereignWalletViewModel.getGlobalPrefs() - val availableWallets by sovereignWalletViewModel.availableWallets.collectAsState() + val availableIdentities by sovereignWalletViewModel.availableIdentities.collectAsState() val defaultWallet = globalPrefs.getDefaultWallet.collectAsState(null) val startWalletImmediately by sovereignWalletViewModel.startWalletImmediately.collectAsState() @@ -88,7 +90,7 @@ fun SovereignWalletStartupScreen( val activeIdentity = activeIdentityFlow.value when { - availableWallets.isEmpty() -> { + availableIdentities.isEmpty() -> { LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() } LoadingDataIndicator( text = "Initializing..." @@ -111,29 +113,29 @@ fun SovereignWalletStartupScreen( else -> { when (val startupState = sovereignWalletStartupViewModel.state.value) { is press.mantra.compose.ui.view.model.StartupViewState.Init -> { - var loadingWallet by remember { + var loadingIdentity by remember { mutableStateOf( when { - forceWalletId != null -> availableWallets[forceWalletId] + forceWalletId != null -> availableIdentities[forceWalletId] !startWalletImmediately -> null - availableWallets.size == 1 -> availableWallets.entries.firstOrNull()?.value - desiredWalletId != null -> availableWallets[desiredWalletId] - startWalletImmediately -> availableWallets[defaultWallet.value] + availableIdentities.size == 1 -> availableIdentities.entries.firstOrNull()?.value + desiredWalletId != null -> availableIdentities[desiredWalletId] + startWalletImmediately -> availableIdentities[defaultWallet.value] else -> null } ) } - when (val wallet = loadingWallet) { + when (val stored = loadingIdentity) { null -> { press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector( - wallets = availableWallets, + wallets = availableIdentities, globalPrefs = globalPrefs, walletsMetadata = availableWalletMetadata, activeWalletId = null, onWalletClick = { sovereignWalletViewModel.switchToWallet( - it.walletId - ); loadingWallet = it + it.id + ); loadingIdentity = it }, canEdit = false, modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space300), @@ -157,26 +159,50 @@ fun SovereignWalletStartupScreen( ) } else -> { - val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(wallet.walletId) } + val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(stored.id) } val dataStoreManager = DataStoreManager( ctx = sovereignWalletViewModel.phoenixGlobal.ctx, chain = NodeParamsManager.chain ) + // The screen-lock gate wraps both kinds. It only ever needed the + // id, to read the lock preferences, so it sits outside the branch + // and a future lock is not something to remember to add twice. LoadWallet( - userWallet = wallet, + identity = stored, metadata = metadata, - userPrefs = dataStoreManager.loadUserPrefsForWallet(wallet.walletId), + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), promptScreenLockImmediately = startWalletImmediately, - doLoadWallet = { userWallet -> - sovereignWalletStartupViewModel.startupNode(walletId = userWallet.walletId, words = userWallet.words, onStartupSuccess = { - sovereignWalletViewModel.setActiveWallet(walletId = userWallet.walletId, business = it) - onSuccessfulStartup.invoke() - }) - loadingWallet = null + doLoadWallet = { identity -> + when (identity) { + is StoredIdentity.Mnemonic -> { + sovereignWalletStartupViewModel.startupNode(walletId = identity.id, words = identity.userWallet.words, onStartupSuccess = { + sovereignWalletViewModel.setActiveWallet(walletId = identity.id, business = it) + onSuccessfulStartup.invoke() + }) + } + is StoredIdentity.NostrSecret -> { + // No node to start, so no platformStartupLogic, no + // schedulePlatformLogic and no StartupViewState: the + // identity is active the moment it is read. Setting it + // recomposes into the `activeIdentity != null` branch + // above, which is what reports the startup. + sovereignWalletViewModel.setActiveIdentity( + Identity( + id = identity.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = identity.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + business = null, + ) + ) + } + } + loadingIdentity = null }, - // only show back-to-selector button if there's more than one wallet - goToWalletSelector = availableWallets.takeIf { it.size > 1 }?.let { - { loadingWallet = null; sovereignWalletViewModel.startWalletImmediately.value = false } + // only show back-to-selector button if there's more than one identity + goToWalletSelector = availableIdentities.takeIf { it.size > 1 }?.let { + { loadingIdentity = null; sovereignWalletViewModel.startWalletImmediately.value = false } } ) } @@ -233,15 +259,15 @@ fun SovereignWalletStartupScreen( @Composable private fun BoxScope.LoadWallet( - userWallet: UserWallet, + identity: StoredIdentity, metadata: UserWalletMetadata, userPrefs: UserPrefs, promptScreenLockImmediately: Boolean, - doLoadWallet: (UserWallet) -> Unit, + doLoadWallet: (StoredIdentity) -> Unit, goToWalletSelector: (() -> Unit)? ) { - val isScreenLockRequired = produceState(initialValue = null, key1 = userWallet) { + val isScreenLockRequired = produceState(initialValue = null, key1 = identity) { val biometricLockEnabled = userPrefs.getLockBiometricsEnabled.first() val customPinLockEnabled = userPrefs.getLockPinEnabled.first() @@ -255,10 +281,10 @@ private fun BoxScope.LoadWallet( true -> { LoadingDataIndicator(text = "Unlock to continue") ScreenLockPrompt( - walletId = userWallet.walletId, + walletId = identity.id, walletName = metadata.nameOrDefault(), promptScreenLockImmediately = promptScreenLockImmediately, - onUnlock = { doLoadWallet(userWallet) }, + onUnlock = { doLoadWallet(identity) }, onLock = { }, userPrefs = userPrefs, goToWalletSelector = goToWalletSelector, @@ -267,7 +293,7 @@ private fun BoxScope.LoadWallet( false -> { LoadingDataIndicator(text = "Starting wallet...") LaunchedEffect(Unit) { - doLoadWallet(userWallet) + doLoadWallet(identity) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 07e08e15..7c1b95d5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -502,7 +502,7 @@ fun MantraNavHost( onSeedWritten() sovereignWalletViewModel.loadSovereignData(walletId) - sovereignWalletViewModel.listAvailableWallets { + sovereignWalletViewModel.listIdentities { sovereignWalletViewModel.switchToWallet(walletId) navController.navigate( route = SovereignWalletStartupRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index b21b735d..ad609bd3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -44,22 +44,23 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import press.mantra.compose.ui.composable.widgets.buttons.Clickable -import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing @Composable fun WalletsSelector( modifier: Modifier = Modifier, globalPrefs: GlobalPrefs, - wallets: Map, + wallets: Map, walletsMetadata: Map, activeWalletId: WalletId?, canEdit: Boolean, - onWalletClick: (UserWallet) -> Unit, + onWalletClick: (StoredIdentity) -> Unit, verticalArrangement: Arrangement.Vertical = Arrangement.Top, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, topContent: @Composable (() -> Unit)? = null, @@ -76,9 +77,9 @@ fun WalletsSelector( if (currentWallet != null) { item { AvailableWalletView( - userWallet = currentWallet, + identity = currentWallet, globalPrefs = globalPrefs, - metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.walletId), + metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.id), isCurrent = true, canEdit = canEdit, onClick = { onWalletClick(currentWallet) } @@ -88,14 +89,14 @@ fun WalletsSelector( Spacer(Modifier.height(MaterialTheme.spacing.space100)) } } - items(items = otherWalletsList) { (walletId, userWallet) -> + items(items = otherWalletsList) { (walletId, identity) -> AvailableWalletView( - userWallet = userWallet, + identity = identity, globalPrefs = globalPrefs, metadata = walletsMetadata.getByWalletIdOrDefault(walletId), isCurrent = false, canEdit = canEdit, - onClick = { onWalletClick(userWallet) } + onClick = { onWalletClick(identity) } ) Spacer(Modifier.height(MaterialTheme.spacing.space100)) } @@ -105,11 +106,16 @@ fun WalletsSelector( } } +/** + * The second line is the npub for both kinds. It used to be the node id, which an + * identity made from a bare key does not have -- and the npub is the identifier the rest + * of the app shows, and the one a user might actually recognise. + */ @Composable private fun AvailableWalletView( modifier: Modifier = Modifier, globalPrefs: GlobalPrefs, - userWallet: UserWallet, + identity: StoredIdentity, metadata: UserWalletMetadata, isCurrent: Boolean, canEdit: Boolean, @@ -122,7 +128,7 @@ private fun AvailableWalletView( ) // EditWalletDialog( // onDismiss = { showWalletEditDialog = false }, -// walletId = userWallet.walletId, +// walletId = identity.id, // globalPrefs = globalPrefs, // metadata = metadata, // ) @@ -134,7 +140,7 @@ private fun AvailableWalletView( if (isCurrent) { if (canEdit) showWalletEditDialog = true else return@Clickable } else { - onClick(userWallet.walletId) + onClick(identity.id) } }, shape = RoundedCornerShape(16.dp), @@ -152,7 +158,7 @@ private fun AvailableWalletView( Column { Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(MaterialTheme.spacing.space25)) - Text(text = userWallet.nodeId, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) } } if (isCurrent && canEdit) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index f505ffae..a7366cb9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds @@ -65,7 +64,22 @@ class NavigationViewModel( } suspend fun loadNostrProfile(startupRoute: SovereignWalletStartupRoute? = null) { - val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey + // Which account is "ours" is the active identity's to say, when there is one: with + // two identities on the device, the first kind-0 row is the wrong one half the time. + // Matched on the unsigned event's pubKey, not `profile.publicKey` -- the Profile row is + // what the notary upserts when it *signs* the kind 0, so an account planted by + // `signInToProfile` (or one created moments ago and not yet signed) has none, and + // reading through it answered `Landing` for a key that had just been imported 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. + val accounts = nostrRepository.getLocalAccounts() + val identity = activeIdentityStateFlow.value + val account = if (identity != null) { + accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == identity.nostrPublicKey } + } else { + accounts.firstOrNull() + } + val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey if (activeUserPublicKey == null) { // TODO: We can just go directly to startup witout fear... @@ -81,12 +95,9 @@ class NavigationViewModel( // `observeLocalAccount`, which turned the whole call into a no-op for anyone who // already had an account: the caller had navigated to a loading screen first and then // nothing ever moved the user off it. Every path out of here must leave the navigation - // state pointing somewhere. - processLocalAccount( - nostrRepository.observeLocalAccount( - publicKey = activeUserPublicKey - ).firstOrNull() - ) + // state pointing somewhere. `getLocalAccounts` already joined the relations this + // reads, so the account goes straight in rather than being fetched a second time. + processLocalAccount(account) } else { _navigationUIState.getAndUpdate { NavigationUIState.StartupPhoenix diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index a145bd93..2cf4d2b9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -12,12 +12,16 @@ import co.touchlab.kermit.Logger import fr.acinq.lightning.logging.error import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.DecryptNostrKeysResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NostrKeyManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata @@ -35,6 +39,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.StoredIdentity sealed class WritingSeedState { data object Init : WritingSeedState() @@ -71,15 +77,19 @@ class SovereignWalletViewModel( // A StateFlow, not a `mutableStateOf`, and it has to stay one. This view model is built by // `viewModel()` during composition, so a Compose state created in its constructor is created // inside that composition's snapshot -- and a write from another thread that lands before the - // composition is applied is discarded. `listAvailableWallets` runs from `init` and, on a device + // composition is applied is discarded. `listIdentities` runs from `init` and, on a device // with no seed file, answers in about four milliseconds, which is squarely inside that window: // the write to Success was lost and the app sat on "Decrypting..." for ever. A StateFlow has no // snapshot to belong to, so the same write from the same thread at the same moment is seen. private val _listWalletState = MutableStateFlow(ListWalletState.Init) val listWalletState = _listWalletState.asStateFlow() - private val _availableWallets = MutableStateFlow>(emptyMap()) - val availableWallets = _availableWallets.asStateFlow() + /** + * Every identity this device holds a secret for, from both stores -- `seed.dat` and + * `nostr-keys.dat` -- keyed by the id the preferences and metadata use. + */ + private val _availableIdentities = MutableStateFlow>(emptyMap()) + val availableIdentities = _availableIdentities.asStateFlow() private val _desiredWalletId = MutableStateFlow(null) val desiredWalletId = _desiredWalletId.asStateFlow() @@ -123,7 +133,7 @@ class SovereignWalletViewModel( init { - listAvailableWallets(onDone = {}) + listIdentities(onDone = {}) } /** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */ @@ -160,59 +170,93 @@ class SovereignWalletViewModel( updateBusinessActiveInUI(walletId) } - fun listAvailableWallets(onDone: () -> Unit) { + /** + * Reads both stores and publishes the merged list, registering metadata for any id + * seen for the first time. + * + * A failure in either file is surfaced, not skipped: 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. + */ + fun listIdentities(onDone: () -> Unit) { viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> // log.error("error when initialising startup-view: ", e) _listWalletState.value = ListWalletState.Error.Generic(e) }) { - when (val result = loadAndDecryptSeed(phoenixGlobal)) { + val wallets: Map = when (val result = loadAndDecryptSeed(phoenixGlobal)) { is DecryptSeedResult.Failure.SerializationError -> { log.error {"cannot deserialize seed file: "} _listWalletState.value = ListWalletState.Error.Serialization + return@launch } is DecryptSeedResult.Failure.DecryptionError -> { log.e("cannot decrypt seed file: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + return@launch } is DecryptSeedResult.Failure.KeyStoreFailure -> { log.e("key store failure: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + return@launch } is DecryptSeedResult.Failure.SeedFileUnreadable -> { log.e("aborting, unreadable seed file") _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch } is DecryptSeedResult.Failure.SeedInvalid -> { log.e("aborting, seed is invalid") _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch } + is DecryptSeedResult.Failure.SeedFileNotFound -> emptyMap() + is DecryptSeedResult.Success -> result.userWalletsMap + } - is DecryptSeedResult.Failure.SeedFileNotFound -> { - _listWalletState.value = ListWalletState.Success - _availableWallets.value = emptyMap() + val nostrKeys: Map = when (val result = NostrKeyManager.loadAndDecrypt(phoenixGlobal)) { + is DecryptNostrKeysResult.Failure.SerializationError -> { + log.e { "cannot deserialize nostr keys file" } + _listWalletState.value = ListWalletState.Error.Serialization + return@launch } - - is DecryptSeedResult.Success -> { - - val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() - result.userWalletsMap.forEach { (walletId, _) -> - if (metadataMap[walletId] == null) { - saveAvailableWalletMeta( - phoenixGlobal = phoenixGlobal, - walletId = walletId, - name = null, - avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(), - isHidden = false - ) - } - } - _availableWallets.value = result.userWalletsMap - _listWalletState.value = ListWalletState.Success - viewModelScope.launch(Dispatchers.Main) { - onDone() - } + is DecryptNostrKeysResult.Failure.DecryptionError -> { + log.e("cannot decrypt nostr keys file: ", throwable = result.cause) + _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + return@launch } + is DecryptNostrKeysResult.Failure.KeyStoreFailure -> { + log.e("key store failure: ", throwable = result.cause) + _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + return@launch + } + is DecryptNostrKeysResult.Failure.FileUnreadable -> { + log.e("aborting, unreadable nostr keys file") + _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch + } + is DecryptNostrKeysResult.Failure.FileNotFound -> emptyMap() + is DecryptNostrKeysResult.Success -> result.keys + } + + val identities = StoredIdentity.merge(wallets, nostrKeys) + + val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() + identities.keys.forEach { walletId -> + if (metadataMap[walletId] == null) { + saveAvailableWalletMeta( + phoenixGlobal = phoenixGlobal, + walletId = walletId, + name = null, + avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(), + isHidden = false + ) + } + } + _availableIdentities.value = identities + _listWalletState.value = ListWalletState.Success + viewModelScope.launch(Dispatchers.Main) { + onDone() } } } @@ -282,10 +326,10 @@ class SovereignWalletViewModel( onSeedWriteError: (WritingSeedState.Error) -> Unit = {} ) { if (writingState is WritingSeedState.Error || writingState is WritingSeedState.WrittenToDisk) { - // A previous attempt finished; reset so platformWriteSeed does not silently skip this one. + // A previous attempt finished; reset so writeMnemonic does not silently skip this one. writingState = WritingSeedState.Init } - platformWriteSeed( + IdentityWriter.writeMnemonic( log = log, phoenixGlobal = phoenixGlobal, globalPrefs = getGlobalPrefs(), @@ -322,18 +366,3 @@ class SovereignWalletViewModel( } } } - -expect fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index cb153206..a8aef044 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.UnsignedNostrEvent import press.mantra.compose.database.model.intermdiate.LocalAccount @@ -94,6 +95,30 @@ class NavigationRoutingTest { synchronizeNostrEventRequests = emptyList(), ) + /** + * What `signInToProfile` plants for a key that already exists elsewhere: a kind 0 + * stamped `GENESIS_AT` so the notary skips it, and nothing else -- no `Profile` + * row, which only the notary's signing writes, and no sync request yet. + */ + private fun placeholderAccount() = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = GENESIS_AT, + createdAt = at, + updatedAt = at, + savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + /** * The view model's `init` starts a wallet observer that would write to the * same state after its 2.1s wait. Cancelling the scope leaves that observer @@ -161,4 +186,26 @@ class NavigationRoutingTest { // is up, so the answer is to go and start it. assertEquals(NavigationUIState.StartupPhoenix, viewModel.navigationUIState.value) } + + /** + * `loadNostrProfile` used to read the account through `profile.publicKey`. A + * placeholder has no Profile, so that read was null and the answer was Landing -- + * "go and make an account" -- for a key that had just been imported, while the + * identity observer, reading the same rows, answered the sync screen. Two writers + * to one state, and Landing pops the back stack. + */ + @Test + fun `a key just imported is sent to fetch its profile, not to make one`() = runTest { + val viewModel = bootedOn(placeholderAccount()) + + viewModel.loadNostrProfile(SovereignWalletStartupRoute) + + assertNotEquals(NavigationUIState.Landing, viewModel.navigationUIState.value) + assertEquals( + NavigationUIState.UnqueuedProfileSynchronization( + unsignedNostrEvent = placeholderAccount().unsignedNostrEvent!! + ), + viewModel.navigationUIState.value, + ) + } } diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt index 6c04560d..6d4e242c 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt @@ -1,29 +1,14 @@ 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.ios.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) { @@ -70,80 +55,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - 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) - } - } -} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index 62d6645a..e076aae7 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -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, - 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) - } - } -} \ No newline at end of file diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt new file mode 100644 index 00000000..b1a44869 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt @@ -0,0 +1,87 @@ +package press.mantra.compose.identity + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals + +/** + * The merged list the startup screen reads, and the one derivation it rests on. + * + * The vector is NIP-06's own: twelve words, the key at `m/44'/1237'/0'/0/0`, and the + * x-only public key. It pins that a wallet's `nostrPublicKey` is the same key an nsec + * import of that wallet's nostr secret would produce -- which is the whole basis of the + * duplicate check in `IdentityWriter`. + */ +class StoredIdentityJvmTest { + + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") + private val nip06PrivateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a")) + private val nip06PublicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + + private val wallet = UserWallet( + walletId = WalletId("ab".repeat(20)), + nodeId = "02" + "cd".repeat(32), + words = words, + ) + private val bareKey = PrivateKey(ByteVector32("02".repeat(32))) + + @Test + fun `a wallet's nostr key is the NIP-06 derivation of its words`() { + assertEquals(nip06PublicKey, StoredIdentity.nostrPublicKeyOf(words)) + assertEquals(nip06PublicKey, StoredIdentity.nostrSecret(nip06PrivateKey).nostrPublicKey) + } + + @Test + fun `both stores land in one map, each under its own id`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + nostrKeys = mapOf(bareKey.publicKey().xOnly().value.toHex() to bareKey), + ) + + assertEquals(2, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) + assertEquals(IdentityKind.Mnemonic, mnemonic.kind) + + val secret = assertIs(merged[bareKey.publicKey().xOnly().toWalletId()]) + assertEquals(IdentityKind.NostrSecret, secret.kind) + assertEquals(40, secret.id.nodeIdHash.length, "same shape as a wallet's id") + assertNotEquals(wallet.walletId, secret.id) + } + + /** + * The same npub as a wallet, imported as a bare key, gets a *different* id -- one + * hash is of the node key, the other of the nostr key -- so a map keyed by id holds + * both. That is not a bug in the merge; it is why the writers dedupe by public key. + */ + @Test + fun `a wallet and its own nostr key as a bare secret do not collide by id`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + nostrKeys = mapOf(nip06PublicKey to nip06PrivateKey), + ) + + assertEquals(2, merged.size) + assertEquals(1, merged.values.map { it.nostrPublicKey }.toSet().size, "one npub, twice") + } + + @Test + fun `a key filed under a public key it does not derive is dropped`() { + val merged = StoredIdentity.merge( + wallets = emptyMap(), + nostrKeys = mapOf(nip06PublicKey to bareKey), + ) + + assertEquals(emptyMap(), merged) + } + + @Test + fun `the nsec form and the hex form name the same key`() { + assertEquals(nip06PublicKey.toXonlyPublicKey().value.toHex(), nip06PublicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt index f8f1ccb8..ce80da88 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -27,6 +27,7 @@ import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.toWalletId import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import press.mantra.compose.ui.view.state.NavigationUIState import kotlin.test.Test import kotlin.test.assertEquals @@ -149,4 +150,30 @@ class NavigationIdentityRoutingJvmTest { scope.cancel() } } + + /** + * The startup screen's own entrance, `loadNostrProfile(startupRoute)`, used to take + * the *first* kind-0 account on the device whichever identity was active. With one + * wallet that was harmless; with two it is the wrong account half the time, and an + * imported key is how a device gets its second identity. + */ + @Test + fun `with two accounts on the device, the startup entrance reads the active identity's`() { + val identity = nostrSecretIdentity() + val someoneElse = "a".repeat(64) + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(someoneElse), syncedAccount(identity.nostrPublicKey))), + scope = scope, + ).also { scope.cancel() } + + runBlocking { viewModel.loadNostrProfile(SovereignWalletStartupRoute) } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + viewModel.navigationUIState.value, + ) + } } diff --git a/lightning-kmp-app b/lightning-kmp-app index 84cc44c8..59c11ed9 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 84cc44c855e3d173e26016b807c449153a3af597 +Subproject commit 59c11ed926ac05c820b98afb4cc961a5a3506310