fix(nwc): drop authors and #p from response subscription filter

Some wallet services and relays don't produce or index the `p` tag on NIP-47
response events the way the spec implies for ephemeral kinds, which made
Amethyst's strict relay-side filter (kinds + authors + #e + #p) match
nothing while looser clients (Primal uses just kinds + #e) work against the
same connection string.

Reduce the relay filter to the same shape Primal uses. The request event id
in #e is a unique 32-byte identifier, so the false-positive rate is
effectively zero, and the wallet's identity is still authenticated end-to-end
by NIP-04 decryption against the per-connection shared secret — the relay
filter was never the security boundary.

NWCPaymentQueryState no longer needs `fromServiceHex` or `toUserHex`; remove
them and propagate the simpler ctor through callers.
This commit is contained in:
Claude
2026-05-21 20:49:31 +00:00
parent a1cca4a748
commit aca4529d72
5 changed files with 17 additions and 25 deletions
@@ -144,8 +144,6 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
@@ -185,8 +183,6 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
@@ -24,17 +24,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
fun filterNWCPaymentsFromRequests(
serviceKeys: Set<HexKey>,
paymentRequests: Set<HexKey>,
fromUsers: Set<HexKey>,
): Filter =
// The request event id (#e) is a unique 32-byte identifier — sufficient on
// its own to match the response. Adding `authors` or `#p` makes the filter
// strictly stricter at the relay, which causes silent timeouts on services
// or relays that don't set / index those fields the way NIP-47 expects
// (e.g. wallets that omit the `p` tag, relays that don't index single-letter
// tags on ephemeral kinds). Primal's client uses the same minimal filter
// (kinds + #e), and the wallet's identity is still authenticated end-to-end
// by NIP-04 decryption against the per-connection shared secret.
fun filterNWCPaymentsFromRequests(paymentRequests: Set<HexKey>): Filter =
Filter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = serviceKeys.sorted(),
tags =
mapOf(
"e" to paymentRequests.sorted(),
"p" to fromUsers.sorted(),
),
tags = mapOf("e" to paymentRequests.sorted()),
)
@@ -49,11 +49,9 @@ fun NWCFinderFilterAssemblerSubscription(
remember(note) {
val zapPaymentRequestNote = note
(zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent ->
noteEvent.walletServicePubKey()?.let { serviceId ->
noteEvent.walletServicePubKey()?.let {
zapPaymentRequestNote.relays.map {
NWCPaymentQueryState(
fromServiceHex = serviceId,
toUserHex = noteEvent.pubKey,
replyingToHex = noteEvent.id,
relay = it,
)
@@ -26,11 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
// This allows multiple screen to be listening to tags, even the same tag
// This allows multiple screen to be listening to tags, even the same tag.
// The subscription filter only needs the request event id (#e) — the wallet
// service's identity is authenticated by NIP-04 decryption against the
// per-connection shared secret, not by relay-side `authors`/`#p` filtering.
@Stable
class NWCPaymentQueryState(
val fromServiceHex: HexKey,
val toUserHex: HexKey,
val replyingToHex: HexKey,
val relay: NormalizedRelayUrl,
)
@@ -32,15 +32,13 @@ class NWCPaymentWatcherSubAssembler(
if (keys.isEmpty()) return null
return keys.groupBy { it.relay }.map { relayGroup ->
val fromAuthors = relayGroup.value.mapTo(mutableSetOf()) { it.fromServiceHex }
val replyingToPayments = relayGroup.value.mapTo(mutableSetOf()) { it.replyingToHex }
val aboutUsers = relayGroup.value.mapTo(mutableSetOf()) { it.toUserHex }
if (fromAuthors.isEmpty() || replyingToPayments.isEmpty()) return null
if (replyingToPayments.isEmpty()) return null
RelayBasedFilter(
relay = relayGroup.key,
filter = filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers),
filter = filterNWCPaymentsFromRequests(replyingToPayments),
)
}
}