From af4810e2ec8e454e4bb616c685e907eaee04c874 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 10 Sep 2026 07:18:09 -0700 Subject: Ark state moves to IndexedDB: raw bytes on disk, memory-fronted, write-behind. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localStorage held a deep wallet's ark state as ~700KB of JSON (UTF-16 on disk), parsed on every boot and stringified on every save, and the wallet cache blob carried a second ~600KB copy. src/ark/store.js keeps the state in IndexedDB (db coinos-ark) with each coin's signed bytes as a Uint8Array, fronted by an in-memory map so loadArkState/saveArkState stay synchronous: reads return clones, writes coalesce (200ms) and flush immediately when the page hides. Existing localStorage copies migrate on first open (newest _rev wins) and are removed only after the IndexedDB write completed; no IndexedDB means localStorage exactly as before. Records keep their btc-wallet-cache::ark… keys, and a wallet wipe clears both stores via the new per-feature wipeCache hook. The ark cache extension is now syncOnly: it still rides the sync snapshot (and the Blossom blob) but the local wallet cache no longer embeds it — measured on a live wallet the cache blob went from 655KB to 10KB. Verified in the live tab: all wallets migrated, bytes raw on disk, balance and coin set unchanged, localStorage ark keys gone. tools/ark-store-test.js covers migration by _rev, clone-on-read, write-behind, wipe and fallback. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE --- tools/ark-store-test.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tools/ark-store-test.js (limited to 'tools') diff --git a/tools/ark-store-test.js b/tools/ark-store-test.js new file mode 100644 index 0000000..4e02fa0 --- /dev/null +++ b/tools/ark-store-test.js @@ -0,0 +1,90 @@ +// Ark state store: memory-fronted IndexedDB (a memory backend here) with +// localStorage migration by _rev, raw bytes on disk, clone-on-read, +// write-behind flush, wipe by prefix, and the localStorage fallback. +// Run: bun tools/ark-store-test.js +import assert from 'node:assert/strict'; +import { ArkStore, memoryBackend } from '../src/ark/store.js'; +import { vtxoBytesToStr } from '../src/ark/proto.js'; + +class Storage { + data = new Map(); + get length() { return this.data.size; } + key(i) { return [...this.data.keys()][i] ?? null; } + getItem(k) { return this.data.get(k) ?? null; } + setItem(k, v) { this.data.set(k, String(v)); } + removeItem(k) { this.data.delete(k); } +} +const raw = new Uint8Array(64).map((_, i) => i * 3); +const b64 = vtxoBytesToStr(raw); +const K = 'btc-wallet-cache:abc123:ark:fsobdp'; +const HOLD = 'btc-wallet-cache:abc123:ark:pk:03ff959f88f41d5f'; +const OTHER = 'btc-wallet-cache:zzz999:ark:fsobdp'; + +// ---- migration: newest _rev wins, localStorage retired after the write ---- +const ls = new Storage(); +ls.setItem(K, JSON.stringify({ _rev: 7, vtxos: [{ id: 'a', state: 'spendable', bytes: b64 }], actions: [], movements: [] })); +ls.setItem(HOLD, JSON.stringify({ vtxos: [{ id: 'h', state: 'spendable', bytes: b64 }] })); +ls.setItem('btc-wallet-cache:abc123', '{"txs":[]}'); // the wallet cache is not ours +const backend = memoryBackend([[K, { _rev: 5, vtxos: [{ id: 'a', state: 'spendable', bytes: raw }], actions: [], movements: [] }], + [OTHER, { _rev: 1, vtxos: [], actions: [], movements: [] }]]); +const store = new ArkStore({ backend, ls: () => ls }); +assert.equal(store.ready, false); +await store.open(); +assert.equal(store.get(K)._rev, 7, 'the newer localStorage copy wins'); +assert.equal(store.get(HOLD).vtxos[0].bytes, b64, 'hold state migrates too'); +await store.flush(); +await new Promise((r) => setTimeout(r, 0)); +assert.equal(ls.getItem(K), null, 'localStorage copy retired after the write'); +assert.equal(ls.getItem(HOLD), null); +assert.equal(ls.getItem('btc-wallet-cache:abc123'), '{"txs":[]}', 'unrelated keys untouched'); +assert.ok(backend.dump().get(K).vtxos[0].bytes instanceof Uint8Array, 'bytes are raw on disk'); +assert.equal(store.get(K).vtxos[0].bytes, b64, 'and base64 strings in memory'); +console.log('✓ localStorage state migrates by _rev, bytes land raw, old copies retired'); + +// ---- reads are clones; writes are write-behind ---- +const got = store.get(K); got.vtxos.push({ id: 'evil' }); +assert.equal(store.get(K).vtxos.length, 1, 'a caller cannot mutate the store through a read'); +const writes = backend.writes; +store.set(K, { _rev: 8, vtxos: [{ id: 'a', state: 'spent', bytes: b64 }, { id: 'b', state: 'spendable', bytes: b64 }], actions: [], movements: [] }); +assert.equal(store.get(K)._rev, 8, 'memory is current immediately'); +assert.equal(backend.writes, writes, 'disk write is deferred'); +await store.flush(); +assert.equal(backend.dump().get(K)._rev, 8); +assert.equal(backend.dump().get(K).vtxos.length, 2); +store.remove(OTHER); +await store.flush(); +assert.equal(backend.dump().has(OTHER), false); +console.log('✓ clone-on-read, coalesced write-behind, delete'); + +// ---- a fresh open reads back what was written ---- +const again = new ArkStore({ backend, ls: () => new Storage() }); +await again.open(); +assert.equal(again.get(K).vtxos[1].bytes, b64, 'round trip through raw bytes'); +assert.deepEqual(again.keys('btc-wallet-cache:abc123').sort(), [K, HOLD].sort(), 'keys by prefix'); +console.log('✓ reopen restores state'); + +// ---- wipe by wallet prefix (both stores) ---- +const ls2 = new Storage(); ls2.setItem('btc-wallet-cache:abc123:ark', '{"vtxos":[]}'); +const wipe = new ArkStore({ backend, ls: () => ls2 }); +await wipe.open(); await wipe.flush(); +wipe.removePrefix('btc-wallet-cache:abc123'); +await wipe.flush(); +assert.equal(wipe.keys('btc-wallet-cache:abc123').length, 0); +assert.equal(backend.dump().has(K), false); +assert.equal(ls2.getItem('btc-wallet-cache:abc123:ark'), null); +console.log('✓ wallet wipe clears IndexedDB and localStorage copies'); + +// ---- no IndexedDB: localStorage exactly as before ---- +const ls3 = new Storage(); +const fallback = new ArkStore({ backend: null, ls: () => ls3 }); +assert.equal(fallback.ready, true); +fallback.set(K, { _rev: 1, vtxos: [] }); +assert.equal(JSON.parse(ls3.getItem(K))._rev, 1); +assert.equal(fallback.get(K)._rev, 1); +const broken = new ArkStore({ backend: { list: async () => { throw new Error('nope'); }, write: async () => {} }, ls: () => ls3 }); +await broken.open(); +assert.equal(broken.lsMode, true, 'a refusing IndexedDB drops to localStorage'); +assert.equal(broken.get(K)._rev, 1); +console.log('✓ localStorage fallback'); + +console.log('\n✅ ark store behaves'); -- cgit v1.2.3