diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5952610a09..6ac097df8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -425,6 +425,8 @@ class Account( scope = scope, assembler = cashuWalletFilterAssembler(), outboxRelaysFlow = outboxRelays.flow, + inboxRelaysFlow = notificationRelays.flow, + dmRelaysFlow = dmRelays.flow, settings = settings, okHttpClient = okHttpClientForMoney, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 938dccf661..7dc52b4ca1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -97,6 +97,8 @@ class CashuWalletState( private val scope: CoroutineScope, private val assembler: CashuWalletFilterAssembler, private val outboxRelaysFlow: StateFlow>, + private val inboxRelaysFlow: StateFlow>, + private val dmRelaysFlow: StateFlow>, 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) { + 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index d70325c0fc..c761981205 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -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, + val ownEventRelays: Set, + val inboxRelays: Set, ) /** @@ -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 } }