A Private Bitcoin Wallet on a Phone
Framing paper for Winnow. The other papers — read, write, vaults, import — each own one job. This one owns the device.
1. The constraint
A Bitcoin wallet is keys plus four questions about the chain (what’s mine, where’s the tip, what fee, did my transaction get out). On a desktop, any of the usual answers work. On a phone, most of them don’t.
A phone is not a full node: it will not download, store, or validate the chain. A phone is also a poor thin client of someone else’s node: it has a stable identity (an IP, an Apple ID, a push token), it is almost always on a network that can be logged, and the user will not read a privacy policy before tapping Receive.
The product lives in the space between those two refusals. The phone does the matching. Nobody is asked which addresses are yours. The chain is not stored. Work happens while the app is open. Everything else — Taproot-only, fresh wallets, import-with-history, bounded mempool windows, and warned external explorer links — is a consequence of that sentence, not a feature list.
This paper states the device constraints, the architecture they force, and which paper answers which question. It does not re-argue compact filters; that is the read side.
2. What a phone actually is
Constraints that are load-bearing, not stylistic:
- Outbound TCP only. The app talks to full-node peers over
Network.framework. No inbound ports, no hidden services, no background socket the OS will kill. Peers are a small outbound pool (default 3): manual endpoints first, then a persisted good-peers list, then a few hardcoded fallback peers (IP literals verified filter-serving; seeNetworkParams) racing the DNS-seed results — seeds resolve over DNS-over-HTTPS (Cloudflare dns-json at 1.1.1.1, configurable) and fall back togetaddrinfoif DoH is empty or down; RFC1918/loopback answers are dropped on public networks. Dials race a batch of candidates at a time with a short per-attempt timeout, so a fresh launch fills the pool in seconds and reports exhaustion instead of spinning forever. No addr gossip, no scoring buckets — a misbehaving peer is dropped and replaced. - Foreground only. There is no
UIBackgroundModesentry. Sync-while-active is the design, not a v1 omission. A payment you are not looking at waits for the next open, or for a confirmation the next time filters are scanned. Push notifications would require a server that knows your addresses or your txids. - This-device keychain. Secrets live in the iOS Keychain as
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, with iCloud Keychain sync off. They are not in the app’s JSON state and not in iCloud backup. After the first screen the words reappear only behind an explicit control gated on device-owner authentication (Settings → Backup → Show recovery phrase, fail-closed when no passcode is set). Both recovery screens offer an explicit copy action; its pasteboard item is local-only, expires after two minutes, and warns that paper is more private. Signing loads the secret for the call and drops it. - One runtime dependency.
swift-secp256k1— Bitcoin Core’s libsecp256k1. Swift never does raw curve math on secrets. Everything else (filters, headers, PSBT, descriptors, P2P) is in-tree. - App Store reality. No embedded full node, no always-on VPN, no custom kernel. iOS 17+, iPhone only, portrait. The app target is a thin SwiftUI shell; if the UI needs a capability, a library exposes it. That split is an architecture invariant, not a directory preference: tests run against the SPM package, not against views.
Bandwidth and battery are real but secondary. Steady-state compact filters are a few megabytes a day (approx.; see read-side §2.5). The worse mobile cost is policy: an always-on mempool subscription, a historical back-scan, or a server that answers getbalance. Those are refused even when the radio would allow them.
3. The four jobs, and two more
| Job | Default on this phone | Paper |
|---|---|---|
| What’s mine? | BIP157/158 filters, matched on-device, from creation height forward | read-side |
| Where’s the tip? | 80-byte headers, PoW + chainwork | read-side |
| What fee? | User override, else median of our confirmed spends, else presets; clamped by BIP133 feefilter |
write-side |
| Did my tx get out? | inv → getdata → tx to the peer pool, rebroadcast until a filter match, echo-watch while Send is open |
write-side |
| Who holds the keys? | This-device Keychain; Taproot key-path for the everyday wallet | this paper, write-side |
| Shared custody? | MuSig2 n-of-n or multi_a k-of-n, coordinated by PSBTv2, no server |
vaults |
| Moving a wallet in? | A history bundle. There is no back-scan. | import |
One user-controlled escape hatch crosses several rows: a selected external explorer website. Opening an exact address or transaction requires a warning; its response never becomes wallet state.
4. What the device forces onto the wallet
4.1 Fresh, and forward-only
A new wallet and its recovery phrase are created immediately, offline. Header synchronization continues while the user secures the phrase; the wallet starts scanning from the last already-validated height so the interval cannot be missed. Recovering an old wallet privately from the chain would mean gigabytes of historical filters on a radio the user pays for. The product does not do that. Import requires the previous wallet’s answers to come along — descriptor, UTXOs, history, a last-known height — and the phone verifies them by scanning forward from that height. The argument and the bundle format are import.
This is the constraint that makes the rest cheap. It is not a v1 shortcut.
4.2 Taproot only
Receiving is BIP86 P2TR. There is no legacy / nested-segwit / P2WPKH path, and no ECDSA signing path. One output type means one watch-list shape, one sighash, one witness size for fee math (66 bytes for a key-path spend), and no “which address type did this contact use?” branch in the UI. The planned 0.7 P2WSH Safe is a deliberately scoped exception: an opt-in wsh(pk(KEY)) store with its own hardened keys, its own watch list and an ECDSA/BIP143 signing path, added beside the Taproot account rather than in place of it. Nothing about the account described here changes; see the Safe’s design and release criteria.
The phone can pay standard P2PKH, P2SH, P2WPKH, P2WSH and P2TR addresses. Recovery supports descriptor-derived coins; wallet files requiring unsupported signing data are refused. Keep the original wallet and backup for any unsupported coin types. See import and recovery.
4.3 Two modern multisig flavors, same read path
A vault is another descriptor and another watch list. The phone does not run a coordinator, a coinjoin server, or a notification service for cosigners. The PSBT is the coordinator; AirDrop, a file, a message are the transport. Vaults.
4.4 Default network is signet
Development and the shipped TestFlight path target signet. Mainnet is in the Settings picker and has its own on-device wallet; it is not the network the e2e suite, the screenshots, or the timings are taken on. The papers describe the protocol as it runs on any network; the numbers in screenshots/timings.json are signet.
5. Trust on a phone
What the user is asked to trust, stated as mechanisms rather than intentions:
- The keychain and the device. A stolen backup does not contain the seed. A stolen phone that can be unlocked does. This-device-only is not a substitute for a passphrase or a vault; it is the floor.
- libsecp256k1. Curve math on secrets is not our Swift. Side-channel claims against the wallet’s signing path are claims against that library, or against how we call it (we pass 32-byte auxiliary randomness from
SystemRandomNumberGeneratorper BIP340). - The peers you can reach. Compact filters are not consensus-committed. Multi-peer
cfcheckpt/cfheaderscomparison detects disagreement; it cannot prove which peer lied, and a fully eclipsed phone can be shown a consistent lie. Manual peers exist so a user who has a node they trust can skip DNS seeds. The residual risk is read-side §2.7.1 and §2.9. - No one, on the read path, by default. No server is handed an address, an xpub, or a scripthash. A network observer sees a compact-filter client syncing from a height — the same bytes every such client downloads.
What the user is not asked to trust: an indexer operator’s retention policy, a push-notification vendor, iCloud Keychain, or the app target. Wallet, protocol, and crypto logic are forbidden from moving into WinnowApp.
6. Deliberate absences
These are product decisions, not missing tickets:
- Always-on mempool / background 0-conf / live fee histograms
- Historical filter back-scan
- BIP37, Electrum, or any “ask a node about my address”
- An embedded consensus engine or fraud-proof verifier
- A vault coordinator server
- Cloud backup of the seed
Each absence has a paper that says what you get instead. The landing page lists the user-visible costs (slower news, coarser fees, bring your history, sending still shows at the relay peers). This list is the engineering version of the same honesty.
7. How the implementation is checked
The validation appendix of the read side covers the stack. The mobile-specific claim — that the app does what the libraries promise — is the signet UI suite: it drives the real binary, mines to it, spends from it, cosigns a vault, verifies an import, and writes the screenshots on this site plus screenshots/timings.json. Latest run (iPhone 17 Pro Max simulator, 2026-08-17): wallet create 2.74s, address shown 2.16s, mined→filter-detected 0.69s, form→broadcast 0.66s.
The suite is ~200 tests plus those UI scenarios, on every push.
8. Status
Protocol core, both vault schemes, and the P2P read/write path are complete and vector-tested. The app runs on signet. Mainnet is available and treated as needing more care than a picker flip.
v1 is therefore: a foreground iOS client, Taproot-only, fresh-wallet, one dependency, talking only to NODE_COMPACT_FILTERS peers unless the user opts into a named server — with the four jobs split across the papers that follow.