Winnow

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:

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:

A malicious bundle can:

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


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.