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() {