Skip to content

ltc-electrum: pin MWEB's P2P sync to our own Litecoin nodes - #9

Merged
sethforprivacy merged 2 commits into
mainfrom
sfp/mweb-pinned-peers
Oct 26, 2026
Merged

sethforprivacy merged 2 commits into
mainfrom
sfp/mweb-pinned-peers

Conversation

@sethforprivacy

Copy link
Copy Markdown

Why

-1 -s pins Electrum-LTC's server, but not MWEB. Electrum-LTC runs an embedded mwebd that syncs over Litecoin P2P (neutrino), using peers from DNS seeds and gossip. On our staging host each LTC Electrum container held 8 random :9333 peers.

It can't be pinned by configuration:

  • Electrum-LTC 4.8.3 (contrib/mwebd/main.go) passes mwebd only Chain, DataDir and ProxyAddr.
  • mwebd v0.1.17's ServerArgs.PeerAddr maps to neutrino AddPeers. That adds a peer, but DNS seeding and 8 random outbound peers continue (ConnectPeers, the exclusive option, isn't exposed).
  • mwebd's one input from Electrum is the network proxy, and neutrino dials every peer through it.

What

docker/ltc-electrum/pin-proxy.py is a SOCKS5 proxy on the container's loopback, using only the standard library and run by the entrypoint. Electrum gets -p socks5:127.0.0.1:1080, which mwebd also uses. The proxy:

  • sends every P2P connection (:9333) to one of MWEB_PEERS, whichever peer mwebd asked for. It rewrites rather than refuses: a fresh wallet's address book holds none of our nodes, so refusing would mean MWEB never syncs and peg-outs stall.
  • holds at most one connection per node. Other dials are held without a reply until one closes (mwebd's dialer has no timeout). Letting all 8 through made the host the largest netgroup on each litecoind, and inbound eviction then dropped our connections about as fast as they were opened (~600/min, median life 0.05 s).
  • passes Electrum's connection to ELECTRUM_SERVER_ADDRESS. Electrum hands the hostname to the proxy, so DNS for it happens in the proxy.
  • refuses everything else.

On mainnet the LTC containers refuse to start without MWEB_PEERS, the same way they already do without a server. Peers must be IP literals. New env var: LITECOIN_MWEB_PEERS (README, .env.example, compose). CI now byte-compiles and pyflakes the proxy, and tests/test_pin_proxy.py covers the routing.

Tested on staging (dry run)

LTC wallet volumes were backed up first.

  • Before: each container had 8 random :9333 peers.
  • After, over 11 minutes: each container has one socket to each of our two litecoinds' :9333 and one to our Electrum server, and nothing else. mwebd itself talks only to the proxy.
  • Both mwebds stayed "Fully caught up with mweb" through three new blocks.
  • LTC and MWEB passes were ok on every pass, with balances and address counts unchanged.
  • A blank MWEB_PEERS on mainnet exits 1, and a hostname exits 2.

The full evidence is in the deploy PR in our private infra repo, which must land with this one, since the new image refuses to start without MWEB_PEERS. Not deployed to production.

Electrum-LTC's embedded mwebd syncs MWEB over Litecoin P2P (neutrino), from
DNS seeds and whatever peers gossip, so `-1 -s` never covered it: on
btcpay-stg it held connections to eight random :9333 peers. Electrum-LTC
passes mwebd nothing but its proxy setting, and mwebd's own peer option only
adds a peer beside the random ones.

So the LTC Electrum containers now run pin-proxy.py, a loopback SOCKS5 proxy,
and point Electrum at it with -p. It sends every P2P connection to one of
MWEB_PEERS, whatever peer mwebd asked for, passes Electrum's connection to its
pinned server, and refuses anything else. Rewriting rather than refusing
matters: a fresh wallet's address book holds none of our nodes, so refusing
would leave MWEB never syncing. On mainnet the containers refuse to start
without MWEB_PEERS, as they already do without a server.
mwebd keeps 8 outbound peers. Sent all to our two nodes, that made
btcpay-stg the largest netgroup on each, so a full litecoind's inbound
eviction dropped our youngest connection every time mwebd opened another:
~600 connections a minute per container, median lifetime 0.05s.

Now each container holds at most one connection per node. mwebd's other
dials are held, unanswered, until one closes: its dialer has no timeout, so
they just wait, where refusing them would have it retry in a loop.
@sethforprivacy
sethforprivacy merged commit 2bebda9 into main Oct 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant