feat(nip46): persist serviced request ids so a restart never re-signs replays

The 30s window shrank the restart re-sign problem but couldn't close it: a
relay replays stored ephemeral requests on re-subscribe, and the in-memory
dedup set is wiped on restart, so anything within the window came back.

Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed
the service's dedup set from them on start, so a replay after an app restart
is dropped by EXACT event id. Chosen over a created_at high-water mark on
purpose: a global timestamp floor would wrongly drop a second connected app
whose clock lags behind another's, whereas id-matching is immune to client
clock skew. The `since` filter still bounds how far back relays replay.

- AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side
  bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and
  advanced through onHandledId.
- Tests: a fresh request whose id was serviced last session is not repeated;
  the serviced id is reported for persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:31 +00:00
parent 9a0af15f36
commit 5fa8a0a2aa
5 changed files with 98 additions and 5 deletions
@@ -252,6 +252,42 @@ class NostrConnectSignerServiceTest {
assertEquals(0, client.published.size, "a minutes-old replayed request is dropped, not re-signed")
}
@Test
fun aFreshRequestWhoseIdWasServicedLastSessionIsNotRepeated() =
runTest {
val client = LoopbackClient()
val signer = serverSigner()
val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer())
// A still-fresh request whose id the previous run already serviced (seeded via initialSeen,
// as the host would restore from disk). It must be deduped by exact id — even though its
// created_at is within the window — so an app restart doesn't re-sign a relay's replay.
val template = EventTemplate<Event>(createdAt = 1L, kind = 1, tags = emptyArray(), content = "again")
val replayed = request(BunkerRequestSign(id = "req", event = template))
val service = NostrConnectSignerService(client, signer, processor, setOf(relay), initialSeen = setOf(replayed.id))
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() }
client.deliver(replayed)
assertEquals(0, client.published.size, "an id serviced last session is not signed again after restart")
}
@Test
fun aHandledIdIsReportedForPersistence() =
runTest {
val client = LoopbackClient()
val signer = serverSigner()
val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer())
val handled = mutableListOf<String>()
val service = NostrConnectSignerService(client, signer, processor, setOf(relay), onHandledId = { handled.add(it) })
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() }
val event = request(BunkerRequestSign(id = "req", event = EventTemplate<Event>(createdAt = 1L, kind = 1, tags = emptyArray(), content = "x")))
client.deliver(event)
assertEquals(listOf(event.id), handled, "the serviced event id is reported so the host can persist it")
}
@Test
fun logoutRequestInvokesAuthorizerAndAcks() =
runTest {