Files
amethyst/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md
T
nrobi144 5475d6c2bc 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>
2026-03-11 10:20:18 +02:00

7.0 KiB

date, topic
date topic
2026-03-06 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):

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()):

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

Replace relayStatuses.keys with a debounced/stable relay set.

// 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.

// 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.

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

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:

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)