Phases 1–8 are implemented, in order, one commit each; Phase 9 is the rollout and stays as written. The plan's phases are kept as the reasoning, and the table at the top says where the build chose differently: the repair reads before it writes and hands the listing its result; one WalletAttached outcome with two ways in; the node stop and the relay scope injected for their tests; the default save that cannot crash; a ProfilesViewModel over flows; a NewProfileWriter and a route flag where the plan expected the create screen's existing writer to serve; the colliding id on the outcome rather than on the enum; the DAO's own requests left unowned because they fetch public kinds; the inbox reopened by re-indexing each wrap in its own transaction rather than by lifting the unseal branch out; and the round trip's A made through the create view model with a never-started PhoenixBusiness for the switch to stop. Three things found on the way and in no phase are recorded beside the table: the library's unsynchronised global-preferences cache, reached by two threads at once for the first time, now behind JvmGlobalPrefs on the jvm target; the same cache's consequence for tests that construct the sovereign view model; and the gift wrap seal's link to its wrap, a foreign key that existed and was never written until the inbox sweep asked the question it answers. The README's row and reading order say the note is built, and name switchToIdentity where they named switchToWallet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@29027f2b92
72 KiB
More than one profile on a device
How a user holds several profiles on one device, moves between them, and adds another by signing in or by creating one — why a profile is a credential and a wallet is a thing attached to one, and why the switch has to be a restart of the signed-in app rather than a swap underneath it.
Read this after nsec-sign-in.md and
npub-sign-in.md. It takes the Identity type, the two key
stores, the sign-in screen and the forget sequence as given, changes one rule the
two of them share, and asks the question both deferred without naming it: what
happens when the device holds two of them.
Built, phases 1–8, one commit each, in the order given; Phase 9 is the rollout and is process rather than code. The phases are kept as written because they are the reasoning, and the code reads better against the argument it came from than against a summary of itself. Where the implementation chose differently the table below says so:
| what the plan said | what it turned out to be |
|---|---|
| the repair runs "before the credentials file is read for the listing" | after both files are read, and the listing is built from what it returns — the repaired map, or the one read if the write failed — so the file is decrypted once |
NotACredential becomes WalletAttached for a key a seed derives |
and for a key not in the file at all, which since the repair can only be a seed's key the repair could not write; one outcome, two ways in |
stopPlatformBusiness called from switchToIdentity |
injected into the view model as a function, defaulting to the expect, so the branch can be pinned with a recorder — a node cannot be started in a test, but a PhoenixBusiness can be built, since everything in it is lazy |
| the relay observer restructured | as planned, plus an injectable scope and a relayUrls read for the test that fails against the old observer |
setActiveIdentity saves the default |
wrapped: a default that could not be written is a selector on the next boot, not a crash now. The forget tails call forgetDefaultIdentity() on the view model rather than clearDefaultWallet() inline |
| "Initializing…" among the four that become opening profile | it and "Preparing wallet…" both became preparing profiles; they are the same wait |
ProfilesViewModel "owns the four states" |
takes the four flows it joins rather than the view model that holds them, so a compose test can drive it with MutableStateFlows and a fake repository; the fourth state, empty, is not modelled, as the plan said it could not happen |
| a row's fallback "the npub and the metadata's emoji" | the npub as the title over the emoji avatar, with no second line; a key with a kind 0 gets the name over the picture with the npub under it |
"createAccount already takes its writer as a function" |
a NewProfileWriter interface returning the key it derived and the id it filed it under, with seedProfileWriter and bareKeyProfileWriter on the view model and CreateProfileRoute(withWallet) choosing; the view model plants the six events after the write and calls the tail after that, so the account is in the database before the identity is activated — an ordering the create flow used to get away with by luck |
CredentialProblem.AlreadyOnThisDevice "carries it" |
the id rides on Outcome.Failed and the Error state instead; CredentialProblem stays an enum and the parser's tests stay as they were |
the DAO's own requests "stamped from the activeKeyPair it is already handed" |
left unowned, on purpose: the placeholder-profile syncs and a Marmot join's participant syncs fetch public kinds that need no key to open, and any profile that is open may as well fetch them. Only what goes through the repository is stamped |
openGiftWrap lifted out of indexNostrEvent |
not lifted: the branch is a screenful of room and participant handling, and running the stored wrap through indexNostrEvent again, in a transaction of its own per wrap, gives the same result with one code path. reopenGiftWrap is that |
ForgetIdentityJvmTest gains "forgetting B while A remains" |
the round trip covers it, forgetting B and C and listing A alone; the per-outcome tests were done in Phase 1 |
| the round trip "creates A from a phrase" | through the create view model with the seed writer, as Landing does, rather than through a pasted phrase; and A's node is a PhoenixBusiness that was never started, which is enough for the switch to have something to stop |
Three things found on the way that are in no phase. DataStoreManager caches one
GlobalPrefs per process behind an unsynchronised check-then-set, and DataStore
refuses a second instance over the same file: two first calls at once — the listing
on IO and a sign-in's write — could both build one, and the loser threw at first
use. JvmGlobalPrefs is now the one way the jvm target reaches the prefs, under a
lock; Android goes through the Application's single instance and never raced. The
same cache means a test that constructs SovereignWalletViewModel must not build a
GlobalPrefs of its own. And GiftWrapSeal.giftWrapMessageId, a foreign key to the
wrap a seal came out of, existed and was never written — so nothing in the database
could say which wraps had been opened, which is the question the inbox sweep asks;
decryptGiftWrapSeal now sets it, and a seal from before is swept once and comes
back with the link.
The vocabulary
The user asks for "accounts". The code says identity (Identity,
StoredIdentity, activeIdentity), the storage layer says wallet
(WalletId, WalletsSelector, select_a_wallet, "Opening wallet"), and every
string a user reads says profile ("Create profile", "Sign out of this
profile?", "This profile is read only"). Three words, and they are not three
names for one thing.
A profile is a nostr key. A wallet is a different thing that a profile may have
attached to it. The two live in different files for a reason both sign-in plans
spelled out: seed.dat holds twelve words, from which a Lightning node and a
nostr key are derived; nostr-credentials.dat holds a bare key, or a bare public
key, from which nothing else can be. The relationship runs one way — a seed
always yields a key; a key does not imply a seed — and the code has, until this
plan, drawn the line in the wrong place: a seed's key is not written to the
credentials file, so a seed-backed profile exists only as a derivation, and the
app has to start a Lightning node to find out who it is
(What actually blocks it).
This plan settles the model and the words together:
- The credentials file is the list of profiles. Every profile the device can
sign as is a
Secretentry in it, and every one it can only look at is aPublicentry. A seed contributes a wallet, and the wallet is attached to the profile whose key it derives — matched by public key, and written as a credential when the seed is, which is Phase 1. - The code keeps saying identity; the user reads profile; and wallet is used
only of a wallet. Account is not used anywhere. The two Phoenix-era strings
the user can still see,
select_a_walletandchange_account, become choose a profile and switch profile; the startup screen's loading literals follow, except the one that is genuinely a wallet starting. - No renaming of types.
WalletIdkeys every preference file in the library and was left alone by both sign-in plans for that reason. A profile's id is its wallet's when it has one —hash160(nodeId), as it has always been for a seed — andhash160(pubkey)when it has none, which is the rule the npub plan set and this plan keeps, for the reason in the appendix.
What the distinction decides, phase by phase:
| the wallet is not the profile, so | where |
|---|---|
| a seed's key is a credential like any other, written when the seed is and repaired into the file for every seed already here; the identity's key comes from the credential, not the node | Phase 1 |
| a switch is between profiles; the node, where there is one, follows the profile it belongs to — stopped when that profile is left, never started for one that has none | Phase 2 |
| the switcher says which profiles have a wallet attached, before the tap | Phase 4 |
| a profile added from inside is a key, not a second seed: wanting another profile is not wanting another wallet | Phase 5 |
| a bare key can be forgotten; a key with a wallet attached has funds behind it, and its sign-out stays a wallet question | Phase 7 |
| a wallet attached to a profile that did not derive it, or one wallet every profile on the device pays from, is the model this leads to — and its own plan, which Phase 1 makes small | Out of scope |
The constraint
One database, one process, one set of pumps, and every route on the stack addressed by the key it was pushed for.
Every screen from the home tab inward takes activeUserPublicKey as a route
argument — HomeRoute, SearchRoute, ActiveProfileRoute, ChatRoomDetailRoute,
all of them. The navigation component reads the signed-in key off the current
route rather than holding it
(MantraNavHost.kt:418),
precisely so that it cannot drift from the screen underneath. That is the right
design for one profile and it decides the shape of a switch: a stack of routes
built for key A cannot be handed key B. The only honest switch is to clear the
stack and rebuild it for B — which is, as it happens, exactly what the app already
does when the active identity changes.
The second half of the constraint is the database. It is one Room file for the
device, and it is mostly keyed by whose it is: ChatRoom.userPublicKey — whose
comment reads "should help us have multiple user support"
(ChatRoom.kt:48)
— UnsignedNostrEvent.pubKey, MarmotKeyPackageBundle.publicKey,
GiftWrapMessage.receiverPublicKey, the Connection relation by both keys. The
profile cache is shared, and should be: a kind 0 is the same event whoever fetched
it. What is not keyed by whose it is are the three request queues, and
Phase 6
is about the one place that matters.
What is already built
Most of a switch. The Phoenix fork this app grew from is a multi-wallet application, and its switching machinery came across intact and unused.
| piece | where | state |
|---|---|---|
the credentials file, its encrypted writer, and the app writing it from writeMnemonic — to delete a public entry a seed supersedes |
IdentityWriter.kt:148 | built; writing a Secret from the same place is one line more |
migrateFromNostrKeys: a named, idempotent write that runs from listIdentities before anything reads either file |
SovereignWalletViewModel.kt:183 | built; the shape Phase 1's repair copies |
StoredIdentity.merge, deriving every seed's nostr public key at listing to compare stores by pubkey |
StoredIdentity.kt | built; the derivation is what attaches a seed to its credential |
switchToWallet(id): sets desiredWalletId, clears the active identity |
SovereignWalletViewModel.kt:304 | built; called by the sign-in and create tails and the startup selector, never from a signed-in screen |
resetToSelector(): clears both and asks startup for the list |
SovereignWalletViewModel.kt:315 | built; the sign-out and forget tails use it |
NavigationViewModel.observeProfile: a null identity is StartupPhoenix, which the nav host navigates to with popUpTo(0); a new identity is observed from its account |
NavigationViewModel.kt:184 | built — this is the restart |
the notary, the sync pumps and the live subscriptions run as children of collectLatest over the identity, so a change cancels them — the comments say "a wallet switch" in as many words |
NotaryViewModel.kt:62, SynchronizationViewModel.kt:193 | built |
the startup screen: lists, picks by forceWalletId / desiredWalletId / the default, shows WalletsSelector otherwise, and wraps every activation in the lock gate |
SovereignWalletStartupScreen.kt:106 | built; the precedence is wrong for a switch, see below |
WalletsSelector: a current section with a divider, the others below, a read only label, topContent and bottomContent slots |
WalletsSelector.kt:59 | built; shows "Default name" and a random emoji for every row |
BusinessManager: a map of running nodes by wallet id; starting one that runs returns it; stopBusiness(walletId) on all three platforms |
BusinessManager.kt:208 | built; nothing in the app calls stopBusiness |
GlobalPrefs.getDefaultWallet, saveDefaultWallet, clearDefaultWallet |
GlobalPrefs.kt:102 | read by startup; never written by anything |
| the sign-in and create tails: re-list, select, go to startup | MantraNavHost.kt:746, :545 | built; a shape that works from inside the app as well as from Landing |
| Change account on the profile tab | ActiveProfileScreen.kt:309 | ImplementationPendingRoute |
ForgetIdentity and SignOutViewModel for a read-only identity; forget this key for an nsec |
identity/ForgetIdentity.kt, NostrSecretScreen |
built; the tail lands on the selector or Landing, which is right for several profiles too |
NpubPreviewRoundTripJvmTest: a real key store, a real in-memory database, real view models |
jvmTest/.../identity/ |
the harness every test below reuses |
So the switch exists as a state transition and is reachable from nowhere, and the list it would switch between is drawn from two files that disagree about what a profile is. That is the whole shape of this plan: fix what a profile is, build the two entrances, fix what the transition gets wrong, and make the one data problem it exposes go away.
What actually blocks it
Seven things, in the order a user would hit them.
A seed's profile is not a credential. writeMnemonic writes seed.dat and
nothing else — and if a Public entry for the seed's key exists, it deletes
it, so the seed becomes the only holder
(IdentityWriter.kt:148).
The key is then derived twice on every launch: at listing, from the words, to
learn the pubkey the selector shows; and at activation, from the running node's
key manager (SovereignWalletViewModel.kt:151),
which is why a seed-backed profile has to start Lightning before it can be
anyone. Both sign-in plans made this an invariant rather than an accident — one
entry per pubkey across both files, writeNostrKey refusing a key a seed
derives, writeMnemonic refusing a seed whose key is here as a secret — and it
is the wrong invariant: it says a wallet is a profile, and the switcher this plan
builds would inherit a list in which one kind of row is a key and another is a
seed pretending to be one.
There is no way in. Landing is reached only when availableIdentities is
empty (SovereignWalletStartupScreen.kt:95),
and the sign-in and create screens are reached only from Landing. A device with
one profile cannot be given a second: the flow that adds one is behind a screen
the device never shows again.
There is no way across. The selector is drawn by the startup screen and
nowhere else, and nothing in the signed-in app calls resetToSelector() except
the sign-out tails. The row that promises it on the profile tab routes to the
pending screen.
The startup precedence is wrong for a switch. The when that picks what to
open reads !startWalletImmediately -> null before desiredWalletId != null
(:122),
and startWalletImmediately is set to false by resetToSelector() and by the
lock prompt's back button and set back to true by nothing. So after the first
visit to the selector, every sign-in on that device lands on the selector instead
of the profile that was just signed in. And because saveDefaultWallet is never
called, availableIdentities[defaultWallet] is always null: a cold boot with two
profiles is the selector, every time, with no memory of which one was open.
Two things are not torn down. RelaysSocketManager.observeActiveUserId keeps
one relay-list observer per pubkey and only ever cancels the one for the pubkey
being started; its own comment says TODO: Cancel all pending jobs?
(RelaysSocketManager.kt:65).
After a switch, A's observer and B's observer both feed updateRelayPools, and
the one pool follows whichever relay list emitted last. And the previous
profile's node, where it had one, keeps running in BusinessManager — Electrum,
the LSP peer, the swap-in watcher — for a profile nobody is looking at. The nsec
plan established that nothing in this app reads the node but the key; a node
running for a profile that is not active is that finding with the sign flipped.
The queues belong to the device, not the identity. SynchronizeNostrEventRequest,
NegentropySynchronizeRequest and BroadcastNostrEventRequest have no owner
column, and the pumps drain whatever is pending with the active key pair
(DatabaseNostrRepository.kt:111).
Requests A queued a moment before the switch — its sign-in sync, its room
reconciliation — are answered while B is active, and everything that comes back
is indexed as B: a gift wrap addressed to A is stored, found not to be addressed
to the active key, and left (NostrDao.kt:439).
Then storeNostrEvent never indexes an event it already holds
(NostrDao.kt:176,
the TODO: Check if event got indexed), so when A is opened again, its live
subscription re-receives the wrap, the DAO sees a known id, and returns. A
message that arrives while the other profile is open is lost to the one it was
for. Silently, and it looks in the logs like nothing happened, because nothing
did.
Two destructive controls assume one profile. CreateProfileScreen's end
this calls wipeDatabase() (CreateProfileScreen.kt:381)
— on a device with a second profile, that profile's rooms and MLS state go with
it, from a screen the user opened to add one. And once a default is remembered,
forgetting the identity it names has to clear it, or the next boot opens a
profile the list no longer has.
The two decisions to make first
What a profile is
Two models were on the table.
A seed is a profile by derivation. What the code does: the credentials file holds only bare keys, a seed's key is recomputed from the words at listing and from the node at activation, and the writers refuse to hold the same key in both files. One copy of every secret, nothing to migrate, and the two sign-in plans' tests pass as written. The costs are the ones the blocker above lists: the node has to run for the profile to know its own key, the list of profiles is a merge of two files with opposite ideas of what a row is, and there is no place for a wallet that belongs to a profile it did not derive.
A profile is a credential; a seed is a wallet attached to one. Creating or
restoring a seed writes the seed and a Secret credential for the key it
derives; every seed already on the device gets its credential written at the
first listing; merge lists the credentials file and attaches each seed to the
entry it derives. The identity's key is read from the credential for every kind,
so the node stops being where a profile's key comes from and becomes what a
wallet is — which is the sentence this plan wants to be true, and the one that
makes "attach a wallet to this profile" a later plan instead of a redesign.
This plan takes the second. Its costs are real and Phase 1 pays them: a
repair step at listing, the derived secret held in a second file under the same
keystore key, one of the sign-in plans' invariants inverted, and every when
over IdentityKind re-read for what the kind now means. What it does not do is
make the node lazy, though it makes that possible — the reason is under Phase 1,
and the decision is named under out of scope.
What a switch is
Is a switch a restart or a swap?
A swap sets the new identity while the stack stands. Every route on that stack carries the old key as an argument; every view model built from those routes is observing the old key's rows; the notary and pumps restart under the new key while the screens above them show the old one. Making that consistent means every screen re-reads the identity from a flow instead of its route — the snackbar-host problem again, in forty-three places — or a re-navigation that amounts to a restart anyway. Rejected, and the appendix says so at more length.
A restart is what observeProfile already does when the identity goes null:
StartupPhoenix, popUpTo(0), the startup screen activates the next one, the
machine routes it from its account. Every screen is torn down; every collector is
cancelled and rebuilt; nothing built for A survives into B. It costs a visible
pass through the startup screen — "Opening profile", under a second for a bare
key, a little over one for a profile with a wallet attached — and it is honest
about what it is.
This plan takes the restart. It is what the code does, it is the only design that cannot leak one identity into another's screen, and it settles four things that would otherwise each be an argument:
- The back stack is cleared by a switch, so the control that switches is a pushed screen reached by one deliberate tap from the profile tab — never a global control in an app bar, a long press, or a swipe that could fire from inside a half-written message.
- The previous node is stopped. It belongs to the profile being left, not to the device; nothing reads it, the restart tears down everything else, and a library that already handles "start one that exists" makes switching back cheap enough.
- The profile that opens on launch is the last one used. A restart through
startup is also what a cold boot is, and the two should agree:
desiredWalletIdfor a switch, the saved default for a boot, the selector only when the user asked for it. - A profile added from inside is switched to. The sign-in and create tails already select what they wrote; the one they left stays on the device.
Everything below is downstream of those two words: credential, and restart.
Phase 1 — the credential a seed always implied
App only. No library change. The file format, the encrypted writer and the manager all exist; the app already writes the credentials file from the seed writer. This phase changes what it writes there, and what the listing believes.
The write
writeMnemonic writes two things, in this order:
- the credentials file, with a
SecretforkeyManager.nostrPrivateKey()under its x-only public key — replacing aPublicentry for that key if one is there, which is the read-only upgrade the npub plan built, now landing on a secret rather than on nothing; seed.dat, as today.
Credential first, for the reason the npub plan gave for the order it chose: a crash between the two leaves a bare-key profile whose key the phrase derives, which is a valid thing for the device to hold and which pasting the phrase again completes. The reverse order would leave a seed with no credential, which the listing below tolerates and the repair fixes — also recoverable, but the first order says the model out loud: the profile exists, and then a wallet is attached to it.
writeNostrKey and writeNostrPublicKey are unchanged. The duplicate table
becomes:
| the pubkey is already here as | pasting its nsec | pasting its npub | pasting its phrase |
|---|---|---|---|
| a secret with a wallet attached | AlreadyExists |
AlreadyExists |
AlreadyExists |
| a secret, bare | AlreadyExists |
AlreadyExists |
attach: the seed is written, the entry stays as it is, Written with the wallet's id |
| a public entry | upgrade to secret, one write | AlreadyExists |
upgrade to secret and attach, two writes |
| nothing | Written |
Written |
Written, two writes |
The one new cell is the phrase pasted over a bare key, which the nsec plan
refused as SeedAlreadyExists. It was a duplicate under the old model because
two files would have held one key; under this one it is a profile acquiring the
wallet that derives it, and it is the first attachment the app performs. The
profile keeps its key and every row keyed by its pubkey. Its id changes to the
wallet's, so its preferences and metadata are fresh, as a new wallet's are — the
backup flags reset, which is right: there is a new secret to back up.
The repair
Every seed already on a device has no credential. SeedCredentials.reconcile,
beside migrateFromNostrKeys in listIdentities and shaped like it — a named,
idempotent write that runs before anything reads the merged list:
object SeedCredentials {
sealed interface Result {
data class Written(val count: Int) : Result
data object NotNeeded : Result
data class Failed(val cause: Throwable) : Result
}
/**
* Writes a Secret credential for every seed whose derived key has none, replacing a
* Public entry for that key where there is one. One write, or none. A failure is
* logged and the listing goes on: a seed that cannot be repaired into the file is
* still listed, with its key derived, by [StoredIdentity.merge].
*/
suspend fun reconcile(phoenixGlobal, wallets: Map<WalletId, UserWallet>, credentials: Map<HexKey, NostrCredential>): Result
}
It runs after both files are read and the nostr-keys migration has run, and the
listing is built from what it returns — the repaired map, or the one read if the
write failed — so the listing sees the repaired file without decrypting it twice.
On a device with no seeds it does nothing; on a device already repaired it does
nothing; on the first launch after this phase it writes once. Failed is logged,
not surfaced: this is a repair of something the app can still work around, and a
listing that goes red because a background write failed would be worse than the
derivation it was replacing.
The listing
merge inverts. Today the seeds are the list and the credentials are added to
it; now the credentials are the list and the seeds are attached to it.
fun merge(wallets, credentials): Map<WalletId, StoredIdentity> {
val walletsByPubkey = wallets.values.associateBy { nostrPublicKeyOf(it.words) }
val merged = LinkedHashMap<WalletId, StoredIdentity>()
credentials.forEach { (pubkey, credential) ->
when (credential) {
is Secret -> {
val wallet = walletsByPubkey[pubkey]
val stored = if (wallet != null) mnemonic(wallet, credential.privateKey) else nostrSecret(credential.privateKey)
if (stored.nostrPublicKey == pubkey) merged[stored.id] = stored
}
is Public -> if (pubkey in walletsByPubkey) log.w { "public entry for a key a seed derives; the next repair upgrades it" }
else nostrPublic(pubkey)?.let { merged[it.id] = it }
}
}
// A seed the repair could not write a credential for. Listed as it always was,
// with the key derived from the words, so that a failed write never hides a wallet.
walletsByPubkey.forEach { (pubkey, wallet) ->
if (pubkey !in credentials) { log.w { "seed with no credential; deriving" }; mnemonic(wallet).let { merged[it.id] = it } }
}
return merged
}
StoredIdentity.Mnemonic gains the key: Mnemonic(userWallet, privateKey), with
nostrPublicKey derived from the key and checked at construction against what
the words derive — a mismatch is corruption, and the entry is dropped with a log
line rather than listed under a key it cannot sign as. The derivation from the
words stays, because it is what matches a seed to its credential; the nsec plan
noted SeedManager had already built that key manager once, so this is the same
second derivation it was.
IdentityKind keeps its three values and its doc comment changes:
/** A key the device also holds a seed for. The key is a credential like any other; the seed is the wallet attached to it. */
Mnemonic,
Every when over the kind is re-read in this phase for what the kind now means,
and none of them change: KeyRecoveryScreen offers the phrase for this kind
because the phrase is the wallet's backup and derives the key; NostrSecretScreen
refuses to forget it because a wallet is attached; the startup branch starts the
node because a wallet is attached. The meaning shifted under them and the answers
held, which is what a kind that names the attachment rather than the source
buys.
The activation
setActiveWallet(walletId, business) becomes setActiveWallet(stored: StoredIdentity.Mnemonic, business)
and builds the identity from the credential's key:
val derived = business.walletManager.keyManager.value?.nostrPrivateKey()
check(derived == stored.privateKey) { "the node derives a different nostr key than the credential holds" }
setActiveIdentity(Identity.signing(id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, …, business = business))
The check cannot fail on a file the repair wrote — it derived the key from the same seed — and it is kept because a credentials file is now the thing the app signs with, and a node disagreeing with it is the one corruption worth refusing to run under.
The node still starts. Not for the key any more: for what startNewBusiness
does besides — it registers the wallet's metadata, applies its Electrum and Tor
preferences, records the last-used build, and on Android schedules the channel
watcher that notices a force-close while the app is shut. A phrase pasted from a
real Phoenix wallet can have channels behind it, and a build that stopped
watching them to save a second at startup would be trading a spinner for funds.
With the key in the credential, making the node lazy is one branch in
doLoadWallet and a decision about who watches channels while it is not running;
it is named under out of scope and this phase does not take it.
The forget
forgetNostrCredential refuses two things instead of one: a key that is not in
the file, as today, and — new — a key that a seed derives. The seed would
re-derive it and the next repair would write it back, so a forget that succeeded
would undo itself at the next launch. ForgetNostrCredentialResult.NotACredential
becomes WalletAttached, because that is now the only reason a signing profile
cannot be forgotten, and ForgetIdentity.Outcome follows.
The two plans that said otherwise
Each of the sign-in docs carries a table of where the build chose differently
from the plan. Each gains one row at the bottom, pointing here: the
one-entry-per-pubkey rule across both files, superseded — a seed's key is a
credential, and the seed is attached to it. Their tests change in the same
places: IdentityWriterJvmTest's "the npub of a key a seed derives is refused"
stays; its "a seed whose key is here as a secret is refused" becomes "…is
attached to it".
Tests
SeedCredentialsJvmTest, through the jvm key store: two seeds and an empty
credentials file — Written(2), two Secret entries under the keys the words
derive; run again — NotNeeded, and the file's bytes are unchanged; a Public
for one seed's key — upgraded to Secret, Written(1).
StoredIdentityJvmTest: a Secret whose key a seed derives lists once, as
Mnemonic, under the wallet's id, carrying the credential's key; a seed with no
credential lists as Mnemonic with a derived key; a Secret whose key no seed
derives is NostrSecret; a Public for a seed's key is dropped.
IdentityWriterJvmTest: creating from a phrase writes both files and the
credential is a Secret for the derived key; the phrase of a bare key attaches —
Written with the wallet's id, the entry unchanged; the nsec of a key with a
wallet attached is AlreadyExists; forgetting a key with a wallet attached is
WalletAttached and changes nothing.
And a mnemonic activation in the round-trip harness, which neither sign-in plan wrote because both were about the other kinds: the identity's key equals the credential's and the node's.
Phase 2 — a switch that tears down what it should
App only. No UI. Needs Phase 1. The transition, made correct, with nothing yet calling it from a screen. Its diff is the view model, one manager, and three one-line actuals.
The call
// SovereignWalletViewModel
/**
* Makes [id] the identity to open next and clears the active one, which is the
* whole of a switch: the navigation observer sends a null identity to startup,
* startup opens [id] through the lock gate, and the machine routes it from its
* account. Everything reading [activeIdentity] is cancelled by the null and
* rebuilt by the activation.
*/
fun switchToIdentity(id: WalletId) {
val previous = _activeIdentity.value
_desiredWalletId.value = id
startWalletImmediately.value = true
_activeIdentity.value = null
if (previous?.business != null) {
viewModelScope.launch(Dispatchers.IO) { stopPlatformBusiness(previous.id) }
}
}
switchToWallet is renamed to this; its three callers — the two tails and the
startup selector's onWalletClick — follow. startWalletImmediately is set
back to true because a switch is the user saying which one, and the flag was
only ever the user saying show me the list. Nothing set it back before because
nothing could switch.
The order inside is deliberate. The identity is cleared before the node is
stopped: clearing cancels every collector that could reach the business, so the
stop finds nothing reading it. The stop is BusinessManager.stopBusiness behind
expect fun stopPlatformBusiness(walletId: WalletId)
with an actual per platform beside updateBusinessActiveInUI, which has the
same shape and the same reason to be an expect: the manager is a per-platform
object.
The test is business != null, not kind == Mnemonic. The question is whether
the profile being left has a node behind it, and today those are the same fact —
but the first is the fact, and the second is how it currently comes to be true.
previous.id is the wallet's id when a wallet is attached, which is why
stopBusiness can take it, and why a profile's id is its wallet's. For a bare
key there is nothing to stop and the branch is not taken; for a read-only profile
likewise.
The tail keeps its own navigate. The sign-in tail both switches and
navigates to startup with popUpTo(0), and so does the observer on
StartupPhoenix; two writers to the stack, one entry popped by the other. It
looks redundant and is not: _navigationUIState is a StateFlow, and a
getAndUpdate to a state equal to the current one emits nothing. The explicit
navigation is what guarantees the stack moves even on the day the state does not.
Leave both, and leave a comment saying so, because the next reader will want to
remove one.
The relay observer
observeActiveUserId becomes what the pumps already are — a child of
collectLatest, cancelled by the next emission:
private fun observeActiveUserId() = scope.launch {
activeIdentityStateFlow.collectLatest { identity ->
val pubkey = identity?.nostrPublicKey ?: return@collectLatest
relayRepository.observePublicKeyRelays(pubkey).collect { relays ->
updateRelayPools(relays.filter { it.type == "user" }.map { it.mapToRelayDTO() })
}
}
}
The observeRelayJobs map goes; it existed to replace an observer per pubkey and
could only ever leak. A null identity closes nothing — the pool is shared by the
pumps that a null identity has already cancelled, and the next identity's list
replaces it through changeRelays as it does today.
What needs nothing
Listed so that nobody adds a guard that already exists.
SynchronizationViewModel's pumps and LiveSubscriptionManager.observe are
children of collectLatest; hasRequeuedStaleBroadcasts is once per process
on purpose, and its comment already names the wallet switch as the reason.
NotaryViewModel keys on the private key and restarts its four observers.
MlsGroupCache and MarmotInboundManager's pending commits are keyed by room,
and a room belongs to one identity by its userPublicKey. DataStoreManager
caches preferences per id for the life of the process, which is right for a
switch and only wrong for a test that reuses a key across directories, as the
nsec plan found.
Tests
IdentitySwitchJvmTest, in the round-trip harness beside NpubPreviewRoundTripJvmTest:
write an nsec A and an nsec B through the writers, list, activate A with a real
NotaryViewModel and NavigationViewModel on the same database. Queue an
unsigned kind 1 for A: it is signed. Switch to B, activate B the way startup
does: the machine lands on B's state. Queue an unsigned kind 1 for A: it is
not signed while B is active, and is when A is opened again. That is the
assertion a switch is for — the notary signs as whoever is open and nobody else.
switchToIdentity on its own, with stopPlatformBusiness injected as a recorder
the way the writers are injected into SignInToProfileViewModel: called once
with A's id when A had a node; not called for a bare key; not called when nothing
was active. And a RelaysSocketManager test over a fake relay repository with two
pubkeys' lists: after the switch the pool holds B's relays, and a re-emission of
A's list changes nothing.
Phase 3 — which profile opens, on launch and after a switch
App. Needs Phase 2. The startup screen learns to agree with the switch.
The precedence
The when that picks what to open, lifted out of the composable's remember
into a function that can be read and tested, in ui/view/state:
object StartupChoice {
/** Which stored identity startup opens without asking, or null to show the selector. */
fun resolve(
force: WalletId?,
desired: WalletId?,
startImmediately: Boolean,
identities: Map<WalletId, StoredIdentity>,
default: WalletId?,
): StoredIdentity? = when {
force != null -> identities[force]
desired != null -> identities[desired] // a switch or a sign-in said which
!startImmediately -> null // the user asked for the list
identities.size == 1 -> identities.values.single()
default != null -> identities[default] // the last one used
else -> null
}
}
Two rows move. desired goes above !startImmediately — it is the more
specific instruction and the one a switch relies on — and the size == 1 row
drops below it, because when both are set they name the same thing and the order
only mattered for reading. Everything else is as it was.
The default
setActiveIdentity saves what it activates:
fun setActiveIdentity(identity: Identity) {
_activeIdentity.value = identity
viewModelScope.launch(Dispatchers.IO) { getGlobalPrefs().saveDefaultWallet(identity.id) }
}
Every activation goes through here — startup for all three kinds, and the sign-in and create tails through startup — so the default is always the profile most recently open, and a cold boot with two profiles opens it. The selector is one tap away in Phase 4, and the lock prompt's back-to-selector button stays for the case it was built for.
The inverse: the two forget tails — sign out of a read-only identity, forget
this key for a bare key — call clearDefaultWallet() before resetToSelector().
resetToSelector already nulls desiredWalletId; the default is the one memory
of the identity that would otherwise outlive it, and identities[default] on a
forgotten id is a selector anyway, but a null read is better than a miss that
happens to be handled.
The words
The five literals the startup screen shows — "Decrypting...", "Initializing...",
"Preparing wallet...", "Opening wallet", "Starting wallet" — move to the catalogue
in this phase because the phase rewrites the when they sit in. Four of them say
"wallet" about something that is not one and become preparing profiles and
opening profile. The fifth is right as it is: "Starting wallet" is shown from
StartupViewState.StartingBusiness, which only the branch with a wallet attached
reaches, and what is starting there is a Lightning node. It stays starting
wallet, so that a user with a bare-key profile never sees the word and a user
with a wallet sees it at the one moment it is true. The selector's title,
select_a_wallet, becomes choose_a_profile. The lock prompt's "coming soon"
literal stays; it is a stub and marked as one.
Tests
StartupChoiceTest in commonTest, one case per row and the two orderings this
phase exists for: desired set and startImmediately false opens the desired
one; one identity and startImmediately false shows the selector, because the
user asked. IdentitySwitchJvmTest gains: after activating B, getDefaultWallet
is B's id; after forgetting B, it is empty; a fresh SovereignWalletViewModel
over the same store and a StartupChoice.resolve with its list and default
answers A.
Phase 4 — the switcher
App. Needs Phases 2 and 3. One pushed screen, and the startup selector brought up to match it.
The screen
ProfilesRoute, reached from the profile tab's row — switch profile, where it
said change account — and drawn by ProfilesScreen: a TopAppBar titled
profiles with NavigateBackButton in the leading slot, and WalletsSelector
as its whole content, with activeWalletId set so the current profile sits above
the divider and the others below.
Tapping another row is switchToIdentity(id) and nothing else — the observer
restarts the app as that identity, through its lock gate. Tapping the current row
does nothing; canEdit stays false, the edit dialog behind it is a stub. The
bottomContent slot holds the two rows Phase 5
adds.
Four states, said so. Loading while listWalletState is Init, which in
practice is never seen — the list was read at startup — but the state exists and
the screen says what it is waiting for. Loaded is the list. Error is
ListWalletState.Error, through ErrorState with onRetry = listIdentities,
because an unreadable credentials file is exactly the failure a retry can answer.
Empty cannot happen while something is signed in and the screen does not
pretend it can. ScreenStateTransition wraps the when; readableContent() on
the root; one column at every width, since a list of a handful of rows has no
detail to pair with.
What a row shows
WalletsSelector shows metadata.nameOrDefault() — "Default name", for every
row, because nothing in this app writes the wallet metadata's name — over a
random emoji (WalletsSelector.kt:162).
Two profiles side by side, both called Default name, distinguished by a bech32
string, is not a switcher.
The row takes the nostr profile instead: WalletsSelector gains a
profiles: Map<HexKey, Profile?> parameter, and the row shows
humanReadableNameOrPubkey() over ProfileAvatar(publicKey, profile) — the
widgets the profile tab already draws itself with — falling back to the npub and
the metadata's emoji for a profile the device has no kind 0 for yet: one just
created, or a read-only one that was never found. ProfilesScreen collects the
map through observeProfileWithPublicKey for every listed pubkey, in a
ProfilesViewModel that owns the four states above. The read only label stays
where it is and means what it meant.
And a second label of the same kind, wallet, on a row with a seed attached. What the device holds for a profile — nothing, a key, or a key and a wallet — is the one thing the switcher knows that the profile tab never shows. It is the difference between a profile that can be signed out of and one whose sign-out is a wallet question, and between two profiles with the same name, and it belongs before the tap for the reason the read-only label does. A bare-key profile carries no label; it is the plain case.
The startup screen draws the same widget and gets the same map: MantraNavHost
already holds databaseNostrRepository where it composes
SovereignWalletStartupScreen, and passes it through. Both lists then look the
same, which is what a user who has just seen one and is now looking at the other
expects.
Tests
ProfilesScreenJvmTest, with runDesktopComposeUiTest and the unmerged tree, as
ReadOnlyEntrancesJvmTest does: two identities, A current — A's name is above the
divider and B's below; tapping B invokes onSwitch(B.id) exactly once; tapping A
invokes nothing; a read-only B carries read only, an A with a seed attached
carries wallet, and a bare key carries neither; a B with no profile row shows
its npub. And ActiveProfileScreen under LocalCanSign true and false both have
switch profile: a read-only identity can leave for another profile the same way
a signing one can.
Phase 5 — adding a profile from inside
App. Needs Phase 4. The two entrances, and the two things that were only safe with one profile.
The rows
Under the list, in bottomContent, on the switcher and on the startup selector
alike:
| row | goes to | why not Landing |
|---|---|---|
| sign in with a key | SignInRoute |
Landing has no back button and clears the stack on its way out: it is the first-run screen, and pushing it from inside would make it a pushed screen on some days and a root on others. The sign-in screen has NavigateBackButton already |
| create a new profile | CreateProfileRoute |
same; and its tail is the one the create flow uses today |
Landing stays exactly what it is. The caption on each row says what the tails below make true: the profile you are in stays on this device.
The sign-in tail
Unchanged in shape — listIdentities { switchToIdentity(id); navigate(Startup) { popUpTo(0) } }
— and now correct from inside: Phase 2 stops the previous node and Phase 3 makes
startup open the id it was handed. A read-only identity upgrading itself through
the Messages tab's sign in with the nsec is the same tail with the same id, and
keeps working; a bare key acquiring its wallet through a pasted phrase is the
same tail with the wallet's id.
One thing changes. IdentityWriter.WriteNostrCredentialResult.AlreadyExists is a
data object and says only that the key was refused. From Landing that was the
whole message; from inside, "this key is already on this device" is a sentence
with an obvious next step, and the writers know which id they collided with —
the credentials map by pubkey, and the attached seed's wallet id where there is
one. It becomes AlreadyExists(val id: WalletId),
CredentialProblem.AlreadyOnThisDevice carries it, and the sign-in screen's
error state gains one action, switch to it, which is switchToIdentity(id) and
the same tail. The recovery-phrase writer's SeedAlreadyExists maps the same way.
Two writers, one distinction, and a duplicate paste becomes the fastest switch in
the app.
A profile created from inside is a key
CreateProfileScreen generates twelve words behind its form
(CreateProfileViewModel.kt):
a seed, a node, a wallet, and — at the NIP-06 path — the key that becomes the
profile. That is the right thing for the first profile on a device, which is the
one the wallet will belong to, and the wrong thing for the second: a user who
wants another profile has not asked for another Lightning node, another set of
channels, or a second twelve-word phrase with funds behind it, and would not thank
the app for handing them one.
From the switcher, create a new profile makes a bare key: thirty-two random
bytes as a PrivateKey, written through writeNostrKey — the writer the sign-in
screen uses for a pasted nsec — then createNewProfile(pubkey, name, bio), the
six-event bootstrap a new key gets, then the tail. Same screen, same form:
createAccount already takes its writer as a function, so the nav host passes
the seed writer from Landing and the key writer from the switcher, and the
screen does not know which it was given. Under this plan's model the two writers
differ by exactly one thing — whether a wallet is attached to the profile they
make — which is what the model was for. The declaration and confirmation copy say
"profile" and "keys" and are true of both. The backup lives where it does for a
signed-in nsec: the key recovery screen already shows the secret for that kind,
and the backup flags are per id, so a profile made this way is reminded to back
up its key the way a seed's profile is reminded about its phrase.
The previous identity's notary observes observeUnsignedNostrEvents(publicKey = its own), so it never sees the new key's events; they wait for the new
identity's notary, which starts when startup activates it — at once, since there
is no node to start. Nothing crosses. The tail gains the popUpTo(0) the sign-in
tail has, so that back from the new profile's startup does not return to a form
whose key is already on disk.
A second wallet is not offered from inside. Restoring one is the sign-in row — a pasted recovery phrase brings its wallet with it, and the confirm step already says so — and creating a fresh one waits for a wallet feature to want it, which is named under out of scope. Landing's create profile keeps making a seed; if the product decides the first profile should be a bare key as well, that is the same writer swapped in one more place.
And end this goes. It wipes the database — every profile's rooms, MLS state,
key packages and queues — from a screen whose purpose is to add a profile, and it
was only ever a development exit. Remove the button and the string;
wipeDatabase stays on the repository for the tests that use it. If a
device-level reset is ever wanted, it is a settings screen with a confirmation
naming every profile it will take, and not this.
Tests
SignInToProfileViewModelJvmTest: the nsec of a key already here fails with the
id it is already here under, for each of the three writers.
AddProfileFromInsideJvmTest, in the round-trip harness: A active; B's nsec
committed through the sign-in view model; list — two entries; switch; B active;
switch back — A active, and getLocalAccounts() holds two accounts, each with its
own kind 0 and neither with the other's. A profile created from inside through
the key writer lists as NostrSecret, its account holds the six bootstrap rows,
and BusinessManager.businessFlow is empty afterwards — no node ran, the
assertion the nsec round trip already makes. CreateProfileScreen composed has
no node with the text end this.
Phase 6 — a queue per identity, and the inbox that arrived while another profile was open
App. Needs nothing above it to compile; Phase 2 to matter. The one data problem, closed from both ends.
Whose request
The two queues that fetch gain an owner, because what a fetch brings back is opened with the fetcher's key. The one that sends does not, because a signed event is anyone's to carry: the signature is the author's whoever puts it on the wire, and holding A's outgoing message until A is opened again would be worse than sending it as B. A read-only identity does not run the broadcast pump at all and the npub plan's rule stands.
// SynchronizeNostrEventRequest, NegentropySynchronizeRequest
/** The identity that asked. Null for rows written before there was one to record, which any identity may drain. */
val ownerPublicKey: HexKey? = null,
Room database version 20, an AutoMigration with a comment in the list the way
every nullable addition before it has one: rows written before it read back null,
meaning "the device's", which is what they were.
The stamp is the repository's, not the call site's. Eighteen view models and
two DAO paths queue requests, and every one of them does so as the active
identity — a screen cannot queue anything as anyone else. DatabaseNostrRepository
takes activeIdentity: StateFlow<Identity?> at construction, which the nav host
has in hand where it builds the repository, and queueSynchronizeNostrEvent and
queueNegentropySynchronizeRequest stamp ownerPublicKey = activeIdentity.value?.nostrPublicKey
on every row. The DAO's own requests — the placeholder-profile syncs it plants
while indexing — are stamped from the activeKeyPair it is already handed. No
call site changes, and no call site can forget.
The pumps then ask for their own:
SELECT * FROM SynchronizeNostrEventRequest
WHERE status = 'pending' AND (ownerPublicKey = :publicKey OR ownerPublicKey IS NULL)
ORDER BY createdAt ASC LIMIT 1
observePendingSynchronizeNostrEventRequests(publicKey) and its negentropy twin
take the pubkey the pump already builds its key pair from. A's requests wait for
A; B never opens a wrap that was fetched for A, because B never fetches it.
The inbox, reopened
The other end: wraps that were fetched under the wrong key — before this migration, or by a read-only identity whose nsec was pasted later, the case the npub plan's DAO guard left with the words "the key that opens this one may be signed in later" and no code behind them.
// NostrDao
/**
* Opens every gift wrap addressed to [activeKeyPair] that the device stored but
* could not open at the time -- because another identity fetched it, or because
* this one held no key yet. One pass: wraps do not depend on one another, so a
* wrap that cannot be opened now will not be helped by opening its neighbours.
*/
open suspend fun reopenInbox(activeKeyPair: KeyPair): Int
The query is every GiftWrapMessage with receiverPublicKey = me that no
GiftWrapSeal names as its giftWrapMessageId; the body is the unseal branch of
indexNostrEvent from the isAddressedTo check down, lifted into an
openGiftWrap(giftWrapMessage, activeKeyPair) that both call. A Marmot welcome
inside one of them reaches MarmotInboundManager the way it would have on the
day; a direct message is filed as one.
It runs once per activation, from SynchronizationViewModel's collector, inside
if (identity.canSign) and before the live subscriptions and the broadcast
pump are launched — so that the sweep and the live inbox are not opening the same
wrap at the same moment. Both are idempotent by event id, so the order is about
not doing the work twice rather than about correctness.
Tests
ReadOnlyGiftWrapDaoJvmTest has the fixture: a wrap addressed to A, stored
through B's key pair — event and wrap rows, no seal. reopenInbox(A) returns one,
and afterwards the seal and payload are stored and the message is filed;
reopenInbox(A) again returns zero and changes nothing; reopenInbox(B) returns
zero. A DAO test for the filter: A's pending request is not emitted to B's pump,
a null-owner row is emitted to both, and A's is emitted to A's. And the
repository stamp: a request queued while A is active carries A's pubkey, while
nothing is active carries null.
Phase 7 — leaving one profile among several
App. Needs Phases 3 and 4. Small, because the forget sequence was built right.
Sign out of a read-only identity and forget this key for a bare key both run
ForgetIdentity and then the tail listIdentities { resetToSelector() }, which
shows the selector with what remains or Landing if nothing does. That is the right
behaviour with several profiles as well; Phase 3 added clearDefaultWallet() to
it, and this phase adds nothing else to the sequence. A profile with a wallet
attached answers WalletAttached from Phase 1 and is not offered either exit.
Two exits learn about the others:
- The not-found screen offers try again and, for a read-only identity, use
a different key, which forgets it. With other profiles on the device a third
action makes sense and costs one row: switch profile, pushing
ProfilesRoute. The identity that was not found stays listed; forgetting it remains a separate decision. - Sign out for a profile with a wallet attached stays
ImplementationPendingRoute, for the reason both sign-in plans gave: removing a seed is a wallet question, and now — since the credential would come back at the next repair — it is also the only way that profile can leave. What changes is that the user is no longer stuck behind it: switch profile is the row above.
Tests
ForgetIdentityJvmTest gains: forgetting B while A remains leaves A listed and
the default cleared; forgetting a profile with a wallet attached is
WalletAttached. UnsyncedProfileScreen composed with two identities has switch
profile; with one, it does not.
Phase 8 — the tests that actually prove it
One round trip, beside the two that exist.
Two profiles, both ways, and a boot. Create A from a phrase — both files
written, A listed once with a wallet attached; write B's npub; list; activate A;
sign B in from inside through the sign-in view model; the tail switches; B is
active and read-only, A's node was stopped and B's never started;
NavigationViewModel lands on B's state; switch back; A's state and A's node
again. Queue a kind 1 for A while B is open — not signed; open A — signed. Create
C from inside: a NostrSecret, active, with no node. Forget B and C; one
identity, default cleared. Then a fresh SovereignWalletViewModel over the
same directory, which is what a cold boot is: the repair finds nothing to do, it
lists A, and StartupChoice resolves A without asking.
And ./gradlew :composeApp:m3Audit: two new screens, a new route, and eight new
strings are where a 16.dp, a "Switch Profile" and an untriaged
contentDescription arrive.
Phase 9 — rollout
No library change. BusinessManager.stopBusiness exists on all three
platforms, saveDefaultWallet has been in GlobalPrefs since Phoenix, and the
credentials file's format already holds what Phase 1 writes into it. This plan
owes no tag, which is the first of the three sign-in plans that can say so.
Phase 1 ships alone, and first. It changes what is on disk: the first launch
after it writes a credential for every seed on the device. A build before it,
reached by a downgrade after that write, lists each seed-backed profile twice —
once as a wallet with a derived key, once as a bare key under a different id —
because the old merge kept a secret for a seed's key and trusted the writers to
have prevented it. Nothing is lost: both rows sign as the same key and share
every row keyed by it, and the next upgrade lists them as one. It is the one
line of this rollout worth a release note.
Phases 2 and 3 ship together. A switch without the precedence fix lands on the selector instead of the profile it was asked for — not broken, but it is the thing the phase is for. Phase 3 without Phase 2 is a startup screen that remembers a default nobody can change without it.
Phases 4 and 5 ship together. A switcher with no way to add a profile is half a feature; the add rows without the switcher leave a newly added profile reachable only through startup, which is the state the device is in today with two.
Phase 6 ships whenever it is ready, and should ship early if it can. It is a
Room migration and a DAO change, and it fixes a case that exists today: a device
with two identities, switched at the startup selector a moment after one of them
signed in. The migration is a nullable column, which Room generates and an older
build cannot open — the same downgrade story every migration in this database has
had, and PlatformDatabaseBuilder has the same answer.
Phase 7 can follow by a release. Without it, the not-found screen has two exits instead of three and a wallet-attached profile's sign-out row is the same pending route it is now.
Old builds. A build before Phase 3 ignores the saved default and shows the selector, as it does today. A build before Phase 6 drains every queue as whoever is open, as it does today. Apart from the double listing above, nothing written by this plan is misread by a build that predates it; it is only not read.
Estimate
| phase | work | days | blocked by |
|---|---|---|---|
| 1 | the credential written and repaired, merge inverted, the activation, the forget guard |
1.5–2 | — |
| 2 | the switch, the node stop, the relay observer | 1 | 1 |
| 3 | the precedence, the default, the words | 0.5–1 | 2 |
| 4 | the switcher screen, the profile on the row | 1 | 3 |
| 5 | the add rows, a key-only create, the duplicate that switches, end this | 1–1.5 | 4 |
| 6 | the owner column, the pump filter, the inbox reopened | 1.5 | — |
| 7 | the exits | 0.5 | 3, 4 |
| 8 | the round trip | 0.5 | all |
| 9 | rollout | — | all |
Roughly a week and a half, with Phase 6 in parallel from the start if two
people are on it. Two places carry the uncertainty. Phase 1 is where the two
sign-in plans' tests are re-read against a rule they were written to enforce, and
a test that turns out to have been asserting the old model somewhere unexpected
is a test to rewrite, not to delete. Phase 6's unseal branch of indexNostrEvent
has never been called from anywhere but indexNostrEvent, and lifting it out is
where a transaction boundary will turn out to have been load-bearing.
Phases 1–8 are now implemented, in one sitting and in order. What each turned out to require, as against what was predicted here, is in the table at the top and in the commit messages on this branch. The Phase 6 uncertainty resolved itself by not lifting the branch at all; the uncertainty that did bite was in no phase — a process-wide cache in the library, reached by two threads at once for the first time, which the tests for Phase 5 hit one run in three.
Out of scope
- A lazy node. Phase 1 puts the key in the credential, so a profile with a
wallet attached could be activated without starting its node and start it when
a wallet feature asks. Not done here, for the reason under Phase 1: the node is
also where the channel watcher is scheduled, and a restored Phoenix phrase can
have channels. Its own decision, one branch in
doLoadWallet, and the nsec plan's "start the node lazily" now has nothing in front of it. - A wallet attached to a profile that did not derive it, or one wallet on the
device that every profile pays from. Phase 1 makes attachment a fact about the
listing — a seed matched to a credential by pubkey — and both sign-in plans
deferred what it would mean to attach by choice.
Identity.businessis the seam, the identity would carry the wallet's id beside its own, and Phase 2's stop readsbusinessrather than the kind so that the switch does not have to be rewritten when it lands. - A second fresh wallet from inside. Reachable today only through a pasted recovery phrase. Until a wallet feature exists there is nothing to want it for.
- Showing the nsec of a profile with a wallet attached. Trivially possible
after Phase 1 — the key is in the file — and a real want, for a user taking
their profile to another client. A row on
KeyRecoveryScreenbeside the phrase; its own small change, with the phrase's warnings rewritten for a secret that has a wallet behind it. - Sign out for a profile with a wallet attached. Unchanged from both sign-in plans, and now the only way such a profile can leave the device.
- Two of the device's own profiles in one Marmot group.
ChatRoom's primary key is the group id anduserPublicKeysays whose the row is; a second local member of the same group would overwrite the first's MLS state. Today it cannot happen — one profile per device — and after this plan it can, by inviting yourself. Refusing the invite at the join is the cheap fix; keying the room by(id, userPublicKey)is the real one and is a migration of every table that references a room. Named here so that the first report of it is not a mystery. - A database per identity. The only design that isolates the queues, the cache and the collision above wholesale. Rejected below, but it is the shape the collision would eventually force.
- The social precondition gate. Every
ProfileLoadedroutes through it, on every launch, and so on every switch. It is an onboarding screen shown to people who have finished onboarding; a machine that skips it for an account it has seen before is one branch inprocessLocalAccount, and its own decision. - Per-profile lock, per-profile notifications. The lock prompt is a stub and the app has no push. Both are per-id already in the preferences and would slot into the activation this plan routes everything through.
- Editing a profile's local name and avatar. The metadata dialog is a stub, and after Phase 4 the row shows the nostr profile, which is the name.
- Tightening secrets in memory; NIP-46; NIP-49. Inherited, unchanged. Phase 1 adds one copy of a secret the device already held in derivable form, under the same keystore key, and the decrypt-at-activation both plans deferred applies to it as to the others.
Appendix — what was considered and rejected
A seed's profile by derivation only
The model the code had, and the one this plan's first draft built on: the credentials file holds only bare keys, a seed is a profile because its key can be derived, and the writers keep one key out of two files. It needs no repair and holds every secret once. It was rejected because it puts the wallet where the profile should be: the list of profiles is a merge of two files with opposite ideas of what a row is, the node has to run for a profile to know its own key, and a wallet can never belong to a profile it did not derive. The cost of changing it — Phase 1 — is a day and a half; the cost of building a switcher on it and changing later is that switcher, twice.
One id space for every profile
Under the model this plan takes, a profile could be keyed by hash160(pubkey)
whether or not a wallet is attached, with the wallet's hash160(nodeId) carried
beside it. Cleaner on paper, and rejected: the library keys the node's
preferences and metadata by the wallet's id inside startNewBusiness, so a
seed-backed profile would have two preference files — one the app writes, one
the node reads — and every existing device would need its metadata and backup
flags copied from the wallet's id to the profile's. A profile's id being its
wallet's when it has one keeps every file where it is, keeps stopBusiness and
updateBusinessActiveInUI one argument, and costs only what the npub plan already
accepted for the read-only upgrade: a profile that acquires a wallet gets fresh
preferences.
A live swap under the stack
Set the identity and keep the screens. Every route carries the old key, every
view model was built from a route, and the navigation component reads the key off
the current route by design. Either every screen re-reads the identity from a
flow — the parameter-in-forty-three-lists problem the snackbar host and the
signing capability were both invented to avoid — or the stack is rebuilt, which
is the restart. The restart also gets the lock gate for free: an identity is only
ever activated through LoadWallet.
A global switcher control
The avatar in the app bar, as Google's apps do it; a long press on the profile
tab; a swipe on the navigation bar. The home app bar lost its avatar by an
information-architecture decision recorded in TopLevelDestination, and putting
one back for a different purpose is two meanings on one control. A long press is
undiscoverable. And every one of them fires a popUpTo(0) from wherever the user
is — a switch is one deliberate tap on a screen that says what it does, and the
profile tab's row is where the app has been promising it.
A bottom sheet instead of a screen
The other common shape, and the app has a ModalBottomSheet. Rejected for
consistency rather than on merit: the startup selector is a screen, both lists
are the same widget with the same rows, and the add rows push screens anyway. A
sheet that pushes screens from under itself is a screen with a worse back story.
Keeping the previous node running
The cheaper switch-back — startNewBusiness returns a running node in
milliseconds. But nothing reads the node except the key, and after Phase 1 not
even that; the restart tears down everything else built for that identity, and a
background node is Electrum, the LSP peer and the swap-in watcher for a profile
that is not open. If a wallet feature ships and a node has something to do while
its profile is closed, this is the decision to revisit, and it is one branch in
switchToIdentity.
Stamping the owner at every call site
Eighteen view models each passing activeUserPublicKey into a queue call.
Mechanical, wide, and forgettable: the nineteenth would pass nothing. The
repository is built once, has the identity flow one parameter away, and every
request it writes is made as the active identity by construction.
Filtering the broadcast queue by owner
Symmetry with the fetch queues. Rejected because a signed event needs no key to send and holding A's message until A returns is a delivery failure the user would never be told about. The asymmetry is the point: fetches are opened with a key, sends are not.
A database per identity
Isolates everything at once, including the group-membership collision. Costs the
shared profile cache — every kind 0 fetched once per profile — a Room instance
rebuilt on every switch, and a migration that splits the existing file by
userPublicKey across every table that has one and guesses for the tables that
do not. Deferred rather than rejected: it is the answer if the collision becomes
common, and nothing in this plan makes it harder to do later.