@xmbl/state-machine 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmbl/state-machine",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -0,0 +1,94 @@
1
+ // THE THREE DEFECTS THAT KEPT NODES OFF A SHARED ROOT, each asserted by the symptom the fleet showed,
2
+ // not by the mechanism. All three were measured live on 2026-09-15 before being fixed here.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { rmSync } from 'node:fs';
6
+ import { StateMachine } from './state-machine.js';
7
+
8
+ const ZERO = '0'.repeat(64);
9
+ const anchors = (n) => Array.from({ length: n }, (_, i) => ({
10
+ event: 'task.created', hash: `h${String(i).padStart(4, '0')}`.padEnd(12, '0'), ts: 1000 + i,
11
+ }));
12
+ const blockFor = (a, tag) => ({ id: `blk-${tag}-${Math.random().toString(16).slice(2)}`,
13
+ tx: { type: 'anchor', event: a.event, hash: a.hash, ts: a.ts } });
14
+ const countDiffRows = async (sm) => { let n = 0; for await (const [] of sm.db.iterator({ gt: 'diff:', lt: 'diff:\xFF' })) n++; return n; };
15
+
16
+ async function open(dir) { const sm = new StateMachine({ dbPath: dir }); await sm.ready(); return sm; }
17
+
18
+ test('a restarted node publishes a REAL root, not 64 zeros, from the state: keyspace alone', async () => {
19
+ const dir = `/tmp/xvsm-rehydrate-${process.pid}-a`;
20
+ rmSync(dir, { recursive: true, force: true });
21
+ let sm = await open(dir);
22
+ for (const a of anchors(30)) await sm._handleLedgerBlock(blockFor(a, 'x'));
23
+ const before = sm.stateTree.getRoot();
24
+ assert.notStrictEqual(before, ZERO);
25
+ await sm.db.close();
26
+
27
+ // Reopen. The diff replay is NOT the thing under test — delete every diff row first, so the only
28
+ // surviving source for the trie is `state:`. 39 of 44 reporting fleet nodes published ZERO here.
29
+ sm = await open(dir);
30
+ await sm.db.clear({ gte: 'diff:', lt: 'diff:\xFF' });
31
+ await sm.db.close();
32
+ sm = await open(dir);
33
+ assert.strictEqual(sm.stateTree.state.size, 30, 'keys must come back');
34
+ assert.notStrictEqual(sm.stateTree.getRoot(), ZERO, 'a tree holding 30 keys must not commit to zeros');
35
+ assert.strictEqual(sm.stateTree.getRoot(), before, 'and it must be the SAME root it had before the restart');
36
+ await sm.db.close();
37
+ rmSync(dir, { recursive: true, force: true });
38
+ });
39
+
40
+ test('re-submitting the same anchor under fresh tx ids writes ONE durable row, not one per submission', async () => {
41
+ const dir = `/tmp/xvsm-rehydrate-${process.pid}-b`;
42
+ rmSync(dir, { recursive: true, force: true });
43
+ const sm = await open(dir);
44
+ const set = anchors(50);
45
+ for (let pass = 0; pass < 4; pass++) for (const a of set) await sm._handleLedgerBlock(blockFor(a, pass));
46
+ assert.strictEqual(await countDiffRows(sm), 50, '200 submissions of 50 anchors is 50 state changes');
47
+ assert.strictEqual(sm.getStatistics().totalTransactions, 50, 'and the published count must agree with disk');
48
+ await sm.db.close();
49
+ rmSync(dir, { recursive: true, force: true });
50
+ });
51
+
52
+ test('the canonical set a node adopts SURVIVES a restart', async () => {
53
+ const dir = `/tmp/xvsm-rehydrate-${process.pid}-c`;
54
+ rmSync(dir, { recursive: true, force: true });
55
+ let sm = await open(dir);
56
+ const local = anchors(50);
57
+ for (const a of local) await sm._handleLedgerBlock(blockFor(a, 'local'));
58
+ const canonical = local.slice(0, 10);
59
+ const reb = await sm.rebuildFromCanonical(canonical);
60
+ assert.strictEqual(reb.applied, 10);
61
+ const canonicalRoot = reb.state_root;
62
+ await sm.db.close();
63
+
64
+ sm = await open(dir);
65
+ assert.strictEqual(sm.stateTree.state.size, 10, 'the 40 non-canonical keys must not come back');
66
+ assert.strictEqual(sm.stateTree.getRoot(), canonicalRoot, 'the node must still be on the canonical root');
67
+ assert.strictEqual(sm.getStatistics().totalTransactions, 10);
68
+ await sm.db.close();
69
+ rmSync(dir, { recursive: true, force: true });
70
+ });
71
+
72
+ test('legacy rows keyed by block id are re-keyed to content identity on the next boot', async () => {
73
+ const dir = `/tmp/xvsm-rehydrate-${process.pid}-d`;
74
+ rmSync(dir, { recursive: true, force: true });
75
+ let sm = await open(dir);
76
+ // Write rows the way every node on the fleet already has them: one per submission, keyed by block id.
77
+ const set = anchors(40);
78
+ let written = 0;
79
+ for (let pass = 0; pass < 3; pass++) for (const a of set) {
80
+ const key = `anchor:${a.event}:${a.hash}`;
81
+ await sm.db.put(`diff:blk-${pass}-${a.hash}`, JSON.stringify({
82
+ txId: `blk-${pass}-${a.hash}`, timestamp: 1000 + pass, changes: { [key]: { ts: a.ts } },
83
+ }));
84
+ written++;
85
+ }
86
+ assert.strictEqual(await countDiffRows(sm), written);
87
+ await sm.db.close();
88
+
89
+ sm = await open(dir);
90
+ assert.strictEqual(await countDiffRows(sm), 40, `${written} legacy rows collapse to 40 distinct changes`);
91
+ assert.strictEqual(sm.getStatistics().totalTransactions, 40);
92
+ await sm.db.close();
93
+ rmSync(dir, { recursive: true, force: true });
94
+ });
package/src/state-diff.js CHANGED
@@ -5,6 +5,17 @@ export class StateDiff {
5
5
  this.changes = changes; // key -> new value
6
6
  }
7
7
 
8
+ // THE IDENTITY OF A STATE CHANGE IS THE KEY IT WRITES, not the id of whatever block happened to carry it.
9
+ // A node mints a fresh tx id every time it re-submits an anchor, so `diff:<txId>` made one durable row per
10
+ // SUBMISSION: measured on a live node 2026-09-15, 92,505 rows over 50,497 distinct anchors — 42,008 of them
11
+ // redundant, still growing by ~34 every seven hours, and every one of them replayed into the tree on boot.
12
+ // Keying by the change itself makes a re-submission an upsert, which is what it always was semantically.
13
+ // Multi-key changes (explicit `state_diff` txs) have no single content key, so they keep the tx id.
14
+ identity() {
15
+ const keys = Object.keys(this.changes || {});
16
+ return keys.length === 1 ? keys[0] : this.txId;
17
+ }
18
+
8
19
  apply(state) {
9
20
  const newState = { ...state };
10
21
  for (const [key, value] of Object.entries(this.changes)) {
@@ -32,8 +32,10 @@ export class StateMachine extends EventEmitter {
32
32
  // Integration: xclt for state commitments from ledger
33
33
  this.xclt = options.xclt || null;
34
34
 
35
- // Initialize database
36
- this._initDb().catch(() => {});
35
+ // Initialize database. KEEP THE PROMISE: `_dbOpen` flips true the moment Level opens, long before the
36
+ // diff sweep and the tree rehydration have finished, so anything that polls `_dbOpen` and then reads a
37
+ // count is reading a store mid-rebuild. `ready()` is the only honest join point.
38
+ this._ready = this._initDb().catch(() => {});
37
39
 
38
40
  // Listen to ledger events if available
39
41
  if (this.xclt) {
@@ -51,10 +53,16 @@ export class StateMachine extends EventEmitter {
51
53
  }
52
54
  }
53
55
 
56
+ // Resolves once the store is open, the tree rehydrated from `state:`, and the diff rows swept and loaded.
57
+ ready() { return this._ready || Promise.resolve(); }
58
+
54
59
  async _initDb() {
55
60
  try {
56
61
  await this.db.open();
57
62
  this._dbOpen = true;
63
+ // The tree rehydrates itself from the `state:` keyspace; wait for it before deciding whether the diff
64
+ // rows still need to be replayed, or an unfinished load reads as an empty tree.
65
+ await this.stateTree.ready();
58
66
  await this._loadDiffs();
59
67
  await this._loadTransactionLog();
60
68
  } catch (error) {
@@ -72,24 +80,46 @@ export class StateMachine extends EventEmitter {
72
80
  // Replay is safe to do in iterator order because the tree is a key->value map: the root is a function
73
81
  // of the final key set, not of application order (proven in verkle-integration.test.mjs, "same set in
74
82
  // any order yields the SAME root"). Later diffs for the same key legitimately overwrite earlier ones.
83
+ // RE-KEY SWEEP. Rows written before content identity existed are keyed `diff:<block.id>`, so the same
84
+ // anchor re-submitted under a fresh tx id left one row per submission and every one of them came back
85
+ // here on boot. Rewriting each row under `diff:<identity>` collapses them: the identity-keyed row is
86
+ // PUT first and the old key deleted only after, so nothing is lost if this is interrupted. Measured on
87
+ // a live store 2026-09-15: 92,505 rows over 50,497 distinct anchors, 42,008 of them redundant.
75
88
  const loaded = [];
89
+ let rekeyed = 0;
76
90
  for await (const [key, value] of this.db.iterator({ gt: 'diff:', lt: 'diff:\xFF' })) {
77
91
  const diffData = JSON.parse(value.toString());
78
92
  const diff = new StateDiff(diffData.txId, diffData.changes);
79
93
  diff.timestamp = diffData.timestamp;
80
- this._recordDiff(diff);
81
- loaded.push(diff);
94
+ const want = this._diffKey(diff);
95
+ const have = key.toString();
96
+ if (have !== want) {
97
+ try { await this.db.put(want, value); await this.db.del(have); rekeyed++; } catch { /* leave it */ }
98
+ }
99
+ if (this._recordDiff(diff)) loaded.push(diff);
82
100
  }
83
- loaded.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || String(a.txId).localeCompare(String(b.txId)));
101
+ if (rekeyed) console.log(`[XVSM] diff rows re-keyed to content identity: ${rekeyed}, distinct now ${this.diffs.length}`);
102
+
103
+ // ⛔ REPLAY ONLY AS RECOVERY. This loop replayed every diff into the tree unconditionally, which is how
104
+ // it papered over the real defect — VerkleStateTree._loadState restored the key map but never rebuilt
105
+ // the trie, so the root read 64 zeros and the replay was the only thing putting keys back through
106
+ // insert(). With the trie rebuilt on open, an unconditional replay is actively harmful: it reinstates
107
+ // every key that rebuildFromCanonical deliberately dropped, so the canonical root a node adopts SURVIVES
108
+ // until its next restart and no further. MEASURED 2026-09-15: rebuild to root 02eaf3e76c over 10
109
+ // canonical anchors, restart, root 4bc7dd2813 over 50 keys — the node left the canonical set by booting.
110
+ // Replay stays for the one case it is still needed: a store whose `state:` keyspace is genuinely empty.
84
111
  let replayed = 0;
85
- for (const diff of loaded) {
86
- for (const [k, v] of Object.entries(diff.changes || {})) {
87
- if (v === null) { await this.stateTree.delete?.(k); continue; }
88
- await this.stateTree.insert(k, v);
89
- replayed++;
112
+ if (this.stateTree.state.size === 0 && loaded.length) {
113
+ loaded.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || String(a.txId).localeCompare(String(b.txId)));
114
+ for (const diff of loaded) {
115
+ for (const [k, v] of Object.entries(diff.changes || {})) {
116
+ if (v === null) { await this.stateTree.delete?.(k); continue; }
117
+ await this.stateTree.insert(k, v);
118
+ replayed++;
119
+ }
90
120
  }
91
121
  }
92
- if (replayed) console.log(`[XVSM] verkle tree rehydrated: ${replayed} change(s) from ${loaded.length} diff(s), root=${this.stateTree.getRoot().slice(0, 16)}…`);
122
+ if (replayed) console.log(`[XVSM] verkle tree recovered from diffs (state: keyspace was empty): ${replayed} change(s) from ${loaded.length} diff(s), root=${this.stateTree.getRoot().slice(0, 16)}…`);
93
123
  } catch (error) {
94
124
  // Ignore load errors
95
125
  }
@@ -112,7 +142,7 @@ export class StateMachine extends EventEmitter {
112
142
  if (!this._dbOpen) return;
113
143
 
114
144
  try {
115
- await this.db.put(`diff:${diff.txId}`, JSON.stringify({
145
+ await this.db.put(this._diffKey(diff), JSON.stringify({
116
146
  txId: diff.txId,
117
147
  changes: diff.changes,
118
148
  timestamp: diff.timestamp
@@ -153,13 +183,22 @@ export class StateMachine extends EventEmitter {
153
183
  //
154
184
  // REPLACE, don't skip: re-applying a txId with different changes is a legitimate later state for that key
155
185
  // and the disk already resolves it that way. Skipping would leave the array disagreeing with the tree.
186
+ //
187
+ // INDEXED BY CONTENT IDENTITY, NOT BY TX ID, so the in-memory set matches the durable one key-for-key. Two
188
+ // submissions of the same anchor carry different tx ids and are the SAME state change; keying either side
189
+ // by the tx id makes them two of everything. See StateDiff.identity().
156
190
  _recordDiff(diff) {
157
- const at = this._diffIndex.get(diff.txId);
158
- if (at === undefined) { this._diffIndex.set(diff.txId, this.diffs.length); this.diffs.push(diff); return true; }
191
+ const id = diff.identity();
192
+ const at = this._diffIndex.get(id);
193
+ if (at === undefined) { this._diffIndex.set(id, this.diffs.length); this.diffs.push(diff); return true; }
159
194
  this.diffs[at] = diff;
160
195
  return false;
161
196
  }
162
197
 
198
+ // The one place a diff's durable key is spelled. Every writer goes through it, or the re-key sweep in
199
+ // _loadDiffs is fighting a writer that still uses the block id.
200
+ _diffKey(diff) { return `diff:${diff.identity()}`; }
201
+
163
202
  _stateChangesFor(block) {
164
203
  const tx = block?.tx;
165
204
  if (!tx || typeof tx !== 'object') return null;
@@ -208,7 +247,7 @@ export class StateMachine extends EventEmitter {
208
247
  // StateDiff.serialize() ALREADY returns a JSON string — wrapping it in JSON.stringify again
209
248
  // double-encodes, so _loadDiffs parses back a string instead of an object and `changes` comes out
210
249
  // undefined, silently replaying nothing. Store the serialized form directly.
211
- try { await this.db.put(`diff:${block.id}`, diff.serialize()); }
250
+ try { await this.db.put(this._diffKey(diff), diff.serialize()); }
212
251
  catch { /* in-memory fallback */ }
213
252
  }
214
253
  } catch (error) {
@@ -252,7 +291,7 @@ export class StateMachine extends EventEmitter {
252
291
  this._recordDiff(diff);
253
292
  for (const [key, val] of Object.entries(changes)) await this.stateTree.insert(key, val);
254
293
  if (this._dbOpen !== false) {
255
- try { await this.db.put(`diff:${block.id}`, diff.serialize()); } catch { /* in-memory fallback */ }
294
+ try { await this.db.put(this._diffKey(diff), diff.serialize()); } catch { /* in-memory fallback */ }
256
295
  }
257
296
  out.applied++;
258
297
  } catch { out.failed++; }
@@ -273,6 +312,18 @@ export class StateMachine extends EventEmitter {
273
312
  await this.stateTree.clear();
274
313
  this.diffs = [];
275
314
  this._diffIndex.clear();
315
+ // ⛔ THE DURABLE ROWS COME TOO. Clearing `this.diffs` in memory while 92,505 `diff:` rows stay on disk is
316
+ // not an authoritative rebuild — it is one that lasts until the next boot reads them back. The tree's
317
+ // `state:` keyspace is cleared above for exactly this reason; the diff keyspace is the same commitment
318
+ // written twice, and leaving half of it behind is what made the canonical set un-adoptable across a
319
+ // restart. The blocks themselves are untouched in the ledger: backfillFromLedger regenerates local
320
+ // history on demand, and the canonical rows are re-persisted below.
321
+ if (this._dbOpen) {
322
+ try {
323
+ if (typeof this.db.clear === 'function') await this.db.clear({ gte: 'diff:', lt: 'diff:\xFF' });
324
+ else for await (const [k] of this.db.iterator({ gte: 'diff:', lt: 'diff:\xFF' })) { try { await this.db.del(k); } catch { /* */ } }
325
+ } catch { /* best-effort */ }
326
+ }
276
327
  // ⛔ RECORD A DIFF FOR EVERY ANCHOR APPLIED, or applied_tx_count IS ZERO BY CONSTRUCTION. This loop wrote
277
328
  // straight into the tree and never touched `this.diffs`, which it had just emptied — and
278
329
  // getStatistics().totalTransactions is transactionLog.length + diffs.length. So the moment a node runs the
@@ -288,7 +339,9 @@ export class StateMachine extends EventEmitter {
288
339
  const key = `anchor:${a.event}:${a.hash}`;
289
340
  const value = { ts: a.ts ?? null };
290
341
  await this.stateTree.insert(key, value);
291
- this._recordDiff(new StateDiff(key, { [key]: value }));
342
+ const diff = new StateDiff(key, { [key]: value });
343
+ this._recordDiff(diff);
344
+ if (this._dbOpen) { try { await this.db.put(this._diffKey(diff), diff.serialize()); } catch { /* in-memory fallback */ } }
292
345
  out.applied++;
293
346
  } catch { out.skipped++; }
294
347
  }
@@ -16,9 +16,10 @@ export class VerkleStateTree {
16
16
  this.db = options.db || null;
17
17
  this._dbOpen = false;
18
18
 
19
- if (this.db) {
20
- this._initDb().catch(() => {});
21
- }
19
+ // KEEP THE PROMISE. This was fire-and-forget, so XVSM's own _loadDiffs raced the tree's _loadState and
20
+ // could not tell "the tree is empty" from "the tree has not finished loading" — the exact distinction the
21
+ // replay decision below now turns on. `ready()` is the join point.
22
+ this._ready = this.db ? this._initDb().catch(() => {}) : Promise.resolve();
22
23
  }
23
24
 
24
25
  async _initDb() {
@@ -33,6 +34,9 @@ export class VerkleStateTree {
33
34
  }
34
35
  }
35
36
 
37
+ // Resolves once the `state:` keyspace has been read and the trie rebuilt from it.
38
+ ready() { return this._ready || Promise.resolve(); }
39
+
36
40
  async _loadState() {
37
41
  if (!this.db || !this._dbOpen) return;
38
42
 
@@ -45,6 +49,30 @@ export class VerkleStateTree {
45
49
  } catch (error) {
46
50
  // Ignore errors during load
47
51
  }
52
+ // ⛔ REBUILD THE TRIE. This loop restored `this.state` — the key->value map — and stopped there, leaving
53
+ // `this.root` a fresh empty VerkleNode. So a node that had loaded EVERY key off disk answered getRoot()
54
+ // with 64 zeros: a tree that holds the whole state and commits to nothing. MEASURED 2026-09-15: after a
55
+ // canonical rebuild of 10 anchors, restart loaded all 10 keys and published root 0000…0000; across the
56
+ // fleet 39 of 44 reporting nodes published exactly that, including one with 11,691 blocks persisted. The
57
+ // only boxes showing a real root were showing it by accident, via XVSM's diff replay re-inserting keys
58
+ // through insert(). The state map is not the commitment — the hashed trie over it is, and it has to be
59
+ // reconstructed on open or every restart silently un-commits the node's entire state.
60
+ this._rebuildTrie();
61
+ }
62
+
63
+ // Rebuild the hashed trie from `this.state` in ONE bottom-up pass. Per-key _updateHashPath would re-hash
64
+ // every ancestor once per key (32 levels x 8KB of child hashes each); this inserts every key first and
65
+ // hashes each node exactly once, so a 50k-key store rehydrates in a single walk instead of 1.6M of them.
66
+ _rebuildTrie() {
67
+ this.root = new VerkleNode();
68
+ for (const [k, v] of this.state) {
69
+ this._insertNode(this.root, this._hashKey(k), this._hashValue(v), 0, []);
70
+ }
71
+ const hashSubtree = (node) => {
72
+ for (const child of node.children.values()) hashSubtree(child);
73
+ this._updateHash(node);
74
+ };
75
+ hashSubtree(this.root);
48
76
  }
49
77
 
50
78
  async _saveState(key, value) {