From the archive

A Tailscale Workaround for Tor and I2P

Winnow still does not bundle Tor or I2P. This is how it reaches them anyway, through gateways you run yourself, and how to build those gateways.

Every power eventually learns that its map of the world was drawn too small. Mine is a phone wallet, and the map was the Bitcoin network.

In early September I dialed every reachable Bitcoin node I could find, 26,746 of them, for a piece about a dead fork. The fork made the headline. The footnote mattered more. Of the 7,761 endpoints that could serve Winnow the compact filters it reads the chain with, 6,478 lived behind Tor or I2P. On clearnet, one node in eight will give a light client what it needs. A wallet confined to clearnet is a great power that has decided to trade only with its smallest neighbor.

So I built Tor into Winnow. Then I took it out. Then I found a way around the problem that let me keep the ground without garrisoning it, and this month that workaround became the default.

The first campaign

The instrument was Arti, the Tor Project's rewrite of Tor in Rust, pinned at release 2.6.0. A thin Rust and C bridge carried it into Swift. It listened for SOCKS connections on loopback and resolved onion names itself, so none of them touched the phone's resolver. With the switch on, everything went through Tor, and if Tor could not bootstrap the wallet went dark. I would rather hand someone an error than a surprise.

A screenshot that says "Ready" proves about as much as a press release, so CI captured packets and required the routed run to show no direct traffic at all. It passed on an iPhone and an iPad and shipped in Winnow 0.6.3 on September 14.

For about a day, it was glorious.

The retreat

Glory has a supply chain. On September 15, 0.7.1 removed it: a Rust bridge, an XCFramework, 407 crates nobody had reviewed, onion validation, about 2,000 bundled onion peers, and build steps in three CI workflows. Then a hole turned up in code I wrote myself. The loopback SOCKS listener accepted anyone, and as far as I can tell iOS does not give each app a private loopback, so any app beside Winnow could have used it as a free Tor client or learned that Winnow was running with Tor on.

A wallet that promises to fail closed cannot stand on ground nobody has surveyed. The code rests at the v0.7.0 tag. Tor will come back into the app, but not as 407 crates nobody has read; more on that at the end.

Holding the line

Byzantium lasted a thousand years largely by declining battles it did not need to fight. I declined this one.

I already ran Tor and I2P on my own machines. The workaround leaves them there. Winnow keeps a SOCKS5 client of about 130 lines of Swift (#179), short enough to read over coffee, and hands peer connections to whatever gateway it finds on my Tailscale network (#181):

Winnow → Tailscale → Tor gateway → Tor → .onion Bitcoin peer
Winnow → Tailscale → I2P gateway → I2P → .b32.i2p Bitcoin peer

It passes the destination name to the gateway unresolved and runs the ordinary Bitcoin handshake through the tunnel. It never falls back to a direct connection. The gateway never sees keys, addresses or filter matches; Winnow still fetches and checks every header, filter and block itself. The gateway does see which peers you talk to, and it can cut you off. Run it yourself, or let someone run it whom you would lend your car.

Now the part that made me grin. The workaround reaches I2P, which the app never could: i2pd is a large C++ daemon with no iOS build anyone would want to embed. On a gateway it is one more port. In the census, three in five I2P endpoints serve filters, against one in seven on clearnet; the numbers are below.

Built-in Tor (0.6.3 to 0.7.0) Tailscale gateways (0.7.10 on)
Code inside the wallet Arti, a Rust bridge, 407 crates A SOCKS5 client
Works on any network Yes Needs Tailscale and your gateways
Who learns your peer destinations Only Tor Your gateway, then Tor or I2P
I2P No Yes
Local attack surface on the phone A loopback listener other apps could reach None added

Private by default

The phone humbled me twice with built-in Tor, and once more with the gateways. In 0.7.10, Automatic routing treated Tor and I2P as extras: clearnet stayed on, peers were seated first come, first served, and an ordinary node answers in milliseconds while a hidden service takes seconds. Automatic almost always ended up talking to clearnet peers that could see my IP address. The preference I had written was ornamental again.

In 0.7.11 (#190), when either of my gateways answers, Automatic drops clearnet entirely. Every peer, the signed peer-list download and any explorer lookup I approve go through Tor or I2P. Only when no gateway answers does it fall back to clearnet, so the wallet keeps syncing when Tailscale is off. The home screen says which it is, in colour.

Winnow's home screen with a green banner: Private: peers reached through Tor and I2P

Winnow's home screen with an orange banner: Direct: peers can see your IP address

Two rules keep the pool honest once clearnet is gone. Onion and I2P names have no address block, so the usual "one peer per /16" rule can't limit them; instead, with both gateways up, neither network may hold all three slots, and a round that cannot fill with a mix relaxes the rule rather than leave the wallet short. And every peer is still judged on the headers and filters it serves, not on its label.

Since 0.7.12 the wallet also checks the chain when iOS lets it run in the background, through the same routing. One rule is stricter there. If the foreground last reached peers through a gateway and no gateway answers, the background check is skipped rather than made in the clear. There is no banner in the background to say which it was, so the background doesn't get to choose clearnet.

Advanced settings, Connected peers: one peer labelled I2P with a .b32.i2p address and one labelled Tor with a .onion address, both at height 969,195

Build your own gateways

Winnow finds gateways by name. On your tailnet, give them these MagicDNS names:

Gateway MagicDNS name SOCKS5 port
Tor winnow-tor-gateway 9050
I2P winnow-i2p-gateway 4447

When networking starts, Winnow asks Tailscale's local resolver at 100.100.100.100 for those two names, accepts only a tailnet IPv4 address, and checks for a SOCKS5 greeting, two seconds each, with no public DNS as a fallback. It looks again each time it comes to the foreground.

Mine are two small VMs, and the whole recipe is public in winnowwallet/census gateways/. It is what actually runs: provision.py renders cloud-init byte for byte identical to both deployed guests. Each VM gets two vCPUs, 1.5 GiB of RAM and a 12 GiB disk from a pinned Ubuntu cloud image, apt pinned to a dated snapshot, Tor from that snapshot, i2pd 2.61.0 and Tailscale 1.102.4 checked by hash. On any Linux host with KVM:

git clone https://github.com/winnowwallet/census && cd census/gateways
sudo python3 provision.py ~/.ssh/id_ed25519.pub

The whole Tor configuration is three lines:

SocksPort 0.0.0.0:9050
ClientOnly 1
SafeSocks 1

The I2P side is i2pd with only its SOCKS proxy turned on and no outproxy, so I2P-only really means I2P-only:

[socksproxy]
enabled = true
address = 0.0.0.0
port = 4447
outproxy.enabled = false

[http]
enabled = false
[httpproxy]
enabled = false
[sam]
enabled = false

Then join each VM to your tailnet under its conventional name, with the helper the recipe installs:

ssh -J your-host -p 22051 gateway@127.0.0.1 'sudo /usr/local/sbin/enroll-gateway-tailscale tor'
ssh -J your-host -p 22052 gateway@127.0.0.1 'sudo /usr/local/sbin/enroll-gateway-tailscale i2p'

Let the phones that should use them reach TCP 9050 and 4447 in your tailnet policy. There is no SOCKS password; the tailnet policy is the lock on the door. I tag mine tag:winnow-tor and tag:winnow-i2p to keep them straight, but Winnow looks up names and ignores tags. Give Tor a minute to bootstrap and i2pd ten to integrate before you judge whether Bitcoin peers work, and use the recipe's check-peer.py to complete a real handshake through each.

On the phone, connect Tailscale and open Winnow. Automatic is the default; Advanced → Settings → Peer networks shows what it found.

Advanced settings, Peer networks: Gateway routing Automatic, Active Tor gateway and Active I2P gateway with their tailnet addresses

What I want Setting
Tor and I2P when my gateways answer, clearnet otherwise Automatic
Clearnet only Direct only
Tor only, never clearnet Manual, only Tor peers, gateway winnow-tor-gateway:9050
I2P only, never clearnet Manual, only I2P peers, gateway winnow-i2p-gateway:4447

In Manual, a failed overlay connection stays failed: with Tailscale off, the wallet goes quiet rather than direct.

What the census sees

Every peer Winnow dials starts as a line in a census. Once a day a GitHub runner takes the full BTCNodes snapshot and dials every endpoint in it with Winnow's own handshake code, through a Tor client and an i2pd router of its own. It does not use my gateways; they only ever carry my phone's traffic. It keeps the peers that answer near the tip and serve compact filters, signs the list, and publishes it at census.winnowwallet.com. The wallet trusts the signature, not the website.

The census site for September 30, 2026: 25,394 endpoints attempted, 7,150 advertised compact filters, 6,587 filter peers within 100 blocks of the tip

September 30, split by transport:

Dialled Serve compact filters In the wallet's list
Tor 12,457 3,257 (26%) 2,988
I2P 4,389 2,681 (61%) 2,490
Clearnet 8,548 1,212 (14%) 939

The wallet's list keeps only peers within a hundred blocks of the tip, and on clearnet only one per address block.

The census By transport table: clearnet 8,548 attempts and 1,212 with filters, I2P 4,389 and 2,681, Tor 12,457 and 3,257

Until this week the list stopped at 2,000 per overlay. The wallet refused anything longer, so the census kept a sample of 2,000 and threw the rest away: on September 29, 1,212 of the 3,212 Tor peers at the tip and 451 of the 2,451 I2P peers. It was a border drawn for the map-maker's convenience. Winnow 0.7.13 accepts every verified peer (winnowwallet/winnow#193), and the census now publishes them all (winnowwallet/census#23).

The shape barely moves from day to day. About a quarter of the endpoints it dials serve filters, and the peers still stuck at the old fork's split height keep thinning out.

The census daily chart from September 4 to 30: filter handshakes steady near 27% of attempts, split-height reports below 10% and falling

The I2P trap, disarmed

A proxy is useless without peers on the far side. Winnow gets them from its signed mainnet census, which expires after seven days and lives on an ordinary website. For a wallet told to use I2P alone, an ordinary website is foreign territory. A fortress that must send to the capital for bread is not much of a fortress.

So the census has an I2P embassy. The I2P gateway keeps a copy of the signed files and serves them as an I2P site:

http://yts2d2oyrsz2eytnofuutgnsixymdkj2nmcmmpfv3aofzsnjt4eq.b32.i2p/census/peers.json

It is four small pieces, all in the recipe's i2p-mirror/: an i2pd server tunnel from I2P port 80 to a loopback web server, Python's http.server in a sandboxed systemd unit, and a sync script on a thirty-minute timer that fetches the list and its signature with size and time limits. The mirror needs no trust of its own; Winnow checks the publisher's signature exactly as it does for the public copy, so a mirror that lies simply gets ignored. Anyone can run one.

One warning I learned the careful way: the mirror's .b32.i2p address comes from the tunnel's key file, and that address is compiled into the wallet. Rebuild the VM without backing up winnow-census-mirror.dat and every I2P-only wallet loses its bread until the next release.

The evidence

On September 29, from a Mac on the same tailnet, Winnow's opt-in live test found both gateways, fetched the census from the I2P mirror and verified its signature, and completed real Bitcoin handshakes through each. A separate Python client dialed six random Tor and six random I2P census peers through the gateways: all twelve answered at the current tip and advertised compact filters. The census downloaded through the Tor gateway from its Cloudflare-hosted site in about three seconds. The full UI journey (Automatic, then I2P alone, then Tor alone) passed on a physical iPhone on 5G before #181 merged, and again on a simulator.

On September 30 the mirror served the first uncapped census, 1.28 MB, through the I2P gateway in twenty seconds, byte for byte the public copy, signature intact. Earlier that night I thought the gateway had died: nothing answered for the better part of half an hour. It was fine. My laptop's Tailscale had wandered onto a relay and was dropping the traffic, and when it found a direct path again, so did I2P. If your I2P goes quiet, look at the tailnet before the router.

The simulator also caught something the handshake tests could not. With clearnet gone, the wallet seated a Tor peer and an I2P peer and synced headers, then sat on its first batch of filters forever. A chunk of a hundred mainnet filters is a few megabytes; the deadline was thirty seconds; I2P had carried the census at about half a megabit. Tor and I2P peers now get deadlines sized for them, and the same wallet scanned two thousand blocks in two minutes. Slower than clearnet, and honest about it.

Tor does not make a peer forget what you asked it. Compact filters keep your addresses off the wire, but the blocks you fetch afterward, and when you fetch them, still say something about you; BIP157 spells this out. An explorer still learns the transaction you look up. Tailscale's coordination server knows your devices exist, though not what they say to each other. And one gateway is one point of failure for everyone behind it.

The longer game

This all began with a stranger ambition than Tor: Bitcoin over bad links. Signed transactions out over radio, just enough chain data back, connections that vanish for hours and return without apology. None of that exists yet.

Tor and I2P belong inside the app eventually. I doubt they get there as imported daemons. The likely route is to write both clients in native Swift, small enough to read, and have them audited before any wallet trusts them, with no silent fallback to a direct connection and physical-iPhone testing through foreground, background, failure and off. That is a long campaign. Until it is won, the gateways hold the line.

What exists is a narrow door between the wallet and whatever carries its bytes. Durable systems are built from narrow doors, because a narrow door can be guarded. This one is the default way through today, and it will still be standing when native Tor and I2P come home to the app.

← All writing