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

One paper in the btc-swift set. The phone itself is A phone wallet; paying is the write side; shared custody is vaults; moving a wallet in is import. This paper owns one question: how the phone learns which coins are its own. All numbers marked “approx.” are order-of-magnitude estimates for orientation, not measurements; they are labeled as such wherever they appear. Exact protocol constants are exact. Signet UI timings are measured and live in screenshots/timings.json.


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: btc-swift 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

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:

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 limits are owned in the UI: 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”. (The opt-in esplora path shows mempool-stage transactions without these bounds — one of the things the warning dialog can honestly list as the trade.)
  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. (Again: the opt-in path gets real fee estimates.)
  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; headers still need PoW + chainwork A partitioned phone can be lied to — the standard light-client eclipse caveat
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.


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 means the phone learns its own history without anyone else learning it.
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: everything, faster User opts into an esplora backend in settings, past a warning naming the leak (address set, balances, IP) and linking to §2.2 It’s the user’s threat model; the app’s job is to make the trade explicit, not to make it for them.

Two use cases are 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. No server learns anything because no server is asked anything. 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. Every server-based alternative either resurrects BIP37’s mistake (letting someone else match against your addresses) or requires operating trusted infrastructure (self-host, OHTTP pair, TEE+ORAM enclave); those are the user’s to choose, knowingly, one toggle away — never the default.

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 — with an esplora fast path available strictly as an opt-in behind an explicit privacy warning. The phone those bytes land on, the spend, the vault, and the import are the other papers.


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:

The suite is ~200 tests and runs in CI on every push and pull request.


6. References