mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -425,6 +425,8 @@ class Account(
|
||||
scope = scope,
|
||||
assembler = cashuWalletFilterAssembler(),
|
||||
outboxRelaysFlow = outboxRelays.flow,
|
||||
inboxRelaysFlow = notificationRelays.flow,
|
||||
dmRelaysFlow = dmRelays.flow,
|
||||
settings = settings,
|
||||
okHttpClient = okHttpClientForMoney,
|
||||
)
|
||||
|
||||
+28
-8
@@ -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)
|
||||
}
|
||||
|
||||
+31
-15
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user