feat(tor): money-operations relay category

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-11 13:35:11 -04:00
co-authored by Claude Opus 4.8
parent d1bd5734cd
commit f9f7de3ed0
6 changed files with 172 additions and 19 deletions
@@ -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)
}
@@ -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<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val perAccountFlows =
snapshot.map { (_, account) ->
combine(
account.settings.nwcWallets,
account.settings.clinkDebitWallets,
) { nwcWallets, clinkDebitWallets ->
val relays = mutableSetOf<NormalizedRelayUrl>()
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<NormalizedRelayUrl>()
perAccount.forEach { moneyOpRelays.addAll(it) }
moneyOpRelays.toSet()
},
)
}.onEach {
torEvaluatorFlow.moneyOpRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -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<Set<NormalizedRelayUrl>>(emptySet())
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(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<Set<NormalizedRelayUrl>>(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<Map<NormalizedRelayUrl, Int>>(emptyMap())
private fun currentMoneyOpRelays(): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl>) {
if (relays.isEmpty()) return
adHocMoneyOpCounts.update { current ->
current.toMutableMap().apply {
relays.forEach { this[it] = (this[it] ?: 0) + 1 }
}
}
}
fun unregisterMoneyOpRelays(relays: Set<NormalizedRelayUrl>) {
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<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> ->
moneyOpRelays,
adHocMoneyOpCounts,
) {
torSettings: TorRelaySettings,
trustedRelayList: Set<NormalizedRelayUrl>,
dmRelayList: Set<NormalizedRelayUrl>,
moneyOpRelayList: Set<NormalizedRelayUrl>,
adHocMoneyOps: Map<NormalizedRelayUrl, Int>,
->
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))
}
@@ -28,6 +28,7 @@ class TorRelayEvaluation(
val torSettings: TorRelaySettings,
val trustedRelayList: Set<NormalizedRelayUrl>,
val dmRelayList: Set<NormalizedRelayUrl>,
val moneyOpRelayList: Set<NormalizedRelayUrl> = 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) {
@@ -26,4 +26,5 @@ data class TorRelaySettings(
val dmRelaysViaTor: Boolean = false,
val newRelaysViaTor: Boolean = false,
val trustedRelaysViaTor: Boolean = false,
val moneyOperationsViaTor: Boolean = false,
)
@@ -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<NormalizedRelayUrl> = setOf(dmRelay),
trustedRelays: Set<NormalizedRelayUrl> = setOf(trustedRelay),
moneyOpRelays: Set<NormalizedRelayUrl> = 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() {