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