fix(desktop): verify user pubkey via get_public_key after nostrconnect login

The nostrconnect flow was trusting params[0] from the signer's connect
message as the user's pubkey. Some signers (e.g. nsec.app) don't put
the actual user pubkey there, causing all relay subscriptions to query
the wrong identity — contact lists, profiles, and DMs all returned empty.

Now calls remoteSigner.getPublicKey() after the handshake to get the
verified pubkey from the signer. Also stabilizes FeedScreen relay
subscriptions with distinctUntilChanged() and adds relay.primal.net
to defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-09 12:44:58 +02:00
parent 3a1e38a2f2
commit 5475d6c2bc
15 changed files with 2634 additions and 110 deletions
@@ -0,0 +1,189 @@
---
date: 2026-03-06
topic: nip46-post-login-bugs
---
# NIP-46 Post-Login Bugs: Content Not Loading + Bunker Timeout
## What's Happening
Two bugs on branch `feat/nip46-bunker-login` after the NIP-46 relay isolation work:
### Bug 1: `followedUsers=0` — Feed Never Populates After Login
**Symptoms:**
- Contact list subscription opens/closes rapidly (subscription churn)
- No `[DEBUG-FEED] contactList event:` log — no kind-3 event ever arrives
- Feed stays on "Loading followed users..." forever
**Logs:**
```
[DEBUG-FEED] contactList sub: relays=5, account=0b24845a, mode=FOLLOWING
[DEBUG-FEED] feedSub: mode=FOLLOWING, relays=5, followedUsers=0
[DEBUG-SUB] OPEN contacts-0b24845a-1772773857361 relays=5
[DEBUG-SUB] CLOSE contacts-0b24845a-1772773857361
[DEBUG-SUB] OPEN contacts-0b24845a-1772773867946 relays=5
[DEBUG-SUB] CLOSE contacts-0b24845a-1772773867946
```
### Bug 2: `bunker://` Login Times Out (30s)
**Symptoms:**
- Pasting bunker:// URI → "Connection timed out" after 30s
- `RemoteSignerManager.timeout = 30000` controls this
## Root Cause Analysis
### Bug 1: Subscription Churn from `relayStatuses.keys`
**The pattern** (`FeedScreen.kt:193`):
```kotlin
rememberSubscription(relayStatuses.keys, account, feedMode, ...) { ... }
```
**The problem:**
1. `relayStatuses` is a `StateFlow<Map<NormalizedRelayUrl, RelayStatus>>`
2. Every relay status change (connect, disconnect, ping) emits a new map
3. `.keys` produces a structurally-equal but reference-different set
4. `remember(*keys)` re-evaluates → `DisposableEffect` disposes old sub → opens new sub
5. New sub gets a fresh `subId` (uses `System.currentTimeMillis()`)
6. Relay's response to old REQ arrives → client drops it (unknown subId)
7. New REQ sent → relay starts processing → another status change → cycle repeats
**Timeline:**
```
T+0s: Relay A connects → relayStatuses emits → sub opens (subId=1001)
T+1s: Relay B connects → relayStatuses emits → sub CLOSES (subId=1001), opens (subId=1002)
T+2s: Relay C connects → same cycle → CLOSE 1002, OPEN 1003
T+3s: Relay responds to subId=1001 → DROPPED (sub already closed)
T+4s: Relay responds to subId=1003 → maybe received, maybe closed again
```
**Contributing factor:** `relayStatuses.keys` includes configured-but-not-connected relays. REQs to unconnected relays are queued but may never be sent.
### Bug 2: NIP-46 Relay Connection Race
**The flow** (`AccountManager.loginWithBunker()`):
```kotlin
val nip46Client = getOrCreateNip46Client() // creates NostrClient, calls connect() (no-op: no relays)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client)
remoteSigner.openSubscription() // calls client.req(relays=[relay.nsec.app]) → lazy connect
val remotePubkey = remoteSigner.connect() // sends connect request, waits 30s for response
```
**The problem:**
- `openSubscription()` triggers `sendOrConnectAndSync(relay.nsec.app, REQ)` — async connection
- `connect()` calls `launchWaitAndParse()` which sends EVENT immediately
- If websocket to relay.nsec.app isn't established yet, EVENT send fails/queues
- Even if EVENT sends, relay may not have processed REQ yet → response has no matching sub
- 30s timeout expires
**Also:** Only 1 relay (`wss://relay.nsec.app`) — single point of failure.
## Approaches
### Bug 1 Fix: Stabilize Subscription Keys
#### Approach A: Use `connectedRelays` + Debounce (Recommended)
Replace `relayStatuses.keys` with a debounced/stable relay set.
```kotlin
// Stable relay set that only updates when relay URLs actually change
val stableRelays by remember {
snapshotFlow { connectedRelays }
.distinctUntilChanged()
.debounce(2000) // wait for relays to stabilize
}.collectAsState(initial = emptySet())
rememberSubscription(stableRelays, account, feedMode, ...) { ... }
```
**Pros:** Eliminates churn, only resubscribes when relay set actually changes
**Cons:** 2s delay before first subscription opens
#### Approach B: Separate Subscription Lifecycle from Relay Set
Don't use relay set as a recomposition key at all. Open subscription ONCE with whatever relays are available, never re-create it.
```kotlin
// Only recompose on account/feedMode changes, not relay changes
rememberSubscription(account, feedMode, relayManager = relayManager) {
val relays = connectedRelays // snapshot, not reactive
if (relays.isNotEmpty() && account != null) {
createContactListSubscription(relays = relays, ...)
} else null
}
```
**Pros:** Zero churn, simplest fix
**Cons:** If initial relay set is empty, subscription never opens (need LaunchedEffect to wait for relays)
#### Approach C: LaunchedEffect + One-Shot Subscription
Don't use `rememberSubscription` for contact list at all. Use a `LaunchedEffect` that waits for relays then subscribes once.
```kotlin
LaunchedEffect(account) {
val relays = relayManager.connectedRelays.first { it.isNotEmpty() }
relayManager.subscribe("contacts-${account.pubKeyHex.take(8)}",
listOf(FilterBuilders.contactList(account.pubKeyHex)), relays,
listener = object : IRequestListener { ... })
}
```
**Pros:** No churn, explicit lifecycle, clear intent
**Cons:** Manual cleanup needed, doesn't auto-update if relay set changes later
### Bug 2 Fix: Wait for NIP-46 Relay Connection
#### Approach A: Wait for Connected Relay Before connect() (Recommended)
```kotlin
val nip46Client = getOrCreateNip46Client()
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client)
remoteSigner.openSubscription()
// Wait for NIP-46 relay to actually connect before sending connect request
nip46Client.connectedRelaysFlow().first { relays ->
remoteSigner.relays.any { it in relays }
}
val remotePubkey = remoteSigner.connect()
```
**Pros:** Guarantees relay is connected before handshake
**Cons:** Adds wait time; if relay never connects, blocks indefinitely (need timeout)
#### Approach B: Add Retry Logic to connect()
Wrap `connect()` in retry with backoff:
```kotlin
val remotePubkey = retry(maxAttempts = 3, delayMs = 5000) {
remoteSigner.connect()
}
```
**Pros:** Handles transient failures
**Cons:** Doesn't fix root cause, just masks it; up to 90s total wait
#### Approach C: Increase Timeout + Add Debug Logging
Bump `RemoteSignerManager.timeout` to 60s, add logging to track relay connection state.
**Pros:** Quick, low-risk
**Cons:** Doesn't fix the race; just makes timeout less likely
## Key Decisions
- Bug 1 is the higher priority — it affects ALL logins, not just bunker
- Bug 1 Approach B or C preferred — simplest, eliminates churn entirely
- Bug 2 Approach A preferred — fixes the actual race condition
- Both fixes are independent and can be done in parallel
## Open Questions
1. Does the contact list fail for nsec/key login too, or only bunker? (Would confirm if it's relay churn vs NIP-46 specific)
2. Is `relay.nsec.app` reachable from this machine? (`websocat wss://relay.nsec.app` test)
3. Should the bunker URI's relays be used for content subscriptions too? (User's relays vs default relays)
4. Should we add a fallback relay for NIP-46? (e.g., `wss://relay.damus.io` alongside `relay.nsec.app`)