fix(nutzaps): receive nutzaps on inbox/dm/kind:10019 relays, not outbox

Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to*
the user, so per the NIP-65 outbox model they must be read from the
user's inbox-side relays, not their outbox. The Cashu subscription used a
single relay set (outbox) for both the user's own NIP-60 events and
inbound nutzaps, so a sender following NIP-61 correctly (publishing to
the relays advertised in the recipient's kind:10019, or to the
recipient's NIP-65 inbox) could be missed.

Split the subscription relay sets per filter:
  - own NIP-60 wallet/token/history events keep reading from outbox,
    where the user published them (needed to restore on a fresh device);
  - inbound kind:9321 nutzaps now read from the union of the user's
    NIP-65 inbox + DM relays + the `relay` tags in the user's own
    kind:10019. The last one is NIP-61's source of truth for "where to
    send me nutzaps" and may be written by another client to a relay set
    unrelated to our NIP-65 lists, so we listen there too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
This commit is contained in:
Claude
2026-06-22 09:38:31 -04:00
committed by Vitor Pamplona
parent bd7c5c78cc
commit 4e582d198d
3 changed files with 61 additions and 23 deletions
@@ -425,6 +425,8 @@ class Account(
scope = scope,
assembler = cashuWalletFilterAssembler(),
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
dmRelaysFlow = dmRelays.flow,
settings = settings,
okHttpClient = okHttpClientForMoney,
)
@@ -97,6 +97,8 @@ class CashuWalletState(
private val scope: CoroutineScope,
private val assembler: CashuWalletFilterAssembler,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val settings: AccountSettings,
okHttpClient: (String) -> OkHttpClient,
) {
@@ -436,12 +438,31 @@ class CashuWalletState(
triggerAutoRedeem()
}
// Keep the relay subscription in sync with the outbox set.
// Keep the wallet subscription in sync with the relay sets it reads
// from. Following the NIP-65 outbox model, the two halves of the
// subscription read from different places:
// - our own NIP-60 events (wallet/token/history) are read back from
// our OUTBOX relays, where we published them;
// - inbound kind:9321 nutzaps are read from our INBOX set, since
// that is where other people deliver them. Per NIP-61 the source
// of truth for "where to send me nutzaps" is the `relay` tags in
// our own kind:10019 — and another client may have published that
// with relays unrelated to our NIP-65 lists — so we listen on the
// union of those plus our NIP-65 inbox + DM relays.
jobs +=
scope.launch(Dispatchers.IO) {
outboxRelaysFlow.collect { relays ->
syncSubscription(relays)
}
combine(
outboxRelaysFlow,
inboxRelaysFlow,
dmRelaysFlow,
_nutzapInfoEvent,
) { outbox, inbox, dm, info ->
CashuWalletQueryState(
pubkey = pubKey,
ownEventRelays = outbox,
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
)
}.collect { syncSubscription(it) }
}
// Reactive incremental update: any new event arrival that matches our
@@ -504,17 +525,16 @@ class CashuWalletState(
// ============================================================
// Subscription management
// ============================================================
private fun syncSubscription(relays: Set<NormalizedRelayUrl>) {
private fun syncSubscription(next: CashuWalletQueryState) {
val previous = currentSubscription
if (relays.isEmpty()) {
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
return
}
if (previous != null && previous.relays == relays) return // unchanged
if (previous == next) return // unchanged
previous?.let { runCatching { assembler.unsubscribe(it) } }
val next = CashuWalletQueryState(pubKey, relays)
currentSubscription = next
assembler.subscribe(next)
}
@@ -42,13 +42,23 @@ import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEven
* Query state for the NIP-60 / NIP-61 wallet subscription.
*
* `pubkey` is the wallet owner — used both as `authors=` for their own
* NIP-60 events and as the `#p` tag value for inbound nutzaps. `relays` is
* the union of relays to subscribe on (NIP-65 outbox + DM relays at minimum).
* NIP-60 events and as the `#p` tag value for inbound nutzaps.
*
* The two filters read from different relay sets, following the NIP-65
* outbox model:
* - [ownEventRelays] — the user's own write/outbox relays, where they
* published their NIP-60 wallet/token/history events. Restoring those
* means reading from where they were written.
* - [inboxRelays] — where *other* people deliver kind:9321 nutzaps to this
* user. Per NIP-61 the source of truth is the `relay` tags in the user's
* own kind:10019; in practice we listen on the union of those plus the
* user's NIP-65 inbox + DM relays so a nutzap can't slip past us.
*/
@Immutable
data class CashuWalletQueryState(
val pubkey: HexKey,
val relays: Set<NormalizedRelayUrl>,
val ownEventRelays: Set<NormalizedRelayUrl>,
val inboxRelays: Set<NormalizedRelayUrl>,
)
/**
@@ -91,11 +101,9 @@ private class CashuWalletSubAssembler(
if (keys.isEmpty()) return null
val pubkey = keys.first().pubkey
val relays =
keys
.flatMap { it.relays }
.toSet()
.ifEmpty { return null }
val ownEventRelays = keys.flatMap { it.ownEventRelays }.toSet()
val inboxRelays = keys.flatMap { it.inboxRelays }.toSet()
if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return null
val ownedFilter =
Filter(
@@ -121,18 +129,26 @@ private class CashuWalletSubAssembler(
tags = mapOf("p" to listOf(pubkey)),
)
return relays.flatMap { relay ->
val sinceTime = since?.get(relay)?.time
listOf(
// Own NIP-60 events are read from the user's outbox; inbound nutzaps
// from the user's inbox set. A relay that appears in both gets both
// filters.
val ownedSubs =
ownEventRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
RelayBasedFilter(
relay,
if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter,
),
)
}
val inboundSubs =
inboxRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
RelayBasedFilter(
relay,
if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter,
),
)
}
)
}
return ownedSubs + inboundSubs
}
}