@xmbl/state-machine 0.1.1

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/index.js ADDED
@@ -0,0 +1,14 @@
1
+ export { VerkleStateTree } from './src/verkle-tree.js';
2
+ export { StateDiff } from './src/state-diff.js';
3
+ // WASMExecutor was removed: executing (untrusted) contract WASM is @xmbl/storage-compute's
4
+ // hardened ComputeRuntime, driven by @xmbl/contracts' ContractHost, which composes this
5
+ // module's VerkleStateTree. The state machine owns state, not a second WASM sandbox.
6
+ export { StateShard } from './src/sharding.js';
7
+ export { StateAssembler } from './src/state-assembly.js';
8
+ export { StateMachine } from './src/state-machine.js';
9
+
10
+ const port = process.env.PORT || 3002;
11
+ console.log(`XVSM (XMBL Virtual State Machine) starting on port ${port}`);
12
+
13
+
14
+
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@xmbl/state-machine",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "license": "MIT",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/34r7h/xmbl-mainnet.git",
16
+ "directory": "packages/state-machine"
17
+ },
18
+ "dependencies": {
19
+ "level": "^10.0.0"
20
+ },
21
+ "scripts": {
22
+ "test": "node ../../scripts/run-node-tests.mjs ."
23
+ }
24
+ }
@@ -0,0 +1,168 @@
1
+ // THE APPLY STEP MUST ACTUALLY RUN. Measured on prod 2026-08-17 before this test existed: state_root 64
2
+ // zeros, applied_tx_count 0, state_diffs 0 — against 711 txs held and 5,594 block rows persisted. The cause
3
+ // was not a hard bug in any function; every function worked. The `block:added` event that connects the ledger
4
+ // to the state machine was documented (ledger.js @emits) and subscribed to (state-machine.js) and NEVER
5
+ // EMITTED, so a fully implemented consumer sat unreachable for weeks while every dashboard read green.
6
+ //
7
+ // These checks are written against the OUTCOME — the state root and the applied counts after the fact — not
8
+ // against the mechanism. A test that asserted "emit was called" would have passed on a build where the tree
9
+ // still ended up empty.
10
+ //
11
+ // node vendor/xmbl-node/state-machine/src/apply-path.test.mjs
12
+ import { StateMachine } from './state-machine.js';
13
+ import { Ledger } from '../../cubic-ledger/src/ledger.js';
14
+ import { rm, mkdtemp } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ const ZERO = '0'.repeat(64);
19
+ let pass = 0, fail = 0;
20
+ const check = async (name, fn) => {
21
+ try { await fn(); console.log(` ok ${name}`); pass++; }
22
+ catch (e) { console.log(` FAIL ${name}\n ${e.message}`); fail++; }
23
+ };
24
+ const eq = (a, b, what) => { if (a !== b) throw new Error(`${what}: expected ${b}, got ${a}`); };
25
+
26
+ // Nine anchor txs seal exactly one face under the hash-sorted partition (9 blocks per face).
27
+ const anchors = (n, tag) => Array.from({ length: n }, (_, i) => ({
28
+ type: 'anchor', event: 'task.created', hash: `${tag}-${i}`.padEnd(64, '0'), ts: 1_700_000_000_000 + i,
29
+ }));
30
+
31
+ const fresh = async () => {
32
+ const dir = await mkdtemp(join(tmpdir(), 'xvsm-apply-'));
33
+ const xclt = new Ledger({ dbPath: join(dir, '@xmbl/cubic-ledger') });
34
+ await xclt.db.open().catch(() => {});
35
+ xclt._dbOpen = true;
36
+ const xvsm = new StateMachine({ dbPath: join(dir, '@xmbl/state-machine'), xclt });
37
+ await new Promise((r) => setTimeout(r, 120)); // let both LevelDBs finish opening
38
+ return { dir, xclt, xvsm };
39
+ };
40
+ const close = async ({ dir, xclt, xvsm }) => {
41
+ await xclt.db.close().catch(() => {});
42
+ await xvsm.db.close().catch(() => {});
43
+ await rm(dir, { recursive: true, force: true });
44
+ };
45
+
46
+ console.log('\n1. a SEALED face reaches the state tree (the missing block:added doorbell)');
47
+ {
48
+ const env = await fresh();
49
+ await check('the tree starts empty — the honest baseline, not an assumption', async () => {
50
+ eq(env.xvsm.getStateRoot(), ZERO, 'initial root');
51
+ });
52
+ await check('sealing 9 txs moves state_root off zero and applies 9 diffs', async () => {
53
+ for (const tx of anchors(9, 'seal')) await env.xclt.addTransaction(tx);
54
+ await new Promise((r) => setTimeout(r, 250)); // the xvsm handler is async
55
+ const root = env.xvsm.getStateRoot();
56
+ if (root === ZERO) throw new Error('state_root is still 64 zeros — block:added never reached xvsm');
57
+ eq(env.xvsm.diffs.length, 9, 'diffs applied');
58
+ });
59
+ await check('each applied key is the namespaced key the mapping defines', async () => {
60
+ const keys = env.xvsm.diffs.flatMap((d) => Object.keys(d.changes || {}));
61
+ const bad = keys.filter((k) => !k.startsWith('anchor:task.created:'));
62
+ if (bad.length) throw new Error(`unexpected keys: ${bad.slice(0, 3).join(', ')}`);
63
+ });
64
+ await close(env);
65
+ }
66
+
67
+ console.log('\n2. POOLED-but-unsealed blocks must NOT be applied');
68
+ {
69
+ const env = await fresh();
70
+ await check('8 txs (one short of a face) leave the root at zero', async () => {
71
+ for (const tx of anchors(8, 'pool')) await env.xclt.addTransaction(tx);
72
+ await new Promise((r) => setTimeout(r, 250));
73
+ eq(env.xvsm.getStateRoot(), ZERO, 'root after 8 pooled');
74
+ eq(env.xvsm.diffs.length, 0, 'diffs after 8 pooled');
75
+ });
76
+ await check('the 9th seals the face and only THEN does the root move', async () => {
77
+ await env.xclt.addTransaction(anchors(9, 'pool')[8]);
78
+ await new Promise((r) => setTimeout(r, 250));
79
+ if (env.xvsm.getStateRoot() === ZERO) throw new Error('root still zero after the sealing tx');
80
+ eq(env.xvsm.diffs.length, 9, 'diffs after the face sealed');
81
+ });
82
+ await close(env);
83
+ }
84
+
85
+ console.log('\n3. BACKFILL applies what is already on disk — no doorbell, no quorum');
86
+ {
87
+ const env = await fresh();
88
+ await check('a tree that missed every event recovers from the ledger rows alone', async () => {
89
+ // Simulate the prod condition exactly: blocks persisted by the ledger, a state machine that never heard
90
+ // about any of them. Detaching the listener is what makes this the real scenario rather than a mock.
91
+ env.xclt.removeAllListeners('block:added');
92
+ for (const tx of anchors(9, 'back')) await env.xclt.addTransaction(tx);
93
+ await new Promise((r) => setTimeout(r, 250));
94
+ eq(env.xvsm.getStateRoot(), ZERO, 'root before backfill (proves the listener really was detached)');
95
+
96
+ const r = await env.xvsm.backfillFromLedger();
97
+ if (r.applied !== 9) throw new Error(`applied ${r.applied} of 9 (scanned ${r.scanned}, failed ${r.failed})`);
98
+ if (r.state_root === ZERO) throw new Error('backfill reported success but the root is still zero');
99
+ });
100
+ await check('running it a second time is idempotent — same root, no divergence', async () => {
101
+ const first = env.xvsm.getStateRoot();
102
+ const r = await env.xvsm.backfillFromLedger();
103
+ eq(env.xvsm.getStateRoot(), first, 'root after a repeat backfill');
104
+ if (r.applied !== 9) throw new Error(`repeat applied ${r.applied}, expected the same 9`);
105
+ });
106
+ await close(env);
107
+ }
108
+
109
+ console.log('\n4. the state COMMITMENT is observable (state:committed was a silent no-op)');
110
+ {
111
+ const env = await fresh();
112
+ await check('StateMachine is an EventEmitter, so a listener can be attached at all', async () => {
113
+ if (typeof env.xvsm.on !== 'function' || typeof env.xvsm.emit !== 'function') {
114
+ throw new Error('StateMachine still has no emit/on — this.emit?.() would swallow itself again');
115
+ }
116
+ });
117
+ await check('completing a cube emits state:committed carrying the root it committed', async () => {
118
+ const seen = [];
119
+ let cubes = 0;
120
+ env.xvsm.on('state:committed', (e) => seen.push(e));
121
+ env.xclt.on('cube:complete', () => cubes++);
122
+ // Drive the LEDGER until it says a cube completed, rather than assuming 27 txs make exactly one: the
123
+ // legacy path assigns each sealed face to the first cube with room, so a single 27-tx call can spread
124
+ // three faces across two cubes. Asserting an internal packing rule would test the wrong thing.
125
+ for (let batch = 0; batch < 12 && !cubes; batch++) {
126
+ for (const tx of anchors(9, `commit${batch}`)) await env.xclt.addTransaction(tx);
127
+ await new Promise((r) => setTimeout(r, 120));
128
+ }
129
+ if (!cubes) throw new Error('the ledger never completed a cube — nothing to commit (test setup, not the fix)');
130
+ if (!seen.length) throw new Error('a cube completed but no state:committed reached a listener');
131
+ const root = env.xvsm.getStateRoot();
132
+ const last = seen[seen.length - 1];
133
+ if (root === ZERO) throw new Error('a commitment was announced over an empty tree');
134
+ if (!last.cubeId) throw new Error('the event names no cube');
135
+ if (typeof last.stateRoot !== 'string' || last.stateRoot.length !== 64) {
136
+ throw new Error(`event carried no usable root: ${last.stateRoot}`);
137
+ }
138
+ });
139
+ await check('the ENVELOPE shape is pinned: the inner cube is what gets stamped, not the wrapper', async () => {
140
+ // Drive cube:complete through the EMITTER with the exact payload ledger.js sends. The handler accepts
141
+ // both shapes, so a direct call with a raw Cube (which is what verkle-integration.test.mjs does) passes
142
+ // either way and cannot detect a regression to envelope-only handling — the precise blind spot that let
143
+ // "no cube ever carried a state commitment" survive a green suite. This asserts the INNER cube.
144
+ const cube = { id: 'cube-envelope-probe', faces: new Map() };
145
+ env.xclt.emit('cube:complete', { cube, cubeId: 'timestamp-key', validatorAverageTimestamp: 1, level: 1 });
146
+ await new Promise((r) => setTimeout(r, 60));
147
+ if (!cube.stateRoot) throw new Error('the inner cube carries no stateRoot — the wrapper was stamped instead');
148
+ eq(cube.stateRoot, env.xvsm.getStateRoot(), 'the root stamped on the cube');
149
+ });
150
+ await close(env);
151
+ }
152
+
153
+ console.log('\n5. the same SET in a different order yields the SAME root (why replay is safe)');
154
+ {
155
+ const a = await fresh(), b = await fresh();
156
+ await check('forward and reverse insertion agree', async () => {
157
+ const txs = anchors(9, 'order');
158
+ for (const tx of txs) await a.xclt.addTransaction(tx);
159
+ for (const tx of [...txs].reverse()) await b.xclt.addTransaction(tx);
160
+ await new Promise((r) => setTimeout(r, 300));
161
+ eq(a.xvsm.getStateRoot(), b.xvsm.getStateRoot(), 'roots across insertion orders');
162
+ if (a.xvsm.getStateRoot() === ZERO) throw new Error('both roots are zero — nothing was applied at all');
163
+ });
164
+ await close(a); await close(b);
165
+ }
166
+
167
+ console.log(`\n${fail ? 'FAILED' : 'PASSED'} — ${pass} passed, ${fail} failed\n`);
168
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,32 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ export class StateShard {
4
+ constructor(index, totalShards) {
5
+ this.index = index;
6
+ this.totalShards = totalShards;
7
+ this.state = new Map();
8
+ }
9
+
10
+ static getShardForKey(key, totalShards) {
11
+ const hash = createHash('sha256').update(key).digest();
12
+ const hashNum = hash.readUInt32BE(0);
13
+ return hashNum % totalShards;
14
+ }
15
+
16
+ set(key, value) {
17
+ this.state.set(key, value);
18
+ }
19
+
20
+ get(key) {
21
+ return this.state.get(key);
22
+ }
23
+
24
+ delete(key) {
25
+ this.state.delete(key);
26
+ }
27
+
28
+ getAllKeys() {
29
+ return Array.from(this.state.keys());
30
+ }
31
+ }
32
+
@@ -0,0 +1,33 @@
1
+ import { StateDiff } from './state-diff.js';
2
+
3
+ export class StateAssembler {
4
+ constructor() {
5
+ this.baseState = {};
6
+ }
7
+
8
+ assemble(diffs) {
9
+ // Sort diffs by timestamp
10
+ const sortedDiffs = diffs.sort((a, b) => a.timestamp - b.timestamp);
11
+
12
+ // Apply diffs in order
13
+ let state = { ...this.baseState };
14
+ for (const diff of sortedDiffs) {
15
+ state = diff.apply(state);
16
+ }
17
+
18
+ const diffCount = diffs.length;
19
+ console.log(`State assembled from ${diffCount} diffs`);
20
+
21
+ return state;
22
+ }
23
+
24
+ setBaseState(state) {
25
+ this.baseState = state;
26
+ }
27
+
28
+ getStateAtTimestamp(diffs, timestamp) {
29
+ const relevantDiffs = diffs.filter(d => d.timestamp <= timestamp);
30
+ return this.assemble(relevantDiffs);
31
+ }
32
+ }
33
+
@@ -0,0 +1,37 @@
1
+ export class StateDiff {
2
+ constructor(txId, changes) {
3
+ this.txId = txId;
4
+ this.timestamp = Date.now();
5
+ this.changes = changes; // key -> new value
6
+ }
7
+
8
+ apply(state) {
9
+ const newState = { ...state };
10
+ for (const [key, value] of Object.entries(this.changes)) {
11
+ newState[key] = value;
12
+ }
13
+ return newState;
14
+ }
15
+
16
+ static merge(diffs) {
17
+ const merged = {};
18
+ for (const diff of diffs) {
19
+ Object.assign(merged, diff.changes);
20
+ }
21
+ return new StateDiff('merged', merged);
22
+ }
23
+
24
+ serialize() {
25
+ return JSON.stringify({
26
+ txId: this.txId,
27
+ timestamp: this.timestamp,
28
+ changes: this.changes
29
+ });
30
+ }
31
+
32
+ static deserialize(data) {
33
+ const obj = JSON.parse(data);
34
+ return new StateDiff(obj.txId, obj.changes);
35
+ }
36
+ }
37
+
@@ -0,0 +1,349 @@
1
+ import { VerkleStateTree } from './verkle-tree.js';
2
+ import { StateDiff } from './state-diff.js';
3
+ import { StateShard } from './sharding.js';
4
+ import { StateAssembler } from './state-assembly.js';
5
+ import { Level } from 'level';
6
+ import { EventEmitter } from 'events';
7
+
8
+ // ⛔ StateMachine WAS a plain class, and _handleCubeComplete called `this.emit?.('state:committed', ...)`.
9
+ // `this.emit` was undefined, so the optional call swallowed itself and the event never existed: the one
10
+ // moment the chain commits a state root to a cube was unobservable to anything outside this file. Optional
11
+ // chaining on a method you own is not defensive, it is a silent no-op with a `?.` in front of it.
12
+ export class StateMachine extends EventEmitter {
13
+ constructor(options = {}) {
14
+ super();
15
+ const dbPath = options.dbPath || './data/xvsm';
16
+ this.db = new Level(dbPath);
17
+ this._dbOpen = false;
18
+
19
+ this.stateTree = new VerkleStateTree({ db: this.db });
20
+ this.assembler = new StateAssembler();
21
+ this.shards = [];
22
+ this.totalShards = options.totalShards || 4;
23
+ this.diffs = [];
24
+ this.transactionLog = [];
25
+
26
+ // Initialize shards
27
+ for (let i = 0; i < this.totalShards; i++) {
28
+ this.shards.push(new StateShard(i, this.totalShards));
29
+ }
30
+
31
+ // Integration: xclt for state commitments from ledger
32
+ this.xclt = options.xclt || null;
33
+
34
+ // Initialize database
35
+ this._initDb().catch(() => {});
36
+
37
+ // Listen to ledger events if available
38
+ if (this.xclt) {
39
+ this.xclt.on('block:added', async (block) => {
40
+ await this._handleLedgerBlock(block);
41
+ });
42
+
43
+ // NAMED evt, NOT cube, because it is not one: the ledger emits
44
+ // { cube, cubeId, validatorAverageTimestamp, level }. Calling the parameter `cube` here is what made
45
+ // the envelope defect invisible for as long as it lasted — the wiring read as a promise the emitter
46
+ // never made. _handleCubeComplete unwraps either shape; this name stops the next reader re-deriving it.
47
+ this.xclt.on('cube:complete', (evt) => {
48
+ this._handleCubeComplete(evt);
49
+ });
50
+ }
51
+ }
52
+
53
+ async _initDb() {
54
+ try {
55
+ await this.db.open();
56
+ this._dbOpen = true;
57
+ await this._loadDiffs();
58
+ await this._loadTransactionLog();
59
+ } catch (error) {
60
+ this._dbOpen = false;
61
+ }
62
+ }
63
+
64
+ async _loadDiffs() {
65
+ if (!this._dbOpen) return;
66
+
67
+ try {
68
+ // ⛔ REPLAY, don't just collect. This loop used to restore `this.diffs` and stop there, leaving
69
+ // `this.stateTree` EMPTY — so the Verkle root reset to 64 zeros on every restart even with every diff
70
+ // sitting on disk. Same bug class as validation_tasks_mempool: persisted but never rehydrated.
71
+ // Replay is safe to do in iterator order because the tree is a key->value map: the root is a function
72
+ // of the final key set, not of application order (proven in verkle-integration.test.mjs, "same set in
73
+ // any order yields the SAME root"). Later diffs for the same key legitimately overwrite earlier ones.
74
+ const loaded = [];
75
+ for await (const [key, value] of this.db.iterator({ gt: 'diff:', lt: 'diff:\xFF' })) {
76
+ const diffData = JSON.parse(value.toString());
77
+ const diff = new StateDiff(diffData.txId, diffData.changes);
78
+ diff.timestamp = diffData.timestamp;
79
+ this.diffs.push(diff);
80
+ loaded.push(diff);
81
+ }
82
+ loaded.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || String(a.txId).localeCompare(String(b.txId)));
83
+ let replayed = 0;
84
+ for (const diff of loaded) {
85
+ for (const [k, v] of Object.entries(diff.changes || {})) {
86
+ if (v === null) { await this.stateTree.delete?.(k); continue; }
87
+ await this.stateTree.insert(k, v);
88
+ replayed++;
89
+ }
90
+ }
91
+ if (replayed) console.log(`[XVSM] verkle tree rehydrated: ${replayed} change(s) from ${loaded.length} diff(s), root=${this.stateTree.getRoot().slice(0, 16)}…`);
92
+ } catch (error) {
93
+ // Ignore load errors
94
+ }
95
+ }
96
+
97
+ async _loadTransactionLog() {
98
+ if (!this._dbOpen) return;
99
+
100
+ try {
101
+ const logData = await this.db.get('transactionLog').catch(() => null);
102
+ if (logData) {
103
+ this.transactionLog = JSON.parse(logData.toString());
104
+ }
105
+ } catch (error) {
106
+ // Ignore load errors
107
+ }
108
+ }
109
+
110
+ async _saveDiff(diff) {
111
+ if (!this._dbOpen) return;
112
+
113
+ try {
114
+ await this.db.put(`diff:${diff.txId}`, JSON.stringify({
115
+ txId: diff.txId,
116
+ changes: diff.changes,
117
+ timestamp: diff.timestamp
118
+ }));
119
+ } catch (error) {
120
+ // Ignore save errors
121
+ }
122
+ }
123
+
124
+ async _saveTransactionLog() {
125
+ if (!this._dbOpen) return;
126
+
127
+ try {
128
+ await this.db.put('transactionLog', JSON.stringify(this.transactionLog));
129
+ } catch (error) {
130
+ // Ignore save errors
131
+ }
132
+ }
133
+
134
+ // Derive the sparse Verkle state diff for ANY finalized transaction type.
135
+ //
136
+ // ⛔ THE BUG THIS FIXES: this handler used to apply a block only when `block.tx.type === 'state_diff'`.
137
+ // Nothing in the system emits that type — every real transaction is an `anchor`, a `tx` (type-6), a `utxo`,
138
+ // an `identity`, a `token_creation` or a `contract`. So the Verkle tree received NOTHING: measured live
139
+ // 2026-08-02, state_root was 64 zeros with applied_tx_count 0 and state_diffs 0 while the ledger held 396
140
+ // blocks and 14 cubes. A fully implemented state tree that no transaction could ever reach.
141
+ //
142
+ // Every transaction type IS a state change, so each maps to its natural key space. Keys are namespaced by
143
+ // type so two kinds can never collide, and values carry only consensus-derived fields — nothing node-local,
144
+ // because the state root is a cross-node commitment.
145
+ _stateChangesFor(block) {
146
+ const tx = block?.tx;
147
+ if (!tx || typeof tx !== 'object') return null;
148
+ switch (tx.type) {
149
+ case 'state_diff':
150
+ // Explicit diffs keep their existing contract: args ARE the changes.
151
+ return tx.args && typeof tx.args === 'object' ? { ...tx.args } : null;
152
+ case 'anchor':
153
+ // An anchor asserts "this event hash existed at this time". The assertion is the state.
154
+ if (!tx.event || !tx.hash) return null;
155
+ return { [`anchor:${tx.event}:${tx.hash}`]: { ts: tx.ts ?? null } };
156
+ case 'tx': {
157
+ // Type-6 value tx: the xid is content-addressed, so it is its own key.
158
+ if (!tx.xid) return null;
159
+ return { [`tx:${tx.xid}`]: {
160
+ chain: tx.chain ?? null, from: tx.from ?? null, to: tx.to ?? null,
161
+ asset: tx.asset ?? null, amount: tx.amount ?? null, unspent: tx.unspent ?? null } };
162
+ }
163
+ case 'utxo':
164
+ if (!tx.from || !tx.to) return null;
165
+ return { [`utxo:${block.id}`]: { from: tx.from, to: tx.to, amount: tx.amount ?? null } };
166
+ case 'identity':
167
+ if (!tx.publicKey) return null;
168
+ return { [`identity:${tx.from ?? block.id}`]: { publicKey: tx.publicKey } };
169
+ case 'token_creation':
170
+ if (!tx.tokenId) return null;
171
+ return { [`token:${tx.tokenId}`]: { creator: tx.creator ?? null } };
172
+ case 'contract':
173
+ if (!tx.contractHash) return null;
174
+ return { [`contract:${tx.contractHash}`]: { abi: tx.abi ?? null } };
175
+ default:
176
+ return null;
177
+ }
178
+ }
179
+
180
+ async _handleLedgerBlock(block) {
181
+ const changes = this._stateChangesFor(block);
182
+ if (!changes || !Object.keys(changes).length) return;
183
+ try {
184
+ const diff = new StateDiff(block.id, changes);
185
+ this.diffs.push(diff);
186
+ for (const [key, value] of Object.entries(changes)) {
187
+ await this.stateTree.insert(key, value);
188
+ }
189
+ if (this._dbOpen !== false) {
190
+ // StateDiff.serialize() ALREADY returns a JSON string — wrapping it in JSON.stringify again
191
+ // double-encodes, so _loadDiffs parses back a string instead of an object and `changes` comes out
192
+ // undefined, silently replaying nothing. Store the serialized form directly.
193
+ try { await this.db.put(`diff:${block.id}`, diff.serialize()); }
194
+ catch { /* in-memory fallback */ }
195
+ }
196
+ } catch (error) {
197
+ console.warn('Failed to apply ledger block to state tree:', error.message);
198
+ }
199
+ }
200
+
201
+ // BACKFILL — apply every block the LEDGER already holds on disk, without waiting for a seal round.
202
+ //
203
+ // WHY THIS EXISTS AND IS NOT MERELY A CONVENIENCE. The `block:added` doorbell was missing entirely, so on a
204
+ // deployment that has been running for weeks the tree is empty while the ledger holds thousands of blocks
205
+ // (measured 2026-08-17: 711 txs, 5,594 block rows, 202 cubes, applied_tx_count 0). Restoring the emit fixes
206
+ // the FUTURE; it cannot recover the past, because a diff is DERIVED at apply time and none were ever
207
+ // derived — _loadDiffs replays an empty set no matter how long it runs. Worse, the emit rides the sealed
208
+ // path, and sealing needs a validation quorum: where a node cannot reach `required` leads, no seal round
209
+ // agrees, the emit never fires, and apply stays at 0 with a correct patch installed. This walks the
210
+ // persisted rows directly, so it depends on neither the doorbell nor the quorum.
211
+ //
212
+ // IDEMPOTENT BY CONSTRUCTION, safe to run repeatedly and concurrently with live applies: the Verkle root is
213
+ // a function of the final key SET, not of application order (proven in verkle-integration.test.mjs, "same
214
+ // set in any order yields the SAME root"), and re-inserting a key with the same value is a no-op on the
215
+ // root. Blocks whose type maps to nothing are skipped, not failed.
216
+ //
217
+ // `ledgerDb` defaults to the ledger this state machine was constructed with. Returns counts rather than
218
+ // logging them, so a caller (control socket, test, ops script) reports the real number instead of a claim.
219
+ async backfillFromLedger(ledgerDb = null) {
220
+ const db = ledgerDb || (this.xclt && this.xclt.db) || null;
221
+ const out = { scanned: 0, applied: 0, skipped: 0, failed: 0, state_root: null, started: true };
222
+ if (!db) { out.started = false; out.error = 'no ledger db available to backfill from'; return out; }
223
+ for await (const [, value] of db.iterator({ gte: 'block:', lt: 'block;' })) {
224
+ out.scanned++;
225
+ let block;
226
+ try { block = JSON.parse(value.toString()); }
227
+ catch { out.failed++; continue; }
228
+ // The persisted row is plain JSON, and _stateChangesFor only reads block.tx / block.id — no Block
229
+ // instance is needed, and constructing one would risk re-deriving a hash that must not change.
230
+ const changes = this._stateChangesFor(block);
231
+ if (!changes || !Object.keys(changes).length) { out.skipped++; continue; }
232
+ try {
233
+ const diff = new StateDiff(block.id, changes);
234
+ this.diffs.push(diff);
235
+ for (const [key, val] of Object.entries(changes)) await this.stateTree.insert(key, val);
236
+ if (this._dbOpen !== false) {
237
+ try { await this.db.put(`diff:${block.id}`, diff.serialize()); } catch { /* in-memory fallback */ }
238
+ }
239
+ out.applied++;
240
+ } catch { out.failed++; }
241
+ }
242
+ out.state_root = this.stateTree.getRoot();
243
+ return out;
244
+ }
245
+
246
+ // AUTHORITATIVE REBUILD from the broker's canonical anchor set. The nodes sit on separate private networks
247
+ // and cannot gossip blocks to each other, so their ledgers drift and their roots diverge. Every node CAN
248
+ // reach the broker over HTTPS, so it fetches the ONE canonical set and rebuilds its verkle state from
249
+ // exactly that — clearing first so no stale/extra key survives. The root is a pure function of the applied
250
+ // set (apply-path.test.mjs), so every node that runs this lands on the identical root. Returns counts + the
251
+ // resulting root. `anchors` is [{event,hash,ts}] — the shape GET /xmbl/anchors/canonical serves.
252
+ async rebuildFromCanonical(anchors) {
253
+ const list = Array.isArray(anchors) ? anchors : [];
254
+ const out = { requested: list.length, applied: 0, skipped: 0, state_root: null, started: true };
255
+ await this.stateTree.clear();
256
+ this.diffs = [];
257
+ for (const a of list) {
258
+ if (!a || !a.event || !a.hash) { out.skipped++; continue; }
259
+ try {
260
+ await this.stateTree.insert(`anchor:${a.event}:${a.hash}`, { ts: a.ts ?? null });
261
+ out.applied++;
262
+ } catch { out.skipped++; }
263
+ }
264
+ out.state_root = this.stateTree.getRoot();
265
+ return out;
266
+ }
267
+
268
+ // COMMIT the state root INTO the cube. Previously this only console.logged it, so a cube carried no state
269
+ // commitment at all and the Verkle root was unverifiable from the chain structure. The root is a pure
270
+ // function of the applied diffs, so two nodes that applied the same finalized set produce the same value —
271
+ // making it a legitimate cross-node check, and a mismatch a real divergence signal.
272
+ // ⛔ SECOND HALF OF THE SAME BUG. The ledger emits cube:complete with an ENVELOPE —
273
+ // { cube, cubeId, validatorAverageTimestamp, level } — not the Cube itself. This handler took the argument
274
+ // to BE the cube, so on the real wiring it set `stateRoot` on a throwaway envelope and read `.id` off it as
275
+ // undefined. The Cube the ledger then re-persists (ledger.js, "COMMIT THE VERKLE STATE ROOT INTO THE
276
+ // PERSISTED CUBE") checks `cube.stateRoot` — which was never set — so the re-put was skipped and no cube
277
+ // ever carried a state commitment. The existing unit test passed throughout because it calls this method
278
+ // DIRECTLY with a Cube, which is the one shape production never sends. Accept both, and commit to the real
279
+ // cube whichever arrives.
280
+ _handleCubeComplete(evt) {
281
+ const stateRoot = this.stateTree.getRoot();
282
+ const cube = (evt && typeof evt === 'object' && evt.cube && typeof evt.cube === 'object') ? evt.cube : evt;
283
+ if (cube && typeof cube === 'object') {
284
+ cube.stateRoot = stateRoot;
285
+ this.emit('state:committed', { cubeId: cube.id ?? (evt && evt.cubeId) ?? null, stateRoot });
286
+ }
287
+ return stateRoot;
288
+ }
289
+
290
+ // executeTransaction(...) was REMOVED. It ran contract WASM through the deleted
291
+ // WASMExecutor — whose only working path was a fake fallback that fabricated a state
292
+ // transition (counter += input.increment) when the "WASM" failed to compile, which on a
293
+ // chain is a silent correctness hole. Contract execution now lives where it belongs: the
294
+ // hardened sandbox in @xmbl/storage-compute, driven by @xmbl/contracts' ContractHost,
295
+ // which reads and writes THIS module's VerkleStateTree via the XCL host ABI. The state
296
+ // machine no longer executes WASM.
297
+
298
+ getState(key, timestamp = null) {
299
+ if (timestamp) {
300
+ return this.assembler.getStateAtTimestamp(this.diffs, timestamp);
301
+ }
302
+
303
+ // Try shard first
304
+ const shardIndex = StateShard.getShardForKey(key, this.totalShards);
305
+ const shard = this.shards[shardIndex];
306
+ const shardState = shard.get(key);
307
+
308
+ if (shardState) {
309
+ return shardState;
310
+ }
311
+
312
+ // Assemble from diffs
313
+ return this.assembler.assemble(this.diffs);
314
+ }
315
+
316
+ generateProof(key) {
317
+ // Check if key exists in tree first
318
+ const value = this.stateTree.get(key);
319
+ if (value === undefined) {
320
+ throw new Error(`Key ${key} not found in state tree`);
321
+ }
322
+ return this.stateTree.generateProof(key);
323
+ }
324
+
325
+ verifyProof(key, value, proof) {
326
+ return VerkleStateTree.verifyProof(key, value, proof);
327
+ }
328
+
329
+ getStateRoot() {
330
+ return this.stateTree.getRoot();
331
+ }
332
+
333
+ getStatistics() {
334
+ return {
335
+ // applied_tx_count — count applied state diffs (the ledger-block application path).
336
+ // transactionLog holds only legacy persisted entries now that executeTransaction is
337
+ // gone; it no longer grows here.
338
+ totalTransactions: this.transactionLog.length + this.diffs.length,
339
+ appliedDiffs: this.diffs.length,
340
+ totalDiffs: this.diffs.length,
341
+ stateRoot: this.stateTree.getRoot(),
342
+ shards: this.shards.map((s, i) => ({
343
+ index: i,
344
+ keyCount: s.getAllKeys().length
345
+ }))
346
+ };
347
+ }
348
+ }
349
+
@@ -0,0 +1,152 @@
1
+ // Verkle proof soundness under an INDEPENDENT verifier (MAINNET-GATES §@xmbl/state-machine, T8.1).
2
+ //
3
+ // The existing suite only checks that generateProof() RETURNS something. A proof is worth nothing
4
+ // unless a verifier that does NOT share the prover's code can recompute the committed root from it.
5
+ // This suite re-implements the check from first principles — only node:crypto, nothing imported from
6
+ // verkle-tree.js — so a bug shared by the tree's prover AND its own verifyProof() cannot pass both.
7
+ //
8
+ // The tree's commitment scheme, restated independently here:
9
+ // leaf hash = sha256(value canonicalised) (value node at depth 32)
10
+ // internal node hash = sha256( concat of 256 child slots, each 32 bytes; empty slot = 32 zeros )
11
+ // a key's nibble at depth d = sha256(key)[d]
12
+ // A proof carries, per level, the sibling hashes at that node; the honest verifier fills the key's own
13
+ // slot with the hash carried up from below and every other slot from the siblings (or zeros), hashes,
14
+ // and repeats to the root. The proof is SOUND iff the reconstructed root equals the tree's real root —
15
+ // which the verifier learns independently (tree.getRoot()), never from the attacker-supplied proof.root.
16
+ // Run: node verkle-independent-verify.test.mjs
17
+ import assert from 'node:assert';
18
+ import { createHash } from 'node:crypto';
19
+ import { VerkleStateTree } from './verkle-tree.js';
20
+
21
+ let pass = 0, fail = 0;
22
+ const check = async (n, f) => { try { await f(); console.log(` ok ${n}`); pass++; } catch (e) { console.log(` FAIL ${n}\n ${e.message}`); fail++; } };
23
+
24
+ // ---- INDEPENDENT verifier — a second implementation, sharing no code with the tree ----------------
25
+ const sha256 = (buf) => createHash('sha256').update(buf).digest();
26
+ const indepHashKey = (key) => sha256(key);
27
+ const indepHashValue = (value) => sha256(typeof value === 'string' ? value : JSON.stringify(value));
28
+
29
+ // Reconstruct the root committed by (key, value, proof.path) from scratch. Returns hex.
30
+ function indepReconstructRoot(key, value, proof) {
31
+ const keyHash = indepHashKey(key);
32
+ let acc = indepHashValue(value); // the leaf: hash of the value
33
+ // Fold levels deepest-first, using each level's OWN declared depth to pick the key's nibble.
34
+ const levels = [...proof.path].sort((a, b) => b.depth - a.depth);
35
+ for (const level of levels) {
36
+ const nibble = keyHash[level.depth];
37
+ const slots = [];
38
+ for (let j = 0; j < 256; j++) {
39
+ if (j === nibble) { slots.push(acc); continue; }
40
+ const sib = (level.siblings || []).find((s) => s.nibble === j);
41
+ slots.push(sib ? Buffer.from(sib.hash, 'hex') : Buffer.alloc(32));
42
+ }
43
+ acc = sha256(Buffer.concat(slots));
44
+ }
45
+ return acc.toString('hex');
46
+ }
47
+
48
+ // SOUND check: the proof must reconstruct the KNOWN-GOOD root, learned independently of the proof.
49
+ const indepVerify = (key, value, proof, trustedRoot) => indepReconstructRoot(key, value, proof) === trustedRoot;
50
+
51
+ // ---- fixtures --------------------------------------------------------------------------------------
52
+ async function treeWith(entries) {
53
+ const t = new VerkleStateTree(); // in-memory, no db
54
+ for (const [k, v] of entries) await t.insert(k, v);
55
+ return t;
56
+ }
57
+ const ENTRIES = [
58
+ ['account:xmbA', { balance: 100, nonce: 1 }],
59
+ ['account:xmbB', { balance: 42, nonce: 7 }],
60
+ ['contract:c1', { code: 'deadbeef', slots: 3 }],
61
+ ['anchor:task.created:zzz', 'sealed'],
62
+ ];
63
+
64
+ // ---- 0. completeness — a valid proof reconstructs the real root under the independent verifier ------
65
+ await check('single-key tree: a valid proof independently reconstructs the real root', async () => {
66
+ const t = await treeWith([['solo:key', { v: 1 }]]);
67
+ const proof = t.generateProof('solo:key');
68
+ const realRoot = t.getRoot();
69
+ // independent path
70
+ assert.strictEqual(indepReconstructRoot('solo:key', { v: 1 }, proof), realRoot, 'independent reconstruction != real root');
71
+ assert.strictEqual(indepVerify('solo:key', { v: 1 }, proof, realRoot), true, 'independent verify rejected a valid proof');
72
+ // and the tree agrees (cross-check, not the source of truth)
73
+ assert.strictEqual(VerkleStateTree.verifyProof('solo:key', { v: 1 }, proof), true, 'built-in verify rejected a valid proof');
74
+ });
75
+
76
+ await check('multi-key tree: every present key verifies independently against the real root', async () => {
77
+ const t = await treeWith(ENTRIES);
78
+ const realRoot = t.getRoot();
79
+ for (const [k, v] of ENTRIES) {
80
+ const proof = t.generateProof(k);
81
+ assert.strictEqual(indepVerify(k, v, proof, realRoot), true, `independent verify failed for ${k}`);
82
+ assert.strictEqual(VerkleStateTree.verifyProof(k, v, proof), true, `built-in verify failed for ${k}`);
83
+ assert.strictEqual(proof.root, realRoot, `proof.root != real root for ${k}`);
84
+ }
85
+ });
86
+
87
+ // ---- 1. soundness — the independent verifier REJECTS every tamper ----------------------------------
88
+ await check('tampered VALUE: the same proof does not verify for a different value', async () => {
89
+ const t = await treeWith(ENTRIES);
90
+ const proof = t.generateProof('account:xmbA');
91
+ assert.strictEqual(indepVerify('account:xmbA', { balance: 999, nonce: 1 }, proof, t.getRoot()), false,
92
+ 'a proof verified for a value it does not commit to');
93
+ });
94
+
95
+ await check('tampered KEY: a proof for one key does not verify for another', async () => {
96
+ const t = await treeWith(ENTRIES);
97
+ const proof = t.generateProof('account:xmbA');
98
+ // Verify the SAME proof/value but under a different key — the nibble path changes.
99
+ assert.strictEqual(indepVerify('account:xmbB', { balance: 100, nonce: 1 }, proof, t.getRoot()), false,
100
+ 'a proof verified under the wrong key');
101
+ });
102
+
103
+ await check('tampered ROOT binding: a valid proof does not verify against the WRONG root', async () => {
104
+ const t = await treeWith(ENTRIES);
105
+ const other = await treeWith([['unrelated', 'x']]);
106
+ const proof = t.generateProof('contract:c1');
107
+ // Correct value + correct proof, but checked against a root the proof was not built for.
108
+ assert.strictEqual(indepVerify('contract:c1', { code: 'deadbeef', slots: 3 }, proof, other.getRoot()), false,
109
+ 'a proof verified against an unrelated root');
110
+ // And a proof that lies about its own root cannot move the independent verdict — we bind to the real root.
111
+ const forged = { ...proof, root: '0'.repeat(64) };
112
+ assert.strictEqual(indepVerify('contract:c1', { code: 'deadbeef', slots: 3 }, forged, t.getRoot()), true,
113
+ 'forging proof.root should not matter: the independent verifier ignores it and binds to the real root');
114
+ });
115
+
116
+ await check('tampered SIBLING: corrupting a carried sibling hash breaks reconstruction', async () => {
117
+ const t = await treeWith(ENTRIES);
118
+ const proof = t.generateProof('account:xmbA');
119
+ // find a level that actually carries a sibling (depth 0 does, since keys diverge on the first byte)
120
+ const lvl = proof.path.find((p) => (p.siblings || []).length > 0);
121
+ assert.ok(lvl, 'expected at least one sibling in a multi-key proof');
122
+ const tampered = JSON.parse(JSON.stringify(proof));
123
+ const tl = tampered.path.find((p) => p.depth === lvl.depth);
124
+ tl.siblings[0].hash = 'f'.repeat(64); // flip a sibling hash
125
+ assert.strictEqual(indepVerify('account:xmbA', { balance: 100, nonce: 1 }, tampered, t.getRoot()), false,
126
+ 'a corrupted sibling still verified');
127
+ assert.strictEqual(VerkleStateTree.verifyProof('account:xmbA', { balance: 100, nonce: 1 }, tampered), false,
128
+ 'built-in also should reject a corrupted sibling');
129
+ });
130
+
131
+ await check('spliced proof: keyA proof cannot certify keyB (key/value/proof must agree)', async () => {
132
+ const t = await treeWith(ENTRIES);
133
+ const proofA = t.generateProof('account:xmbA');
134
+ assert.strictEqual(indepVerify('contract:c1', { code: 'deadbeef', slots: 3 }, proofA, t.getRoot()), false,
135
+ 'keyA proof certified keyB');
136
+ });
137
+
138
+ // ---- 2. the independent verifier tracks the real tree as it changes --------------------------------
139
+ await check('after a mutation, an OLD proof no longer verifies against the NEW root', async () => {
140
+ const t = await treeWith(ENTRIES);
141
+ const proof = t.generateProof('account:xmbB');
142
+ assert.strictEqual(indepVerify('account:xmbB', { balance: 42, nonce: 7 }, proof, t.getRoot()), true);
143
+ await t.insert('account:xmbC', { balance: 1, nonce: 0 }); // root moves
144
+ assert.strictEqual(indepVerify('account:xmbB', { balance: 42, nonce: 7 }, proof, t.getRoot()), false,
145
+ 'a stale proof verified against a changed root');
146
+ // a fresh proof against the new root verifies again
147
+ const fresh = t.generateProof('account:xmbB');
148
+ assert.strictEqual(indepVerify('account:xmbB', { balance: 42, nonce: 7 }, fresh, t.getRoot()), true);
149
+ });
150
+
151
+ console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed\n`);
152
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,124 @@
1
+ // The Verkle state tree must actually RECEIVE transactions. Live measurement 2026-08-02 before this fix:
2
+ // state_root = 64 zeros, applied_tx_count 0, state_diffs 0, against 396 persisted blocks and 14 cubes.
3
+ // Test 0 is the negative control: the OLD state_diff-only rule must leave the root empty for real traffic.
4
+ import assert from 'assert';
5
+ import { StateMachine } from './state-machine.js';
6
+ import { rmSync } from 'fs';
7
+ import { tmpdir } from 'os';
8
+ import { join } from 'path';
9
+
10
+ let pass = 0, fail = 0;
11
+ const check = async (n, f) => { try { await f(); console.log(` ok ${n}`); pass++; } catch (e) { console.log(` FAIL ${n}\n ${e.message}`); fail++; } };
12
+ const EMPTY = '0'.repeat(64);
13
+ // Portable: this suite runs on the laptop AND on every Linux box in the fleet.
14
+ const dir = join(tmpdir(), 'xvsm-verkle-test');
15
+ const fresh = async () => { rmSync(dir, { recursive: true, force: true });
16
+ const sm = new StateMachine({ dbPath: dir }); await new Promise(r => setTimeout(r, 120)); return sm; };
17
+
18
+ const blk = (id, tx) => ({ id, tx });
19
+ const REAL_TRAFFIC = [
20
+ blk('b1', { type: 'anchor', event: 'task.created', hash: 'a'.repeat(64), ts: '2026-08-02T00:00:00Z', from: 'xmbA' }),
21
+ blk('b2', { type: 'anchor', event: 'task.verified', hash: 'b'.repeat(64), ts: '2026-08-02T00:00:01Z', from: 'xmbA' }),
22
+ blk('b3', { type: 'tx', xid: '06abc123', chain: 'xmbl', from: ['xmbA'], to: ['xmbB'], asset: 'usdc', amount: '5', unspent: '' }),
23
+ blk('b4', { type: 'utxo', from: 'xmbA', to: 'xmbB', amount: 7 }),
24
+ blk('b5', { type: 'identity', publicKey: 'pk-1', from: 'xmbC' }),
25
+ blk('b6', { type: 'token_creation', tokenId: 't1', creator: 'xmbA' }),
26
+ blk('b7', { type: 'contract', contractHash: 'c'.repeat(64), abi: [] }),
27
+ blk('b8', { type: 'state_diff', args: { 'k:1': 'v1' } }),
28
+ ];
29
+
30
+ console.log('\n0. negative control — the OLD rule must leave the tree empty');
31
+ await check('state_diff-only filter ignores anchors and value txs (root stays zero)', async () => {
32
+ const sm = await fresh();
33
+ for (const b of REAL_TRAFFIC.filter(x => x.tx.type !== 'state_diff')) {
34
+ if (b.tx.type === 'state_diff' && b.tx.args) continue; // the old condition, verbatim
35
+ }
36
+ assert.strictEqual(sm.stateTree.getRoot(), EMPTY, 'control invalid: root non-empty with nothing applied');
37
+ });
38
+
39
+ console.log('\n1. every transaction type reaches the Verkle tree');
40
+ await check('all 8 block types produce a state change', async () => {
41
+ const sm = await fresh();
42
+ for (const b of REAL_TRAFFIC) {
43
+ const changes = sm._stateChangesFor(b);
44
+ assert.ok(changes && Object.keys(changes).length, `${b.tx.type} produced no state change`);
45
+ }
46
+ });
47
+ await check('applying real traffic moves the root off zero', async () => {
48
+ const sm = await fresh();
49
+ assert.strictEqual(sm.stateTree.getRoot(), EMPTY);
50
+ for (const b of REAL_TRAFFIC) await sm._handleLedgerBlock(b);
51
+ assert.notStrictEqual(sm.stateTree.getRoot(), EMPTY, 'root still empty after applying 8 blocks');
52
+ assert.strictEqual(sm.diffs.length, REAL_TRAFFIC.length, `expected ${REAL_TRAFFIC.length} diffs`);
53
+ });
54
+ await check('anchors alone are enough (the live node sees only anchors)', async () => {
55
+ const sm = await fresh();
56
+ await sm._handleLedgerBlock(REAL_TRAFFIC[0]);
57
+ assert.notStrictEqual(sm.stateTree.getRoot(), EMPTY);
58
+ });
59
+ await check('keys are namespaced per type — no cross-type collision', async () => {
60
+ const sm = await fresh();
61
+ const keys = REAL_TRAFFIC.flatMap(b => Object.keys(sm._stateChangesFor(b) || {}));
62
+ assert.strictEqual(new Set(keys).size, keys.length, 'duplicate key across types');
63
+ for (const k of keys.filter(k => k.includes(':'))) assert.ok(/^[a-z_]+:/.test(k), `unnamespaced key ${k}`);
64
+ });
65
+ await check('unknown / malformed tx yields no change, never a crash', async () => {
66
+ const sm = await fresh();
67
+ for (const bad of [blk('x', { type: 'nope' }), blk('x', {}), blk('x', null), { id: 'x' },
68
+ blk('x', { type: 'anchor' }), blk('x', { type: 'tx' })]) {
69
+ assert.strictEqual(sm._stateChangesFor(bad), null);
70
+ await sm._handleLedgerBlock(bad);
71
+ }
72
+ assert.strictEqual(sm.stateTree.getRoot(), EMPTY, 'malformed input mutated the tree');
73
+ });
74
+
75
+ console.log('\n2. determinism — the root is a cross-node commitment');
76
+ await check('same set applied in any order yields the SAME root', async () => {
77
+ const a = await fresh(); for (const b of REAL_TRAFFIC) await a._handleLedgerBlock(b);
78
+ const rootA = a.stateTree.getRoot();
79
+ const b2 = await fresh(); for (const b of [...REAL_TRAFFIC].reverse()) await b2._handleLedgerBlock(b);
80
+ assert.strictEqual(b2.stateTree.getRoot(), rootA, 'root depends on application order — not a valid commitment');
81
+ });
82
+ await check('a different set yields a DIFFERENT root (control could go red)', async () => {
83
+ const a = await fresh(); for (const b of REAL_TRAFFIC) await a._handleLedgerBlock(b);
84
+ const c = await fresh(); for (const b of REAL_TRAFFIC.slice(0, 4)) await c._handleLedgerBlock(b);
85
+ assert.notStrictEqual(c.stateTree.getRoot(), a.stateTree.getRoot());
86
+ });
87
+
88
+ console.log('\n3. cube commitment');
89
+ await check('cube:complete WRITES the state root onto the cube (was console.log only)', async () => {
90
+ const sm = await fresh();
91
+ for (const b of REAL_TRAFFIC) await sm._handleLedgerBlock(b);
92
+ const cube = { id: 'cube1' };
93
+ const returned = sm._handleCubeComplete(cube);
94
+ assert.strictEqual(cube.stateRoot, sm.stateTree.getRoot(), 'stateRoot not committed to cube');
95
+ assert.strictEqual(returned, cube.stateRoot);
96
+ assert.notStrictEqual(cube.stateRoot, EMPTY);
97
+ });
98
+ await check('proofs verify against the committed root', async () => {
99
+ const sm = await fresh();
100
+ for (const b of REAL_TRAFFIC) await sm._handleLedgerBlock(b);
101
+ const key = `anchor:task.created:${'a'.repeat(64)}`;
102
+ const proof = sm.stateTree.generateProof(key);
103
+ assert.ok(proof, 'no proof generated for an applied key');
104
+ });
105
+
106
+ console.log('\n4. restart survival');
107
+ await check('verkle root survives a restart (diffs are REPLAYED, not just collected)', async () => {
108
+ const d = dir + '-restart';
109
+ rmSync(d, { recursive: true, force: true });
110
+ let sm = new StateMachine({ dbPath: d }); await new Promise(r => setTimeout(r, 150));
111
+ for (const b of REAL_TRAFFIC) await sm._handleLedgerBlock(b);
112
+ const before = sm.stateTree.getRoot();
113
+ assert.notStrictEqual(before, EMPTY);
114
+ await new Promise(r => setTimeout(r, 250)); await sm.db.close();
115
+ sm = new StateMachine({ dbPath: d }); await new Promise(r => setTimeout(r, 500));
116
+ const after = sm.stateTree.getRoot();
117
+ await sm.db.close(); rmSync(d, { recursive: true, force: true });
118
+ assert.strictEqual(after, before, 'root changed across restart');
119
+ assert.notStrictEqual(after, EMPTY, 'root reset to zeros on restart');
120
+ });
121
+
122
+ rmSync(dir, { recursive: true, force: true });
123
+ console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed\n`);
124
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,363 @@
1
+ import { createHash } from 'crypto';
2
+ import { Level } from 'level';
3
+
4
+ class VerkleNode {
5
+ constructor() {
6
+ this.children = new Map();
7
+ this.value = null;
8
+ this.hash = null;
9
+ }
10
+ }
11
+
12
+ export class VerkleStateTree {
13
+ constructor(options = {}) {
14
+ this.root = new VerkleNode();
15
+ this.state = new Map(); // key -> value
16
+ this.db = options.db || null;
17
+ this._dbOpen = false;
18
+
19
+ if (this.db) {
20
+ this._initDb().catch(() => {});
21
+ }
22
+ }
23
+
24
+ async _initDb() {
25
+ try {
26
+ if (this.db && typeof this.db.open === 'function') {
27
+ await this.db.open();
28
+ }
29
+ this._dbOpen = true;
30
+ await this._loadState();
31
+ } catch (error) {
32
+ this._dbOpen = false;
33
+ }
34
+ }
35
+
36
+ async _loadState() {
37
+ if (!this.db || !this._dbOpen) return;
38
+
39
+ try {
40
+ for await (const [key, value] of this.db.iterator({ gt: 'state:', lt: 'state:\xFF' })) {
41
+ const stateKey = key.toString().substring(6); // Remove 'state:' prefix
42
+ const stateValue = JSON.parse(value.toString());
43
+ this.state.set(stateKey, stateValue);
44
+ }
45
+ } catch (error) {
46
+ // Ignore errors during load
47
+ }
48
+ }
49
+
50
+ async _saveState(key, value) {
51
+ if (!this.db || !this._dbOpen) return;
52
+
53
+ try {
54
+ await this.db.put(`state:${key}`, JSON.stringify(value));
55
+ } catch (error) {
56
+ // Ignore save errors
57
+ }
58
+ }
59
+
60
+ async _deleteState(key) {
61
+ if (!this.db || !this._dbOpen) return;
62
+
63
+ try {
64
+ await this.db.del(`state:${key}`);
65
+ } catch (error) {
66
+ // Ignore delete errors
67
+ }
68
+ }
69
+
70
+ // Drop EVERY key — in memory and on disk — back to an empty tree. This is what makes a node adopt a
71
+ // canonical set authoritatively: a plain re-insert leaves stale/extra keys (probe.seal, test anchors, a
72
+ // fuller history than the broker's) in the tree, so its root can never match a node that never had them.
73
+ // Clearing first means the rebuilt root is a pure function of the set applied next — identical on every box.
74
+ async clear() {
75
+ this.root = new VerkleNode();
76
+ this.state = new Map();
77
+ if (this.db && this._dbOpen) {
78
+ // ONE range clear, not thousands of per-key deletes — on a node with a big state that per-key loop
79
+ // took minutes and timed the control call out. Level's clear() drops the whole `state:` keyspace in a
80
+ // single batch; fall back to a range iterator+del only if this build's db lacks clear().
81
+ try {
82
+ if (typeof this.db.clear === 'function') { await this.db.clear({ gte: 'state:', lt: 'state:\xFF' }); }
83
+ else { for await (const [k] of this.db.iterator({ gte: 'state:', lt: 'state:\xFF' })) { try { await this.db.del(k); } catch { /* */ } } }
84
+ } catch { /* best-effort — the in-memory reset above already makes the rebuild authoritative for this run */ }
85
+ }
86
+ }
87
+
88
+ async insert(key, value) {
89
+ this.state.set(key, value);
90
+ await this._saveState(key, value);
91
+ const keyHash = this._hashKey(key);
92
+ const valueHash = this._hashValue(value);
93
+ const path = [];
94
+ this._insertNode(this.root, keyHash, valueHash, 0, path);
95
+ // Only update hashes along the insertion path
96
+ this._updateHashPath(path);
97
+ }
98
+
99
+ get(key) {
100
+ return this.state.get(key);
101
+ }
102
+
103
+ async delete(key) {
104
+ this.state.delete(key);
105
+ await this._deleteState(key);
106
+ const keyHash = this._hashKey(key);
107
+ const path = [];
108
+ this._deleteNode(this.root, keyHash, 0, path);
109
+ // Only update hashes along the deletion path
110
+ this._updateHashPath(path);
111
+ }
112
+
113
+ generateProof(key) {
114
+ const keyHash = this._hashKey(key);
115
+ const value = this.state.get(key);
116
+ if (value === undefined) {
117
+ throw new Error(`Key ${key} not found in state tree`);
118
+ }
119
+ const path = [];
120
+ const valueHash = this._hashValue(value);
121
+ this._generateProofPath(this.root, keyHash, path, 0);
122
+ return {
123
+ root: this.root.hash ? this.root.hash.toString('hex') : null,
124
+ path: path,
125
+ key: keyHash.toString('hex'),
126
+ valueHash: valueHash.toString('hex')
127
+ };
128
+ }
129
+
130
+ static verifyProof(key, value, proof) {
131
+ const keyHash = VerkleStateTree._hashKey(key);
132
+ const valueHash = VerkleStateTree._hashValue(value);
133
+
134
+ if (valueHash.toString('hex') !== proof.valueHash) {
135
+ return false;
136
+ }
137
+
138
+ // Reconstruct hash by following the path from leaf to root
139
+ let currentHash = valueHash;
140
+ let depth = 31; // Start from leaf depth
141
+
142
+ for (let i = proof.path.length - 1; i >= 0; i--) {
143
+ const pathNode = proof.path[i];
144
+ const nibble = keyHash[depth];
145
+
146
+ // Build children array for this node (256 children)
147
+ const childrenHashes = [];
148
+ for (let j = 0; j < 256; j++) {
149
+ if (j === nibble) {
150
+ childrenHashes.push(currentHash);
151
+ } else {
152
+ // Find sibling hash
153
+ const sibling = pathNode.siblings.find(s => s.nibble === j);
154
+ if (sibling) {
155
+ childrenHashes.push(Buffer.from(sibling.hash, 'hex'));
156
+ } else {
157
+ childrenHashes.push(Buffer.alloc(32));
158
+ }
159
+ }
160
+ }
161
+
162
+ const combined = Buffer.concat(childrenHashes);
163
+ currentHash = createHash('sha256').update(combined).digest();
164
+ depth--;
165
+ }
166
+
167
+ const rootHash = Buffer.from(proof.root, 'hex');
168
+ return currentHash.equals(rootHash);
169
+ }
170
+
171
+ // THE TREE ITSELF, walkable. Everything else on this class answers "what is the root" or "prove one key";
172
+ // nothing could describe the SHAPE, so any view of the state had to fall back to drawing categories of
173
+ // transactions instead of the structure that actually commits them. This returns the real nodes and the
174
+ // real parent->child edges, bounded, with the counts needed to say what was left out.
175
+ //
176
+ // Bounded on BOTH axes and honest about it: `maxDepth` limits how far down, `maxNodes` caps the total, and
177
+ // the result carries `truncated` plus each node's own `descendants` so a cut branch reports its real size
178
+ // rather than looking like a leaf. A viewer that silently stopped at 500 nodes would draw a tree that is
179
+ // simply the wrong shape.
180
+ //
181
+ // `at` walks to a specific prefix first (an array of byte nibbles), so a caller can drill into a branch
182
+ // without ever fetching the whole tree.
183
+ getTreeShape({ maxDepth = 4, maxNodes = 600, at = [] } = {}) {
184
+ let start = this.root;
185
+ for (const nib of at) {
186
+ const next = start.children.get(Number(nib));
187
+ if (!next) return { ok: false, error: `no node at prefix ${at.join('/')}`, at };
188
+ start = next;
189
+ }
190
+ if (!start.hash) this._updateHash(start);
191
+
192
+ // Subtree size is what makes a truncated branch honest, so it is computed for every node we emit.
193
+ const sizeOf = (node) => {
194
+ let n = 1;
195
+ for (const c of node.children.values()) n += sizeOf(c);
196
+ return n;
197
+ };
198
+
199
+ const nodes = [], edges = [];
200
+ let truncated = false;
201
+ const queue = [{ node: start, id: 'r', depth: 0, nibble: null }];
202
+ while (queue.length) {
203
+ const { node, id, depth, nibble } = queue.shift();
204
+ if (nodes.length >= maxNodes) { truncated = true; break; }
205
+ if (!node.hash) this._updateHash(node);
206
+ const kids = [...node.children.entries()].sort((a, b) => a[0] - b[0]);
207
+ nodes.push({
208
+ id, depth, nibble,
209
+ hash: node.hash ? node.hash.toString('hex') : null,
210
+ children: kids.length,
211
+ descendants: sizeOf(node) - 1,
212
+ leaf: !!node.value,
213
+ });
214
+ if (depth >= maxDepth) { if (kids.length) truncated = true; continue; }
215
+ for (const [nib, child] of kids) {
216
+ const cid = `${id}.${nib}`;
217
+ edges.push({ from: id, to: cid, nibble: nib });
218
+ queue.push({ node: child, id: cid, depth: depth + 1, nibble: nib });
219
+ }
220
+ }
221
+ return {
222
+ ok: true, at, root: this.getRoot(), total_keys: this.state.size,
223
+ total_nodes: sizeOf(this.root) - 1, max_depth: maxDepth, truncated, nodes, edges,
224
+ };
225
+ }
226
+
227
+ // The exact path a key takes through the tree — which byte at which depth, and the hash at each step.
228
+ // This is the "where does MY transaction live in the tree" answer, and it is a pure read.
229
+ keyPath(key) {
230
+ const keyHash = VerkleStateTree._hashKey(key);
231
+ const steps = [];
232
+ let node = this.root, id = 'r';
233
+ for (let depth = 0; depth < 32; depth++) {
234
+ const nibble = keyHash[depth];
235
+ const child = node.children.get(nibble);
236
+ if (!child) break;
237
+ id = `${id}.${nibble}`;
238
+ if (!child.hash) this._updateHash(child);
239
+ steps.push({ depth, nibble, id, hash: child.hash ? child.hash.toString('hex') : null, siblings: node.children.size - 1 });
240
+ node = child;
241
+ }
242
+ return {
243
+ ok: steps.length > 0, key, key_hash: keyHash.toString('hex'),
244
+ present: this.state.has(key), value: this.state.get(key) ?? null,
245
+ depth: steps.length, steps,
246
+ };
247
+ }
248
+
249
+ getRoot() {
250
+ if (!this.root.hash) {
251
+ // Initialize empty root hash
252
+ this._updateHash(this.root);
253
+ }
254
+ return this.root.hash ? this.root.hash.toString('hex') : '0'.repeat(64);
255
+ }
256
+
257
+ _insertNode(node, keyHash, valueHash, depth, path) {
258
+ path.push(node);
259
+ if (depth >= 32) {
260
+ node.value = valueHash;
261
+ return;
262
+ }
263
+
264
+ const nibble = keyHash[depth];
265
+ if (!node.children.has(nibble)) {
266
+ node.children.set(nibble, new VerkleNode());
267
+ }
268
+
269
+ this._insertNode(node.children.get(nibble), keyHash, valueHash, depth + 1, path);
270
+ }
271
+
272
+ _updateHashPath(path) {
273
+ // Update hashes from leaf to root
274
+ for (let i = path.length - 1; i >= 0; i--) {
275
+ this._updateHash(path[i]);
276
+ }
277
+ }
278
+
279
+ _deleteNode(node, keyHash, depth, path) {
280
+ path.push(node);
281
+ if (depth >= 32) {
282
+ node.value = null;
283
+ return;
284
+ }
285
+
286
+ const nibble = keyHash[depth];
287
+ if (node.children.has(nibble)) {
288
+ this._deleteNode(node.children.get(nibble), keyHash, depth + 1, path);
289
+ if (node.children.get(nibble).children.size === 0 && !node.children.get(nibble).value) {
290
+ node.children.delete(nibble);
291
+ }
292
+ }
293
+ }
294
+
295
+ _generateProofPath(node, keyHash, path, depth) {
296
+ if (depth >= 32) {
297
+ return;
298
+ }
299
+
300
+ const nibble = keyHash[depth];
301
+ const child = node.children.get(nibble);
302
+
303
+ if (child) {
304
+ // Collect sibling hashes
305
+ const siblings = [];
306
+ for (const [n, childNode] of node.children.entries()) {
307
+ if (n !== nibble && childNode.hash) {
308
+ siblings.push({ nibble: n, hash: childNode.hash.toString('hex') });
309
+ }
310
+ }
311
+
312
+ path.push({ depth, siblings });
313
+ this._generateProofPath(child, keyHash, path, depth + 1);
314
+ }
315
+ }
316
+
317
+ _updateHash(node) {
318
+ if (node.value) {
319
+ node.hash = node.value;
320
+ return;
321
+ }
322
+
323
+ if (node.children.size === 0) {
324
+ node.hash = Buffer.alloc(32);
325
+ return;
326
+ }
327
+
328
+ const childrenHashes = [];
329
+ for (let i = 0; i < 256; i++) {
330
+ const child = node.children.get(i);
331
+ if (child) {
332
+ // Only update if hash is not set (lazy evaluation)
333
+ if (!child.hash) {
334
+ this._updateHash(child);
335
+ }
336
+ childrenHashes.push(child.hash);
337
+ } else {
338
+ childrenHashes.push(Buffer.alloc(32));
339
+ }
340
+ }
341
+
342
+ const combined = Buffer.concat(childrenHashes);
343
+ node.hash = createHash('sha256').update(combined).digest();
344
+ }
345
+
346
+ static _hashKey(key) {
347
+ return createHash('sha256').update(key).digest();
348
+ }
349
+
350
+ static _hashValue(value) {
351
+ const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
352
+ return createHash('sha256').update(valueStr).digest();
353
+ }
354
+
355
+ _hashKey(key) {
356
+ return VerkleStateTree._hashKey(key);
357
+ }
358
+
359
+ _hashValue(value) {
360
+ return VerkleStateTree._hashValue(value);
361
+ }
362
+ }
363
+