Merge branch 'worktree-agent-a1ce4be81c12be0f5' into claude/serene-lamport-3eyza2

This commit is contained in:
Claude
2026-09-27 20:34:44 +00:00
51 changed files with 1741 additions and 193 deletions
@@ -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()
@@ -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 {
@@ -4632,6 +4632,7 @@
<string name="podcast_trailer_upload_hint">Short audio or video preview</string>
<string name="podcast_value_error_title">Value-for-Value error</string>
<string name="podcast_value_keysend_requires_nwc">Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients.</string>
<string name="podcast_value_keysend_not_supported">Your wallet does not support keysend, so node recipients were skipped.</string>
<string name="podcast_value_no_recipients">This podcast has no payable value recipients.</string>
<string name="podcast_value_stream_requires_wallet">Connect a Nostr Wallet Connect or debit wallet to stream sats while listening.</string>
<string name="podcast_value_user_no_lnaddress">This user has no Lightning address</string>
@@ -5282,6 +5283,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Could not load transactions</string>
<string name="wallet_transactions_load_more_failed">Could not load more transactions</string>
<string name="wallet_transactions_not_supported">Your wallet does not offer transaction history</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts or profiles have reports from your follows</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Warn on reports</string>
<string name="web_bookmark_add_title">Add Web Bookmark</string>
@@ -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<Message> {
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<Message> {
}
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 -> {
@@ -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<String>? = 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<HexKey>? = 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<Filter>?,
hints: List<String>?,
) {
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<String>?.hasHint(hint: String) = this != null && contains(hint)
@@ -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<NormalizedRelayUrl, RelayAuthStatus>()
/** The challenge each relay was last re-authenticated on because of an EOSE `"auth"` hint. */
private val authHintRetried = LargeCache<NormalizedRelayUrl, String>()
private val _authStateFlow = MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>(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
@@ -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
@@ -30,6 +30,18 @@ interface SubscriptionListener {
forFilters: List<Filter>?,
) {}
/**
* 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<Filter>?,
hints: List<String>?,
) = onEose(relay, forFilters)
suspend fun onEvent(
event: Event,
isLive: Boolean,
@@ -20,11 +20,29 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient
/**
* `["EOSE", <subId>]`, optionally with NIP-67 completeness hints:
* `["EOSE", <subId>, [<hint>, ...]]`.
*
* [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<String>? = 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","<subId>"]` — 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"
}
}
@@ -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<Filter>,
/** 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<String>? =
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<Filter>): 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)
}
}
}
@@ -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,
),
)
@@ -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) {
@@ -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<String>,
val reason: String,
) : BunkerRequest(id, method, params)
@@ -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<String>,
): 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")
}
}
@@ -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<BunkerMessage> {
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<String>,
): 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)
}
}
}
@@ -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<BunkerRequest> {
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<String> =
(element as? JsonArray)
?.map { (it as? JsonPrimitive)?.content ?: it.toString() }
?.toTypedArray()
?: emptyArray()
}
@@ -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"
}
}
@@ -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<String, Window>()
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 =
@@ -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)
}
}
@@ -33,7 +33,7 @@ class Nip04DecryptResponse {
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
SignerResult.RequestAddressed.Rejected(response.error)
}
else -> {
@@ -33,7 +33,7 @@ class Nip04EncryptResponse {
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
SignerResult.RequestAddressed.Rejected(response.error)
}
else -> {
@@ -33,7 +33,7 @@ class Nip44DecryptResponse {
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
SignerResult.RequestAddressed.Rejected(response.error)
}
else -> {
@@ -33,7 +33,7 @@ class Nip44EncryptResponse {
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
SignerResult.RequestAddressed.Rejected(response.error)
}
else -> {
@@ -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.")
}
@@ -33,7 +33,7 @@ class PingResponse {
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
SignerResult.RequestAddressed.Rejected(response.error)
}
else -> {
@@ -33,7 +33,7 @@ class PubKeyResponse {
}
is BunkerResponseError -> {
SignerResult.RequestAddressed.Rejected()
SignerResult.RequestAddressed.Rejected(response.error)
}
else -> {
@@ -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()
}
@@ -28,7 +28,14 @@ sealed interface SignerResult<T : IResult> {
val result: T,
) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
/**
* 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<T : IResult>(
val message: String? = null,
) : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
@@ -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<String>? = null,
val notificationTypes: List<String>? = null,
/**
* NWC extension specs to advertise. Null derives them from [capabilities] and
* [notificationTypes] via [ExtensionsTag.forCapabilities] (eg. `pay_keysend` → `04`).
*/
extensions: List<String>? = null,
) {
val extensions: List<String> = 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<String>? = null,
notifications: List<String>? = null,
lud16: String? = null,
extensions: List<String>? = 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,
@@ -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 <https://github.com/nostr-wallet-connect/nwc> (`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`.
@@ -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
@@ -237,6 +237,9 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
}
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<Response> {
notifications = it.stringListOrNull("notifications"),
metadata = it.anyMapOrNull("metadata"),
lud16 = it.stringOrNull("lud16"),
extensions = it.stringListOrNull("extensions"),
)
},
)
@@ -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"
@@ -155,9 +155,12 @@ class GetInfoSuccessResponse(
val block_height: Long? = null,
val block_hash: String? = null,
val methods: List<String>? = null,
// NWC-02: notification types authorized for this connection.
val notifications: List<String>? = null,
val metadata: Map<String, Any?>? = null,
val lud16: String? = null,
// NIP-47: optional NWC extension specs supported by this connection (eg. ["02", "05"]).
val extensions: List<String>? = null,
)
}
@@ -36,6 +36,8 @@ class EncryptionTag {
return tag.drop(1)
}
fun assemble(schemes: List<String>) = 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<String>) = arrayOf(TAG_NAME, schemes.joinToString(" "))
}
}
@@ -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<String>,
notificationTypes: List<String>? = null,
): List<String> {
val result = mutableSetOf<String>()
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<String>): List<String>? {
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<String>) = 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<String>) = arrayOf(TAG_NAME, extensions.joinToString(" "))
}
}
@@ -36,6 +36,8 @@ class NotificationsTag {
return tag.drop(1)
}
fun assemble(types: List<String>) = 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<String>) = arrayOf(TAG_NAME, types.joinToString(" "))
}
}
@@ -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<String>()
val send: (String) -> Unit = { messages.add(it) }
fun parsed(): List<Message> =
messages
.filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") }
.map { OptimizedJsonMapper.fromJsonToMessage(it) }
fun events() = parsed().filterIsInstance<EventMessage>()
fun eoses() = parsed().filterIsInstance<EoseMessage>()
}
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()
}
}
@@ -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<String>()
val send: (String) -> Unit = { messages.add(it) }
fun parsed(): List<Message> =
messages
.filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") }
.map { OptimizedJsonMapper.fromJsonToMessage(it) }
fun events() = parsed().filterIsInstance<EventMessage>()
fun eoses() = parsed().filterIsInstance<EoseMessage>()
}
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()
}
}
@@ -36,6 +36,38 @@ class BunkerRequestTest {
assertEquals(1, bunkerRequest.event.kind)
}
@Test
fun testSignEventWithoutParamsIsInvalidNotThrown() {
val bunkerRequest = OptimizedJsonMapper.fromJsonTo<BunkerRequest>("""{"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<BunkerMessage>("""{"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<BunkerMessage>("""{"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<BunkerMessage>("""{"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"]}"""
@@ -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<BunkerResponseError>(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<BunkerResponseError>(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<BunkerResponseError>(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
@@ -52,6 +52,14 @@ class ConvertExceptionsTest {
assertIs<SignerExceptions.ManuallyUnauthorizedException>(ex)
}
@Test
fun rejectedCarriesTheBunkerErrorText() {
val result = SignerResult.RequestAddressed.Rejected<PingResult>("invalid params for sign_event: bad")
val ex = remote.convertExceptions("Test", result)
assertIs<SignerExceptions.ManuallyUnauthorizedException>(ex)
assertTrue(ex.message!!.contains("invalid params for sign_event: bad"), ex.message)
}
@Test
fun timedOutReturnsTimedOutException() {
val result = SignerResult.RequestAddressed.TimedOut<PingResult>()
@@ -177,6 +177,7 @@ class ResponseParserTest {
val response = BunkerResponseError("req-3", "denied")
val result = SignResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<SignResult>>(result)
assertEquals("denied", result.message)
}
@Test
@@ -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<String>,
) = 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<NwcInfoEvent>(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<NwcInfoEvent>(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<NwcInfoEvent>(server.buildInfoEvent())
assertFalse(event.advertisesExtensions())
}
}
@@ -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<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
assertEquals(listOf("02", "05"), response.result?.extensions)
assertEquals(listOf("payment_received"), response.result?.notifications)
val reparsed = OptimizedJsonMapper.fromJsonTo<Response>(OptimizedJsonMapper.toJson(response))
assertIs<GetInfoSuccessResponse>(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<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
assertNull(response.result?.extensions)
}
// --- ListTransactions with total_count ---
@Test
@@ -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
@@ -54,9 +54,26 @@ class MessageDeserializer : StdDeserializer<Message>(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<String>(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 -> {
@@ -78,6 +78,12 @@ class MessageSerializer : StdSerializer<Message>(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 -> {
@@ -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>(BunkerRequest::class.java) {
override fun deserialize(
@@ -44,19 +36,16 @@ class BunkerRequestDeserializer : StdDeserializer<BunkerRequest>(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)
}
}
@@ -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<NormalizedRelayUrl, List<Filter>>,
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)
}
}
@@ -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<Pair<String, (String) -> 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<EoseMessage>(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<EoseMessage>(back, name)
assertEquals(listOf("more"), back.hints, name)
}
}
}