Trades and tokens, from Hive to SQLite
Two tables in a wider migration: a person's trade history and token lists, built up over years on their own phone, moved into SQLite on first launch, with every design choice bent around the fact that there is no second copy.
Context
Cake Wallet has been moving its local storage from Hive boxes to SQLite for a while. The versioned upgrade ladder, the first wallet-info migration and the pattern of keeping a legacy model alive only to read the old box were already there when I started. That effort is the repo’s. What is mine is two tables in it: trades, and tokens for the EVM, Solana and Tron wallets, with their schemas, migrations, read paths and the call sites that had to change.
Problem
A trade history and a token list, both built up over years, both sitting in encrypted boxes on the device, and the migration runs unattended when the app starts. There is no server copy. Whatever the migration does wrong, the person keeps.
The trades box had a second problem: it stored each trade’s currency as a number to parse back, and tokens do not have a number, so a trade involving a token could not resolve its currency.
The token boxes had a third: up to seventeen per person, named by string convention, wallet name with the spaces underscored plus a chain suffix, plus one global box from before the app supported more than one EVM chain. Two differently named wallets could collide after sanitising, and there was no way to ask “which tokens does this person have” without knowing every box name in advance.
Constraints
No data loss, and no way to verify from the outside that there was none. The code has to be built so that loss is the thing it cannot do.
The migration has to work on the restore path too. A backup taken before the change, restored onto a build after it, would otherwise restore boxes that nothing reads.
The legacy models are dead code that has to stay compilable and correct for as long as anyone might launch an old install or restore an old backup. That is a permanent cost, taken on purpose.
Decisions and trade-offs
Columns, not a JSON blob (May 2026). My first version stored each trade’s currency as a JSON document in one column. Two reviewers objected: the blob carried icon asset paths that go stale across releases, and a blob cannot be queried or migrated without reading and parsing every row. Ten columns per side is uglier to write and it is the version that survives the next change. That is what shipped. It is the clearest case in either change of review improving the design rather than the code.
Resolve against the live registry first, fall back to the stored columns. The stored row is a snapshot of what the currency looked like when the trade happened. Reading it back, the code asks the app’s current currency list first and reconstructs from the row only if that finds nothing. A token the app has since added properly gets today’s name and icon. One it still does not know renders from the snapshot rather than as a blank. Neither pure approach gives both.
Delete each record only after its row lands (May 2026, hardened in August). The drain saves, then deletes, so a record that fails to save stays in the box and is retried next launch. In the token migration a reviewer pushed further: a run interrupted halfway would re-insert on the next attempt. So each merged group is deleted from the box as soon as its rows land, and inserts replace on conflict against a unique index, which makes the migration idempotent whether it is re-run or resumed. There is a test named for exactly that case.
Per-record isolation, and a migration that can never stop the app. Every record is wrapped, every box is wrapped, every migration is wrapped, and each layer logs what failed and continues. The honest cost: a record that fails repeatedly is absent from the list until someone reads the log. The alternative, aborting on the first bad record, leaves a person with a half-migrated database and no way forward.
Merge duplicate token rows rather than take the last one (August 2026). Hive keyed tokens by the contract address as written, so casing produced duplicates. Merging means deciding which side wins per field, and I chose the safe direction each time: enabled if either was enabled, so a token someone added does not vanish; flagged if either was flagged as a possible scam, so a warning cannot be lost.
Key rows by wallet name, and make rename move them. Asked in review whether a wallet id would be better, I gave the two candidates and why neither is durable today, and pointed out that the trade and bridge tables already key on the name. The cost is that rename became a data operation, so renaming a wallet now moves its rows, and there is a test for that too.
Run the same migration from the backup restore path. Both changes add their migration next to the existing wallet-info one in the restore flow. Without that, a person restoring an older backup would silently lose their trade history and token list.
What was hard
Two things were being fixed at once in the trades change: the storage engine, and a model that could not represent a token. Either alone would have been smaller. Doing both is what made the currency columns and the two-stage read path necessary, and it is why it took 49 days and 19 inline comments.
Reconstructing the full set of token box names from the wallet list, including the pre-chain global box that predates the naming convention, is most of the work in the second change.
Both changes touch code I did not write: eleven exchange providers, the dashboard store, the trade details view model, the iOS legacy filesystem migration, the three chain wallets and their services, and the app’s startup sequence, twice.
Result
Nothing, and that is the point. Trade history and token lists carried over on first launch, from every wallet, including token lists from before the app supported more than one EVM chain, and including restores from older backups. Trades involving tokens now show the token’s name and icon instead of an unnamed entry. Disabling a token survives the move. A token flagged as a possible scam keeps its flag. Renaming a wallet keeps its token list.
What changed underneath, without numbers because nothing was benchmarked: fetching a trade by id went from a scan of every trade to a keyed query against a unique index, token lookups went from opening a box by a constructed name to a query against a unique index on wallet, chain and contract, and the app no longer opens the trades box or up to seventeen token boxes at startup.
Links
EVM, Solana and Tron token boxes to SQL
20 August 2026
23 files. Up to seventeen boxes per person become three tables.