* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  fix: resolve conflicts with upstream PR #1789 merge
  fix(desktop): remove noisy debug logs and silence SLF4J warnings
  feat(desktop): add bunker heartbeat indicator to sidebar navigation
  fix(desktop): verify user pubkey via get_public_key after nostrconnect login
  fix(desktop): address code review findings for NIP-46 bunker login
  feat(desktop): add nostrconnect:// login flow for QR-based signer pairing
  revert: remove QR code scanning from login
  feat(desktop): webcam QR scanning via ffmpeg for bunker login
  feat(desktop): add webcam QR code scanning for bunker login
  feat(desktop): add QR code scanning for bunker login
  fix(desktop): clear stored credentials on logout
  feat(desktop): update login hints to mention bunker:// option
  fix(desktop): simplify settings logout to just a button
  feat(desktop): add logout option to menu bar and settings
  test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors
  feat(desktop): add NIP-46 remote signer (bunker://) login
This commit is contained in:
Vitor Pamplona
2026-03-11 08:42:44 -04:00
53 changed files with 6037 additions and 268 deletions
@@ -42,6 +42,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class NostrSignerRemote(
val signer: NostrSignerInternal,
@@ -51,6 +56,8 @@ class NostrSignerRemote(
val permissions: String? = null,
val secret: String? = null,
) : NostrSigner(signer.pubKey) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val manager =
RemoteSignerManager(
signer = signer,
@@ -69,7 +76,9 @@ class NostrSignerRemote(
),
) { event ->
if (event is NostrConnectEvent) {
manager.newResponse(event)
scope.launch {
manager.newResponse(event)
}
}
}
@@ -79,6 +88,7 @@ class NostrSignerRemote(
fun closeSubscription() {
subscription.close()
scope.cancel()
}
override fun isWriteable(): Boolean = true
@@ -41,8 +41,9 @@ class RemoteSignerManager(
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
fun newResponse(responseEvent: NostrConnectEvent) {
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
}
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip46RemoteSigner
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for NostrConnectEvent pure logic (canDecrypt, talkingWith, verifiedRecipientPubKey).
*
* Crypto-dependent tests (create + decrypt roundtrip) live in androidDeviceTest/Nip46Test.kt
* because NIP-44 encryption requires lazysodium which is only available on Android/device tests.
*/
class NostrConnectEventTest {
private val senderKey = NostrSignerInternal(KeyPair())
private val recipientKey = NostrSignerInternal(KeyPair())
private val thirdPartyKey = NostrSignerInternal(KeyPair())
/** Construct a NostrConnectEvent with known pubKey and p-tag, no real crypto needed */
private fun buildEvent(
authorPubKey: String,
recipientPubKey: String,
) = NostrConnectEvent(
id = "a".repeat(64),
pubKey = authorPubKey,
createdAt = 1L,
tags = arrayOf(arrayOf("p", recipientPubKey)),
content = "encrypted-placeholder",
sig = "b".repeat(128),
)
// --- canDecrypt ---
@Test
fun canDecryptAsSender() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertTrue(event.canDecrypt(senderKey))
}
@Test
fun canDecryptAsRecipient() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertTrue(event.canDecrypt(recipientKey))
}
@Test
fun canDecryptUnauthorizedReturnsFalse() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertFalse(event.canDecrypt(thirdPartyKey))
}
// --- talkingWith ---
@Test
fun talkingWithAsSenderReturnsRecipient() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(recipientKey.pubKey, event.talkingWith(senderKey.pubKey))
}
@Test
fun talkingWithAsRecipientReturnsSender() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(senderKey.pubKey, event.talkingWith(recipientKey.pubKey))
}
@Test
fun talkingWithUnknownReturnsSender() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
// When oneSideHex doesn't match pubKey, returns pubKey (sender)
assertEquals(senderKey.pubKey, event.talkingWith(thirdPartyKey.pubKey))
}
// --- verifiedRecipientPubKey ---
@Test
fun verifiedRecipientPubKeyWithValidHex() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(recipientKey.pubKey, event.verifiedRecipientPubKey())
}
@Test
fun verifiedRecipientPubKeyWithInvalidHex() {
val event =
NostrConnectEvent(
id = "a".repeat(64),
pubKey = senderKey.pubKey,
createdAt = 1L,
tags = arrayOf(arrayOf("p", "not-hex!")),
content = "encrypted",
sig = "b".repeat(128),
)
assertNull(event.verifiedRecipientPubKey())
}
@Test
fun verifiedRecipientPubKeyWithNoPTag() {
val event =
NostrConnectEvent(
id = "a".repeat(64),
pubKey = senderKey.pubKey,
createdAt = 1L,
tags = emptyArray(),
content = "encrypted",
sig = "b".repeat(128),
)
assertNull(event.verifiedRecipientPubKey())
}
// --- Kind ---
@Test
fun kindIs24133() {
assertEquals(24133, NostrConnectEvent.KIND)
}
@Test
fun eventHasCorrectKind() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertEquals(24133, event.kind)
}
// --- isContentEncoded ---
@Test
fun isContentEncodedReturnsTrue() {
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
assertTrue(event.isContentEncoded())
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertTrue
class ConvertExceptionsTest {
private val remote =
NostrSignerRemote.fromBunkerUri(
"bunker://${"a".repeat(64)}?relay=wss://r.com",
NostrSignerInternal(KeyPair()),
EmptyNostrClient,
)
@Test
fun successfulReturnsBug() {
val result = SignerResult.RequestAddressed.Successful(PingResult("pong"))
val ex = remote.convertExceptions("Test", result)
assertIs<IllegalStateException>(ex)
assertTrue(ex.message!!.contains("bug"))
}
@Test
fun rejectedReturnsManuallyUnauthorized() {
val result = SignerResult.RequestAddressed.Rejected<PingResult>()
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.ManuallyUnauthorizedException>(ex)
}
@Test
fun timedOutReturnsTimedOutException() {
val result = SignerResult.RequestAddressed.TimedOut<PingResult>()
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.TimedOutException>(ex)
}
@Test
fun couldNotPerformReturnsCouldNotPerformException() {
val result = SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PingResult>("custom msg")
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.CouldNotPerformException>(ex)
assertTrue(ex.message!!.contains("custom msg"))
}
@Test
fun couldNotParseReturnsIllegalState() {
val result = SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<PingResult>("{bad}")
val ex = remote.convertExceptions("Test", result)
assertIs<IllegalStateException>(ex)
assertTrue(ex.message!!.contains("{bad}"))
}
@Test
fun couldNotVerifyReturnsIllegalState() {
val event =
Event(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1L,
kind = 1,
tags = emptyArray(),
content = "",
sig = "c".repeat(128),
)
val result = SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<PingResult>(event)
val ex = remote.convertExceptions("Test", result)
assertIs<IllegalStateException>(ex)
assertTrue(ex.message!!.contains("verify"))
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class FromBunkerUriTest {
private val signer = NostrSignerInternal(KeyPair())
private val client = EmptyNostrClient
private val validHex = "a".repeat(64)
@Test
fun validSingleRelay() {
val uri = "bunker://$validHex?relay=wss://relay.example.com"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals(1, remote.relays.size)
assertEquals(validHex, remote.remotePubkey)
assertNull(remote.secret)
}
@Test
fun validMultipleRelays() {
val uri = "bunker://$validHex?relay=wss://a.com&relay=wss://b.com"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals(2, remote.relays.size)
}
@Test
fun validWithSecret() {
val uri = "bunker://$validHex?relay=wss://r.com&secret=abc123"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals("abc123", remote.secret)
assertEquals(1, remote.relays.size)
}
@Test
fun missingSchemeThrows() {
assertFailsWith<Exception> {
NostrSignerRemote.fromBunkerUri("npub1abc", signer, client)
}
}
@Test
fun invalidHexPubkeyThrows() {
assertFailsWith<Exception> {
NostrSignerRemote.fromBunkerUri("bunker://notHex?relay=wss://r.com", signer, client)
}
}
@Test
fun missingQueryParamsThrows() {
assertFailsWith<Exception> {
NostrSignerRemote.fromBunkerUri("bunker://$validHex", signer, client)
}
}
@Test
fun skipsMalformedParams() {
val uri = "bunker://$validHex?relay=wss://r.com&badparam"
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
assertEquals(1, remote.relays.size)
assertNull(remote.secret)
}
}
@@ -0,0 +1,269 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* INostrClient that records openReqSubscription calls for verification.
* Used instead of mockk since commonTest doesn't have mockk.
*/
private class TrackingNostrClient : INostrClient {
data class SubscriptionRecord(
val subId: String,
val filters: Map<NormalizedRelayUrl, List<Filter>>,
)
val subscriptions = mutableListOf<SubscriptionRecord>()
val sentEvents = mutableListOf<Pair<Event, Set<NormalizedRelayUrl>>>()
override fun connectedRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
override fun availableRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
override fun connect() {}
override fun disconnect() {}
override fun reconnect(
onlyIfChanged: Boolean,
ignoreRetryDelays: Boolean,
) {}
override fun isActive() = false
override fun renewFilters(relay: IRelayClient) {}
override fun openReqSubscription(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener?,
) {
subscriptions.add(SubscriptionRecord(subId, filters))
}
override fun queryCount(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {}
override fun close(subId: String) {}
override fun send(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
sentEvents.add(event to relayList)
}
override fun subscribe(listener: IRelayClientListener) {}
override fun unsubscribe(listener: IRelayClientListener) {}
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = emptySet()
}
/**
* Tests verifying NIP-46 relay isolation at the NostrSignerRemote level.
* Ensures subscription filters only target bunker relays and contain
* the correct kind + p-tag.
*/
class NostrSignerRemoteIsolationTest {
private val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
private val validHex = "a".repeat(64)
@Test
fun subscriptionFilterTargetsOnlyBunkerRelays() {
val trackingClient = TrackingNostrClient()
val ephemeralSigner = NostrSignerInternal(KeyPair())
// Construction triggers client.req() which calls openReqSubscription
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
// Verify subscription was opened
assertTrue(
trackingClient.subscriptions.isNotEmpty(),
"No subscription opened on construction",
)
// Verify ALL filters only target the bunker relay
trackingClient.subscriptions.forEach { record ->
assertTrue(
record.filters.keys.all { it == bunkerRelay },
"Filter contains non-bunker relay: ${record.filters.keys}",
)
assertTrue(
generalRelay !in record.filters.keys,
"General relay leaked into NIP-46 subscription",
)
}
}
@Test
fun subscriptionFilterContainsCorrectKindAndPTag() {
val trackingClient = TrackingNostrClient()
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
val allFilters =
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
assertTrue(allFilters.isNotEmpty(), "No filters captured")
allFilters.forEach { filter ->
// Must filter for NIP-46 event kind (24133)
assertTrue(
filter.kinds?.contains(NostrConnectEvent.KIND) == true,
"Filter missing kind ${NostrConnectEvent.KIND}, got: ${filter.kinds}",
)
// Must filter for our ephemeral pubkey in p-tag
val pTags = filter.tags?.get("p")
assertNotNull(pTags, "Filter missing p-tag")
assertTrue(
pTags.contains(ephemeralSigner.pubKey),
"Filter p-tag doesn't contain ephemeral pubkey",
)
}
}
@Test
fun multipleRelaysAllIncludedInFilter() {
val trackingClient = TrackingNostrClient()
val relay1 = NormalizedRelayUrl("wss://relay1.nsec.app/")
val relay2 = NormalizedRelayUrl("wss://relay2.nsec.app/")
NostrSignerRemote(
signer = NostrSignerInternal(KeyPair()),
remotePubkey = validHex,
relays = setOf(relay1, relay2),
client = trackingClient,
)
assertTrue(trackingClient.subscriptions.isNotEmpty())
val allRelayKeys =
trackingClient.subscriptions.flatMap { it.filters.keys }.toSet()
assertTrue(relay1 in allRelayKeys, "Missing relay1 in subscription")
assertTrue(relay2 in allRelayKeys, "Missing relay2 in subscription")
// General relay should NOT be present
assertTrue(
generalRelay !in allRelayKeys,
"General relay leaked into multi-relay subscription",
)
}
/**
* Verify subscription filter does NOT use a `since` timestamp,
* matching upstream behavior (PR #1789 removed it).
*/
@Test
fun subscriptionFilterHasNoSinceTimestamp() {
val trackingClient = TrackingNostrClient()
NostrSignerRemote(
signer = NostrSignerInternal(KeyPair()),
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
val allFilters =
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
assertTrue(allFilters.isNotEmpty())
allFilters.forEach { filter ->
assertTrue(
filter.since == null,
"Filter should not have a 'since' timestamp",
)
}
}
@Test
fun fromBunkerUriPreservesRelayIsolation() {
val trackingClient = TrackingNostrClient()
val ephemeralSigner = NostrSignerInternal(KeyPair())
val remote =
NostrSignerRemote.fromBunkerUri(
"bunker://$validHex?relay=wss://relay.nsec.app",
ephemeralSigner,
trackingClient,
)
// Verify relay set matches URI
assertEquals(1, remote.relays.size)
val parsedRelay = remote.relays.first()
assertTrue(
parsedRelay.url.contains("relay.nsec.app"),
"Parsed relay doesn't match URI: ${parsedRelay.url}",
)
// Verify subscription was opened targeting ONLY the bunker relay
assertTrue(trackingClient.subscriptions.isNotEmpty())
trackingClient.subscriptions.forEach { record ->
assertTrue(
record.filters.keys.all { it in remote.relays },
"fromBunkerUri subscription targets wrong relays: ${record.filters.keys}",
)
// General relay must NOT appear
assertTrue(
generalRelay !in record.filters.keys,
"General relay leaked into fromBunkerUri subscription",
)
}
}
}