fix(cashu): show "discovering" state instead of empty-create CTA on first launch

NIP-60 wallets are portable — kind:17375 + 7375 + 7376 + 10019 are
stored on relays, so a wallet created in another client (cashu.me,
Boardwalk, etc.) should appear in Amethyst when the user signs in with
the same Nostr key. The plumbing already handles this: our
CashuWalletFilterAssembler subscribes to kinds=[17375, 7375, 7376,
7374, 10019] authored by us, and CashuWalletState.applyEvents() indexes
incoming events regardless of which client published them.

The UX bug: the wallet screen had two states (wallet event present /
absent). On first launch, before relays delivered the existing wallet
event, we rendered the "No Cashu wallet — Create" state. Tapping Create
there published a fresh kind:17375 which (being replaceable) clobbered
the remote wallet — destroying the P2PK key and orphaning any inbound
nutzaps locked to it.

Fix:

 * CashuWalletState gets a `discovering: StateFlow<Boolean>` set to
   true at start() until either a wallet event arrives (cleared from
   applyEvents) or DISCOVERY_TIMEOUT_MS (8 s) elapses, whichever first.
 * CashuWalletScreen renders a "Looking for your wallet…" pane with a
   spinner + explainer while discovering is true. Empty-create CTA only
   fires after timeout for genuinely wallet-less users.

playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:41 +00:00
parent 811aa26d57
commit bb86df246a
4 changed files with 104 additions and 17 deletions
@@ -133,6 +133,24 @@ class CashuWalletState(
private val _pendingQuotes = MutableStateFlow<List<CashuMintQuoteEvent>>(emptyList())
val pendingQuotes: StateFlow<List<CashuMintQuoteEvent>> = _pendingQuotes.asStateFlow()
/**
* True while we're still waiting for relays to deliver an existing
* wallet event for this account.
*
* NIP-60 wallets are portable across clients — if the user previously
* created one in (say) Boardwalk or cashu.me, our subscription should
* pull the kind:17375 in within a few seconds of sign-in. The UI uses
* this flag to show a "Looking for your wallet…" state during that
* window instead of jumping straight to the "Create" CTA, which would
* overwrite the remote wallet (kind:17375 is replaceable).
*
* Cleared when:
* - a wallet event arrives via cache backfill or live update, OR
* - the discovery timeout elapses (~8 seconds), whichever first.
*/
private val _discovering = MutableStateFlow(false)
val discovering: StateFlow<Boolean> = _discovering.asStateFlow()
fun hasWallet(): Boolean = _walletEvent.value != null
/** Read the wallet's P2PK pubkey (33-byte compressed hex). */
@@ -181,6 +199,21 @@ class CashuWalletState(
started = true
this.publish = publish
// Show the "discovering" state until either a wallet event lands
// (set inside applyEvents()) or the timeout below fires. Without
// this the UI would render the empty-wallet CTA the moment the
// screen opens — even for users whose wallet exists on relays but
// hasn't yet been delivered — and tapping Create there would
// overwrite the remote kind:17375.
if (_walletEvent.value == null) {
_discovering.value = true
jobs +=
scope.launch(Dispatchers.Default) {
kotlinx.coroutines.delay(DISCOVERY_TIMEOUT_MS)
_discovering.value = false
}
}
// Backfill from cache once.
scope.launch(Dispatchers.Default) {
val initial = scanCacheForOwnEvents()
@@ -294,6 +327,9 @@ class CashuWalletState(
if (dirtyWallet) {
_walletEvent.value = walletEventInternal
// Any wallet event resolves the "discovering" state — whether it
// came from cache backfill or a fresh relay delivery.
_discovering.value = false
walletEventInternal?.let { evt ->
_mints.value =
runCatching { evt.mints(signer) }
@@ -524,6 +560,16 @@ class CashuWalletState(
private suspend fun publishEvent(event: Event) {
publish(event)
}
private companion object {
/**
* How long to remain in the "discovering" state before falling back
* to the empty-wallet CTA. Long enough that a typical relay round-
* trip on a cold start completes; short enough that genuinely
* wallet-less users don't stare at a spinner.
*/
const val DISCOVERY_TIMEOUT_MS = 8_000L
}
}
/** Mint + recipient pubkey resolved from a kind:10019. */
@@ -96,6 +96,7 @@ fun CashuWalletScreen(
viewModel.init(accountViewModel)
val walletEvent by viewModel.walletEvent.collectAsState()
val discovering by viewModel.discovering.collectAsState()
val mints by viewModel.mints.collectAsState()
val balanceSats by viewModel.balanceSats.collectAsState()
val history by viewModel.history.collectAsState()
@@ -139,23 +140,33 @@ fun CashuWalletScreen(
)
},
) { padding ->
if (walletEvent == null) {
EmptyCashuWallet(
modifier = Modifier.padding(padding),
onCreate = { nav.nav(Route.WalletAddCashu) },
)
} else {
CashuWalletContent(
modifier = Modifier.padding(padding),
balanceSats = balanceSats,
mints = mints,
history = history,
onReceive = { receiveOpen = true },
onSendLn = { sendLnOpen = true },
onSendToken = { sendTokenOpen = true },
onRedeem = { redeemOpen = true },
onRecommendMint = { viewModel.recommendMint(it) },
)
when {
walletEvent != null ->
CashuWalletContent(
modifier = Modifier.padding(padding),
balanceSats = balanceSats,
mints = mints,
history = history,
onReceive = { receiveOpen = true },
onSendLn = { sendLnOpen = true },
onSendToken = { sendTokenOpen = true },
onRedeem = { redeemOpen = true },
onRecommendMint = { viewModel.recommendMint(it) },
)
// NIP-60 wallets are portable across clients — show a "looking
// for your wallet" state until the relay subscription returns
// anything (or times out). Without this, a user who created
// their wallet in another app would see the Create CTA on
// first launch and would clobber the remote kind:17375.
discovering ->
DiscoveringCashuWallet(modifier = Modifier.padding(padding))
else ->
EmptyCashuWallet(
modifier = Modifier.padding(padding),
onCreate = { nav.nav(Route.WalletAddCashu) },
)
}
}
@@ -200,6 +211,33 @@ fun CashuWalletScreen(
}
}
@Composable
private fun DiscoveringCashuWallet(modifier: Modifier) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(36.dp), strokeWidth = 3.dp)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringRes(R.string.cashu_discovering),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringRes(R.string.cashu_discovering_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
@Composable
private fun EmptyCashuWallet(
modifier: Modifier,
@@ -145,6 +145,7 @@ class CashuWalletViewModel : ViewModel() {
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
val history get() = state.history
val pendingQuotes get() = state.pendingQuotes
val discovering get() = state.discovering
private val _createState = MutableStateFlow<CashuWalletCreateState>(CashuWalletCreateState.Idle)
val createState = _createState.asStateFlow()
+2
View File
@@ -1876,6 +1876,8 @@
<string name="cashu_wallet_title">Cashu Wallet</string>
<string name="cashu_no_wallet">No Cashu wallet yet</string>
<string name="cashu_no_wallet_description">Create a Cashu wallet to hold ecash tokens and receive nutzaps.</string>
<string name="cashu_discovering">Looking for your wallet…</string>
<string name="cashu_discovering_description">NIP-60 wallets sync across clients. If you created one in another app under this Nostr key, it should appear in a few seconds.</string>
<string name="cashu_balance">Balance</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_mint_url">Mint URL</string>