mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
co-authored by
Claude Opus 4.8
parent
d1bd5734cd
commit
f9f7de3ed0
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+38
@@ -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))
|
||||
}
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+1
@@ -26,4 +26,5 @@ data class TorRelaySettings(
|
||||
val dmRelaysViaTor: Boolean = false,
|
||||
val newRelaysViaTor: Boolean = false,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
val moneyOperationsViaTor: Boolean = false,
|
||||
)
|
||||
|
||||
+43
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user