How KPOP backups work

What a backup carries, what it leaves out, and why. Written up from the design pass of 18 September 2026.

A KPOP backup holds no secrets. Never a seed, a private key or a fragment — only public keys, your names and notes, custody records and receipts. So it cannot restore your ability to sign. What it restores is the map: which keys exist, what they are for, which fragments built them, and who holds a copy. The signing itself lives in your envelopes and in KSIGN.

That cuts both ways. A printed backup is far safer than it feels, because there is no key material on it. It is also a document naming your custodians and the places your envelopes are kept — which is a targeting list. It wants a drawer, not a wall.

Three depths

They nest: recovery ⊂ working ⊂ everything. A field belongs to the smallest one that carries it. Depth is chosen on the Back up panel of the Share tab.

Everythingfor replacing a device

Every key, account, token, fragment, receipt and setting on this device — bar the Activity log, which is a memory jog rather than a record worth restoring. This is what Back up always produced before profiles existed, and it is the only depth that may be imported with replacing everything.

Working setfor a device that will look things up again

The same, minus everything the mirror node answers on demand: key trees, balances, token names and supplies, which accounts a key sits on, and when each was last looked at. All of it is fetched again on import. In a ledger weighted towards accounts this is the single biggest saving available — around 43% of a full backup in the modelling below.

Recovery cardfor a printed or QR card

The map back to your secrets and nothing else: keys with their labels, fingerprints, the fragments they were assembled from and who holds copies; the fragment records in full; and any custom account salts. No receipts, no storage findings, no tokens, no account naming — and no authority, which is a check policy and says nothing about getting a key back, no curve, which the key's own length gives away, and no mine, written only when it is false since a card is almost all your own keys.

Nothing empty is written down

At every depth, a field that is empty — null, "", [], {} — is left out entirely, recursively, so an object left hollow by the pruning goes too. false and 0 stay: they are answers, not absences (a fragment copy that is not sealed, a count of none).

This is lossless because import puts every default back, and that is the part worth stating, because it is where it can go wrong: a parent object can survive pruning with some of its keys missing. progress keeping its points and losing levelAt is enough to make level() read undefined[4]. So the normalisers fill sub-fields individually rather than replacing a missing parent — normaliseState, normalise, normaliseAccount, normaliseToken, normaliseFragment and revState between them — and the smoke test round-trips a pruned backup and checks the ledger comes back identical.

Scoped by network

A backup covers whichever network the toggle is on, unless you ask for both. A record declaring the other network is left out; a record declaring none is kept, because undeclared means "could be either" and dropping it would lose it from both.

One consequence worth knowing: replacing a device needs a backup covering both networks. A one-network backup used that way would take the other network's keys down with it — the same silent-deletion trap as a partial depth, and guarded the same way.

What is recoverable, and what is not

The useful question is not "is this important?" but "if I lost this, could anything give it back?". Three classes:

classfieldsif lost
A — unrecoverable, load-bearingkey ids, fragment fingerprints, which fragments built which key, custom account saltsthe map is broken and nothing rebuilds it
B — unrecoverable, not load-bearingyour labels, notes, owners, custodians, copy places, storage findings, receiptspainful, not fatal — the keys still work
C — recoverablekey trees, balances, token details, which accounts a key is on, lookup timestampsnothing: ask the mirror node again

Class C should never be exported at all, at any depth — which is what Working set does. Class A is the recovery card. Class B is the part worth keeping somewhere online, and note the inversion: class A is public keys and hashes, so it is the safer half to sync; class B is the targeting data and the half that actually wants encryption. That is backwards from the instinct that the keys are the sensitive bit.

The account salt is the exception worth knowing

KSUB salts an account's child keys with the account number, which is derivable from the id and so never needs recording. A custom salt is the exception — and it is the only field in the whole ledger that is both unrecoverable and load-bearing. Without it, a salted child sitting on that account can never be matched back to the master it came from; the account simply looks like someone else's.

So every depth carries custom salts, the recovery card included, as a stub of { id, net, salt } per affected account. It costs about a hundred bytes. Most accounts have no custom salt and cost nothing.

What it costs

Modelled on a synthetic ledger — 95 accounts, 11 keys, 9 fragments, three receipts each — built to the shape of a real one where accounts outweigh keys roughly two to one. Your own numbers are on the Back up panel, which sizes each depth live, and in Inspect…, which breaks any export down field by field.

depthJSONbase64 linegzipped for QR
Everything82,396110,11619,631
Working set~39,000~52,000~11,000
Recovery card7,1629,5721,713

Four formats

formatwhat it isimports back?
JSONthe default — reads as it stands, and what a compressor wantsyes
base64 linethe tagged line KPOP made until September 2026; more compact as textyes
printable sheeta self-contained A4 page of cards, for a drawerno — keep the JSON
printable + QRthe same sheet with a code beside every cardcard by card

The sheet is the same body at whatever depth was chosen, laid out rather than serialised: small type, cards packed in columns, and a hard rule that a card never breaks across a page. It carries no script, no fonts to fetch and no data URLs, so it opens and prints anywhere, years from now, with no app. A recovery-card sheet of eight keys, nine fragments and a couple of salted accounts comes to two A4 pages.

It is deliberately not machine-readable. If a sheet could be typed back in, it would have to carry every field exactly, and it would stop being a thing you can read at a glance.

A code per card

Each code holds one record, tagged rather than wrapped in the whole backup envelope — that envelope is about sixty bytes, which is dead weight repeated once per card:

formkey cardfragmentaccount
full backup envelope481300126
tagged record42524067
tagged, then deflated367235— larger

So the tag, which costs nothing and stays readable to any scanner, saves more than compressing does. Deflating on top (?qrz=1) buys about one more QR version on the biggest cards and nothing on the smallest — base64 costs more than deflate saves there — so the builder keeps whichever came out shorter, card by card. A deflated code is also unreadable to anything but KPOP, which is the real reason it is off by default.

Scan cards on the Share tab reads a sheet back: the camera stays open, each code merges as it lands, and duplicates are free. Merging never overwrites what this device already holds, so scanning a card over a fuller record cannot lose anything.

Sizing them so they actually scan

Codes are sized by module pitch, not to a fixed width — every code on the sheet then scans alike, and a 67-byte account card gets a small code instead of a key card's block blown up. Below about 0.4mm per module a home printer and a phone camera stop agreeing, and the largest cards fail first. The default is 0.45mm; the note under the sheet states what it achieved.

The knobs are in the query string, because they are settings to find once and forget: ?qrmod=0.45 millimetres per module, ?qrmm=48 cap for the largest code, ?qrecc=M error correction (L M Q H), ?qrz=1 deflate, ?qrmax=0 drop anything over N modules.

Two things that bit

One non-ASCII character used to kill a code. vendor/qrcode.min.js chooses its version by one measure of the text and then encodes by another, so hello — world — thirteen characters — throws code length overflow. (252>224). A single em dash in a note left that card with no code at all. Payloads are escaped to ASCII with \uXXXX, which is still exactly valid JSON and parses back to the same characters.

An Everything card can be too big for a QR at all, because it carries its receipts. That is reported in the note rather than silently dropped — and it is why the QR sheet is a recovery-card thing.

Why JSON, and why not to base64 it

Exports are JSON now. It reads as it stands in any text editor, Inspect… shows it without decoding anything, and — the deciding reason — it is what a compressor wants.

Base64 destroys byte alignment, so gzip cannot see the JSON's repeated structure underneath it; all it recovers is base64's own expansion. Measured on the recovery card above:

what you hand KBQRafter its gzip
the base64 line3,013
the JSON1,713
a line KPOP had already gzipped1,756

So for QR codes: paste the JSON into KBQR, not the line. Note the third row — compressing inside KPOP first would be marginally worse than simply handing over the JSON, as well as making the export unreadable. That is why KPOP does not compress.

The base64 line stays as a format option and every line KPOP ever made still imports. It is more compact as text, which matters where something counts characters, and it is the wrong thing to hand a compressor.

QR capacity, for printed cards

One QR code at version 40 holds 2,953 bytes at error correction L, but only 1,273 at H. A card that will be folded, photocopied and left in a drawer for years wants H. The recovery card gzips to about 1,713 bytes, so it is two codes at H — label them 1/2 and 2/2 rather than trimming the card to chase one.

Scope, and the Cluster tab

Depth is one dimension; scope — which records, rather than what each carries — is the other. Today scope is either this whole device or one cluster.

A cluster starts from accounts you name. It walks forward to the keys on them (directly or as salted children), the fragments those keys were built from, the tokens they are treasury of and those tokens' keys; then backward to anything else those keys and fragments reach; and repeats until nothing new appears. The result is the smallest closed set containing your seeds, with a verdict of closed, closed by keys · fragments shared, or not closed.

The two dimensions are independent, so a cluster exports at whichever depth is chosen on Share — including a recovery card of one cluster, which is the most useful printed sheet there is. The Cluster tab's job is the analysis; exporting from it is a shortcut from a result you are already looking at.

Importing

Import accepts the JSON envelope, any KPOPBACKUP1 line, any KPOPCLUSTER1 line, and bare state from before either existed. Nothing KPOP has ever exported stops being readable.

Import merges. Keys and accounts not already here are added; ones already here keep what this device holds, and receipts are unioned by signature so none is lost either way. A recovery card imported into a full ledger therefore cannot degrade it — which is exactly what you want from the backup you would reach for in a panic.

Import, replacing everything takes an Everything backup of a whole device and refuses anything else. A partial export would silently delete everything it does not carry, since the collections it omits default to empty.

Still to come

Open: whether receipts belong in Working set at all — they are the largest single field in a key, and they are evidence rather than recovery. Whether class B should sync somewhere encrypted while class A stays on paper. And, now that a sheet scans back card by card, whether a whole-backup code set belongs on it too, for the records a card cannot hold.

← back to KPOP