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 ac1ac25b4f..84674960c8 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -4632,6 +4632,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 @@ -5282,6 +5283,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/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/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/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/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/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() + } +} 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/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 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/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) } } 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) + } + } +}