mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge branch 'worktree-agent-a1ce4be81c12be0f5' into claude/serene-lamport-3eyza2
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
+15
@@ -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>
|
||||
|
||||
+11
-1
@@ -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 -> {
|
||||
|
||||
+76
@@ -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)
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+24
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+28
-10
@@ -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) {
|
||||
|
||||
+38
@@ -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)
|
||||
+59
@@ -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")
|
||||
}
|
||||
}
|
||||
+3
-61
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-25
@@ -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()
|
||||
}
|
||||
|
||||
+30
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+64
-10
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Nip04DecryptResponse {
|
||||
}
|
||||
|
||||
is BunkerResponseError -> {
|
||||
SignerResult.RequestAddressed.Rejected()
|
||||
SignerResult.RequestAddressed.Rejected(response.error)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Nip04EncryptResponse {
|
||||
}
|
||||
|
||||
is BunkerResponseError -> {
|
||||
SignerResult.RequestAddressed.Rejected()
|
||||
SignerResult.RequestAddressed.Rejected(response.error)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Nip44DecryptResponse {
|
||||
}
|
||||
|
||||
is BunkerResponseError -> {
|
||||
SignerResult.RequestAddressed.Rejected()
|
||||
SignerResult.RequestAddressed.Rejected(response.error)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Nip44EncryptResponse {
|
||||
}
|
||||
|
||||
is BunkerResponseError -> {
|
||||
SignerResult.RequestAddressed.Rejected()
|
||||
SignerResult.RequestAddressed.Rejected(response.error)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
+5
-1
@@ -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.")
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class PingResponse {
|
||||
}
|
||||
|
||||
is BunkerResponseError -> {
|
||||
SignerResult.RequestAddressed.Rejected()
|
||||
SignerResult.RequestAddressed.Rejected(response.error)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class PubKeyResponse {
|
||||
}
|
||||
|
||||
is BunkerResponseError -> {
|
||||
SignerResult.RequestAddressed.Rejected()
|
||||
SignerResult.RequestAddressed.Rejected(response.error)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+8
-1
@@ -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>
|
||||
|
||||
|
||||
+25
-10
@@ -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,
|
||||
|
||||
+48
-18
@@ -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`.
|
||||
|
||||
|
||||
+39
-1
@@ -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
|
||||
|
||||
|
||||
+4
@@ -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"),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
+16
-2
@@ -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"
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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(" "))
|
||||
}
|
||||
}
|
||||
|
||||
+64
-3
@@ -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(" "))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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(" "))
|
||||
}
|
||||
}
|
||||
|
||||
+198
@@ -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()
|
||||
}
|
||||
}
|
||||
+129
@@ -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()
|
||||
}
|
||||
}
|
||||
+32
@@ -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"]}"""
|
||||
|
||||
+82
-2
@@ -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
|
||||
|
||||
+8
@@ -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>()
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+86
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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
|
||||
|
||||
+38
-7
@@ -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
|
||||
|
||||
+20
-3
@@ -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 -> {
|
||||
|
||||
+6
@@ -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 -> {
|
||||
|
||||
+11
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+201
@@ -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)
|
||||
}
|
||||
}
|
||||
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user