From 92fe94e19550b5756833bebc299a470f05a3738d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:27:58 +0000 Subject: [PATCH 1/4] test(nip01): pin limit:0 REQ semantics on the relay engine NIP-01 now says a filter with `limit: 0` MUST NOT return stored events, the relay MUST still send EOSE, and the subscription MUST stay open for new matching events. The engine already behaves this way (the store compiles limit 0 to LIMIT 0 and LiveEventStore keeps the live tail); these tests pin it, including a multi-filter REQ where only the limit-0 filter is silenced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../relay/server/NostrServerLimitZeroTest.kt | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt new file mode 100644 index 0000000000..e1b89c4c49 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerLimitZeroTest.kt @@ -0,0 +1,129 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * NIP-01: "When `limit` is zero, the relay MUST NOT return stored events for that + * filter. After the initial queries for all filters are complete, the relay MUST send + * `EOSE` and MUST keep the subscription active for newly received matching events." + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerLimitZeroTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + + private val noop: (String) -> Unit = {} + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String, + kind: Int = 1, + createdAt: Long = 1000L, + ) = Event(id, pubkey, createdAt, kind, emptyArray(), "hello", sig) + + private fun server( + dispatcher: CoroutineDispatcher, + store: EventStore, + ) = NostrServer(store = store, policyBuilder = { EmptyPolicy }, parentContext = dispatcher) + + private class Collector { + val messages = mutableListOf() + val send: (String) -> Unit = { messages.add(it) } + + fun parsed(): List = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun events() = parsed().filterIsInstance() + + fun eoses() = parsed().filterIsInstance() + } + + private suspend fun EventStore.seed(count: Int) { + for (i in 1..count) insert(testEvent(hexId(i), createdAt = i.toLong())) + } + + // -- NIP-01: limit 0 --------------------------------------------------------- + + @Test + fun limitZeroSendsNoStoredEventsThenEoseAndStaysLive() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(5) + val server = server(dispatcher, store) + val collector = Collector() + val c1 = server.connect(collector.send) + val publisher = server.connect(noop) + + c1.receive("""["REQ","sub1",{"kinds":[1],"limit":0}]""") + + assertEquals(0, collector.events().size, "limit 0 MUST NOT return stored events") + assertEquals(1, collector.eoses().size, "EOSE is still sent") + assertEquals("sub1", collector.eoses().single().subId) + + publisher.receive(OptimizedJsonMapper.toJson(EventCmd(testEvent(hexId(100), createdAt = 5000L)))) + + val live = collector.events() + assertEquals(1, live.size, "the subscription stays open for new events") + assertEquals(hexId(100), live.single().event.id) + assertTrue(collector.messages.indexOfFirst { it.startsWith("[\"EOSE\"") } < collector.messages.indexOfFirst { it.startsWith("[\"EVENT\"") }) + + server.close() + } + + @Test + fun limitZeroOnlySilencesItsOwnFilter() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(3) + store.insert(testEvent(hexId(50), kind = 7, createdAt = 50L)) + val server = server(dispatcher, store) + val collector = Collector() + val c1 = server.connect(collector.send) + + c1.receive("""["REQ","sub1",{"kinds":[1],"limit":0},{"kinds":[7]}]""") + + assertEquals(listOf(hexId(50)), collector.events().map { it.event.id }) + assertEquals(1, collector.eoses().size) + + server.close() + } +} From 15f207e1489c5c205ecfbeaa48b918209689a31d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:14 +0000 Subject: [PATCH 2/4] feat(nip47): follow the core/extension split of NIP-47 NIP-47 moved notifications, hold invoices, keysend, list_transactions, metadata and deep links out of core into NWC extension specs (02..08), advertised by an `extensions` info-event tag. - NwcInfoEvent.supportsNotifications() also accepts `02` in `extensions` or a non-empty `notifications` tag. New-spec wallets no longer put the `notifications` token in the content, so Amethyst stopped subscribing to their kind 23197 notifications (NwcNotificationsEoseManager). - EncryptionTag/NotificationsTag/ExtensionsTag emit ONE space-separated value (["extensions", "02 03 04"]); parsing still accepts multi-element. - GetInfoResult gains `extensions` (kotlinx serializer both ways). - ExtensionsTag names the known specs and maps methods to them (forMethod / forCapabilities). Nip47Server advertises `extensions` derived from its capabilities and returns them in get_info. - NwcInfoEvent.mayUseExtensionMethod(): skip an extension method only when the wallet publishes an `extensions` tag lacking the extension AND its content lacks the method. Legacy wallets keep being asked. Used to gate list_transactions (05) in WalletViewModel and pay_keysend (04) in V4VPaymentHandler, with new user-facing messages. - README/KDoc say which methods are core and which come from extensions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../amethyst/service/V4VPaymentHandler.kt | 25 +++++- .../screen/loggedIn/wallet/WalletViewModel.kt | 15 ++++ .../composeResources/values/strings.xml | 2 + .../quartz/nip47WalletConnect/Nip47Server.kt | 35 +++++--- .../quartz/nip47WalletConnect/README.md | 66 ++++++++++---- .../nip47WalletConnect/events/NwcInfoEvent.kt | 40 ++++++++- .../Nip47ResponseKSerializer.kt | 4 + .../nip47WalletConnect/rpc/NwcMethod.kt | 18 +++- .../quartz/nip47WalletConnect/rpc/Response.kt | 3 + .../nip47WalletConnect/tags/EncryptionTag.kt | 4 +- .../nip47WalletConnect/tags/ExtensionsTag.kt | 67 ++++++++++++++- .../tags/NotificationsTag.kt | 4 +- .../nip47WalletConnect/NwcInfoEventTest.kt | 86 +++++++++++++++++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 22 +++++ .../quartz/nip47WalletConnect/TagsTest.kt | 45 ++++++++-- 15 files changed, 392 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index cb7cc0ef22..9a8bd71f10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -29,14 +29,17 @@ import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_err import com.vitorpamplona.amethyst.commons.resources.error_parsing_error_message import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice import com.vitorpamplona.amethyst.commons.resources.podcast_value_error_title +import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_not_supported import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_requires_nwc import com.vitorpamplona.amethyst.commons.resources.podcast_value_no_recipients import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord @@ -50,6 +53,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.OkHttpClient /** @@ -110,7 +114,14 @@ class V4VPaymentHandler( // Keysend (node) recipients can only be paid over NWC. if (nodeShares.isNotEmpty()) { if (account.nip47SignerState.hasWalletConnectSetup()) { - payNodeSharesViaKeysend(nodeShares, boostagram, context, onError) + if (defaultWalletMayKeysend()) { + payNodeSharesViaKeysend(nodeShares, boostagram, context, onError) + } else { + onError( + loadStringRes(Res.string.podcast_value_error_title), + loadStringRes(Res.string.podcast_value_keysend_not_supported), + ) + } } else { onError( loadStringRes(Res.string.podcast_value_error_title), @@ -140,6 +151,18 @@ class V4VPaymentHandler( onProgress(1f) } + /** + * `pay_keysend` lives in the NWC-04 extension. Only a wallet whose info event publishes an + * `extensions` tag without 04 (and doesn't list `pay_keysend` in its content) is skipped; + * a legacy wallet, or one whose info is unknown, is still asked, as before extensions + * existed. See [com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent.mayUseExtensionMethod]. + */ + private suspend fun defaultWalletMayKeysend(): Boolean { + val walletUri = account.nip47SignerState.defaultWalletUri.value ?: return true + val info = withTimeoutOrNull(NwcSignerState.NIP44_NEGOTIATION_WAIT_MS) { account.nwcInfoCache.currentOrFetch(walletUri) } + return info?.mayUseExtensionMethod(NwcMethod.PAY_KEYSEND) != false + } + /** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */ private suspend fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index c2ea98850b..40de2f46ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -38,9 +38,11 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_request_timed_out import com.vitorpamplona.amethyst.commons.resources.wallet_request_timed_out_spoofed import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_load_failed import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_load_more_failed +import com.vitorpamplona.amethyst.commons.resources.wallet_transactions_not_supported import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency @@ -59,6 +61,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessRe import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse @@ -72,6 +75,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource sealed class SendState { @@ -572,6 +576,17 @@ class WalletViewModel : ViewModel() { _isLoading.value = true _error.value = null _hasMoreTransactions.value = true + // list_transactions lives in the NWC-05 extension. Only a wallet that publishes an + // `extensions` tag without 05 (and without the method in its content) is skipped; + // legacy wallets are still asked. See NwcInfoEvent.mayUseExtensionMethod. + val info = withTimeoutOrNull(NwcSignerState.NIP44_NEGOTIATION_WAIT_MS) { acc.nwcInfoCache.currentOrFetch(walletUri) } + if (info?.mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS) == false) { + allTransactions.value = emptyList() + _hasMoreTransactions.value = false + _error.value = text(Res.string.wallet_transactions_not_supported) + _isLoading.value = false + return@launch + } var requestId: HexKey? = null val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false } try { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 500df7421b..398c0f229f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -4630,6 +4630,7 @@ Short audio or video preview Value-for-Value error Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. + Your wallet does not support keysend, so node recipients were skipped. This podcast has no payable value recipients. Connect a Nostr Wallet Connect or debit wallet to stream sats while listening. This user has no Lightning address @@ -5280,6 +5281,7 @@ Could not load transactions Could not load more transactions + Your wallet does not offer transaction history Shows a warning message when posts or profiles have reports from your follows Warn on reports Add Web Bookmark diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt index da7baebe4c..004570a987 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag /** * High-level NIP-47 Wallet Connect server (wallet service). @@ -86,18 +87,30 @@ class Nip47Server( val useNip44: Boolean = false, val encryptionSchemes: List? = null, val notificationTypes: List? = null, + /** + * NWC extension specs to advertise. Null derives them from [capabilities] and + * [notificationTypes] via [ExtensionsTag.forCapabilities] (eg. `pay_keysend` → `04`). + */ + extensions: List? = null, ) { + val extensions: List = extensions ?: ExtensionsTag.forCapabilities(capabilities, notificationTypes) + // --- Info event --- /** * Builds a kind 13194 info event advertising wallet capabilities. * Sign and publish this event to your relay. + * + * The content lists every method in [capabilities], including extension methods + * (NIP-47: they SHOULD also be in the content), and the `extensions` tag lists + * [extensions] as one space-separated value when there are any. */ fun buildInfoEvent() = NwcInfoEvent.build( capabilities = capabilities, encryptionSchemes = encryptionSchemes, notificationTypes = notificationTypes, + extensions = extensions.ifEmpty { null }, ) // --- Request parsing --- @@ -188,20 +201,22 @@ class Nip47Server( methods: List? = null, notifications: List? = null, lud16: String? = null, + extensions: List? = this.extensions.ifEmpty { null }, ): NwcResponseEvent = buildResponse( GetInfoSuccessResponse( GetInfoSuccessResponse.GetInfoResult( - alias, - color, - pubkey, - network, - blockHeight, - blockHash, - methods, - notifications, - null, - lud16, + alias = alias, + color = color, + pubkey = pubkey, + network = network, + block_height = blockHeight, + block_hash = blockHash, + methods = methods, + notifications = notifications, + metadata = null, + lud16 = lud16, + extensions = extensions, ), ), requestEvent, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md index 7ee4df5c25..a932edbfd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -99,7 +99,8 @@ nip47WalletConnect/ ├── NostrWalletConnectResponseCache.kt # Response decryption cache └── tags/ ├── EncryptionTag.kt # "encryption" tag parsing - └── NotificationsTag.kt # "notifications" tag parsing + ├── ExtensionsTag.kt # "extensions" tag + method → NWC extension map + └── NotificationsTag.kt # "notifications" tag parsing (NWC-02) ``` ## Event Kinds @@ -109,26 +110,55 @@ nip47WalletConnect/ | 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities | | 23194 | `NwcRequestEvent` | Client → Wallet | NWC request | | 23195 | `NwcResponseEvent` | Wallet → Client | NWC response | -| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) | -| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) | +| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NWC-02, NIP-04, legacy) | +| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NWC-02, NIP-44) | ## Supported Methods -| Method | `Nip47Client` method | Request Class | Success Response Class | -|----------------------|-----------------------------|---------------------------|-----------------------------------| -| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | -| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | -| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | -| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | -| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | -| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | -| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | -| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | -| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | -| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | -| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | -| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| -| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| +NIP-47 now defines only a small **core** command set. Everything else lives in optional +extension specs maintained at (`02.md`, `03.md`, …). +The "Spec" column says where each method is defined; `ExtensionsTag.forMethod()` returns the +same mapping in code. + +| Method | Spec | `Nip47Client` method | Request Class | Success Response Class | +|----------------------|--------------|-----------------------------|---------------------------|-----------------------------------| +| `pay_invoice` | core | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `make_invoice` | core | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | core | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | +| `get_balance` | core | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | core | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `make_hold_invoice` | NWC-03 | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| NWC-03 | `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| NWC-03 | `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| +| `pay_keysend` | NWC-04 | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `list_transactions` | NWC-05 | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_budget` | not in a published spec | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | not in a published spec | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | not in a published spec | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | + +Notifications (`payment_received`, `payment_sent`, kinds 23197/23196) are NWC-02; +`hold_invoice_accepted` is NWC-03 delivered over NWC-02. The `metadata` key conventions are +NWC-06, deep links (`nostrnwc://`) NWC-07. + +## Extension discovery + +A wallet advertises extensions in its kind 13194 info event with ONE space-separated tag +value, `["extensions", "02 03 04"]`, and SHOULD also list extension methods in the content. +`get_info` may return the per-connection set as `"extensions": ["02", "05"]` +(`GetInfoResult.extensions`). The `encryption`, `notifications` and `extensions` tag builders +all emit a single space-separated value; the parsers still accept multi-element tags. + +- `NwcInfoEvent.supportsExtension(id)` — strict: silence means **no**. Use it before changing + a request in a way the wallet might reject (e.g. NWC-06 `metadata`). +- `NwcInfoEvent.supportsNotifications()` — true for the legacy `notifications` content token, + `02` in `extensions`, or a non-empty `notifications` tag. +- `NwcInfoEvent.mayUseExtensionMethod(method)` — lenient: only `false` when the wallet + publishes an `extensions` tag that lacks the method's extension AND its content does not + list the method. Pre-extensions wallets (no `extensions` tag) are still sent the request, + as before, and answer `NOT_IMPLEMENTED` if they can't. Amethyst uses this to gate + `list_transactions` (05) and `pay_keysend` (04). +- `Nip47Server` derives its `extensions` tag from its capabilities and notification types + (`ExtensionsTag.forCapabilities`) unless an explicit list is passed. Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt index 5e3299e7d6..c6eeeb54b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcInfoEvent.kt @@ -43,7 +43,19 @@ class NwcInfoEvent( fun supportsMethod(method: String): Boolean = capabilities().contains(method) - fun supportsNotifications(): Boolean = capabilities().contains("notifications") + /** + * Whether the wallet sends NWC-02 notifications (kind 23197/23196). + * + * Three signals count, since wallets predate and postdate the move of + * notifications out of NIP-47 core into the NWC-02 extension: + * - legacy: the bare `notifications` token in the content; + * - current: `02` in the `extensions` tag; + * - either: a non-empty `notifications` tag listing the notification types. + */ + fun supportsNotifications(): Boolean = + capabilities().contains(ExtensionsTag.LEGACY_NOTIFICATIONS_CAPABILITY) || + supportsExtension(ExtensionsTag.NOTIFICATIONS) || + notificationTypes().isNotEmpty() // NIP-47 carries the schemes/types as a single space-separated string in one // tag value (e.g. ["encryption", "nip44_v2 nip04"]). Split on whitespace so we @@ -71,6 +83,32 @@ class NwcInfoEvent( */ fun supportsExtension(id: String) = extensions().contains(id) + /** Whether the wallet publishes an `extensions` tag at all (a post-extensions NIP-47 wallet). */ + fun advertisesExtensions() = tags.any { ExtensionsTag.parse(it) != null } + + /** + * Whether a client should send [method], a method defined by NWC extension [extension] + * (see [ExtensionsTag.forMethod]). Core methods (no extension) always pass. + * + * - `true` when the content lists [method] or the `extensions` tag lists [extension]. + * - `false` only when the wallet publishes an `extensions` tag that lacks [extension] + * AND the content does not list [method]: that wallet speaks the extension-aware + * NIP-47 and has told us it does not implement it. + * - `true` otherwise: a pre-extensions wallet that simply doesn't mention the method + * is given the benefit of the doubt, as clients always did, and answers with + * `NOT_IMPLEMENTED` if it really can't. + * + * Unlike [supportsExtension] this errs towards sending, because the cost of a wrong + * guess is a clean error response rather than a changed request. + */ + fun mayUseExtensionMethod( + method: String, + extension: String? = ExtensionsTag.forMethod(method), + ): Boolean { + if (extension == null || supportsMethod(method) || supportsExtension(extension)) return true + return !advertisesExtensions() + } + companion object { const val KIND = 13194 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index 92ee8668cd..a148954cb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -237,6 +237,9 @@ object Nip47ResponseKSerializer : KSerializer { } result.metadata?.let { put("metadata", anyToJsonElement(it)) } result.lud16?.let { put("lud16", it) } + result.extensions?.let { extensions -> + put("extensions", buildJsonArray { extensions.forEach { add(it) } }) + } } private fun serializeGetBudgetResult(result: GetBudgetSuccessResponse.GetBudgetResult): JsonObject = @@ -528,6 +531,7 @@ object Nip47ResponseKSerializer : KSerializer { notifications = it.stringListOrNull("notifications"), metadata = it.anyMapOrNull("metadata"), lud16 = it.stringOrNull("lud16"), + extensions = it.stringListOrNull("extensions"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt index 6441c74927..740b8b91bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt @@ -20,17 +20,31 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect.rpc +/** + * NWC method names. NIP-47 core defines `pay_invoice`, `make_invoice`, `lookup_invoice`, + * `get_balance` and `get_info`; the rest come from optional NWC extension specs + * (github.com/nostr-wallet-connect/nwc) — see [com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag.forMethod]. + */ object NwcMethod { + // NIP-47 core const val PAY_INVOICE = "pay_invoice" - const val PAY_KEYSEND = "pay_keysend" const val MAKE_INVOICE = "make_invoice" const val LOOKUP_INVOICE = "lookup_invoice" - const val LIST_TRANSACTIONS = "list_transactions" const val GET_BALANCE = "get_balance" const val GET_INFO = "get_info" + + // NWC-04 keysend payments + const val PAY_KEYSEND = "pay_keysend" + + // NWC-05 transaction history + const val LIST_TRANSACTIONS = "list_transactions" + + // Not (yet) in a published NWC spec const val GET_BUDGET = "get_budget" const val SIGN_MESSAGE = "sign_message" const val CREATE_CONNECTION = "create_connection" + + // NWC-03 hold invoices const val MAKE_HOLD_INVOICE = "make_hold_invoice" const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index 6144cfdd47..92addab6e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -155,9 +155,12 @@ class GetInfoSuccessResponse( val block_height: Long? = null, val block_hash: String? = null, val methods: List? = null, + // NWC-02: notification types authorized for this connection. val notifications: List? = null, val metadata: Map? = null, val lud16: String? = null, + // NIP-47: optional NWC extension specs supported by this connection (eg. ["02", "05"]). + val extensions: List? = null, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt index 3d732528fe..f28dfa1823 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt @@ -36,6 +36,8 @@ class EncryptionTag { return tag.drop(1) } - fun assemble(schemes: List) = arrayOf(TAG_NAME, *schemes.toTypedArray()) + // NIP-47 carries the list as ONE space-separated value (eg. ["encryption", "a b c"]); + // [parse] still tolerates wallets that spread it across several elements. + fun assemble(schemes: List) = arrayOf(TAG_NAME, schemes.joinToString(" ")) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt index df3fdec6b0..d14c38c927 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/ExtensionsTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip47WalletConnect.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.utils.ensure /** @@ -36,11 +37,69 @@ class ExtensionsTag { companion object { const val TAG_NAME = "extensions" - // The specs this client knows how to use, so a caller names a constant - // rather than a bare string at each gate. + // The NWC extension specs (github.com/nostr-wallet-connect/nwc) this library + // knows about, so a caller names a constant rather than a bare string at each gate. + + /** NWC-02: notifications (kind 23197/23196, `notifications` tag, `payment_received`, `payment_sent`). */ + const val NOTIFICATIONS = "02" + + /** NWC-03: `make_hold_invoice`, `cancel_hold_invoice`, `settle_hold_invoice`, `hold_invoice_accepted`. */ + const val HOLD_INVOICES = "03" + + /** NWC-04: `pay_keysend`. */ + const val KEYSEND = "04" + + /** NWC-05: `list_transactions`. */ const val TRANSACTION_HISTORY = "05" + + /** NWC-06: `metadata` conventions on invoices and payments. */ const val METADATA_CONVENTIONS = "06" + /** NWC-07: `nostrnwc://` deep links for pairing. */ + const val DEEP_LINKS = "07" + + /** NWC-08: client-initiated connection creation. */ + const val CLIENT_INITIATED_CONNECTIONS = "08" + + /** + * The NWC extension that defines [method], or null when the method is core + * NIP-47 (`pay_invoice`, `make_invoice`, `lookup_invoice`, `get_balance`, + * `get_info`) or not assigned to any published extension spec. + */ + fun forMethod(method: String): String? = + when (method) { + NwcMethod.MAKE_HOLD_INVOICE, + NwcMethod.CANCEL_HOLD_INVOICE, + NwcMethod.SETTLE_HOLD_INVOICE, + -> HOLD_INVOICES + + NwcMethod.PAY_KEYSEND -> KEYSEND + + NwcMethod.LIST_TRANSACTIONS -> TRANSACTION_HISTORY + + else -> null + } + + /** + * The extensions a wallet service implementing [methods] (and, when non-empty, + * sending [notificationTypes]) should advertise in its info event, in spec order. + */ + fun forCapabilities( + methods: List, + notificationTypes: List? = null, + ): List { + val result = mutableSetOf() + if (!notificationTypes.isNullOrEmpty() || methods.contains(LEGACY_NOTIFICATIONS_CAPABILITY)) result.add(NOTIFICATIONS) + methods.forEach { method -> forMethod(method)?.let { result.add(it) } } + return result.sorted() + } + + /** + * Pre-extensions wallets listed the bare word `notifications` among the + * methods in the info event content to say they send notifications. + */ + const val LEGACY_NOTIFICATIONS_CAPABILITY = "notifications" + fun parse(tag: Array): List? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } @@ -48,6 +107,8 @@ class ExtensionsTag { return tag.drop(1) } - fun assemble(extensions: List) = arrayOf(TAG_NAME, *extensions.toTypedArray()) + // NIP-47 carries the list as ONE space-separated value (eg. ["extensions", "a b c"]); + // [parse] still tolerates wallets that spread it across several elements. + fun assemble(extensions: List) = arrayOf(TAG_NAME, extensions.joinToString(" ")) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt index cb42cce542..575bdf48f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt @@ -36,6 +36,8 @@ class NotificationsTag { return tag.drop(1) } - fun assemble(types: List) = arrayOf(TAG_NAME, *types.toTypedArray()) + // NIP-47 carries the list as ONE space-separated value (eg. ["notifications", "a b c"]); + // [parse] still tolerates wallets that spread it across several elements. + fun assemble(types: List) = arrayOf(TAG_NAME, types.joinToString(" ")) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt index bb87b31171..a6ec9c2716 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlin.test.Test @@ -153,4 +155,88 @@ class NwcInfoEventTest { assertTrue(event.encryptionSchemes().isEmpty()) assertTrue(event.notificationTypes().isEmpty()) } + + private fun info( + content: String, + vararg tags: Array, + ) = NwcInfoEvent("id", "pub", 0L, arrayOf(*tags), content, "sig") + + @Test + fun testSupportsNotificationsViaExtensionsTag() { + // Post-extensions NIP-47: notifications moved to NWC-02, advertised as `02`, + // and the content no longer carries the `notifications` token. + val event = info("pay_invoice get_balance get_info", arrayOf("encryption", "nip44_v2"), arrayOf("extensions", "02 03 04")) + assertTrue(event.supportsNotifications()) + } + + @Test + fun testSupportsNotificationsViaNotificationsTag() { + val event = info("pay_invoice get_info", arrayOf("notifications", "payment_received payment_sent")) + assertTrue(event.supportsNotifications()) + } + + @Test + fun testNoNotificationsWhenExtensionsLackIt() { + val event = info("pay_invoice get_info", arrayOf("extensions", "04 05")) + assertFalse(event.supportsNotifications()) + } + + @Test + fun testBuildEmitsSingleValueTags() { + val template = + NwcInfoEvent.build( + listOf("pay_invoice", "get_info"), + encryptionSchemes = listOf("nip44_v2", "nip04"), + notificationTypes = listOf("payment_received", "payment_sent"), + extensions = listOf("02", "05"), + ) + val event = signer.sign(template) + + assertTrue(event.tags.any { it.contentEquals(arrayOf("encryption", "nip44_v2 nip04")) }) + assertTrue(event.tags.any { it.contentEquals(arrayOf("notifications", "payment_received payment_sent")) }) + assertTrue(event.tags.any { it.contentEquals(arrayOf("extensions", "02 05")) }) + assertEquals(listOf("02", "05"), event.extensions()) + assertEquals(listOf("nip44_v2", "nip04"), event.encryptionSchemes()) + } + + @Test + fun testMayUseExtensionMethod() { + // Listed in content: allowed regardless of the extensions tag. + assertTrue(info("pay_invoice list_transactions", arrayOf("extensions", "02")).mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + // Advertised extension: allowed even if the content forgot the method. + assertTrue(info("pay_invoice", arrayOf("extensions", "05")).mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + // New-spec wallet that advertises extensions but neither 05 nor the method: skip. + assertFalse(info("pay_invoice get_info", arrayOf("extensions", "02 03")).mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + assertFalse(info("pay_invoice get_info", arrayOf("extensions", "02 03")).mayUseExtensionMethod(NwcMethod.PAY_KEYSEND)) + // Legacy wallet with no extensions tag: keep sending, it answers NOT_IMPLEMENTED if needed. + assertTrue(info("pay_invoice get_info").mayUseExtensionMethod(NwcMethod.LIST_TRANSACTIONS)) + assertTrue(info("pay_invoice get_info").mayUseExtensionMethod(NwcMethod.PAY_KEYSEND)) + // Core methods always pass. + assertTrue(info("get_info", arrayOf("extensions", "02")).mayUseExtensionMethod(NwcMethod.PAY_INVOICE)) + } + + @Test + fun testServerAdvertisesExtensions() { + val server = + Nip47Server( + signer = NostrSignerInternal(signer.key), + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_INFO, NwcMethod.PAY_KEYSEND, NwcMethod.LIST_TRANSACTIONS, NwcMethod.MAKE_HOLD_INVOICE), + notificationTypes = listOf("payment_received"), + ) + val event = signer.sign(server.buildInfoEvent()) + + assertEquals(listOf("02", "03", "04", "05"), event.extensions()) + assertTrue(event.tags.any { it.contentEquals(arrayOf("extensions", "02 03 04 05")) }) + // Extension methods SHOULD also be listed in the content. + assertTrue(event.supportsMethod(NwcMethod.PAY_KEYSEND)) + assertTrue(event.supportsMethod(NwcMethod.LIST_TRANSACTIONS)) + assertTrue(event.supportsNotifications()) + } + + @Test + fun testServerWithCoreMethodsOnlyHasNoExtensionsTag() { + val server = Nip47Server(signer = NostrSignerInternal(signer.key), capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_INFO)) + val event = signer.sign(server.buildInfoEvent()) + assertFalse(event.advertisesExtensions()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index be2824b4c9..425ecbdfc2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -395,6 +395,28 @@ class ResponseTest { assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) } + @Test + fun testGetInfoExtensionsRoundTrip() { + val json = + """{"result_type":"get_info","result":{"methods":["pay_invoice","get_info","list_transactions"],"extensions":["02","05"],"notifications":["payment_received"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(listOf("02", "05"), response.result?.extensions) + assertEquals(listOf("payment_received"), response.result?.notifications) + + val reparsed = OptimizedJsonMapper.fromJsonTo(OptimizedJsonMapper.toJson(response)) + assertIs(reparsed) + assertEquals(listOf("02", "05"), reparsed.result?.extensions) + } + + @Test + fun testGetInfoWithoutExtensions() { + val json = """{"result_type":"get_info","result":{"methods":["pay_invoice"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNull(response.result?.extensions) + } + // --- ListTransactions with total_count --- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt index 8010589356..b8fa4377fb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag import kotlin.test.Test import kotlin.test.assertEquals @@ -71,11 +73,11 @@ class TagsTest { @Test fun testEncryptionTagAssemble() { + // NIP-47: one space-separated value, eg. ["encryption", "nip44_v2 nip04"] val tag = EncryptionTag.assemble(listOf("nip44_v2", "nip04")) assertEquals("encryption", tag[0]) - assertEquals("nip44_v2", tag[1]) - assertEquals("nip04", tag[2]) - assertEquals(3, tag.size) + assertEquals("nip44_v2 nip04", tag[1]) + assertEquals(2, tag.size) } @Test @@ -120,12 +122,41 @@ class TagsTest { @Test fun testNotificationsTagAssemble() { + // NWC-02: one space-separated value, eg. ["notifications", "payment_received payment_sent"] val tag = NotificationsTag.assemble(listOf("payment_received", "payment_sent", "hold_invoice_accepted")) assertEquals("notifications", tag[0]) - assertEquals("payment_received", tag[1]) - assertEquals("payment_sent", tag[2]) - assertEquals("hold_invoice_accepted", tag[3]) - assertEquals(4, tag.size) + assertEquals("payment_received payment_sent hold_invoice_accepted", tag[1]) + assertEquals(2, tag.size) + } + + // --- ExtensionsTag --- + + @Test + fun testExtensionsTagAssemble() { + // NIP-47: ["extensions", "02 03 04"] + val tag = ExtensionsTag.assemble(listOf("02", "03", "04")) + assertEquals(2, tag.size) + assertEquals("extensions", tag[0]) + assertEquals("02 03 04", tag[1]) + } + + @Test + fun testExtensionsTagParseToleratesMultiElement() { + assertEquals(listOf("02", "05"), ExtensionsTag.parse(arrayOf("extensions", "02", "05"))) + assertEquals(listOf("02 05"), ExtensionsTag.parse(arrayOf("extensions", "02 05"))) + assertNull(ExtensionsTag.parse(arrayOf("extensions"))) + assertNull(ExtensionsTag.parse(arrayOf("other", "02"))) + } + + @Test + fun testExtensionForMethod() { + assertEquals(ExtensionsTag.KEYSEND, ExtensionsTag.forMethod(NwcMethod.PAY_KEYSEND)) + assertEquals(ExtensionsTag.TRANSACTION_HISTORY, ExtensionsTag.forMethod(NwcMethod.LIST_TRANSACTIONS)) + assertEquals(ExtensionsTag.HOLD_INVOICES, ExtensionsTag.forMethod(NwcMethod.MAKE_HOLD_INVOICE)) + assertEquals(ExtensionsTag.HOLD_INVOICES, ExtensionsTag.forMethod(NwcMethod.SETTLE_HOLD_INVOICE)) + assertEquals(ExtensionsTag.HOLD_INVOICES, ExtensionsTag.forMethod(NwcMethod.CANCEL_HOLD_INVOICE)) + assertNull(ExtensionsTag.forMethod(NwcMethod.PAY_INVOICE)) + assertNull(ExtensionsTag.forMethod(NwcMethod.GET_INFO)) } @Test From 84bc404dfe3b93a95ed71128e472007732ad377b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:26 +0000 Subject: [PATCH 3/4] fix(nip46): answer malformed and rate-limited requests, surface bunker errors NIP-46 now says requests with unknown or unsupported methods MUST be replied with an error. Bunker side: - A known method with bad params (e.g. sign_event with no params) used to throw inside JSON parsing, so NostrConnectSignerService logged "could not decrypt" and dropped it; the client waited for its timeout (#2375). BunkerRequestParser (shared by the kotlinx and Jackson decoders) now returns BunkerRequestInvalid, and BunkerRequestProcessor replies "invalid params for : " with the request id. Params are read leniently (missing/non-array/non-string) so the id survives. - Unknown methods keep getting "unsupported method: " (now tested). - switch_relays is answered with "null": this signer does not migrate. - Rate limiting still happens before decrypting, but the first over-limit request per author per window is decrypted and answered "rate limited"; the rest of the window is dropped. One decrypt + reply per window keeps the flood bound the limiter exists for. Client side: - SignerResult.Rejected carries the bunker's error text; every response parser passes it and convertExceptions puts it in the exception message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../nip46RemoteSigner/BunkerRequestInvalid.kt | 38 +++++++++ .../nip46RemoteSigner/BunkerRequestParser.kt | 59 +++++++++++++ .../BunkerMessageKSerializer.kt | 64 +------------- .../BunkerRequestKSerializer.kt | 41 ++++----- .../server/BunkerRequestProcessor.kt | 34 +++++++- .../server/NostrConnectSignerService.kt | 74 +++++++++++++--- .../signer/ConnectResponse.kt | 2 +- .../signer/Nip04DecryptResponse.kt | 2 +- .../signer/Nip04EncryptResponse.kt | 2 +- .../signer/Nip44DecryptResponse.kt | 2 +- .../signer/Nip44EncryptResponse.kt | 2 +- .../signer/NostrSignerRemote.kt | 6 +- .../nip46RemoteSigner/signer/PingResponse.kt | 2 +- .../signer/PubKeyResponse.kt | 2 +- .../nip46RemoteSigner/signer/SignResponse.kt | 2 +- .../nip46RemoteSigner/signer/SignerResult.kt | 9 +- .../nip46RemoteSigner/BunkerRequestTest.kt | 32 +++++++ .../server/NostrConnectSignerServiceTest.kt | 84 ++++++++++++++++++- .../signer/ConvertExceptionsTest.kt | 8 ++ .../signer/ResponseParserTest.kt | 1 + .../jackson/BunkerRequestDeserializer.kt | 33 +++----- 21 files changed, 365 insertions(+), 134 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt new file mode 100644 index 0000000000..d4c19c6cea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestInvalid.kt @@ -0,0 +1,38 @@ +/* + * 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 + +/** + * A request whose `id` and `method` could be read but whose `params` could not be + * turned into the typed request for a method this library knows (e.g. `sign_event` + * with no params, or a param that is not an event template). + * + * [BunkerRequestParser] returns this instead of throwing so the remote signer can + * still answer with an error carrying the request id — NIP-46: "Requests made with + * unknown or unsupported methods MUST be replied with an error" — rather than + * dropping the request and leaving the client to time out. + */ +class BunkerRequestInvalid( + id: String, + method: String, + params: Array, + val reason: String, +) : BunkerRequest(id, method, params) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt new file mode 100644 index 0000000000..4957c01560 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestParser.kt @@ -0,0 +1,59 @@ +/* + * 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 kotlinx.coroutines.CancellationException + +/** + * Turns a decoded NIP-46 request envelope into its typed [BunkerRequest]. Shared by + * every JSON backend (kotlinx and Jackson) so they agree on the method table and on + * how bad params are handled. + * + * A known method with bad params never throws: it yields a [BunkerRequestInvalid] + * so the remote signer can reply with an error the client can correlate. Unknown + * methods come back as a plain [BunkerRequest], which the signer also answers with + * an error. + */ +object BunkerRequestParser { + fun parse( + id: String, + method: String, + params: Array, + ): BunkerRequest = + try { + when (method) { + BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) + BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) + BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) + BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) + BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) + BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) + BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) + BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) + BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + BunkerRequestInvalid(id, method, params, e.message ?: e::class.simpleName ?: "malformed params") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt index 025e586cc4..23d46b8fb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestParser import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor @@ -30,7 +31,6 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -58,68 +58,10 @@ object BunkerMessageKSerializer : KSerializer { return if (isRequest) { val id = jsonObject["id"]!!.jsonPrimitive.content val method = jsonObject["method"]!!.jsonPrimitive.content - val params = - jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() - ?: emptyArray() - dispatchBunkerRequest(id, method, params) + val params = BunkerRequestKSerializer.lenientParams(jsonObject["params"]) + BunkerRequestParser.parse(id, method, params) } else { BunkerResponseKSerializer.deserializeFromElement(jsonObject) } } - - private fun dispatchBunkerRequest( - id: String, - method: String, - params: Array, - ): BunkerRequest = - when (method) { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing - .parse(id, params) - } - - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign - .parse(id, params) - } - - else -> { - BunkerRequest(id, method, params) - } - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt index 7797cf3371..6c2e8a5ea8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt @@ -21,27 +21,20 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestParser import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.element import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -80,21 +73,19 @@ object BunkerRequestKSerializer : KSerializer { val jsonObject = jsonDecoder.decodeJsonElement().jsonObject val id = jsonObject["id"]!!.jsonPrimitive.content val method = jsonObject["method"]!!.jsonPrimitive.content - val params = - jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() - ?: emptyArray() + val params = lenientParams(jsonObject["params"]) - return when (method) { - BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) - BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) - BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) - BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) - BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) - BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) - BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) - BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) - BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) - else -> BunkerRequest(id, method, params) - } + return BunkerRequestParser.parse(id, method, params) } + + /** + * `params` as strings, tolerating a missing or non-array value and non-string + * elements (kept as their JSON text), so a malformed request still yields its id + * and method and can be answered with an error. + */ + fun lenientParams(element: JsonElement?): Array = + (element as? JsonArray) + ?.map { (it as? JsonPrimitive)?.content ?: it.toString() } + ?.toTypedArray() + ?: emptyArray() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index 2328aa3d5c..1335a4ca25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestInvalid import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt @@ -73,9 +74,11 @@ import kotlinx.coroutines.sync.withLock * - **per-operation consent** ([Nip46RequestAuthorizer.authorize]) gates * signing/encryption/decryption. * - * All failures — decryption, authorization, an unsupported method, or an - * exception from the signer — are turned into a [BunkerResponseError] carrying - * the request id, so the client always gets a reply it can correlate. + * All failures — authorization, an unsupported method, a known method with + * unparseable params ([BunkerRequestInvalid]), or an exception from the signer — + * are turned into a [BunkerResponseError] carrying the request id, so the client + * always gets a reply it can correlate (NIP-46: unknown or unsupported methods MUST + * be replied with an error). `switch_relays` is answered with `null`. * * Pairs with [NostrConnectSignerService], which subscribes to the relays, * decrypts each kind-24133 request, calls [process], and publishes the reply. @@ -105,6 +108,10 @@ class BunkerRequestProcessor( ): BunkerResponse = try { when (request) { + // A known method whose params could not be parsed: still answer, so the client + // gets an error it can correlate instead of timing out. + is BunkerRequestInvalid -> BunkerResponseError(request.id, "$ERROR_INVALID_PARAMS for ${request.method}: ${request.reason}") + is BunkerRequestConnect -> when (val decision = authorizer.onConnect(clientPubKey, request)) { is Nip46ConnectDecision.Accept -> BunkerResponse(request.id, decision.ackSecret, null) @@ -158,7 +165,11 @@ class BunkerRequestProcessor( authorizer.onLogout(clientPubKey) BunkerResponseAck(request.id) } - else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + // This signer listens on a fixed relay set it does not migrate, so there is + // never an update to hand out: NIP-46 says reply `null` ("nothing to change"). + // `result` is a string on the wire, so this is the JSON-stringified null. + METHOD_SWITCH_RELAYS -> BunkerResponse(request.id, RESULT_NULL, null) + else -> BunkerResponseError(request.id, "$ERROR_UNSUPPORTED_METHOD: ${request.method}") } } } catch (e: CancellationException) { @@ -219,5 +230,20 @@ class BunkerRequestProcessor( /** NIP-46 `logout` method name — the client asks to be disconnected. */ const val METHOD_LOGOUT: String = "logout" + + /** NIP-46 `switch_relays` method name — the client asks whether the signer moved relays. */ + const val METHOD_SWITCH_RELAYS: String = "switch_relays" + + /** JSON-stringified `null`, the `switch_relays` answer for "no relay change". */ + const val RESULT_NULL: String = "null" + + /** Error prefix for a known method whose params could not be parsed ([BunkerRequestInvalid]). */ + const val ERROR_INVALID_PARAMS: String = "invalid params" + + /** Error prefix for a method this signer does not implement. */ + const val ERROR_UNSUPPORTED_METHOD: String = "unsupported method" + + /** Error returned to a client whose requests exceed the service's rate limit. */ + const val ERROR_RATE_LIMITED: String = "rate limited" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index bc473bcaf1..eaf76ae30c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -86,7 +86,10 @@ class NostrConnectSignerService( * NIP-55 op on the identity signer, so the queue must not run away. */ val maxQueue: Int = 256, - /** Max requests decrypted per author within [rateWindowSeconds] before further ones are dropped. */ + /** + * Max requests decrypted per author within [rateWindowSeconds]. Past it, the first extra request + * in the window is answered with a `rate limited` error and the rest are dropped unanswered. + */ val maxRequestsPerWindow: Int = 40, val rateWindowSeconds: Long = 10, /** Cap on distinct authors tracked for rate-limiting (evicts oldest) so key-rotation can't grow it. */ @@ -132,18 +135,30 @@ class NostrConnectSignerService( private class Window( var start: Long, var count: Int, + var notified: Boolean = false, ) + enum class Decision { + ALLOW, + + /** Over the limit, and the first such request this window: answer it with an error. */ + DENY_AND_NOTIFY, + + /** Over the limit and the author was already told this window: drop silently. */ + DENY, + } + private val windows = LinkedHashMap() - fun allow( + fun check( author: String, now: Long, - ): Boolean { + ): Decision { val window = windows.getOrPut(author) { Window(now, 0) } if (now - window.start >= windowSeconds) { window.start = now window.count = 0 + window.notified = false } if (windows.size > maxAuthors) { windows.iterator().let { @@ -151,9 +166,13 @@ class NostrConnectSignerService( it.remove() } } - if (window.count >= maxPerWindow) return false + if (window.count >= maxPerWindow) { + if (window.notified) return Decision.DENY + window.notified = true + return Decision.DENY_AND_NOTIFY + } window.count++ - return true + return Decision.ALLOW } } @@ -226,11 +245,32 @@ class NostrConnectSignerService( Log.w("NIP46Signer") { "ignoring stale request ${event.id.take(8)}… (created ${event.createdAt})" } continue } - // Rate-limit per author BEFORE decrypting — decryption can be an external-signer - // round-trip, so a flooding client must not force one per event. - if (!rateLimiter.allow(event.pubKey, TimeUtils.now())) { - Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } - continue + // Rate-limit per author BEFORE decrypting: the limit exists to bound the work (envelope + // decrypt, reply encrypt/sign/publish, identity-signer ops) a flooding client can force. + // The request id is inside the encrypted content, so answering costs a decrypt plus a + // reply. That is paid ONCE per author per window: the first over-limit request gets a + // `rate limited` error (so a legitimate bursty client learns why instead of timing out), + // the rest of the window is dropped silently. + when (rateLimiter.check(event.pubKey, TimeUtils.now())) { + RateLimiter.Decision.ALLOW -> {} + + RateLimiter.Decision.DENY_AND_NOTIFY -> { + Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…; replying with an error" } + handleGate.acquire() + launch { + try { + replyRateLimited(event) + } finally { + handleGate.release() + } + } + continue + } + + RateLimiter.Decision.DENY -> { + Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } + continue + } } // Remember this id (persisted by the host) so a later restart won't re-service the replay. // Done on the single consumer — BEFORE fanning out — because the host's seen-id store is @@ -254,6 +294,20 @@ class NostrConnectSignerService( } } + /** Answers an over-limit request with [BunkerRequestProcessor.ERROR_RATE_LIMITED], without processing it. */ + private suspend fun replyRateLimited(event: NostrConnectEvent) { + val client = event.talkingWith(transportSigner.pubKey) + try { + val request = event.decryptMessage(transportSigner) as? BunkerRequest ?: return + val reply = NostrConnectEvent.create(BunkerResponseError(request.id, BunkerRequestProcessor.ERROR_RATE_LIMITED), client, transportSigner) + this.client.publish(reply, relays) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("NIP46Signer") { "could not answer rate-limited request ${event.id.take(8)}: ${e.message}" } + } + } + private suspend fun handle(event: NostrConnectEvent) { val client = event.talkingWith(transportSigner.pubKey) val request = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt index a6583a80b0..acfcb8047c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -29,7 +29,7 @@ class ConnectResponse { return if (response.error.contains("already connected", ignoreCase = true)) { SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected) } else { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt index 855ad31335..f565cd9b8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt @@ -33,7 +33,7 @@ class Nip04DecryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt index 185a7fa859..9f9eaef894 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt @@ -33,7 +33,7 @@ class Nip04EncryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt index 1baeefbd35..dab618ec42 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt @@ -33,7 +33,7 @@ class Nip44DecryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt index 2819cc82a5..b4049c6ed6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt @@ -33,7 +33,7 @@ class Nip44EncryptResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 5a7820cc32..9dc84100a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -354,7 +354,11 @@ class NostrSignerRemote( is SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<*> -> IllegalStateException("$title: Failed to parse event: ${result.eventJson}.") is SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<*> -> IllegalStateException("$title: Failed to verify event: ${result.invalidEvent.toJson()}.") is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> SignerExceptions.CouldNotPerformException("$title: ${result.message}") - is SignerResult.RequestAddressed.Rejected<*> -> SignerExceptions.ManuallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.Rejected<*> -> + SignerExceptions.ManuallyUnauthorizedException( + result.message?.takeIf { it.isNotBlank() }?.let { "$title: Remote signer returned an error: $it" } + ?: "$title: User has rejected the request.", + ) is SignerResult.RequestAddressed.TimedOut<*> -> SignerExceptions.TimedOutException("$title: User didn't accept or reject in time.") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt index 644dd89ba4..987c47bc90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt @@ -33,7 +33,7 @@ class PingResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt index ed639a9067..6d09d561b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt @@ -33,7 +33,7 @@ class PubKeyResponse { } is BunkerResponseError -> { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt index b7343cc67d..dc8ba68b83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt @@ -35,7 +35,7 @@ class SignResponse { SignerResult.RequestAddressed.Successful(SignResult(response.event)) } } else if (response is BunkerResponseError) { - SignerResult.RequestAddressed.Rejected() + SignerResult.RequestAddressed.Rejected(response.error) } else { SignerResult.RequestAddressed.ReceivedButCouldNotPerform() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt index a0cba9eb9a..d34214b653 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -28,7 +28,14 @@ sealed interface SignerResult { val result: T, ) : RequestAddressed - class Rejected : RequestAddressed + /** + * The remote signer answered with an error. [message] is the bunker's `error` + * text (e.g. "unauthorized", "invalid params for sign_event: ..."), kept so the + * UI/logs can show why instead of a generic rejection. + */ + class Rejected( + val message: String? = null, + ) : RequestAddressed class TimedOut : RequestAddressed diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index bcc724acd7..989144ae8d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -36,6 +36,38 @@ class BunkerRequestTest { assertEquals(1, bunkerRequest.event.kind) } + @Test + fun testSignEventWithoutParamsIsInvalidNotThrown() { + val bunkerRequest = OptimizedJsonMapper.fromJsonTo("""{"id":"x1","method":"sign_event","params":[]}""") + assertTrue(bunkerRequest is BunkerRequestInvalid) + assertEquals("x1", bunkerRequest.id) + assertEquals("sign_event", bunkerRequest.method) + } + + @Test + fun testSignEventWithMissingParamsFieldIsInvalid() { + val bunkerRequest = OptimizedJsonMapper.fromJsonTo("""{"id":"x2","method":"sign_event"}""") + assertTrue(bunkerRequest is BunkerRequestInvalid) + assertEquals("x2", bunkerRequest.id) + } + + @Test + fun testSignEventWithObjectParamKeepsTheId() { + // Some clients send the template as an object instead of a JSON string. + val bunkerRequest = + OptimizedJsonMapper.fromJsonTo("""{"id":"x3","method":"sign_event","params":[{"kind":1,"created_at":1,"tags":[],"content":"hi"}]}""") + assertTrue(bunkerRequest is BunkerRequest) + assertEquals("x3", bunkerRequest.id) + } + + @Test + fun testUnknownMethodStaysGeneric() { + val bunkerRequest = OptimizedJsonMapper.fromJsonTo("""{"id":"x4","method":"frobnicate","params":["a"]}""") + assertTrue(bunkerRequest is BunkerRequest) + assertEquals("frobnicate", bunkerRequest.method) + assertTrue(bunkerRequest !is BunkerRequestInvalid) + } + @Test fun testConnectWithoutMetadata() { val requestJson = """{"id":"1","method":"connect","params":["abc","mysecret","sign_event"]}""" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index db6c888430..b653dbaf39 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent @@ -48,6 +49,8 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -328,12 +331,89 @@ class NostrConnectSignerServiceTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } - // Five distinct requests from the same author within one window → only 2 are serviced. + // Five distinct requests from the same author within one window → only 2 are serviced, + // the first over-limit one is answered with a `rate limited` error, the rest are dropped. repeat(5) { i -> client.deliver(request(BunkerRequestConnect(id = "req$i", remoteKey = serverKey, secret = "s"))) } - assertEquals(2, client.published.size) + assertEquals(3, client.published.size) + val replies = client.published.map { (it as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse } + assertEquals(listOf("req0", "req1", "req2"), replies.map { it.id }) + assertEquals(BunkerRequestProcessor.ERROR_RATE_LIMITED, replies[2].error) + } + + @Test + fun signEventWithoutParamsGetsAnErrorReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // A known method with missing params used to throw while parsing and be dropped, leaving + // the client to time out. It must now be answered with an error carrying the request id. + client.deliver(request(BunkerRequest(id = "noparams", method = BunkerRequestSign.METHOD_NAME))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertIs(reply) + assertEquals("noparams", reply.id) + assertTrue(reply.error!!.startsWith(BunkerRequestProcessor.ERROR_INVALID_PARAMS), reply.error) + } + + @Test + fun signEventWithGarbageParamsGetsAnErrorReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "garbage", method = BunkerRequestSign.METHOD_NAME, params = arrayOf("not an event")))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertIs(reply) + assertEquals("garbage", reply.id) + } + + @Test + fun unknownMethodGetsAnErrorReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "unknown", method = "frobnicate"))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertIs(reply) + assertEquals("unknown", reply.id) + assertEquals("${BunkerRequestProcessor.ERROR_UNSUPPORTED_METHOD}: frobnicate", reply.error) + } + + @Test + fun switchRelaysIsAnsweredWithNull() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "sw", method = BunkerRequestProcessor.METHOD_SWITCH_RELAYS))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse + assertEquals("sw", reply.id) + assertEquals("null", reply.result) + assertNull(reply.error) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt index 73b6b12012..14e5939def 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt @@ -52,6 +52,14 @@ class ConvertExceptionsTest { assertIs(ex) } + @Test + fun rejectedCarriesTheBunkerErrorText() { + val result = SignerResult.RequestAddressed.Rejected("invalid params for sign_event: bad") + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("invalid params for sign_event: bad"), ex.message) + } + @Test fun timedOutReturnsTimedOutException() { val result = SignerResult.RequestAddressed.TimedOut() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt index e925b1e373..dbe80bc1a3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -177,6 +177,7 @@ class ResponseParserTest { val response = BunkerResponseError("req-3", "denied") val result = SignResponse.parse(response) assertIs>(result) + assertEquals("denied", result.message) } @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt index 01f03ce8c0..9da29cae41 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestDeserializer.kt @@ -26,15 +26,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip01Core.jackson.toTypedArray import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestParser class BunkerRequestDeserializer : StdDeserializer(BunkerRequest::class.java) { override fun deserialize( @@ -44,19 +36,16 @@ class BunkerRequestDeserializer : StdDeserializer(BunkerRequest:: val jsonObject: JsonNode = jp.codec.readTree(jp) val id = jsonObject.get("id").asText() val method = jsonObject.get("method").asText() - val params = jsonObject.get("params")?.toTypedArray { it.asText() } ?: emptyArray() + // Lenient: a missing/non-array `params` or non-string element must not lose the id, + // so the signer can still answer with an error (see BunkerRequestParser). + val paramsNode = jsonObject.get("params") + val params = + if (paramsNode != null && paramsNode.isArray) { + paramsNode.toTypedArray { if (it.isValueNode) it.asText() else it.toString() } + } else { + emptyArray() + } - return when (method) { - BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) - BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) - BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) - BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) - BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) - BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) - BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) - BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) - BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) - else -> BunkerRequest(id, method, params) - } + return BunkerRequestParser.parse(id, method, params) } } From 00eb7cf447f179bc38faecca66c2c194b3b2d402 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:38 +0000 Subject: [PATCH 4/4] feat(nip67): EOSE completeness hints (finish / more / auth) Wire model: - EoseMessage gains optional `hints` (the third EOSE element) with isFinished()/hasMore()/needsAuth(); both the kotlinx and Jackson parsers read it (non-string/unknown entries ignored, non-array third element ignored) and both serializers write it only when present. The two-element fast path of toJson() is unchanged. Client: - SubscriptionListener gets onEose(relay, forFilters, hints); the pool calls it and the default forwards to the old two-argument onEose. - fetchAllPages: "finish" ends the walk without the extra empty-page REQ (DRAINED, or LIMIT_REACHED/UNPAGEABLE when a filter already dropped out); "more" keeps paging; "auth" waits once for the NIP-42 verdict like an auth-required CLOSED and reads the post-AUTH re-served page, dropping ids it already delivered. An unanswered "auth" can never yield DRAINED. - RelayAuthenticator treats an EOSE "auth" hint like an auth-required CLOSED (non-interactive re-auth on the stored challenge, whose OK re-REQs via syncFilters), at most once per challenge. Relay engine (opt-in, RelayServerBase.completenessHints, default off): - One filter with limit L: query L+1, forward L, send "more" if the extra row exists else "finish". All filters unbounded: "finish". Several filters: "finish" only when fewer rows than the smallest limit; else no hint. limit 0 and the policy-screened path never get a hint. Opt-in because it relies on the backend honouring limit exactly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../kotlinSerialization/MessageKSerializer.kt | 12 +- .../NostrClientFetchAllPagesExt.kt | 76 +++++++ .../relay/client/auth/RelayAuthenticator.kt | 34 +++ .../relay/client/pool/PoolRequests.kt | 1 + .../relay/client/reqs/SubscriptionListener.kt | 12 ++ .../relay/commands/toClient/EoseMessage.kt | 25 ++- .../relay/server/EoseCompletenessProbe.kt | 99 +++++++++ .../nip01Core/relay/server/RelayServerBase.kt | 10 + .../nip01Core/relay/server/RelaySession.kt | 38 +++- .../relay/server/NostrServerEoseTest.kt | 198 +++++++++++++++++ .../commands/toClient/MessageDeserializer.kt | 23 +- .../commands/toClient/MessageSerializer.kt | 6 + .../NostrClientFetchAllPagesEoseHintsTest.kt | 201 ++++++++++++++++++ .../commands/toClient/EoseHintsParsingTest.kt | 135 ++++++++++++ 14 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index c0a7972e10..394d41831c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -37,6 +37,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject @@ -98,6 +99,10 @@ object MessageKSerializer : KSerializer { is EoseMessage -> { add(JsonPrimitive(value.subId)) + // NIP-67: optional third element, the completeness hints. + value.hints?.let { hints -> + add(buildJsonArray { hints.forEach { add(JsonPrimitive(it)) } }) + } } is LimitsMessage -> { @@ -136,7 +141,12 @@ object MessageKSerializer : KSerializer { } EoseMessage.LABEL -> { - EoseMessage(array[1].jsonPrimitive.content) + // NIP-67: an optional array of hint strings; anything else there is ignored. + val hints = + (array.getOrNull(2) as? JsonArray)?.mapNotNull { hint -> + (hint as? JsonPrimitive)?.takeIf { it.isString }?.content + } + EoseMessage(array[1].jsonPrimitive.content, hints) } NoticeMessage.LABEL -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index adacdbaba0..b5faa9ba59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.awaitAuthOutcome import com.vitorpamplona.quartz.nip01Core.relay.client.auth.hasAuthResponder import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -190,6 +191,23 @@ data class PagedFetchResult( * it a `limit` to bound that single page; without one you get the relay's default page * of top hits. * + * **NIP-67 completeness hints.** A relay may append hints to a page's `EOSE`: + * + * - `"finish"` — every stored match was sent, so the walk stops right there instead of + * spending one more REQ just to observe an empty page. It ends + * [PagedFetchResult.End.DRAINED] (or LIMIT_REACHED / UNPAGEABLE when a filter had + * already dropped out of the page, since `finish` can only speak for what was asked). + * - `"more"` — the relay holds more; paging continues, which is what the walk does + * anyway until it sees an empty page, so this needs no special handling. + * - `"auth"` — more may be available after NIP-42. Handled like an `auth-required:` + * CLOSED: when a responder is attached the walk waits (once) for the AUTH verdict and, + * on success, reads the page the relay re-serves after the AUTH's re-REQ, dropping the + * events it already delivered. If the AUTH does not happen, the page's events still + * count but the walk can no longer claim DRAINED: it ends + * [PagedFetchResult.End.AUTH_REQUIRED] wherever it would have ended DRAINED. + * + * Hints are only ever a shortcut; their absence changes nothing (the heuristic above). + * * @param relay The relay to query. * @param filters Filters to apply on every page (the `until` field is overwritten per page). * @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured @@ -336,6 +354,16 @@ suspend fun INostrClient.fetchAllPages( // declining to give one. var pageEnd: PageSignal? = null + // NIP-67 hints of the EOSE that ended this page (null: none sent). Written on the + // relay's reader thread before the EOSE signal is sent; the channel orders it. + var eoseHints: List? = null + + // Ids delivered on this page, kept only while an EOSE `"auth"` hint could still make + // the relay re-serve the page after AUTH (at most once per walk), so the re-served + // copies of events already handed to [onEvent] are dropped. Reader-thread only. + val pageIds: HashSet? = if (pendingOnAuthRequired && !authRetried) HashSet() else null + var reServing = false + try { val listener = object : SubscriptionListener { @@ -363,6 +391,9 @@ suspend fun INostrClient.fetchAllPages( // Drop a boundary-second event we already delivered on an // earlier page (the inclusive re-fetch returns it again). if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return + // The relay re-serving this page after an EOSE "auth" hint: skip what + // this page already delivered. + if (reServing && pageIds != null && event.id in pageIds) return // Count this event against every active filter it satisfies // (one event can match more than one). Only a non-search filter @@ -390,6 +421,7 @@ suspend fun INostrClient.fetchAllPages( if (atLeastOne) { onEvent(event) delivered++ + pageIds?.add(event.id) // Track the oldest advancing second and the ids delivered // in it — that becomes the next boundary and its dedup set. if (advancesCursor) { @@ -414,6 +446,15 @@ suspend fun INostrClient.fetchAllPages( doneChannel.trySend(PageSignal.EOSE) } + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + hints: List?, + ) { + eoseHints = hints + doneChannel.trySend(PageSignal.EOSE) + } + override fun onClosed( message: String, relay: NormalizedRelayUrl, @@ -454,6 +495,18 @@ suspend fun INostrClient.fetchAllPages( clock.bump() pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs) } + } else if (pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_AUTH) && pendingOnAuthRequired && !authRetried) { + // NIP-67 "auth": the page was answered, but the relay says it held some back. + // Same wait as the CLOSED case — the relay sent its challenge before this EOSE, + // and the AUTH's OK re-sends this very REQ — except the page already delivered + // events, so the re-served copies are dropped via [pageIds]. + authRetried = true + reServing = true + if (awaitAuthOutcome(relay, authMark, DEFAULT_AUTH_GRACE_MS, idleTimeoutMs) == AuthOutcome.AUTHENTICATED) { + eoseHints = null + clock.bump() + pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs) + } } unsubscribe(subId) @@ -465,6 +518,10 @@ suspend fun INostrClient.fetchAllPages( totalEvents += delivered + // The page ended on an EOSE saying more is visible only after AUTH, and no AUTH + // took the wall down: whatever it did deliver stands, but it cannot prove absence. + val authBlocked = pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_AUTH) + // The relay sent nothing at-or-below `until`. Whether that DRAINS the set // depends on why the page ended and on what was asked: // @@ -490,6 +547,23 @@ suspend fun INostrClient.fetchAllPages( pageEnd == null -> PagedFetchResult.End.IDLE cappedByLimit -> PagedFetchResult.End.LIMIT_REACHED filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE + authBlocked -> PagedFetchResult.End.AUTH_REQUIRED + else -> PagedFetchResult.End.DRAINED + } + break + } + + // NIP-67 "finish": the relay says it sent every stored match for this page's + // filters, so there is nothing below the cursor to ask for — stop now rather than + // spend a REQ to watch an empty page come back. It only speaks for the filters this + // page actually carried; one that already dropped out (limit met, or a search after + // its single page) keeps the reading it would have had. + if (pageEnd == PageSignal.EOSE && eoseHints.hasHint(EoseMessage.HINT_FINISH)) { + end = + when { + authBlocked -> PagedFetchResult.End.AUTH_REQUIRED + filters.indices.any { i -> filters[i].limit.let { it != null && matchCountPerFilter[i] >= it } } -> PagedFetchResult.End.LIMIT_REACHED + filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE else -> PagedFetchResult.End.DRAINED } break @@ -587,3 +661,5 @@ suspend fun INostrClient.fetchAllPages( onNewPage = onNewPage, onEvent = onEvent, ) + +private fun List?.hasHint(hint: String) = this != null && contains(hint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 90e62f2991..160e8c13d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnection import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -113,6 +114,9 @@ class RelayAuthenticator( // from RelayAuthStatus.snapshot(). private val authStatus = LargeCache() + /** The challenge each relay was last re-authenticated on because of an EOSE `"auth"` hint. */ + private val authHintRetried = LargeCache() + private val _authStateFlow = MutableStateFlow>(persistentMapOf()) /** @@ -143,6 +147,7 @@ class RelayAuthenticator( is AuthMessage -> authenticate(relay, msg.challenge, interactive = true) is OkMessage -> checkAuthResults(relay, msg) is ClosedMessage -> reauthenticateIfAuthRequired(relay, msg) + is EoseMessage -> reauthenticateIfAuthHinted(relay, msg) } } @@ -153,6 +158,7 @@ class RelayAuthenticator( override fun onDisconnected(relay: IRelayClient) { authStatus.remove(relay.url) + authHintRetried.remove(relay.url) publishSnapshot(relay.url) } } @@ -222,6 +228,34 @@ class RelayAuthenticator( msg: ClosedMessage, ) { if (MachineReadablePrefix.parse(msg.message) != MachineReadablePrefix.AUTH_REQUIRED) return + reauthenticateWithStoredChallenge(relay) + } + + /** + * NIP-67 / NIP-42: an `EOSE` carrying the `"auth"` hint says the relay may hold more + * matches for this subscription if we authenticate. The relay MUST have sent its + * `AUTH` challenge before that EOSE, so the challenge is already stored and the normal + * [authenticate] pass has usually run on it. This takes the same path as an + * `auth-required:` CLOSED: re-attach any approved identity not yet sent on that + * challenge (never prompting), and let the AUTH's `OK` → [INostrClient.syncFilters] + * re-send the REQ so the relay can serve what it held back. Deduped per + * (pubkey, challenge) and skipped while an AUTH is in flight, so it cannot loop. + */ + private fun reauthenticateIfAuthHinted( + relay: IRelayClient, + msg: EoseMessage, + ) { + if (!msg.needsAuth()) return + // A relay that keeps refusing us may tag EVERY EOSE with "auth". Unlike a CLOSED, the + // subscription is still answered, so there is no refusal to recover from — one retry + // per challenge is enough, and it spares an external signer a pass per subscription. + val challenge = authStatus.get(relay.url)?.lastChallenge() ?: return + if (authHintRetried.get(relay.url) == challenge) return + authHintRetried.put(relay.url, challenge) + reauthenticateWithStoredChallenge(relay) + } + + private fun reauthenticateWithStoredChallenge(relay: IRelayClient) { val status = authStatus.get(relay.url) ?: return // Coalesce the burst: a relay refuses EVERY currently-open sub with its own `auth-required` // CLOSED, so a single missing identity yields many CLOSEDs at once. Re-signing on each would diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 4630a5c58b..0f99c7208f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -302,6 +302,7 @@ class PoolRequests( desiredSubListeners.get(msg.subId)?.onEose( relay = relay.url, forFilters = forFilters, + hints = msg.hints, ) // send a newer version when done diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt index 15486f55af..172800d6a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt @@ -30,6 +30,18 @@ interface SubscriptionListener { forFilters: List?, ) {} + /** + * EOSE together with its NIP-67 completeness [hints] (`finish`, `more`, `auth`, …; + * null when the relay sent the plain two-element EOSE). The pool calls this one; + * the default forwards to the two-argument [onEose], so listeners that don't care + * about hints keep overriding that. + */ + fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + hints: List?, + ) = onEose(relay, forFilters) + suspend fun onEvent( event: Event, isLive: Boolean, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt index f376e4bc82..becf423a80 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseMessage.kt @@ -20,11 +20,29 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient +/** + * `["EOSE", ]`, optionally with NIP-67 completeness hints: + * `["EOSE", , [, ...]]`. + * + * [hints] is null when the relay sent the two-element form. Hints are only + * about stored events; their presence is definitive, their absence is not + * (see [isFinished] / [hasMore]). Unknown hint values are kept but ignored. + */ class EoseMessage( val subId: String, + val hints: List? = null, ) : Message { override fun label() = LABEL + /** NIP-67 `finish`: every stored match was sent; do not paginate further. */ + fun isFinished() = hints?.contains(HINT_FINISH) == true + + /** NIP-67 `more`: the relay holds more stored matches than it sent; paginate. */ + fun hasMore() = hints?.contains(HINT_MORE) == true + + /** NIP-67 `auth`: more stored matches may be available after NIP-42 AUTH. */ + fun needsAuth() = hints?.contains(HINT_AUTH) == true + /** * Wire form is `["EOSE",""]` — sent once per REQ, so it is on * the per-subscription floor. Splice it directly when [subId] needs no @@ -33,7 +51,7 @@ class EoseMessage( * any exotic subId falls back. */ override fun toJson(): String { - if (!isEscapeFreeAscii(subId)) return super.toJson() + if (hints != null || !isEscapeFreeAscii(subId)) return super.toJson() return buildString(subId.length + 12) { append("[\"EOSE\",\"") append(subId) @@ -43,5 +61,10 @@ class EoseMessage( companion object { const val LABEL = "EOSE" + + // NIP-67 hint values. + const val HINT_FINISH = "finish" + const val HINT_MORE = "more" + const val HINT_AUTH = "auth" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt new file mode 100644 index 0000000000..9d6cdb5349 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/EoseCompletenessProbe.kt @@ -0,0 +1,99 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +/** + * Works out the NIP-67 completeness hint for one REQ's stored replay, only ever + * claiming what the store's answer actually proves: + * + * - **One filter with `limit = L > 0`**: the store is asked for `L + 1` rows and the + * extra (oldest) row is withheld. Seeing it proves the relay holds more (`"more"`); + * not seeing it proves the replay was complete (`"finish"`). The client receives + * exactly the `L` events it would have without the probe. + * - **Every filter unbounded (`limit = null`)**: the store returns every match + * (STORE-F12), so the replay is complete (`"finish"`). + * - **Several filters, some limited**: limits are per filter and the replay is their + * deduped union, so rows cannot be attributed back to a filter without matching + * each one. Only the cheap, sound case is claimed: fewer rows than the smallest + * limit means no filter reached its limit (`"finish"`). Anything else sends no hint, + * which NIP-67 allows (absence is not definitive). + * + * `limit = 0` never gets a hint and is never probed: NIP-01 forbids returning stored + * events for it, so it keeps its exact query. + * + * This assumes the backing store honours `limit` exactly and treats `null` as + * unbounded, which is why [RelaySession] only uses it when the server opts in. + */ +internal class EoseCompletenessProbe private constructor( + /** The filters to actually query (the limit may be raised by one). */ + val queryFilters: List, + /** Max stored events to forward; the rest are only counted. Null: forward all. */ + private val forwardCap: Int?, + private val rule: Rule, +) { + private enum class Rule { PROBE, ALL_UNBOUNDED, UNDER_MIN_LIMIT } + + private val minLimit: Int = if (rule == Rule.UNDER_MIN_LIMIT) queryFilters.minOf { it.limit ?: Int.MAX_VALUE } else 0 + + /** Stored events the store produced for this REQ, forwarded or not. */ + private var seen = 0 + + /** + * Counts one stored event and says whether it should be forwarded to the client. + * Called from the single replay coroutine, before EOSE. + */ + fun onStored(): Boolean { + seen++ + return forwardCap == null || seen <= forwardCap + } + + /** The hints for this replay's EOSE, or null for none. */ + fun hints(): List? = + when (rule) { + Rule.PROBE -> if (seen > forwardCap!!) MORE else FINISH + Rule.ALL_UNBOUNDED -> FINISH + Rule.UNDER_MIN_LIMIT -> if (seen < minLimit) FINISH else null + } + + companion object { + private val FINISH = listOf(EoseMessage.HINT_FINISH) + private val MORE = listOf(EoseMessage.HINT_MORE) + + fun of(filters: List): EoseCompletenessProbe? { + if (filters.isEmpty()) return null + if (filters.any { it.limit == 0 }) return null + + if (filters.size == 1) { + val limit = filters[0].limit + if (limit != null && limit < Int.MAX_VALUE) { + return EoseCompletenessProbe(listOf(filters[0].copy(limit = limit + 1)), limit, Rule.PROBE) + } + } + + if (filters.all { it.limit == null }) return EoseCompletenessProbe(filters, null, Rule.ALL_UNBOUNDED) + + return EoseCompletenessProbe(filters, null, Rule.UNDER_MIN_LIMIT) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt index b3fcb9ee5b..da26993604 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayServerBase.kt @@ -63,6 +63,15 @@ abstract class RelayServerBase( /** Number of connections currently registered with this server. */ val activeConnections: Long get() = connections.active + /** + * NIP-67 opt-in: when true, connections opened from now on append `"finish"` / + * `"more"` to their EOSEs where the stored replay proves it (see + * [RelaySession.completenessHints]). Enable it only for a backend that honours + * `limit` exactly and returns every match for an unbounded filter, and advertise + * `67` in the NIP-11 `supported_nips` when you do. + */ + var completenessHints: Boolean = false + /** * Builds the per-connection policy, prepending a [LimitsPolicy] when * [limits] is set so requests are clamped/rejected before the application @@ -91,6 +100,7 @@ abstract class RelayServerBase( sink = sink, onClose = { connections.unregister(it.id) }, negentropySettings = negentropySettings, + completenessHints = completenessHints, ), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index df77566329..76743461ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -79,6 +79,13 @@ class RelaySession( * open/close of the same connection. Defaults to a fresh monotonic id. */ val id: Long = nextConnectionId(), + /** + * NIP-67: append a completeness hint (`"finish"` / `"more"`) to each REQ's `EOSE` + * when the stored replay proves one — see [EoseCompletenessProbe]. Off by default: + * the proof assumes the [store] honours `limit` exactly and returns every match + * for an unbounded filter, which an arbitrary backend need not do. + */ + val completenessHints: Boolean = false, ) : AutoCloseable { /** The original, string-only constructor; every frame goes to [onSend] as wire JSON. */ constructor( @@ -353,7 +360,15 @@ class RelaySession( } // Policy may rewrite filters to match the user's access level. - val filters = (result as PolicyResult.Accepted).cmd.filters + val acceptedFilters = (result as PolicyResult.Accepted).cmd.filters + + // NIP-67: may raise a single filter's limit by one to detect "more"; the extra + // stored row is counted but never sent. Zero-decode path only: the screened path's + // single `onEach` also carries live events accepted mid-replay, so stored rows can't + // be told apart there, and a policy that vetoes rows could not honestly say "finish". + val probe = if (completenessHints && !policy.filtersOutgoingEvents) EoseCompletenessProbe.of(acceptedFilters) else null + val filters = probe?.queryFilters ?: acceptedFilters + val eose = { send(EoseMessage(cmd.subId, probe?.hints())) } // UNDISPATCHED: the stored replay runs inline on this coroutine — // the reader-pool acquire doesn't suspend when a connection is @@ -377,7 +392,7 @@ class RelaySession( send(EventMessage(cmd.subId, event)) } }, - onEose = { send(EoseMessage(cmd.subId)) }, + onEose = { eose() }, ) } else { // Zero-decode path: the stored replay splices raw @@ -395,13 +410,16 @@ class RelaySession( ctx = requestContext, filters = filters, onEachStored = { raw -> - sendRaw( - buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) { - append(framePrefix) - raw.appendJsonObjectTo(this) - append(']') - }, - ) + // NIP-67 probe: the one extra row it asked for is counted, not sent. + if (probe == null || probe.onStored()) { + sendRaw( + buildString(framePrefix.length + raw.jsonTags.length + raw.content.length + 256) { + append(framePrefix) + raw.appendJsonObjectTo(this) + append(']') + }, + ) + } }, // Live events arrive with their wire body already // serialized (once per event, shared across every @@ -417,7 +435,7 @@ class RelaySession( }, ) }, - onEose = { send(EoseMessage(cmd.subId)) }, + onEose = { eose() }, ) } } catch (e: CancellationException) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt new file mode 100644 index 0000000000..3c779e1975 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerEoseTest.kt @@ -0,0 +1,198 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The relay engine's opt-in NIP-67 EOSE completeness hints. */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerEoseTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + + private val noop: (String) -> Unit = {} + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String, + kind: Int = 1, + createdAt: Long = 1000L, + ) = Event(id, pubkey, createdAt, kind, emptyArray(), "hello", sig) + + private fun server( + dispatcher: CoroutineDispatcher, + store: EventStore, + hints: Boolean = false, + ) = NostrServer(store = store, policyBuilder = { EmptyPolicy }, parentContext = dispatcher).also { + it.completenessHints = hints + } + + private class Collector { + val messages = mutableListOf() + val send: (String) -> Unit = { messages.add(it) } + + fun parsed(): List = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun events() = parsed().filterIsInstance() + + fun eoses() = parsed().filterIsInstance() + } + + private suspend fun EventStore.seed(count: Int) { + for (i in 1..count) insert(testEvent(hexId(i), createdAt = i.toLong())) + } + + // -- NIP-67: completeness hints ------------------------------------------------ + + @Test + fun hintsAreOffByDefault() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(3) + val server = server(dispatcher, store) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1]}]""") + + assertTrue(collector.messages.contains("""["EOSE","s"]""")) + assertNull(collector.eoses().single().hints) + server.close() + } + + @Test + fun truncatedByLimitSendsMore() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(10) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":3}]""") + + // The probe asks the store for 4 but only the newest 3 go out. + assertEquals(listOf(hexId(10), hexId(9), hexId(8)), collector.events().map { it.event.id }) + assertEquals(listOf(EoseMessage.HINT_MORE), collector.eoses().single().hints) + server.close() + } + + @Test + fun exactlyTheLimitSendsFinish() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(10) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":10}]""") + + assertEquals(10, collector.events().size) + assertEquals(listOf(EoseMessage.HINT_FINISH), collector.eoses().single().hints) + server.close() + } + + @Test + fun unboundedFilterSendsFinish() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(4) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1]}]""") + + assertEquals(4, collector.events().size) + assertEquals(listOf(EoseMessage.HINT_FINISH), collector.eoses().single().hints) + server.close() + } + + @Test + fun multiFilterHintsOnlyWhatTheCountProves() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(4) + val server = server(dispatcher, store, hints = true) + + val under = Collector() + server.connect(under.send).receive("""["REQ","s",{"kinds":[1],"limit":10},{"kinds":[2],"limit":20}]""") + assertEquals(4, under.events().size) + assertEquals(listOf(EoseMessage.HINT_FINISH), under.eoses().single().hints, "4 rows < smallest limit: nothing was cut") + + val ambiguous = Collector() + server.connect(ambiguous.send).receive("""["REQ","s",{"kinds":[1],"limit":2},{"kinds":[2],"limit":20}]""") + assertEquals(2, ambiguous.events().size) + assertNull(ambiguous.eoses().single().hints, "cannot attribute rows to filters cheaply, so no claim") + + server.close() + } + + @Test + fun limitZeroNeverGetsAHint() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(4) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":0}]""") + + assertEquals(0, collector.events().size) + assertNull(collector.eoses().single().hints) + server.close() + } + + @Test + fun probeDoesNotHoldBackLiveEvents() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + store.seed(5) + val server = server(dispatcher, store, hints = true) + val collector = Collector() + server.connect(collector.send).receive("""["REQ","s",{"kinds":[1],"limit":1}]""") + assertEquals(1, collector.events().size) + + server.connect(noop).receive(OptimizedJsonMapper.toJson(EventCmd(testEvent(hexId(100), createdAt = 9000L)))) + server.connect(noop).receive(OptimizedJsonMapper.toJson(EventCmd(testEvent(hexId(101), createdAt = 9001L)))) + + assertEquals(listOf(hexId(5), hexId(100), hexId(101)), collector.events().map { it.event.id }) + server.close() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index a2d3e10df3..a2b853840f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -54,9 +54,26 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { } EoseMessage.LABEL -> { - EoseMessage( - subId = jp.nextTextValue(), - ) + val subId = jp.nextTextValue() + // NIP-67: an optional third element, an array of hint strings. The array is + // consumed here (stepping past its END_ARRAY) so the drain loop below only + // ever sees the outer frame's tokens. + val hints = + if (jp.nextToken() == JsonToken.START_ARRAY) { + val list = ArrayList(2) + while (jp.nextToken() != JsonToken.END_ARRAY) { + if (jp.currentToken == JsonToken.VALUE_STRING) { + list.add(jp.text) + } else { + jp.skipChildren() + } + } + jp.nextToken() + list + } else { + null + } + EoseMessage(subId, hints) } NoticeMessage.LABEL -> { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 76671a396f..a3c588e1af 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -78,6 +78,12 @@ class MessageSerializer : StdSerializer(Message::class.java) { is EoseMessage -> { gen.writeString(msg.subId) + // NIP-67: optional third element, the completeness hints. + msg.hints?.let { hints -> + gen.writeStartArray() + hints.forEach { gen.writeString(it) } + gen.writeEndArray() + } } is LimitsMessage -> { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt new file mode 100644 index 0000000000..620fa58d64 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesEoseHintsTest.kt @@ -0,0 +1,201 @@ +/* + * 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.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-67 completeness hints steering [fetchAllPages]: `finish` ends the walk without the + * extra empty-page REQ, `more` keeps paging, and an unanswered `auth` hint keeps the walk + * from claiming DRAINED. + */ +class NostrClientFetchAllPagesEoseHintsTest { + private class ScriptedClient : INostrClient by EmptyNostrClient() { + @Volatile + var listener: SubscriptionListener? = null + + @Volatile + var subscribeCount = 0 + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscribeCount++ + this.listener = listener + } + + suspend fun awaitPage(n: Int) { + while (subscribeCount < n) delay(2) + } + } + + private val relay = RelayUrlNormalizer.normalize("wss://hints.example.com") + + private fun event(createdAt: Long) = + Event( + id = createdAt.toString(16).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$createdAt", + sig = "0".repeat(128), + ) + + @Test + fun finishEndsTheWalkWithoutAnotherPage() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end, "the relay said it sent every stored match") + assertEquals(1, client.subscribeCount, "no second REQ just to observe an empty page") + } + + @Test + fun moreKeepsPaging() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("more")) + + client.awaitPage(2) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end) + assertEquals(2, client.subscribeCount) + } + + @Test + fun unknownHintsAreIgnored() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("somethingNew")) + client.awaitPage(2) + client.listener!!.onEose(relay, null, emptyList()) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(1, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end, "falls back to the empty-page heuristic") + assertEquals(2, client.subscribeCount) + } + + @Test + fun finishWithAnUnansweredAuthHintCannotClaimDrained() = + runBlocking { + // No NIP-42 responder is attached, so the "auth" hint cannot be acted on. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("auth", "finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(1, result.downloaded, "what was delivered still counts") + assertEquals(PagedFetchResult.End.AUTH_REQUIRED, result.end, "the relay may hold more for an authenticated user") + assertEquals(1, client.subscribeCount) + } + + @Test + fun anEmptyPageWithAnAuthHintIsNotADrain() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("auth")) + client.awaitPage(2) + client.listener!!.onEose(relay, null, listOf("auth")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(1, result.downloaded) + assertEquals(PagedFetchResult.End.AUTH_REQUIRED, result.end) + } + + @Test + fun finishAfterAFilterMetItsLimitReportsLimitReached() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null, listOf("finish")) + } + + val result = client.fetchAllPages(relay = relay, filters = listOf(Filter(kinds = listOf(1), limit = 2)), idleTimeoutMs = 2_000) { } + feeder.join() + + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.LIMIT_REACHED, result.end) + assertEquals(1, client.subscribeCount) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt new file mode 100644 index 0000000000..7fff7c738e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/EoseHintsParsingTest.kt @@ -0,0 +1,135 @@ +/* + * 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.nip01Core.relay.commands.toClient + +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-67 EOSE completeness hints, through both JSON backends. */ +class EoseHintsParsingTest { + private val parsers: List Message>> = + listOf( + "jackson" to { json -> JacksonMapper.fromJsonToMessage(json) }, + "kotlinx" to { json -> KotlinSerializationMapper.fromJsonToMessage(json) }, + ) + + private fun eachParser( + json: String, + check: (String, EoseMessage) -> Unit, + ) = parsers.forEach { (name, parse) -> + val msg = parse(json) + assertIs(msg, name) + check(name, msg) + } + + @Test + fun twoElementEoseHasNoHints() = + eachParser("""["EOSE","sub1"]""") { name, msg -> + assertEquals("sub1", msg.subId, name) + assertNull(msg.hints, name) + assertFalse(msg.isFinished(), name) + assertFalse(msg.hasMore(), name) + assertFalse(msg.needsAuth(), name) + } + + @Test + fun finishHint() = + eachParser("""["EOSE","sub2",["finish"]]""") { name, msg -> + assertEquals("sub2", msg.subId, name) + assertEquals(listOf("finish"), msg.hints, name) + assertTrue(msg.isFinished(), name) + assertFalse(msg.hasMore(), name) + } + + @Test + fun moreHint() = + eachParser("""["EOSE","sub2b",["more"]]""") { name, msg -> + assertTrue(msg.hasMore(), name) + assertFalse(msg.isFinished(), name) + } + + @Test + fun multipleAndUnknownHints() = + eachParser("""["EOSE","sub4",["auth","finish","somethingNew"]]""") { name, msg -> + assertEquals(listOf("auth", "finish", "somethingNew"), msg.hints, name) + assertTrue(msg.needsAuth(), name) + assertTrue(msg.isFinished(), name) + } + + @Test + fun emptyHintArray() = + eachParser("""["EOSE","sub5",[]]""") { name, msg -> + assertEquals(emptyList(), msg.hints, name) + assertFalse(msg.isFinished(), name) + } + + @Test + fun nonStringHintsAreIgnored() = + eachParser("""["EOSE","sub6",[1,{"a":[2]},["x"],"finish",null]]""") { name, msg -> + assertEquals(listOf("finish"), msg.hints, name) + } + + @Test + fun nonArrayThirdElementIsIgnored() = + eachParser("""["EOSE","sub7","finish",{"x":1}]""") { name, msg -> + assertEquals("sub7", msg.subId, name) + assertNull(msg.hints, name) + } + + @Test + fun trailingElementsAfterHintsAreTolerated() = + eachParser("""["EOSE","sub8",["more"],"extra",5]""") { name, msg -> + assertEquals(listOf("more"), msg.hints, name) + } + + @Test + fun serializesWithoutHintsAsTwoElements() { + val msg = EoseMessage("sub1") + assertEquals("""["EOSE","sub1"]""", msg.toJson()) + assertEquals("""["EOSE","sub1"]""", JacksonMapper.toJson(msg)) + assertEquals("""["EOSE","sub1"]""", KotlinSerializationMapper.toJson(msg)) + } + + @Test + fun serializesHintsAsThirdElement() { + val msg = EoseMessage("sub1", listOf("auth", "finish")) + val expected = """["EOSE","sub1",["auth","finish"]]""" + assertEquals(expected, msg.toJson()) + assertEquals(expected, JacksonMapper.toJson(msg)) + assertEquals(expected, KotlinSerializationMapper.toJson(msg)) + } + + @Test + fun roundTripsThroughBothBackends() { + val json = EoseMessage("s", listOf("more")).toJson() + parsers.forEach { (name, parse) -> + val back = parse(json) + assertIs(back, name) + assertEquals(listOf("more"), back.hints, name) + } + } +}