aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 06:59:13 -0700
committerAdam Soltys <[email protected]>2026-09-10 06:59:13 -0700
commit435e9886620deff3d66286e3b85e97ed8f5fae53 (patch)
tree03329d4d892385d55dc0585346c1533389b8ca84
parent57289fd3f87554e9981ddc2c63c3bba805d599bd (diff)
downloadcoinosv3-435e9886620deff3d66286e3b85e97ed8f5fae53.tar.gz
coinosv3-435e9886620deff3d66286e3b85e97ed8f5fae53.zip
Ark: local state stops growing forever — spent coins shed bytes, done actions shed hex.
pruneArkState runs on every manager save: a spent coin loses its signed bytes once its spend is 30 days old and no in-flight action names it (a spent coin can never take the exit those bytes exist for), and an action done more than 7 days ago loses the output-vtxo hex it minted (the change / HTLC coins live in `vtxos` in their own right). Ids, amounts, inputIds and preimages stay for history rows and spend dedupe; failed ln-pays keep their HTLC bytes for the refund. Selective and idempotent; unit-tested. Measured on a live deep wallet the immediate saving is small (every recent spend is inside the window) — what remains is the live coins' own chains, which only a renewal shrinks. The change is a growth cap, not a diet. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
-rw-r--r--src/ark/manager.js52
-rw-r--r--tools/ark-prune-test.js44
2 files changed, 96 insertions, 0 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 471c4e0..d40a65b 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -63,6 +63,57 @@ const EMPTY_STATE = () => ({
movements: [], // { id, type, amountSat, ts, status, detail }
});
+// Local state must not grow forever. Two things did: a spent coin kept its
+// signed bytes (useful only for an exit, which a spent coin can never take)
+// and a done action kept every output vtxo it minted as hex (the change /
+// HTLC coins live in `vtxos` in their own right). Both go once they are
+// safely history — a spend older than SPENT_BYTES_AFTER with no in-flight
+// action naming it, an action done longer than DONE_HEAVY_AFTER ago. Ids,
+// amounts and inputIds stay: history rows and spend dedupe still need them.
+// Mutates in place; idempotent; cheap (no decoding).
+const SPENT_BYTES_AFTER = 30 * 86400_000;
+const DONE_HEAVY_AFTER = 7 * 86400_000;
+const ACTION_HEAVY = ['destBytes', 'changeBytes', 'destBytesList', 'changeBytesList', 'htlcBytesList', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos'];
+const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
+export function pruneArkState(state, now = Date.now()) {
+ if (!state) return state;
+ const actions = state.actions || [];
+ const inFlight = new Set();
+ for (const a of actions) {
+ if (a.step === 'done' || a.step === 'failed') continue;
+ for (const id of a.inputIds || []) inFlight.add(id);
+ for (const p of a.parts || []) if (p.inputId) inFlight.add(p.inputId);
+ if (a.inputId) inFlight.add(a.inputId);
+ }
+ // when was a coin last named by anything? (a spend leaves a movement or a
+ // done action naming it; an unnamed spent coin has no date and is kept)
+ const lastNamed = new Map();
+ const name = (id, ts) => { if (id && ts) lastNamed.set(id, Math.max(lastNamed.get(id) || 0, ts)); };
+ for (const m of state.movements || []) {
+ name(m.vtxoId, m.ts);
+ for (const id of m.inputIds || []) name(id, m.ts);
+ }
+ for (const a of actions) {
+ const ts = actionTs(a);
+ for (const id of a.inputIds || []) name(id, ts);
+ for (const p of a.parts || []) name(p.inputId, ts);
+ name(a.inputId, ts);
+ for (const id of a.htlcVtxoIds || []) name(id, ts);
+ }
+ for (const v of state.vtxos || []) {
+ if (v.state !== 'spent' || !v.bytes || inFlight.has(v.id)) continue;
+ const ts = lastNamed.get(v.id);
+ if (ts && now - ts > SPENT_BYTES_AFTER) delete v.bytes;
+ }
+ for (const a of actions) {
+ if (a.step !== 'done') continue;
+ const ts = actionTs(a);
+ if (!ts || now - ts < DONE_HEAVY_AFTER) continue;
+ for (const k of ACTION_HEAVY) if (k in a) delete a[k];
+ }
+ return state;
+}
+
export class ArkManager {
constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) {
this.account = account; // HDKey node; ark keys derived beneath it
@@ -165,6 +216,7 @@ export class ArkManager {
// derivations (history build, etc.) invalidate exactly when the data
// changes instead of on a timer that re-cools between interactions
this.state._rev = (this.state._rev || 0) + 1;
+ pruneArkState(this.state);
this.storage.save(this.state);
this.onUpdate(this);
}
diff --git a/tools/ark-prune-test.js b/tools/ark-prune-test.js
new file mode 100644
index 0000000..e12b02e
--- /dev/null
+++ b/tools/ark-prune-test.js
@@ -0,0 +1,44 @@
+// Local ark state pruning: spent coins shed bytes once their spend is old and
+// unreferenced; done actions shed output hex; nothing live is touched.
+// Run: bun tools/ark-prune-test.js
+import assert from 'node:assert/strict';
+import { pruneArkState } from '../src/ark/manager.js';
+
+const DAY = 86400_000;
+const now = 1_800_000_000_000;
+const state = {
+ vtxos: [
+ { id: 'live', state: 'spendable', bytes: 'aa' },
+ { id: 'oldspent', state: 'spent', bytes: 'bb' }, // spent 40d ago → bytes go
+ { id: 'freshspent', state: 'spent', bytes: 'cc' }, // spent yesterday → kept
+ { id: 'inflight', state: 'spent', bytes: 'dd' }, // named by a live action → kept
+ { id: 'unnamed', state: 'spent', bytes: 'ee' }, // no date known → kept
+ { id: 'pending', state: 'pending', bytes: 'ff' },
+ ],
+ actions: [
+ { id: `send-${now - 40 * DAY}`, type: 'send', step: 'done', inputIds: ['oldspent'], destBytesList: ['hex'], changeBytesList: ['hex'], amountSat: 5 },
+ { id: `ln-pay-${now - 1 * DAY}`, type: 'ln-pay', step: 'done', inputIds: ['freshspent'], htlcBytesList: ['hex'], preimage: 'p' },
+ { id: `send-${now - 3 * DAY}`, type: 'send', step: 'submitted', inputIds: ['inflight'], changeBytesList: ['hex'] },
+ { id: `board-${now - 30 * DAY}`, type: 'board', step: 'done', fundingTxHex: 'ff', outputVtxos: ['hex'], fundingTxid: 'x' },
+ { id: `ln-pay-${now - 10 * DAY}`, type: 'ln-pay', step: 'failed', htlcBytesList: ['hex'] },
+ ],
+ movements: [{ id: 'm1', type: 'send', status: 'complete', ts: now - 40 * DAY, inputIds: ['oldspent'] }],
+};
+const before = JSON.stringify(state).length;
+pruneArkState(state, now);
+const v = (id) => state.vtxos.find((x) => x.id === id);
+assert.equal(v('live').bytes, 'aa');
+assert.equal(v('pending').bytes, 'ff');
+assert.equal(v('oldspent').bytes, undefined, 'old spend sheds its bytes');
+assert.equal(v('freshspent').bytes, 'cc', 'a recent spend keeps them');
+assert.equal(v('inflight').bytes, 'dd', 'an in-flight action pins them');
+assert.equal(v('unnamed').bytes, 'ee', 'no known spend date → keep');
+const a = (i) => state.actions[i];
+assert.ok(!('destBytesList' in a(0)) && !('changeBytesList' in a(0)) && a(0).amountSat === 5 && a(0).inputIds[0] === 'oldspent', 'old done action loses hex, keeps ids');
+assert.equal(a(1).htlcBytesList[0], 'hex', 'a day-old done action still has its hex');
+assert.equal(a(2).changeBytesList[0], 'hex', 'in-flight action untouched');
+assert.ok(!('fundingTxHex' in a(3)) && !('outputVtxos' in a(3)) && a(3).fundingTxid === 'x');
+assert.equal(a(4).htlcBytesList[0], 'hex', 'a failed ln-pay keeps its HTLC bytes for the refund');
+assert.ok(JSON.stringify(state).length < before);
+pruneArkState(state, now);
+console.log('✓ ark state pruning is selective and idempotent');