aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js1
-rw-r--r--src/ark/store.js213
-rw-r--r--src/features/ark.js46
-rw-r--r--src/wallet.js11
-rw-r--r--tools/ark-store-test.js90
5 files changed, 334 insertions, 27 deletions
diff --git a/src/app.js b/src/app.js
index e03e1a9..35fd225 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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');