From f48373282f0e169001130af1685a58f9ac271561 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 2 Aug 2026 20:56:32 -0400 Subject: [PATCH] electrum: make the fallback privacy tradeoff visible to operators Falling back to public Electrum is a PRIVACY decision, not just an availability one: the queries carry user addresses. It is easy to miss because the failover is silent and correct, so the only symptom is that balances keep working. Our own LTC Fulcrum was dead for 11 days with every lookup going to third parties, and nothing surfaced it. - startup: log how many third-party fallbacks are configured and how to fail closed instead, so the posture is visible in the operator's own logs - .env.example: state the tradeoff at the setting, with both choices spelled out rather than a default presented as obviously correct - OPERATOR_SETUP: same note where an operator configures chains Also expands the shipped fallback lists (BTC 3 -> 8, LTC 4 -> 7), which is both more resilience and a wider spread of which server sees any given query. --- .env.example | 23 +++++++++++++++++++++-- docs/operations/OPERATOR_SETUP.md | 22 ++++++++++++++++++++++ src/config.rs | 23 +++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 8d81696..a806fd2 100644 --- a/.env.example +++ b/.env.example @@ -130,7 +130,25 @@ FEATURE_UTXO_MULTI_ADDRESS=false # ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ───── BTC_NETWORK=mainnet BTC_ELECTRUM_URL= -BTC_ELECTRUM_FALLBACKS= +# +# ── PRIVACY: read this before setting fallbacks ─────────────────────────────── +# These are THIRD-PARTY servers. When your own Electrum/Fulcrum is unreachable, +# the backend fails over to them, and the queries it sends carry YOUR USERS' +# ADDRESSES. The failover is silent and correct, so the only symptom is that +# balances keep working: a dead Fulcrum can go unnoticed for days while every +# lookup goes to someone else's server. (That is not hypothetical; it happened +# to us for 11 days.) +# +# Two honest choices, both defensible: +# * LEAVE THESE SET for availability. Balances survive your node dying. Watch +# for the "third-party Electrum fallback(s) configured" line at startup. +# * LEAVE THEM EMPTY to fail closed. Balances break when your node breaks, and +# every query stays on infrastructure you control. This is the right default +# for a privacy-first or air-gapped deployment. +# +# More entries = more resilience and a wider spread of who sees a given query. +# They are tried in order after the primary. +BTC_ELECTRUM_FALLBACKS=ssl://electrum.blockstream.info:50002,ssl://electrum.emzy.de:50002,ssl://electrum.bitaroo.net:50002,ssl://fulcrum.sethforprivacy.com:50002,ssl://electrum.diynodes.com:50002,ssl://bitcoin.lu.ke:50002,ssl://electrum.jochen-hoenicke.de:50006,ssl://btc.electroncash.dk:60002 # Require CA-verified TLS (webpki + hostname) for ssl:// electrum servers. Applies # to BOTH BTC and LTC. Default false: public electrum servers are self-signed by # convention and their data is treated as hostile regardless, so accepting any @@ -141,7 +159,8 @@ ELECTRUM_STRICT_TLS=false # ── Litecoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ──── LTC_NETWORK=mainnet LTC_ELECTRUM_URL= -LTC_ELECTRUM_FALLBACKS= +# Same privacy tradeoff as BTC_ELECTRUM_FALLBACKS above; read that note. +LTC_ELECTRUM_FALLBACKS=ssl://electrum-ltc.bysh.me:50002,ssl://electrum.ltc.xurious.com:50002,ssl://backup.electrum-ltc.org:443,ssl://electrum1.cipig.net:20063,ssl://electrum2.cipig.net:20063,ssl://electrum3.cipig.net:20063,ssl://ltc.rentonisk.com:50002 # ── Monero (daemon + light wallet server) ───────────────────────────────── XMR_LWS_URL=http://127.0.0.1:8443 diff --git a/docs/operations/OPERATOR_SETUP.md b/docs/operations/OPERATOR_SETUP.md index c24800e..9f4e38a 100644 --- a/docs/operations/OPERATOR_SETUP.md +++ b/docs/operations/OPERATOR_SETUP.md @@ -53,6 +53,28 @@ flag is on but whose source is unconfigured reports `enabled: false` via | XMR / WOW | `{XMR,WOW}_LWS_URL`, `{XMR,WOW}_LWS_ADMIN_URL`, `{XMR,WOW}_LWS_ADMIN_KEY`, `{XMR,WOW}_DAEMON_URL`. | | Grin | `GRIN_OWNER_API_URL` + `GRIN_OWNER_API_SECRET`, `GRIN_WALLET_PASSWORD`, `GRIN_FOREIGN_API_URL`, and the node's `GRIN_NODE_*`. | +> **Electrum fallbacks are a privacy decision.** +> +> `{BTC,LTC}_ELECTRUM_FALLBACKS` are third-party servers. If your own +> Electrum/Fulcrum becomes unreachable, the backend fails over to them and the +> queries it sends carry **your users' addresses**. +> +> The failover is silent and correct, which is the trap: the only symptom is that +> balances keep working. Our own Litecoin Fulcrum was dead for 11 days before +> anyone noticed, with every lookup going to public servers in the meantime. +> +> Decide deliberately: +> +> - **Keep fallbacks** for availability. Balances survive your node dying. The +> backend logs `third-party Electrum fallback(s) configured` at startup so the +> choice is visible in your logs, and you should alert on your own Electrum +> being down rather than relying on users to notice. +> - **Leave them empty** to fail closed. Balances break when your node breaks, +> and every query stays on infrastructure you control. This is the right +> default for a privacy-first or air-gapped instance. +> +> There is no third option where a dead node is both invisible and private. + ## 4. Run ```sh diff --git a/src/config.rs b/src/config.rs index 4d715fd..e67dd5f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1151,6 +1151,29 @@ impl Config { } } + // Electrum fallbacks are a PRIVACY decision, not just an availability one. + // + // When the operator's own Fulcrum is unreachable the client fails over to + // these servers, and the queries it sends carry USER ADDRESSES. On an + // instance whose whole pitch is "run your own backend and send us + // nothing", that is a posture change the operator should make knowingly. + // + // It is easy to miss: the failover is silent and correct, so the only + // symptom is that balances keep working. A dead Fulcrum went unnoticed + // for 11 days on our own fleet for exactly that reason. + for (coin, cfg) in [("BTC", &self.chains.btc), ("LTC", &self.chains.ltc)] { + if cfg.electrum_fallbacks.is_empty() { + continue; + } + tracing::info!( + "{coin}: {} third-party Electrum fallback(s) configured. If your own \ + Electrum server becomes unreachable, address queries will go to \ + them instead. Set {coin}_ELECTRUM_FALLBACKS= (empty) to fail \ + closed and keep every query on infrastructure you control.", + cfg.electrum_fallbacks.len() + ); + } + // Public tips escrow on-chain and deliver by share URL, so an instance // with FEATURE_TIPS on but no chain to escrow on, or no TIP_SHARE_BASE_URL // to build share links from, would advertise a tips subsystem it cannot