From f9f7de3ed078badfca841f8024f7f7d901cc3558 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 11 Jun 2026 13:35:11 -0400 Subject: [PATCH] feat(tor): money-operations relay category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets, so a wallet or payment-service relay fell through to newRelaysViaTor and got forced over Tor regardless of the "Money operations via Tor" toggle (which previously governed only HTTP clients). On services that block Tor exits this silently broke NIP-47 and CLINK payments. Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after the onion reachability check). TorRelayState gains a persistent money-op relay set — fed across all accounts from NIP-47 wallet relays and saved CLINK debit relays via AccountsTorStateConnector — plus a reference-counted ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The websocket builder resolves the per-relay decision from live source values so ad-hoc registration takes effect on the next connect with no race. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../torState/AccountsTorStateConnector.kt | 38 +++++++ .../amethyst/model/torState/TorRelayState.kt | 99 +++++++++++++++---- .../commons/tor/TorRelayEvaluation.kt | 8 ++ .../amethyst/commons/tor/TorRelaySettings.kt | 1 + .../commons/tor/TorRelayEvaluationTest.kt | 43 ++++++++ 6 files changed, 172 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6313aaab74..aabf66a71f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -428,7 +428,7 @@ class AppModules( // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) + val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) okHttpClientForRelays.getHttpClient(useTor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 68aada42cb..a437179a8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -105,4 +105,42 @@ class AccountsTorStateConnector( SharingStarted.Eagerly, emptySet(), ) + + // Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK + // Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the + // money-operations Tor preference instead of being classified as generic "new" relays. + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val allMoneyOpRelaysFlow: Flow> = + accountsCache.accounts + .debounce(200) + .transformLatest { snapshot -> + val perAccountFlows = + snapshot.map { (_, account) -> + combine( + account.settings.nwcWallets, + account.settings.clinkDebitWallets, + ) { nwcWallets, clinkDebitWallets -> + val relays = mutableSetOf() + nwcWallets.forEach { relays.add(it.uri.relayUri) } + clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) } + relays.toSet() + } + } + + val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) } + + emitAll( + combine(ready) { perAccount -> + val moneyOpRelays = mutableSetOf() + perAccount.forEach { moneyOpRelays.addAll(it) } + moneyOpRelays.toSet() + }, + ) + }.onEach { + torEvaluatorFlow.moneyOpRelays.tryEmit(it) + }.stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index b6822fc21d..bd0366a1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient @Stable @@ -45,6 +46,58 @@ class TorRelayState( val dmRelays = MutableStateFlow>(emptySet()) val trustedRelays = MutableStateFlow>(emptySet()) + /** + * Relays known to be used for money operations from persistent configuration: NIP-47 wallet + * relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all + * logged-in accounts. These follow the money-operations Tor preference (see [TorRelayEvaluation]). + */ + val moneyOpRelays = MutableStateFlow>(emptySet()) + + /** + * Money-operation relays registered for the lifetime of a single ad-hoc round-trip whose relay + * isn't a saved wallet — e.g. paying someone's CLINK offer (`noffer`) pointer. Reference-counted + * so overlapping payments that share a relay don't unregister it while another is still in flight. + */ + private val adHocMoneyOpCounts = MutableStateFlow>(emptyMap()) + + private fun currentMoneyOpRelays(): Set = moneyOpRelays.value + adHocMoneyOpCounts.value.keys + + /** + * Marks [relays] as money-operation relays until a matching [unregisterMoneyOpRelays] call. + * Used by the CLINK offer/debit payers so a one-off payment relay honors the money-operations + * Tor preference instead of being treated as a generic "new" relay. + */ + fun registerMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { this[it] = (this[it] ?: 0) + 1 } + } + } + } + + fun unregisterMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { + val next = (this[it] ?: 0) - 1 + if (next <= 0) remove(it) else this[it] = next + } + } + } + } + + private fun currentSettings() = + TorRelaySettings( + torType = torSettingsFlow.torType.value, + onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, + dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, + newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, + trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, + moneyOperationsViaTor = torSettingsFlow.moneyOperationsViaTor.value, + ) + val torSettings = combine( torSettingsFlow.torType, @@ -66,27 +119,15 @@ class TorRelayState( newRelaysViaTor = newRelaysViaTor, trustedRelaysViaTor = trustedRelaysViaTor, ) + }.combine(torSettingsFlow.moneyOperationsViaTor) { settings, moneyOperationsViaTor -> + settings.copy(moneyOperationsViaTor = moneyOperationsViaTor) }.onStart { - emit( - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), - ) + emit(currentSettings()) }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), + currentSettings(), ) val flow = @@ -94,12 +135,21 @@ class TorRelayState( torSettings, trustedRelays, dmRelays, - ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + moneyOpRelays, + adHocMoneyOpCounts, + ) { + torSettings: TorRelaySettings, + trustedRelayList: Set, + dmRelayList: Set, + moneyOpRelayList: Set, + adHocMoneyOps: Map, + -> emit( TorRelayEvaluation( torSettings = torSettings, trustedRelayList = trustedRelayList, dmRelayList = dmRelayList, + moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys, ), ) }.onStart { @@ -108,6 +158,7 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) }.flowOn(Dispatchers.IO) @@ -118,10 +169,22 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) - fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = flow.value.useTor(relay) + /** + * Resolves the Tor preference for [relay] from live source values rather than the cached [flow] + * snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the + * very next connection attempt, with no dependency on the combine pipeline having propagated yet. + */ + fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = + TorRelayEvaluation( + torSettings = currentSettings(), + trustedRelayList = trustedRelays.value, + dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), + ).useTor(relay) fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt index eb5ddbacd3..42987dfb56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -28,6 +28,7 @@ class TorRelayEvaluation( val torSettings: TorRelaySettings, val trustedRelayList: Set, val dmRelayList: Set, + val moneyOpRelayList: Set = emptySet(), ) { fun useTor(relay: NormalizedRelayUrl): Boolean = if (torSettings.torType == TorType.OFF) { @@ -36,7 +37,14 @@ class TorRelayEvaluation( if (relay.isLocalHost()) { false } else if (relay.isOnion()) { + // .onion is only reachable over Tor regardless of any other classification. torSettings.onionRelaysViaTor + } else if (relay in moneyOpRelayList) { + // Relays used for money operations (NIP-47 wallets, CLINK offer/debit services) + // follow the dedicated money-operations preference, taking precedence over the + // generic DM/trusted/new classification so a payment never silently inherits a + // different Tor policy than the one the user set for money. + torSettings.moneyOperationsViaTor } else if (relay in dmRelayList) { torSettings.dmRelaysViaTor } else if (relay in trustedRelayList) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt index cc0e7e3143..781cb88211 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt @@ -26,4 +26,5 @@ data class TorRelaySettings( val dmRelaysViaTor: Boolean = false, val newRelaysViaTor: Boolean = false, val trustedRelaysViaTor: Boolean = false, + val moneyOperationsViaTor: Boolean = false, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt index c5e65775d1..a6b441a8e4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt @@ -33,6 +33,7 @@ class TorRelayEvaluationTest { private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + private val moneyRelay = NormalizedRelayUrl("wss://wallet.relay.com/") private fun buildEvaluation( torType: TorType = TorType.INTERNAL, @@ -40,8 +41,10 @@ class TorRelayEvaluationTest { dmViaTor: Boolean = true, newViaTor: Boolean = true, trustedViaTor: Boolean = false, + moneyViaTor: Boolean = false, dmRelays: Set = setOf(dmRelay), trustedRelays: Set = setOf(trustedRelay), + moneyOpRelays: Set = setOf(moneyRelay), ) = TorRelayEvaluation( torSettings = TorRelaySettings( @@ -50,9 +53,11 @@ class TorRelayEvaluationTest { dmRelaysViaTor = dmViaTor, newRelaysViaTor = newViaTor, trustedRelaysViaTor = trustedViaTor, + moneyOperationsViaTor = moneyViaTor, ), trustedRelayList = trustedRelays, dmRelayList = dmRelays, + moneyOpRelayList = moneyOpRelays, ) // --- Tor OFF --- @@ -107,6 +112,44 @@ class TorRelayEvaluationTest { @Test fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay)) + // --- Money-operation relays --- + @Test + fun money_enabled_returnsTrue() = assertTrue(buildEvaluation(moneyViaTor = true).useTor(moneyRelay)) + + @Test + fun money_disabled_returnsFalse() = assertFalse(buildEvaluation(moneyViaTor = false).useTor(moneyRelay)) + + @Test + fun money_takesPrecedenceOverNew() { + // A money-op relay not in any other list must NOT fall through to the new-relay policy. + val eval = buildEvaluation(moneyViaTor = false, newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet()) + assertFalse(eval.useTor(moneyRelay)) + } + + @Test + fun money_takesPrecedenceOverTrustedAndDm() { + // When the same relay is both a money-op relay and trusted/DM, money policy wins. + val both = NormalizedRelayUrl("wss://wallet-and-trusted.relay.com/") + val eval = + buildEvaluation( + moneyViaTor = true, + dmViaTor = false, + trustedViaTor = false, + dmRelays = setOf(both), + trustedRelays = setOf(both), + moneyOpRelays = setOf(both), + ) + assertTrue(eval.useTor(both)) + } + + @Test + fun money_onionStillWins() { + // .onion reachability check precedes the money classification. + val onionMoney = NormalizedRelayUrl("wss://wallet.onion/") + val eval = buildEvaluation(onionViaTor = false, moneyViaTor = true, moneyOpRelays = setOf(onionMoney)) + assertFalse(eval.useTor(onionMoney)) + } + // --- Priority --- @Test fun onionInDmList_treatedAsOnion() {