Import: The Bundle Is the History
Technical guide for Winnow. The no-back-scan rule is stated in the read side §2.7.5 and the phone paper §4.1. This is the format, the verification algorithm, and what a lying bundle can still do.
1. The problem
A private light client that starts at the tip is cheap. A private light client that starts in 2018 is not: years of compact filters are multiple gigabytes (approx.), and the phone would have to download them to learn a balance it already knew in the previous wallet.
So this product does not back-scan. A new wallet is created here and scans forward. An existing wallet may be imported only with its history included. The previous software already did the scanning; it must give this phone the answers, plus a height those answers claim to be current as of. This phone then checks those answers against the chain, forward from that height, and continues.
The bundle is not a convenience file. The bundle is the history. Without it there is no import.
2. Format (version 2)
JSON, one object:
{
"version": 2,
"network": "signet",
"descriptor": "tr([fp/86'/1'/0']tpub…/<0;1>/*)#checksum",
"mnemonic": "word …",
"lastKnownHeight": 150000,
"nextReceiveIndex": 4,
"nextChangeIndex": 2,
"utxos": [
{
"txid": "<display hex>",
"vout": 0,
"amount": 50000,
"scriptPubKey": "5120…",
"chain": 0,
"index": 3,
"height": 149000,
"isCoinbase": true
}
],
"transactions": [
{
"txid": "<display hex>",
"height": 149000,
"received": 50000,
"spent": 0,
"fee": 250,
"replacedBy": "<replacement transaction id, when fee-bumped>"
}
]
}Rules the importer enforces before any network call:
versionis1or2. Version 1 remains readable for existing descriptor-only exports; writers emit version 2. Unknown versions fail closed.networkissignetormainnet(the two networks the app opens).- At least one of
descriptorormnemonicis present. - If both are present they must agree: the BIP86 descriptor derived from the mnemonic equals the bundle descriptor, or import throws
descriptorMismatch. - Mnemonic-only: the app derives the canonical
tr([fp/86'/coin'/0']xpub/<0;1>/*)and stores the mnemonic in the keychain (spendable). - Descriptor-only: watch-only. The descriptor must be that same single-sig shape. Signing is impossible until a secret is supplied by some later path (there is none today — watch-only stays watch-only).
- For each UTXO,
scriptPubKeymust be exactly what the descriptor derives at(chain, index). A bundle cannot attach an arbitrary output to this wallet by lying about coordinates. - Legacy
silentPaymentTweaksigning data is unsupported. A bundle carrying it is refused so the metadata cannot be silently lost. Keep the original wallet file and use compatible wallet software to recover those coins. isCoinbaseis emitted only when true. It preserves the consensus 100-confirmation maturity rule across export and restore; older bundles omit it and are treated as false.txidvalues are display (big-endian) hex on the wire and in the file; internally they are reversed to Bitcoin’s byte order, same as the rest of the stack.chainis0receive /1change (BIP44 external/internal, matching the multipath descriptor).- Scanning resumes at
lastKnownHeight + 1.creationHeightof the new wallet state islastKnownHeight. nextReceiveIndex/nextChangeIndexare the next unused BIP86 derivation indices. Writers emit them so a restore does not reissue an address whose coins have already been spent (UTXO-derived maxima go to 0 when every receive coin is gone). Older files omit the keys; the importer then usesmax(claimed index) + 1, and never goes below that floor if a file names a smaller cursor.feeon a history entry is optional. The writer includes it only when every input of that transaction was ours (the only case a filter client can compute the fee exactly). Older v1 files omit the key; readers treat absence as unknown. This is display state — omitting it does not create or destroy money. The on-deviceobservedFeeRatessamples FeePolicy uses are not in the bundle; a restored wallet falls back to the static presets until it observes new sends.
Writers emit version 2. Readers accept versions 1 and 2 for ordinary descriptor-derived coins; version compatibility does not enable unsupported signing data.
Vaults are not in this bundle. A vault is a separate descriptor the user adds in-app; its history follows the same forward-only rule (vaults §5).
3. Verification
Wallet.importing seeds state exactly as the bundle claims. That is intentional: the phone must be able to watch the claimed scripts in order to check them. watchScripts() unions every known UTXO script onto the descriptor gap window — BIP158 basic filters include the prevout scriptPubKey, and without that union a later spend would never fetch the block. Wallet.verifyImport then runs FilterSync from lastKnownHeight + 1 and builds an ImportReport:
| Field | Meaning |
|---|---|
scannedFromHeight / scannedToHeight |
The forward window actually scanned. |
confirmedUTXOs |
Claimed by the bundle and still unspent after the scan. |
spentSinceBundle |
Claimed UTXOs the scan saw spent. Stale or wrong bundle. |
discoveredUTXOs |
Found by the scan, absent from the bundle (payments after export). Informational. |
matchesBundle is spentSinceBundle.isEmpty. A stale export (you spent after you wrote the file) is a report the user can see, not a silent rewrite. Discovered UTXOs are applied — the point of the forward scan is to catch up — but a claimed-and-spent coin is never left in the spendable set as if the bundle were current.
The report core (ImportReport.make) is a pure function of the bundle plus the MatchEffects of the scanned blocks, so it is unit-tested without a network. The e2e suite drives the real app through an import and captures the report screen (14-import-report.png); latest run spent 18.79s in verify (signet, iPhone 17 Pro Max simulator, 2026-08-17, screenshots/timings.json).
Cost is proportional to how stale the bundle is, not to how old the wallet is. A bundle exported at the tip is a JSON parse and a zero-length scan.
4. What a lying bundle can and cannot do
The bundle is trusted as a starting claim, then checked against filters. That is a weaker assumption than “the file is true,” and a stronger one than “the file is untrusted input from the internet.”
A malicious bundle cannot:
- Make this wallet spendable for an output it does not derive. ScriptPubKey /
(chain, index)disagreement fails import. - Change the descriptor behind a mnemonic. Mismatch fails import.
- Invent a spendable coin that never existed and have it survive verify. A claimed outpoint that is not in the chain will not be confirmed by a filter match; a claimed outpoint that was spent will land in
spentSinceBundle. - Put the seed in iCloud. The mnemonic, if present, goes to
KeychainStorewith the same this-device-only policy as a freshly created wallet.
A malicious bundle can:
- Omit a UTXO you actually own. Verify will not invent it unless a forward-scan block happens to pay you again (or still contains it — it won’t, it’s old). Omission of old coins is the residual lie, and it is the same lie as any incomplete export. The mitigation is: export from software you trust, inspect the report, do not import a bundle a stranger handed you as a way to “recover” funds.
- Omit history entries. The history list is for display; the spendable set is the UTXOs. Missing history does not create money.
- Point
lastKnownHeighttoo high, so the forward scan skips the window in which a claimed coin was spent. ThenspentSinceBundlestays empty and a spent coin looks live until some later spend attempt fails, or a later rescan from a lower height. A too-high height is the dangerous lie. The importer does not currently prove the height against a checkpoint the previous wallet signed. Mitigation: treat a bundle from an unknown height as suspect; prefer exports that name a height you can see on a header explorer you already use; the report shows the window that was actually scanned. - Point
lastKnownHeighttoo low. That only makes the forward scan longer. Harmless except for bandwidth.
This is the import analogue of read-side §2.7.1 (filters can lie by omission). Here the file can lie by omission or by a too-high height. The chain check catches spends inside the scanned window. It does not reconstruct a past the bundle refused to include.
5. Export (the writer)
Winnow writes version 2 JSON and reads versions 1 and 2. Settings → Export wallet bundle writes descriptor + known UTXOs + history (including fee when known) + the next unused receive and change indices + lastKnownHeight = the live FilterSync frontier (nextScanHeight − 1). The app persists that frontier back into WalletState after each sync pass — apply(match:) alone does not move it — so an export after ordinary app use resumes where the phone actually stopped, not at the creation/import height. The mnemonic is excluded by default; including it is a hot backup and takes an explicit toggle plus a confirm. The on-screen preview redacts the mnemonic line; the shared file is the real JSON. The share-sheet file is a unique, backup-excluded temp that is deleted when the sheet closes, the seed toggle flips, or the write fails. A missing keystore entry or an xprv-only wallet throws mnemonicUnavailable rather than a raw keystore error.
A Winnow-native wallet that is never exported cannot be recovered here from the 12 words alone — that is the no-back-scan rule, not a missing feature of import. Export is the other half.
Pending (height 0) change outputs stay in the UTXO set: they are live spendable state. Export is refused while a send is still pending — commit has already removed the parent inputs, so a mid-send bundle would ship only the unconfirmed change. If that send never confirms, a forward-only restore cannot put the original coins back. Wait for the send to confirm, then export.
6. What this is not
- Not a BIP39-only import. A 12-word phrase without history would force a back-scan. The onboarding UI does not offer “type your words and we’ll find your coins.”
- Not an xpub watcher that asks an indexer. Descriptor-only import is watch-only and still uses filters, from the bundle height forward. External explorer links never get to skip the bundle.
- Not SLIP-39 / Passport / SeedQR / output descriptors of every flavor. v1 is the everyday BIP86 wallet this app itself creates. That is the export we can specify and the import we can verify. Broader descriptor import is a format-version bump, not a silent widening of
tr(KEY).
7. Conclusion
Moving a wallet onto this phone is a data-portability problem, not a scanning problem. The previous wallet ships its answers; this wallet checks the answers it can check (spends after lastKnownHeight, script/descriptor agreement) and refuses to pretend the rest of history can be privately reconstructed on a radio.
The bundle is therefore: versioned JSON (descriptor and/or mnemonic + recoverable UTXOs + history + height), seeded as claimed, verified by forward filter-scan, with omissions and too-high heights documented as residual risk rather than solved.