@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 +14 -0
- package/package.json +24 -0
- package/src/apply-path.test.mjs +168 -0
- package/src/sharding.js +32 -0
- package/src/state-assembly.js +33 -0
- package/src/state-diff.js +37 -0
- package/src/state-machine.js +349 -0
- package/src/verkle-independent-verify.test.mjs +152 -0
- package/src/verkle-integration.test.mjs +124 -0
- package/src/verkle-tree.js +363 -0
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);
|
package/src/sharding.js
ADDED
|
@@ -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
|
+
|