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)
+ }
+ }
+}