winnow

The Read Side: How a Private Mobile Bitcoin Wallet Learns What’s Its Own

Winnow downloads public block filters and checks them against your wallet on the phone. It fetches matching blocks to find payments without sending peers its watch list.

Numbers marked “approx.” are estimates, not measurements. Historical signet timings remain in screenshots/timings.json; the current journey does not update them.

1. The problem

A Bitcoin wallet is, at its core, a holder of private keys plus the ability to answer four questions about the chain:

  1. “What’s mine?” — which transaction outputs are spendable by my keys. From this follows everything the wallet displays (balance, history) and everything it needs to sign (the UTXOs, their amounts, and their scriptPubKeys — required inputs to the BIP341 signature hash).
  2. “Where’s the tip?” — the current block height, so “confirmed” can mean something.
  3. “What fee should I pay?” — fee-market information.
  4. “Did my transaction get out, and did it get mined?” — broadcast and confirmation tracking.

Question 1 is the entire difficulty, and the reason is structural: the Bitcoin P2P protocol has no address index. Full nodes validate every transaction but do not maintain a queryable mapping from addresses or scripts to transactions. There is no getbalance(address) message on the wire. So for a light client, someone has to scan the blockchain on the wallet’s behalf. The design question of this paper is: who scans, what do they learn, and what does it cost?

Every realistic answer falls into one of two families:

This paper walks through every realistic mechanism in both families, with honest cost/privacy/trust accounting, then walks the actual use cases of this product and concludes which mechanism serves each one — and why the answer, for this product, is client-side scanning by default, with a server only ever as an explicit, warned, user-initiated opt-in.

Scope note: Winnow is a fresh-wallet product. Wallets are created new in the app; a new wallet has no history, so scanning runs forward from the moment of creation. Existing wallets may be imported only with their history included — the user supplies an export bundle (descriptor/keys + known transactions and UTXOs + a last-known height) from their previous wallet software, and the app verifies and updates that history by scanning filters forward from the bundle’s height. There is no historical back-scan machinery at all: catch-up cost is proportional to how stale the bundle is, and a bundle exported at the tip costs nothing. This constraint — chosen deliberately — is what makes the pure-P2P answer not merely acceptable but cheap. The bundle format, the verification algorithm, and what a lying file can still do are specified in import, not here.


2. The candidate mechanisms

Other ways to find payments

2.1 Full node on the phone

Run Bitcoin Core (or equivalent) on the device: download and validate every block.

2.2 Central indexer (“esplora”-family APIs)

Public servers (e.g. mempool.space, blockstream.info) run a full node plus an address index, and expose REST endpoints: GET /address/{addr}/utxo, GET /address/{addr}/txs, GET /fee-estimates, POST /tx.

2.3 Electrum protocol

The Electrum server protocol (address scripthash subscriptions over TCP/SSL) is the same shape as §2.2 — a server-side address index queried with your scripthashes — with the same privacy properties: the server learns every scripthash you subscribe to. Noted for completeness; nothing about it improves on §2.2 for this product’s goals.

2.4 BIP37 bloom filters — and why they’re dead

BIP37 (2012) let a light client upload a bloom filter of its keys to a full-node peer, which then forwarded only matching transactions. This is the historically important wrong answer:

BIP37 matters here for one reason: it establishes that server-side matching is inherently leaky, which is why the modern design inverts it — the data moves to the client, the matching happens on the client.

2.5 BIP157/158 compact block filters (client-side filtering)

This is the inversion, and the mechanism this product uses by default. Mechanics, precisely:

Costs, stated plainly:

Server privacy designs considered

2.6 Server-side privacy designs (considered, and why none is the default)

Since the leak in §2.2/§2.3 is the server observing queries, can we build a server that can’t observe? The candidates:

The pattern across §2.6: every server-side fix moves or shrinks the trust rather than deleting it, and every one requires operating infrastructure. Client-side filtering (§2.5) is the only mechanism that requires trusting no one with the read path.

2.7 Honest weaknesses of compact filters

Filters win the privacy argument; they lose elsewhere. Said louder than the strengths:

  1. Filters are not consensus-committed. A malicious peer can serve a filter that omits your transaction (lying by omission), causing the wallet to miss a payment. Mitigation: fetch cfcheckpt/cfheaders from ≥2 independent peers and disconnect peers that disagree — disagreement is detectable, though the protocol cannot by itself prove which peer lied. A future consensus change committing filters to blocks would close this; it does not exist today. Residual risk: a partitioned client (all reachable peers colluding) can be lied to — the standard eclipse-attack caveat for all light clients.
  2. Mempool view: bounded by design, not absent. Filters cover confirmed blocks only, so the steady state is confirmation-time visibility. But the client can open a bounded mempool window (§2.8): while the Receive screen is open — i.e., while a payment is actively expected — the app subscribes to full transaction relay and matches locally, so the payment appears as unconfirmed within seconds of broadcast. The window exists only while the screen is open, anything relayed before it opened is missed, and 0-conf is never final against RBF/double-spend — the app says “unconfirmed”, never “received”. A user who wants a second, faster manual check can open an address or transaction at the selected explorer after a warning; Winnow does not consume that answer. (The one answer Winnow does consume is the warned, single-tap “Infer sender” lookup on a received payment, which returns funding addresses only — never balance, history, or identity.)
  3. Bandwidth is real, if modest. ~3 MB/day (approx.) steady state is trivial on Wi-Fi and fine on cellular, but it is not zero, and a phone that hasn’t synced in a month downloads ~100 MB (approx.) of filters to catch up. Mempool windows add ~180 KB/min (approx.) while open — bounded by a screen session.
  4. Fee estimation is blind. Without a persistent mempool view, the wallet cannot see the current fee market — and relayed transactions alone don’t help, since a feerate needs input amounts, which means recursively fetching parent transactions (the bandwidth blowup returns through the back door). Mitigations: BIP133 feefilter messages from peers give the network’s minimum relay fee floor; feerates of transactions in matched blocks give some signal; beyond that, conservative static presets with user override. The result is cruder than any mempool-aware estimator — the price of asking no one. Owned.
  5. Fresh-wallet scope is what makes this viable. Filters are cheap because scanning starts at creation and runs forward. Recovering an old wallet privately from the chain would mean back-scanning gigabytes of historical filters (approx.; multiple GB for a multi-year-old mainnet wallet) — so this product doesn’t do that. Instead, import requires the history to come with the wallet. The format and the residual lies a bundle can still tell (omitted old coins, a too-high height) are import.

2.8 Bounded mempool windows

The refinement that removes §2.7.2’s sting:

2.9 Threat model (read path)

What an adversary can do to this paper’s mechanism, not to keys or broadcast (those are mobile §5 and write-side §8).

Adversary Sees Can do Mitigation Residual
Honest-but-curious peer Your IP; that you are a compact-filter client syncing from height H; the same filter bytes everyone else downloads Log the connection; infer “this IP runs a light client” Nothing about scripts or addresses is sent The connection itself
Lying-by-omission peer Same Serve a filter that drops your transaction getcfcheckpt / getcfheaders from ≥2 peers; disagreement disconnects the minority; each cfilter must reproduce the pinned filter-header chain Disagreement is detectable; which peer lied is not provable
Fully eclipsed peer set Same, and they agree with each other Show a consistent false filter-header chain, hide payments, or stall the tip Manual peers (Settings) so a user who has a node they trust can skip DNS seeds; seeds themselves resolve over DoH (not on-path UDP) with getaddrinfo fallback, and RFC1918/loopback answers are dropped; headers still need PoW + chainwork A partitioned phone can be lied to — the standard light-client eclipse caveat. DoH removes the unauthenticated-UDP seed rewrite; it does not remove a determined eclipse.
Network observer (not a peer) Timing, sizes, destination IPs of the outbound pool Infer that this IP is syncing filters from height H No addresses on the wire Height H plus “is a BIP157 client”

Filters are not consensus-committed. A future soft fork committing the filter header into the block would collapse the first three rows’ residual column (§5.1). Until then this table is the honest one.


2.10 The two shortcuts, said plainly

Everything above concerns filters. Headers are the other half, and between them there are exactly two places this wallet does not derive everything for itself. Both are listed here rather than left to be found.

One: the chain before the checkpoint. Validating mainnet from genesis means checking the proof of work on ~963,000 headers — about 8.5 minutes of a phone at full CPU, and effectively the whole first-launch experience, since filter scanning for a new wallet is a few hundred blocks. By default Winnow starts instead from a checkpoint compiled into the app: a height, that block's 80-byte header, and the cumulative chainwork at that point. Headers after it are validated exactly as before — proof of work, linkage, most-work fork choice, all unchanged.

Two: history does not come from the chain. Filters are cheap because scanning starts at the wallet's creation and runs forward, so recovering an old wallet privately would mean back-scanning gigabytes of historical filters. Winnow does not do that; an imported wallet brings its history with it (import). That is the same trade in a different place, and §2.7 covers what a bundle can still misstate.

The checkpoint adds no new party. This is the part worth being precise about. The constant ships inside the same signed binary as the code that derives your keys, matches your filters and computes your balance. Anyone able to change the checkpoint is equally able to change any of those — so trusting it is not a further act of trust, it is the one already performed by installing the app. That is a different thing from fetching a checkpoint from a server, which would introduce a party who could serve one value to everybody and another to you. Nothing here is fetched, and every install carries the same value.

And a checkpoint is not only a saving — it is also a defence. A light client with no checkpoint chooses between competing chains on accumulated work alone, which is precisely what a long-range attack exploits: a branch taken near genesis, when difficulty was trivial, can be extended cheaply into something a naive comparison finds plausible. A checkpoint forecloses that entire class. Bitcoin Core shipped checkpoints for the same reason for years. So the honest accounting is not "we gave up verification to be fast" — it is that the shortcut removes one attack while accepting a different, narrower one.

What is still verified, and by how many. Every header after the checkpoint is checked on the device. Filter commitments are cross-checked across several peers. Network-prefix and source diversity reduce concentration, but do not prove independent operators. Tor and I2P names have no prefix, so while clearnet is selected they may not fill every slot, and with only Tor and I2P selected neither overlay may. No peer-address list is bundled. Fresh installs use DNS seeds; saved working peers and signed census downloads provide other candidates. Census validation checks the publisher signature, schema, size, original date (at most seven days old), addresses, duplicate endpoints, clearnet prefix diversity and heights within 100 blocks of the reference tip. Handshakes and reported heights are candidate-selection evidence, not proof of valid filters or chain identity. DNS-only discovery can occupy two of the three default slots under the existing source ceiling.

Refreshing candidates. Mainnet automatically downloads the signed census when its saved catalog is missing or expired. Saved-peer and DNS discovery continue independently. Advanced → Refresh peer list can also download the canonical census artifact and atomically replaces the saved catalog only after validation. Active connections remain. An expired download stops supplying new candidates; saved peers and DNS discovery remain available. Reset and shuffle peers clears learned and transient state while preserving manual settings and the downloaded catalog, and prefers different automatic peers where possible. Downloaded census candidates count as one trust source.

Connections. With clearnet selected, peers, DNS discovery, catalog downloads and consented explorer lookups are made directly from the device. Automatic routing, the default, asks Tailscale’s resolver for the user’s own winnow-tor-gateway and winnow-i2p-gateway; when either answers, clearnet is dropped and every peer is reached through a gateway, never dialled directly, and when neither answers the wallet falls back to clearnet. Manual routing without clearnet skips DNS seeds and sends catalog and explorer requests through the Tor gateway; I2P alone fetches the catalog from its I2P mirror and turns explorer lookups off. The census’s validated onion and I2P entries are kept for those routes (gateway setup). Winnow bundles no Tor or I2P client; the embedded Tor client of 0.6–0.7.0 was removed in 0.7.1 (see the roadmap). Networking stops in the background and is rebuilt in the foreground, or for one bounded check when iOS starts a background task. That check uses the same routing, except that Automatic routing which last reached peers through a gateway skips the check rather than connect directly.

What would still go wrong. If the constant were wrong, the wallet would follow a chain that is not Bitcoin's, and later validation would not notice — every header after a false starting point can be perfectly valid relative to it. What stands behind it: the value carries its provenance in the source, including the tip it was taken from and the command that recomputes it, and the build fails if a chain synced from genesis and a chain synced from the checkpoint ever disagree on tip hash or total chainwork. That test runs through the same loading path the app uses, not a special one.

How to turn it off. Settings → Verify the chain from genesis. On, the app ignores the checkpoint and spends the minutes. Switching rebuilds the stored chain rather than mixing the two, so an install cannot end up half-validated.


3. Use-case walkthrough: what serves what, and why

# Use case Default mechanism One-sentence rationale
1 Fresh wallet, first launch Nothing to scan; record creation height; sync filters forward from tip A new key has no past — the read side starts empty and cheap by construction.
2 Daily open / ongoing sync getcfilters for blocks since stored checkpoint (~3 MB/day, approx.), match locally, fetch matched blocks only Client-side matching avoids sending an address watch list. Subsequent block requests, relay traffic and timing can still reveal information.
3 Receiving a payment While the Receive screen is open: mempool window (§2.8) shows the payment as unconfirmed within seconds; finality arrives via filter match at block confirmation When you’re actively expecting money, a short full-relay subscription is cheap, private, and exactly as honest as “unconfirmed” implies.
4 Sending UTXOs/amounts/scripts already local from scanning; the rest is the write side The read side’s job ends when the coins and their scripts are on the device.
5 Watching a single address One more scriptPubKey in the local match list — same filter stream, zero extra bandwidth The P2P protocol has no per-address query (BIP37 is dead); the granularity is per-block filters regardless of watch-list size.
6 Multisig vault (k-of-n or MuSig2 n-of-n) Identical machinery — watch list derived from the vault’s tr() descriptor A vault is just a different set of scripts; the read side doesn’t care. Ceremony is vaults.
7 Balance & history display Local storage, populated by §2.5 matches After sync, display is a database read — no network at all.
8 “Where’s the tip?” 80-byte block headers over P2P (getheaders), PoW + chainwork-checked Headers are the sync clock and the anchor for the filter-header chain.
9 “Did my tx get out?” Peer inv gossip — peers echoing our txid back prove propagation; confirmation observed via filter match Relay acceptance is write-side §7; this paper observes the confirmation.
10 Importing an existing wallet History bundle, verified by forward filter-scan from its height Specified in import.
11 Optional manual check A warned tap opens an exact address or transaction in an external browser. A separate per-lookup consent discloses a transaction ID and the IP disclosure before requesting funding-address suggestions. Every suggestion requires explicit selection, even if only one exists, and retains unverified provenance. A local name-only label makes no request and cannot send until a destination is attached. Useful while P2P sync catches up, but the explorer learns the exact item and the connection’s IP address. It is a user action, not a fast read path.

One use case is deliberately absent from the default path:


4. Conclusion

For a fresh-wallet product, the read side reduces to a steady-state stream of ~3 MB/day (approx.) of compact filters, matched on-device, with full blocks fetched only on hits — plus bounded mempool windows (§2.8) for the moments a user is actively sending or expecting a payment. Winnow does not query a wallet-history server automatically. Discovery services still receive network requests; peers see protocol traffic, and direct connections expose the device’s IP. The costs — confirmation-time visibility for unexpected payments, crude fee estimation, reliance on honest filter peers cross-checked by header comparison — are real, bounded, and stated to the user instead of hidden. Warned explorer links let the user make a one-off disclosure without turning it into wallet infrastructure.

v1 on the read side is therefore: block headers + BIP157/158 compact filters + bounded mempool windows, over Network.framework, talking only to full-node peers that signal NODE_COMPACT_FILTERS. Esplora is an external, warned link—not a wallet backend. The phone those bytes land on, the spend, the vault, and the import are the other papers.


Future hardening and validation details

5. Future hardening

Three known paths would strengthen this design further. None is buildable today on this product’s constraints; all are worth stating so the current trade-offs are legible against them.

5.1 Consensus-committed block filters

5.2 PoW fraud proofs

5.3 Utreexo proof-based import verification


Appendix A. How the implementation is validated

Nothing in this paper asks to be taken on faith — the code is checked against independent ground truth at every layer:

Inspect the current test runs and artifacts. The payment recording identifies the source and run behind that journey.


6. References