diff options
| -rw-r--r-- | src/app.js | 1 | ||||
| -rw-r--r-- | src/ark/store.js | 213 | ||||
| -rw-r--r-- | src/features/ark.js | 46 | ||||
| -rw-r--r-- | src/wallet.js | 11 | ||||
| -rw-r--r-- | tools/ark-store-test.js | 90 |
5 files changed, 334 insertions, 27 deletions
@@ -1814,6 +1814,7 @@ function wipeAccountCache(acc) { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && bases.some((b) => k.startsWith(b))) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } catch {} + for (const f of FEATURES) { try { f.wipeCache && f.wipeCache(bases); } catch {} } } // Sign out a single wallet: remove it from the device entirely (session, diff --git a/src/ark/store.js b/src/ark/store.js new file mode 100644 index 0000000..b1caa94 --- /dev/null +++ b/src/ark/store.js @@ -0,0 +1,213 @@ +// Ark state storage: IndexedDB with the coins' signed bytes as raw +// Uint8Arrays, fronted by an in-memory copy so every read and write the app +// makes stays synchronous (the render path reads it, the manager saves it). +// +// Why not localStorage: a deep coin set is ~700KB of JSON, which localStorage +// holds as UTF-16 and hands back as a string — every boot parsed it, every +// save stringified it (several times, ~100ms each on a phone). IndexedDB +// structured-clones the object as-is, bytes stay bytes, and the write is +// write-behind: coalesced and asynchronous, flushed immediately when the page +// hides so nothing is lost to a suspend. +// +// Records keep today's localStorage keys (`btc-wallet-cache:<id>:ark…`) so +// the wallet-wipe prefix logic still applies. Existing localStorage copies +// migrate on first open (newest `_rev` wins) and are removed only after the +// IndexedDB write completed. If IndexedDB is unavailable the store falls +// back to localStorage exactly as before. + +import { vtxoBytesToStr, vtxoBytesFromStr } from './proto.js'; + +const DB = 'coinos-ark'; +const STORE = 'states'; +const VERSION = 1; +export const ARK_KEY_RE = /^btc-wallet-cache:[0-9a-f]+:ark(:|$)/; +const DEBOUNCE_MS = 200; + +// ---- backends ------------------------------------------------------------ +export function idbBackend() { + if (typeof indexedDB === 'undefined') return null; + let dbp = null; + const open = () => { + if (dbp) return dbp; + dbp = new Promise((resolve, reject) => { + const req = indexedDB.open(DB, VERSION); + req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); }; + req.onsuccess = () => { + const db = req.result; + db.onversionchange = () => { try { db.close(); } catch {} dbp = null; }; + db.onclose = () => { dbp = null; }; + resolve(db); + }; + req.onerror = () => { dbp = null; reject(req.error); }; + req.onblocked = () => { dbp = null; reject(new Error('IndexedDB blocked')); }; + }); + return dbp; + }; + const run = async (mode, fn) => { + const db = await open(); + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, mode); + let out; + try { out = fn(t.objectStore(STORE)); } catch (e) { reject(e); return; } + t.oncomplete = () => resolve(out && typeof out.then === 'function' ? undefined : out); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error || new Error('aborted')); + }); + }; + return { + async list() { + const db = await open(); + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, 'readonly'); + const s = t.objectStore(STORE); + const keys = s.getAllKeys(), vals = s.getAll(); + t.oncomplete = () => resolve(keys.result.map((k, i) => [k, vals.result[i]])); + t.onerror = () => reject(t.error); + }); + }, + write(puts, dels) { + if (!puts.length && !dels.length) return Promise.resolve(); + return run('readwrite', (s) => { for (const [k, v] of puts) s.put(v, k); for (const k of dels) s.delete(k); }); + }, + }; +} +// For tests: the same contract over a Map. +export function memoryBackend(initial = []) { + const m = new Map(initial); + return { + writes: 0, + async list() { return [...m]; }, + async write(puts, dels) { this.writes++; for (const [k, v] of puts) m.set(k, structuredClone(v)); for (const k of dels) m.delete(k); }, + dump() { return m; }, + }; +} + +// ---- on-disk shape: coins' bytes as raw bytes ------------------------------- +function toDisk(v) { + if (!v || !Array.isArray(v.vtxos)) return v; + return { ...v, vtxos: v.vtxos.map((x) => (typeof x.bytes === 'string' ? { ...x, bytes: vtxoBytesFromStr(x.bytes) } : x)) }; +} +function fromDisk(v) { + if (!v || !Array.isArray(v.vtxos)) return v; + return { ...v, vtxos: v.vtxos.map((x) => (x.bytes instanceof Uint8Array ? { ...x, bytes: vtxoBytesToStr(x.bytes) } : x)) }; +} +const clone = (v) => (typeof structuredClone === 'function' ? structuredClone(v) : JSON.parse(JSON.stringify(v))); + +// ---- the store --------------------------------------------------------------- +export class ArkStore { + constructor({ backend = idbBackend(), ls = () => globalThis.localStorage, now = Date.now } = {}) { + this.backend = backend; // null → localStorage mode (today's behaviour) + this.ls = ls; + this.now = now; + this.mem = new Map(); + this.dirty = new Set(); + this.ready = !backend; // localStorage mode needs no opening + this.timer = 0; + this.opening = null; + this._flushing = null; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this.flush(); }); + addEventListener('pagehide', () => this.flush()); + } + } + get lsMode() { return !this.backend; } + + // Load everything into memory, then fold in (and later retire) any + // localStorage copies. The fold-in is synchronous after the async read, so + // no write can slip between "what localStorage held" and "ready". + open() { + if (this.ready) return Promise.resolve(); + if (this.opening) return this.opening; + this.opening = (async () => { + let rows; + try { rows = await this.backend.list(); } catch (e) { + this.backend = null; this.ready = true; // IndexedDB refused: stay on localStorage + return; + } + for (const [k, v] of rows) this.mem.set(k, fromDisk(v)); + const migrated = []; + const ls = this.ls(); + if (ls) { + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (!k || !ARK_KEY_RE.test(k)) continue; + let v; try { v = JSON.parse(ls.getItem(k)); } catch { continue; } + if (!v || typeof v !== 'object') continue; + const cur = this.mem.get(k); + if (!cur || (v._rev || 0) >= (cur._rev || 0)) { this.mem.set(k, v); this.dirty.add(k); } + migrated.push(k); + } + } + this.ready = true; + if (migrated.length) { + this.flush().then(() => { for (const k of migrated) { try { ls.removeItem(k); } catch {} } }).catch(() => {}); + } + })(); + return this.opening; + } + + get(key) { + if (!this.ready || this.lsMode) { + try { const raw = this.ls().getItem(key); return raw ? JSON.parse(raw) : null; } catch { return null; } + } + const v = this.mem.get(key); + return v ? clone(v) : null; + } + set(key, obj) { + if (!this.ready || this.lsMode) { try { this.ls().setItem(key, JSON.stringify(obj)); } catch {} return; } + this.mem.set(key, obj); + this.dirty.add(key); + this._schedule(); + } + remove(key) { + if (!this.ready || this.lsMode) { try { this.ls().removeItem(key); } catch {} return; } + this.mem.delete(key); + this.dirty.add(key); + this._schedule(); + } + keys(prefix = '') { + if (!this.ready || this.lsMode) { + const out = []; const ls = this.ls(); + try { for (let i = 0; i < ls.length; i++) { const k = ls.key(i); if (k && k.startsWith(prefix) && ARK_KEY_RE.test(k)) out.push(k); } } catch {} + return out; + } + return [...this.mem.keys()].filter((k) => k.startsWith(prefix)); + } + // Wallet wipe: everything under the wallet's cache-key prefix, wherever it lives. + removePrefix(prefix) { + for (const k of this.keys(prefix)) this.remove(k); + const ls = this.ls(); + try { + const gone = []; + for (let i = 0; i < ls.length; i++) { const k = ls.key(i); if (k && k.startsWith(prefix) && ARK_KEY_RE.test(k)) gone.push(k); } + for (const k of gone) ls.removeItem(k); + } catch {} + } + + _schedule() { + const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + if (hidden) { this.flush(); return; } + if (this.timer) return; + this.timer = setTimeout(() => { this.timer = 0; this.flush(); }, DEBOUNCE_MS); + } + // Write every dirty key in one transaction. A failed write keeps the keys + // dirty for the next attempt; the in-memory copy is always authoritative. + flush() { + if (this.timer) { clearTimeout(this.timer); this.timer = 0; } + if (!this.ready || this.lsMode || !this.dirty.size) return this._flushing || Promise.resolve(); + if (this._flushing) return this._flushing.then(() => this.flush()); + const keys = [...this.dirty]; this.dirty.clear(); + const puts = [], dels = []; + for (const k of keys) { if (this.mem.has(k)) puts.push([k, toDisk(this.mem.get(k))]); else dels.push(k); } + this._flushing = this.backend.write(puts, dels) + .catch(() => { for (const k of keys) this.dirty.add(k); }) + .finally(() => { this._flushing = null; }); + return this._flushing; + } +} + +let shared = null; +export function arkStore() { + if (!shared) { shared = new ArkStore(); shared.open().catch(() => {}); } + return shared; +} diff --git a/src/features/ark.js b/src/features/ark.js index 1dcfa8c..2e97a61 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -11,6 +11,7 @@ import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes, vtxoBytesFromStr, vtxoBytesToHex } from '../ark/proto.js'; +import { arkStore } from '../ark/store.js'; import { signedExitTxs, exitTxVsizes, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js'; import { utxoId } from '../wallet.js'; import { @@ -43,7 +44,9 @@ if (typeof document !== 'undefined') { // wallet instance so a build without the feature ships none of it. export function installArkWallet(wallet) { if (wallet.loadArkState) return; // already installed + const store = arkStore(); // IndexedDB-backed, memory-fronted; opens now Object.assign(wallet, { + _arkStore: store, // ---- Ark support ------------------------------------------------------- // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement // history — no secrets: vtxo keys are re-derived from the seed). @@ -76,9 +79,8 @@ export function installArkWallet(wallet) { // here is exactly how you get a phantom balance. Pre-namespacing state // is claimed via adoptArkState() once we know who cosigned it. try { - const cur = localStorage.getItem(this._arkKey(url)); - if (!cur) return null; - const st = JSON.parse(cur); + const st = store.get(this._arkKey(url)); + if (!st) return null; // Self-repair: an earlier build could stamp state with whichever // server happened to be connected, filing one ASP's coins under // another. The vtxos themselves settle it — if they disagree with the @@ -87,10 +89,8 @@ export function installArkWallet(wallet) { const owner = this._arkVtxoOwner(st); if (owner && st.serverPubkey && owner !== st.serverPubkey) { st.serverPubkey = owner; - try { - localStorage.setItem(this._arkHoldKey(owner), JSON.stringify(st)); - localStorage.removeItem(this._arkKey(url)); - } catch {} + store.set(this._arkHoldKey(owner), st); + store.remove(this._arkKey(url)); return null; } return st; @@ -103,16 +103,14 @@ export function installArkWallet(wallet) { adoptArkState(serverPubkey, url) { try { // state parked by the self-repair above belongs to exactly one server - const held = localStorage.getItem(this._arkHoldKey(serverPubkey)); + const held = store.get(this._arkHoldKey(serverPubkey)); if (held) { - const st = JSON.parse(held); - this.saveArkState(st, url); - localStorage.removeItem(this._arkHoldKey(serverPubkey)); - return st; + this.saveArkState(held, url); + store.remove(this._arkHoldKey(serverPubkey)); + return held; } - const raw = localStorage.getItem(this._arkLegacyKey()); - if (!raw) return null; - const st = JSON.parse(raw); + const st = store.get(this._arkLegacyKey()); + if (!st) return null; let owner = st.serverPubkey || null; if (!owner) { const v = (st.vtxos || []).find((x) => x.bytes); @@ -122,12 +120,12 @@ export function installArkWallet(wallet) { if (!owner) return null; // can't prove ownership: leave it st.serverPubkey = owner; this.saveArkState(st, url); - localStorage.removeItem(this._arkLegacyKey()); // now safely namespaced + store.remove(this._arkLegacyKey()); // now safely namespaced return st; } catch { return null; } }, - saveArkState(state, url, json = null) { - try { localStorage.setItem(this._arkKey(url), json ?? JSON.stringify(state)); } catch {} + saveArkState(state, url) { + store.set(this._arkKey(url), state); }, }); } @@ -316,6 +314,7 @@ export function arkFeature(ctx) { wallet.registerCacheExtension({ domain: 'ark', // published as its own sync slot — see splitSnapshotDomains mergeAlways: true, // load() is a commutative merge — apply older snapshots too + syncOnly: true, // ark keeps its own store; the local cache blob needn't carry a copy save: () => { const cfg = getArkConfig(); const s = cfg && wallet.loadArkState(cfg.ark); @@ -338,9 +337,7 @@ export function arkFeature(ctx) { if (owner && owner !== mine) { try { const key = wallet._arkHoldKey(owner); - const held = JSON.parse(localStorage.getItem(key) || 'null'); - localStorage.setItem(key, - JSON.stringify(mergeArkStates(held, { ...d.arkState, serverPubkey: owner }))); + wallet._arkStore.set(key, mergeArkStates(wallet._arkStore.get(key), { ...d.arkState, serverPubkey: owner })); } catch {} // `mine` is null on a FRESH restore (no local state, not yet // connected), so even our own snapshot lands in the hold. Connecting @@ -356,10 +353,9 @@ export function arkFeature(ctx) { // brings nothing — not worth rewriting ~1 MB of state or re-adopting // it into the manager (profiled at ~200ms per replay, twice per boot). // Bytes decide: unchanged means it serializes to what's already stored. - const json = JSON.stringify(merged); let changed = true; - try { changed = json !== localStorage.getItem(wallet._arkKey(cfg.ark)); } catch {} - if (changed) wallet.saveArkState(merged, cfg.ark, json); + try { changed = JSON.stringify(merged) !== JSON.stringify(local); } catch {} + if (changed) wallet.saveArkState(merged, cfg.ark); const mergedN = (merged.vtxos || []).length; const remoteN = (d.arkState.vtxos || []).length; // We know vtxos this snapshot lacked — push our superset back up so the @@ -3070,6 +3066,8 @@ export function arkFeature(ctx) { // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, settingsCards() { return [autoWithdrawCard()]; }, + // a wallet signing out takes its ark state with it, wherever it lives + wipeCache(prefixes) { for (const p of prefixes) wallet._arkStore.removePrefix(p); }, // The onboarding wizard's top-up step borrows the board form wholesale. arkBoardForm() { if (!arkAvailable() || wallet.watchOnly) return null; diff --git a/src/wallet.js b/src/wallet.js index cce0a45..ae9ac1b 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1809,7 +1809,9 @@ export class Wallet { // The serializable wallet state, shared by the localStorage cache and the // Nostr sync. savedAt lets us pick the newest copy across devices. - _snapshot() { + // forSync: include extensions that keep their own local store (syncOnly) + // and only ride the snapshot to reach other devices. + _snapshot(forSync = false) { return { v: 1, savedAt: Date.now(), @@ -1821,7 +1823,7 @@ export class Wallet { nextReceiveIndex: this.nextReceiveIndex, nextChangeIndex: this.nextChangeIndex, feeRates: this.feeRates, - ...Object.assign({}, ...this._cacheExtensions.map((e) => { try { return e.save(); } catch { return {}; } })), + ...Object.assign({}, ...this._cacheExtensions.filter((e) => forSync || !e.syncOnly).map((e) => { try { return e.save(); } catch { return {}; } })), }; } @@ -1861,7 +1863,10 @@ export class Wallet { const xk = this._xpubCacheKey(); if (xk !== this._cacheKey()) localStorage.setItem(xk, JSON.stringify(snap)); // watch-only mirror } catch {} - for (const fn of this._cacheSavedHooks) { try { fn(snap); } catch {} } + // the sync hooks see the full picture (same savedAt, so echoes still match) + const syncOnly = this._cacheExtensions.filter((e) => e.syncOnly); + const forSync = syncOnly.length ? Object.assign(snap, ...syncOnly.map((e) => { try { return e.save(); } catch { return {}; } })) : snap; + for (const fn of this._cacheSavedHooks) { try { fn(forSync); } catch {} } } restoreCache() { 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'); |
