@xmbl/state-machine 0.1.4 → 0.1.11
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/CHANGELOG.md +83 -0
- package/index.js +5 -4
- package/package.json +4 -1
- package/src/apply-path.test.mjs +8 -4
- package/src/rehydration.test.mjs +94 -0
- package/src/state-diff.js +11 -0
- package/src/state-machine.js +78 -17
- package/src/verkle-integration.test.mjs +33 -5
- package/src/verkle-tree.js +31 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @xmbl/state-machine
|
|
2
|
+
|
|
3
|
+
## 0.1.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Return every `@xmbl/*` protocol package to ONE version line (0.1.11) after a night of per-package
|
|
8
|
+
hotfix publishes (cubic-ledger reached 0.1.10 while zero-knowledge sat at 0.1.1, crates at 0.1.0).
|
|
9
|
+
|
|
10
|
+
Changes since the last unified line (0.1.0), carried as one PATCH line — 0.1.x stays the pre-mainnet line until the ⛔ AUDIT gates close:
|
|
11
|
+
|
|
12
|
+
- cubic-ledger: block ids address CONSENSUS CONTENT (`consensusBody`) instead of the whole tx envelope; an
|
|
13
|
+
anchor's `hash` must be a sha-256 digest; invalid txs are evicted for good (`evict`, `evicted:` keyspace);
|
|
14
|
+
legacy envelope-keyed rows converge to content ids on the next boot; a canonical rebuild preserves every
|
|
15
|
+
non-anchor block and reports what it wiped; `ready()` is the boot join point. `capabilities()` (hardcoded
|
|
16
|
+
flags) is gone; `census.mjs` (an operator one-off) no longer ships in the tarball. A FORGERY CAN NO LONGER
|
|
17
|
+
DELETE THE TRANSACTION IT IMPERSONATES: an invalid typed datum is evicted under a digest of its own bytes,
|
|
18
|
+
never under the xid it claims (a datum fails `validateXid` precisely when that xid is somebody else's), and
|
|
19
|
+
the anchor dedup key claimed before validation is released on every failure. Both doors are closed on BOTH
|
|
20
|
+
entry points — `addTransaction` and `addSealedBatch`, the path a finalized transaction actually takes — and
|
|
21
|
+
one invalid entry in a batch no longer discards the honest transactions behind it.
|
|
22
|
+
- consensus: a content-addressed type-6 value tx is admitted on its content address (it carries no in-body
|
|
23
|
+
signature by design); a malformed anchor is refused at the door by the ledger's own `validateTransaction`;
|
|
24
|
+
`finalizeTransaction` preserves the signed `id`.
|
|
25
|
+
- identity: one canonical `signingMessage`; `signingStatus()` / `verifySigning()`; `Identity.fromPrivateKey`
|
|
26
|
+
(a stub that only threw) is removed.
|
|
27
|
+
- state-machine: the verkle trie is rebuilt on load; diffs are keyed by content identity; `ready()`.
|
|
28
|
+
- networking: self-elected circuit-relay server; NO_FATAL transport tolerance; throttled bootstrap warnings.
|
|
29
|
+
- storage-compute: `CoordinateDelivery` (broken: `require` in ESM, keyed on the public key) is removed.
|
|
30
|
+
- core: ships the node daemon as the `xmbl-node` bin; the control socket implements the coordinator
|
|
31
|
+
contract (`xsc`, `submit_batch`, `ledger_capabilities`, `identity_status`, a signed `chain` claim, honest
|
|
32
|
+
`submit_tx` rejections) and reports the RUNNING versions; boot waits for the stores.
|
|
33
|
+
- every package exports a load-time `VERSION`.
|
|
34
|
+
- PROTOCOL (operator, 2026-09-16): every transaction is typed by its xid (tokens.json type codes; `micromineTx`/
|
|
35
|
+
`validateXid`; untyped rows deleted on boot, skipped by a canonical rebuild; an anchor's wire tx carries `prior`);
|
|
36
|
+
consensus validates in order — can it happen, is the xid correct, is the placement right (`validate.js`,
|
|
37
|
+
`verifyPlacement`); block hashes are content-only so every node seals the same cubes.
|
|
38
|
+
- ROLLOUT (operator, 2026-09-16): a node proves its version (`build` digest of the loaded code, signed into the
|
|
39
|
+
`chain` claim; `release` op), suspends itself when behind the npm `latest` of @xmbl/core (no submits, validations
|
|
40
|
+
or seals), installs the latest over the air and restarts (exit 75 under a supervisor).
|
|
41
|
+
- lng: ships its BROWSER build — `dist/lng.browser.js` (`@xmbl/lng/browser`), one dependency-free ES module
|
|
42
|
+
generated from the same `src/*.js` the node runs and byte-checked by the gate (same surface, same bytes, runs
|
|
43
|
+
with no Node globals); the sources no longer assume `process`/`Buffer`.
|
|
44
|
+
- lng: the `~bytes` BYTE-STRING TYPE reaches the WASM backend — a (pointer, length) pair on the operand
|
|
45
|
+
stack, never a 256-bit word: a literal's bytes live in a data segment with a compile-time length, a runtime
|
|
46
|
+
value (a UTXO id from `xmbl_input_id`) is host-written into fresh memory with its length in a local. A
|
|
47
|
+
`~bytes` FIELD or PARAM is now REFUSED — committed state and the call ABI are both 32-byte words with
|
|
48
|
+
nowhere to put a length, and until now both compiled SILENTLY as words. New opt-in host modes
|
|
49
|
+
`compile(src, { crypto: true })` (`xmbl.mayo.verify` / `xmbl.cubic.verify`) and `{ utxo: true }` (the
|
|
50
|
+
five-entry value ABI), so a contract written in LNG verifies a signature and spends a UTXO the caller
|
|
51
|
+
presented. The value ABI's `-1` sentinel TRAPS instead of widening to 2^256-1. The typechecker refuses
|
|
52
|
+
arithmetic/bitwise/ordering on `~bytes` and now walks `~contract` method bodies at all, which it never did.
|
|
53
|
+
A contract with no byte literals emits a byte-identical, import-free module.
|
|
54
|
+
- storage-compute: THE ERASURE CODER RETURNED CORRUPTED DATA WITHOUT SAYING SO. `StorageShard.decode`
|
|
55
|
+
recovers a lost data shard by XOR-ing its parity group, and XOR parity recovers AT MOST ONE loss per
|
|
56
|
+
group — but when two members of one group were missing it filled the hole with zeros and returned the
|
|
57
|
+
buffer as if decoding had succeeded, with no error, no flag and no short read. MEASURED on k=4, m=2:
|
|
58
|
+
losing data shards 0 and 2 handed back a buffer that differed from the original and nothing downstream
|
|
59
|
+
could tell. Two independent causes are fixed: the group is now checked for completeness before the XOR
|
|
60
|
+
is trusted, and `m` — the encoding's PARITY DEGREE — is carried on every shard as the new optional
|
|
61
|
+
`parityCount` field instead of being inferred from however many parity shards happened to survive (that
|
|
62
|
+
inference was wrong exactly when a parity shard was among the losses: given data 0,1,2 and parity 4 only,
|
|
63
|
+
the inferred m was 1, the recovery group became {0,1,2,3} instead of {0,2}, and decode returned wrong
|
|
64
|
+
bytes). An unrecoverable decode now THROWS and names every missing shard. Proven exhaustively over all
|
|
65
|
+
63 non-empty subsets of a k=4/m=2 encoding: every subset either decodes to the exact original or throws,
|
|
66
|
+
and none returns wrong bytes. COMPAT: `parityCount` is additive and optional, so a shard written by an
|
|
67
|
+
older node reads back fine — a new node treats it as legacy and REFUSES parity recovery rather than
|
|
68
|
+
guessing, which fails loudly where the old code failed silently. Shard metadata persists as JSON
|
|
69
|
+
(`meta:<id>`), so an old reader ignores the extra field.
|
|
70
|
+
- core: a boot crash. `Config._applyEnvOverrides()` assigned into `config.network` / `config.logging`
|
|
71
|
+
without creating them, so a node started with `XN_PORT` or `LOG_LEVEL` set against a config that omitted
|
|
72
|
+
those sections died on `undefined.port` before it could log why. The sections are created on demand.
|
|
73
|
+
- the protocol gate is 75 suites (was 67), and line coverage across the twelve protocol packages is 85.5%
|
|
74
|
+
(was 80.6%). `scripts/coverage-report.mjs` is the instrument: `NODE_V8_COVERAGE` + a V8-range reducer,
|
|
75
|
+
since nothing in the tree measured coverage at all. Ten protocol files that no suite had ever loaded now
|
|
76
|
+
have one; four remain, all process entry points.
|
|
77
|
+
- NODE 22 IS NOW DECLARED, because it was already REQUIRED. Every published package gains
|
|
78
|
+
`engines: { node: ">=22" }`; the workspace root's `">=20"` was simply false. `@xmbl/identity` imports
|
|
79
|
+
`node:sqlite` (Node 22.5+) for the durable nonce registry, `@xmbl/storage-compute` meters jobs with
|
|
80
|
+
`process.threadCpuUsage` (22.10+), and libp2p's own dependency chain calls `Promise.withResolvers`
|
|
81
|
+
(22.0). On Node 20 a consumer installed cleanly and crashed at import instead of being told at install
|
|
82
|
+
time. MEASURED: the protocol gate scores 54/75 on Node 20.20.2 and 75/75 on Node 22 — the twenty-one
|
|
83
|
+
failures were the runtime, not the code. CI and the release workflow now run Node 22 as well.
|
package/index.js
CHANGED
|
@@ -7,8 +7,9 @@ export { StateShard } from './src/sharding.js';
|
|
|
7
7
|
export { StateAssembler } from './src/state-assembly.js';
|
|
8
8
|
export { StateMachine } from './src/state-machine.js';
|
|
9
9
|
|
|
10
|
-
const port = process.env.PORT || 3002;
|
|
11
|
-
console.log(`XVSM (XMBL Virtual State Machine) starting on port ${port}`);
|
|
12
|
-
|
|
13
|
-
|
|
14
10
|
|
|
11
|
+
// THE VERSION OF THE CODE THIS PROCESS LOADED. Read once at import time from this package's own manifest, so a
|
|
12
|
+
// running node can report what it is actually executing — an install that lands on disk after this module was
|
|
13
|
+
// loaded changes the file, not this constant. Consumed by @xmbl/core's control socket (`status`.versions).
|
|
14
|
+
import { readFileSync as __readPkg } from 'node:fs';
|
|
15
|
+
export const VERSION = JSON.parse(__readPkg(new URL('./package.json', import.meta.url), 'utf8')).version;
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmbl/state-machine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./index.js"
|
|
8
8
|
},
|
|
9
9
|
"license": "MIT",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
10
13
|
"publishConfig": {
|
|
11
14
|
"access": "public"
|
|
12
15
|
},
|
package/src/apply-path.test.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// node vendor/xmbl-node/state-machine/src/apply-path.test.mjs
|
|
12
12
|
import { StateMachine } from './state-machine.js';
|
|
13
13
|
import { Ledger } from '../../cubic-ledger/src/ledger.js';
|
|
14
|
+
import { micromineTx } from '../../cubic-ledger/src/transaction-validator.js';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
14
16
|
import { rm, mkdtemp } from 'node:fs/promises';
|
|
15
17
|
import { tmpdir } from 'node:os';
|
|
16
18
|
import { join } from 'node:path';
|
|
@@ -23,10 +25,12 @@ const check = async (name, fn) => {
|
|
|
23
25
|
};
|
|
24
26
|
const eq = (a, b, what) => { if (a !== b) throw new Error(`${what}: expected ${b}, got ${a}`); };
|
|
25
27
|
|
|
26
|
-
// Nine anchor txs seal exactly one face under the hash-sorted partition (9 blocks per face).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
})
|
|
28
|
+
// Nine anchor txs seal exactly one face under the hash-sorted partition (9 blocks per face). An anchor's
|
|
29
|
+
// `hash` is a sha-256 digest by contract (cubic-ledger validateTransaction refuses anything else), so the
|
|
30
|
+
// fixture mines a real digest per tag rather than a padded label.
|
|
31
|
+
const anchors = (n, tag) => Array.from({ length: n }, (_, i) => micromineTx({
|
|
32
|
+
type: 'anchor', event: 'task.created', hash: createHash('sha256').update(`${tag}-${i}`).digest('hex'), ts: 1_700_000_000_000 + i,
|
|
33
|
+
})); // typed: every tx carries its xmbl type as a micromined xid
|
|
30
34
|
|
|
31
35
|
const fresh = async () => {
|
|
32
36
|
const dir = await mkdtemp(join(tmpdir(), 'xvsm-apply-'));
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// THE THREE DEFECTS THAT KEPT NODES OFF A SHARED ROOT, each asserted by the symptom the fleet showed,
|
|
2
|
+
// not by the mechanism. All three were measured live on 2026-09-15 before being fixed here.
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert';
|
|
5
|
+
import { rmSync } from 'node:fs';
|
|
6
|
+
import { StateMachine } from './state-machine.js';
|
|
7
|
+
|
|
8
|
+
const ZERO = '0'.repeat(64);
|
|
9
|
+
const anchors = (n) => Array.from({ length: n }, (_, i) => ({
|
|
10
|
+
event: 'task.created', hash: `h${String(i).padStart(4, '0')}`.padEnd(12, '0'), ts: 1000 + i,
|
|
11
|
+
}));
|
|
12
|
+
const blockFor = (a, tag) => ({ id: `blk-${tag}-${Math.random().toString(16).slice(2)}`,
|
|
13
|
+
tx: { type: 'anchor', event: a.event, hash: a.hash, ts: a.ts } });
|
|
14
|
+
const countDiffRows = async (sm) => { let n = 0; for await (const [] of sm.db.iterator({ gt: 'diff:', lt: 'diff:\xFF' })) n++; return n; };
|
|
15
|
+
|
|
16
|
+
async function open(dir) { const sm = new StateMachine({ dbPath: dir }); await sm.ready(); return sm; }
|
|
17
|
+
|
|
18
|
+
test('a restarted node publishes a REAL root, not 64 zeros, from the state: keyspace alone', async () => {
|
|
19
|
+
const dir = `/tmp/xvsm-rehydrate-${process.pid}-a`;
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
let sm = await open(dir);
|
|
22
|
+
for (const a of anchors(30)) await sm._handleLedgerBlock(blockFor(a, 'x'));
|
|
23
|
+
const before = sm.stateTree.getRoot();
|
|
24
|
+
assert.notStrictEqual(before, ZERO);
|
|
25
|
+
await sm.db.close();
|
|
26
|
+
|
|
27
|
+
// Reopen. The diff replay is NOT the thing under test — delete every diff row first, so the only
|
|
28
|
+
// surviving source for the trie is `state:`. 39 of 44 reporting fleet nodes published ZERO here.
|
|
29
|
+
sm = await open(dir);
|
|
30
|
+
await sm.db.clear({ gte: 'diff:', lt: 'diff:\xFF' });
|
|
31
|
+
await sm.db.close();
|
|
32
|
+
sm = await open(dir);
|
|
33
|
+
assert.strictEqual(sm.stateTree.state.size, 30, 'keys must come back');
|
|
34
|
+
assert.notStrictEqual(sm.stateTree.getRoot(), ZERO, 'a tree holding 30 keys must not commit to zeros');
|
|
35
|
+
assert.strictEqual(sm.stateTree.getRoot(), before, 'and it must be the SAME root it had before the restart');
|
|
36
|
+
await sm.db.close();
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('re-submitting the same anchor under fresh tx ids writes ONE durable row, not one per submission', async () => {
|
|
41
|
+
const dir = `/tmp/xvsm-rehydrate-${process.pid}-b`;
|
|
42
|
+
rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
const sm = await open(dir);
|
|
44
|
+
const set = anchors(50);
|
|
45
|
+
for (let pass = 0; pass < 4; pass++) for (const a of set) await sm._handleLedgerBlock(blockFor(a, pass));
|
|
46
|
+
assert.strictEqual(await countDiffRows(sm), 50, '200 submissions of 50 anchors is 50 state changes');
|
|
47
|
+
assert.strictEqual(sm.getStatistics().totalTransactions, 50, 'and the published count must agree with disk');
|
|
48
|
+
await sm.db.close();
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the canonical set a node adopts SURVIVES a restart', async () => {
|
|
53
|
+
const dir = `/tmp/xvsm-rehydrate-${process.pid}-c`;
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
let sm = await open(dir);
|
|
56
|
+
const local = anchors(50);
|
|
57
|
+
for (const a of local) await sm._handleLedgerBlock(blockFor(a, 'local'));
|
|
58
|
+
const canonical = local.slice(0, 10);
|
|
59
|
+
const reb = await sm.rebuildFromCanonical(canonical);
|
|
60
|
+
assert.strictEqual(reb.applied, 10);
|
|
61
|
+
const canonicalRoot = reb.state_root;
|
|
62
|
+
await sm.db.close();
|
|
63
|
+
|
|
64
|
+
sm = await open(dir);
|
|
65
|
+
assert.strictEqual(sm.stateTree.state.size, 10, 'the 40 non-canonical keys must not come back');
|
|
66
|
+
assert.strictEqual(sm.stateTree.getRoot(), canonicalRoot, 'the node must still be on the canonical root');
|
|
67
|
+
assert.strictEqual(sm.getStatistics().totalTransactions, 10);
|
|
68
|
+
await sm.db.close();
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('legacy rows keyed by block id are re-keyed to content identity on the next boot', async () => {
|
|
73
|
+
const dir = `/tmp/xvsm-rehydrate-${process.pid}-d`;
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
let sm = await open(dir);
|
|
76
|
+
// Write rows the way every node on the fleet already has them: one per submission, keyed by block id.
|
|
77
|
+
const set = anchors(40);
|
|
78
|
+
let written = 0;
|
|
79
|
+
for (let pass = 0; pass < 3; pass++) for (const a of set) {
|
|
80
|
+
const key = `anchor:${a.event}:${a.hash}`;
|
|
81
|
+
await sm.db.put(`diff:blk-${pass}-${a.hash}`, JSON.stringify({
|
|
82
|
+
txId: `blk-${pass}-${a.hash}`, timestamp: 1000 + pass, changes: { [key]: { ts: a.ts } },
|
|
83
|
+
}));
|
|
84
|
+
written++;
|
|
85
|
+
}
|
|
86
|
+
assert.strictEqual(await countDiffRows(sm), written);
|
|
87
|
+
await sm.db.close();
|
|
88
|
+
|
|
89
|
+
sm = await open(dir);
|
|
90
|
+
assert.strictEqual(await countDiffRows(sm), 40, `${written} legacy rows collapse to 40 distinct changes`);
|
|
91
|
+
assert.strictEqual(sm.getStatistics().totalTransactions, 40);
|
|
92
|
+
await sm.db.close();
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
});
|
package/src/state-diff.js
CHANGED
|
@@ -5,6 +5,17 @@ export class StateDiff {
|
|
|
5
5
|
this.changes = changes; // key -> new value
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// THE IDENTITY OF A STATE CHANGE IS THE KEY IT WRITES, not the id of whatever block happened to carry it.
|
|
9
|
+
// A node mints a fresh tx id every time it re-submits an anchor, so `diff:<txId>` made one durable row per
|
|
10
|
+
// SUBMISSION: measured on a live node 2026-09-15, 92,505 rows over 50,497 distinct anchors — 42,008 of them
|
|
11
|
+
// redundant, still growing by ~34 every seven hours, and every one of them replayed into the tree on boot.
|
|
12
|
+
// Keying by the change itself makes a re-submission an upsert, which is what it always was semantically.
|
|
13
|
+
// Multi-key changes (explicit `state_diff` txs) have no single content key, so they keep the tx id.
|
|
14
|
+
identity() {
|
|
15
|
+
const keys = Object.keys(this.changes || {});
|
|
16
|
+
return keys.length === 1 ? keys[0] : this.txId;
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
apply(state) {
|
|
9
20
|
const newState = { ...state };
|
|
10
21
|
for (const [key, value] of Object.entries(this.changes)) {
|
package/src/state-machine.js
CHANGED
|
@@ -32,8 +32,10 @@ export class StateMachine extends EventEmitter {
|
|
|
32
32
|
// Integration: xclt for state commitments from ledger
|
|
33
33
|
this.xclt = options.xclt || null;
|
|
34
34
|
|
|
35
|
-
// Initialize database
|
|
36
|
-
|
|
35
|
+
// Initialize database. KEEP THE PROMISE: `_dbOpen` flips true the moment Level opens, long before the
|
|
36
|
+
// diff sweep and the tree rehydration have finished, so anything that polls `_dbOpen` and then reads a
|
|
37
|
+
// count is reading a store mid-rebuild. `ready()` is the only honest join point.
|
|
38
|
+
this._ready = this._initDb().catch(() => {});
|
|
37
39
|
|
|
38
40
|
// Listen to ledger events if available
|
|
39
41
|
if (this.xclt) {
|
|
@@ -51,10 +53,16 @@ export class StateMachine extends EventEmitter {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// Resolves once the store is open, the tree rehydrated from `state:`, and the diff rows swept and loaded.
|
|
57
|
+
ready() { return this._ready || Promise.resolve(); }
|
|
58
|
+
|
|
54
59
|
async _initDb() {
|
|
55
60
|
try {
|
|
56
61
|
await this.db.open();
|
|
57
62
|
this._dbOpen = true;
|
|
63
|
+
// The tree rehydrates itself from the `state:` keyspace; wait for it before deciding whether the diff
|
|
64
|
+
// rows still need to be replayed, or an unfinished load reads as an empty tree.
|
|
65
|
+
await this.stateTree.ready();
|
|
58
66
|
await this._loadDiffs();
|
|
59
67
|
await this._loadTransactionLog();
|
|
60
68
|
} catch (error) {
|
|
@@ -72,24 +80,46 @@ export class StateMachine extends EventEmitter {
|
|
|
72
80
|
// Replay is safe to do in iterator order because the tree is a key->value map: the root is a function
|
|
73
81
|
// of the final key set, not of application order (proven in verkle-integration.test.mjs, "same set in
|
|
74
82
|
// any order yields the SAME root"). Later diffs for the same key legitimately overwrite earlier ones.
|
|
83
|
+
// RE-KEY SWEEP. Rows written before content identity existed are keyed `diff:<block.id>`, so the same
|
|
84
|
+
// anchor re-submitted under a fresh tx id left one row per submission and every one of them came back
|
|
85
|
+
// here on boot. Rewriting each row under `diff:<identity>` collapses them: the identity-keyed row is
|
|
86
|
+
// PUT first and the old key deleted only after, so nothing is lost if this is interrupted. Measured on
|
|
87
|
+
// a live store 2026-09-15: 92,505 rows over 50,497 distinct anchors, 42,008 of them redundant.
|
|
75
88
|
const loaded = [];
|
|
89
|
+
let rekeyed = 0;
|
|
76
90
|
for await (const [key, value] of this.db.iterator({ gt: 'diff:', lt: 'diff:\xFF' })) {
|
|
77
91
|
const diffData = JSON.parse(value.toString());
|
|
78
92
|
const diff = new StateDiff(diffData.txId, diffData.changes);
|
|
79
93
|
diff.timestamp = diffData.timestamp;
|
|
80
|
-
this.
|
|
81
|
-
|
|
94
|
+
const want = this._diffKey(diff);
|
|
95
|
+
const have = key.toString();
|
|
96
|
+
if (have !== want) {
|
|
97
|
+
try { await this.db.put(want, value); await this.db.del(have); rekeyed++; } catch { /* leave it */ }
|
|
98
|
+
}
|
|
99
|
+
if (this._recordDiff(diff)) loaded.push(diff);
|
|
82
100
|
}
|
|
83
|
-
|
|
101
|
+
if (rekeyed) console.log(`[XVSM] diff rows re-keyed to content identity: ${rekeyed}, distinct now ${this.diffs.length}`);
|
|
102
|
+
|
|
103
|
+
// ⛔ REPLAY ONLY AS RECOVERY. This loop replayed every diff into the tree unconditionally, which is how
|
|
104
|
+
// it papered over the real defect — VerkleStateTree._loadState restored the key map but never rebuilt
|
|
105
|
+
// the trie, so the root read 64 zeros and the replay was the only thing putting keys back through
|
|
106
|
+
// insert(). With the trie rebuilt on open, an unconditional replay is actively harmful: it reinstates
|
|
107
|
+
// every key that rebuildFromCanonical deliberately dropped, so the canonical root a node adopts SURVIVES
|
|
108
|
+
// until its next restart and no further. MEASURED 2026-09-15: rebuild to root 02eaf3e76c over 10
|
|
109
|
+
// canonical anchors, restart, root 4bc7dd2813 over 50 keys — the node left the canonical set by booting.
|
|
110
|
+
// Replay stays for the one case it is still needed: a store whose `state:` keyspace is genuinely empty.
|
|
84
111
|
let replayed = 0;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
if (this.stateTree.state.size === 0 && loaded.length) {
|
|
113
|
+
loaded.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || String(a.txId).localeCompare(String(b.txId)));
|
|
114
|
+
for (const diff of loaded) {
|
|
115
|
+
for (const [k, v] of Object.entries(diff.changes || {})) {
|
|
116
|
+
if (v === null) { await this.stateTree.delete?.(k); continue; }
|
|
117
|
+
await this.stateTree.insert(k, v);
|
|
118
|
+
replayed++;
|
|
119
|
+
}
|
|
90
120
|
}
|
|
91
121
|
}
|
|
92
|
-
if (replayed) console.log(`[XVSM] verkle tree
|
|
122
|
+
if (replayed) console.log(`[XVSM] verkle tree recovered from diffs (state: keyspace was empty): ${replayed} change(s) from ${loaded.length} diff(s), root=${this.stateTree.getRoot().slice(0, 16)}…`);
|
|
93
123
|
} catch (error) {
|
|
94
124
|
// Ignore load errors
|
|
95
125
|
}
|
|
@@ -112,7 +142,7 @@ export class StateMachine extends EventEmitter {
|
|
|
112
142
|
if (!this._dbOpen) return;
|
|
113
143
|
|
|
114
144
|
try {
|
|
115
|
-
await this.db.put(
|
|
145
|
+
await this.db.put(this._diffKey(diff), JSON.stringify({
|
|
116
146
|
txId: diff.txId,
|
|
117
147
|
changes: diff.changes,
|
|
118
148
|
timestamp: diff.timestamp
|
|
@@ -153,13 +183,22 @@ export class StateMachine extends EventEmitter {
|
|
|
153
183
|
//
|
|
154
184
|
// REPLACE, don't skip: re-applying a txId with different changes is a legitimate later state for that key
|
|
155
185
|
// and the disk already resolves it that way. Skipping would leave the array disagreeing with the tree.
|
|
186
|
+
//
|
|
187
|
+
// INDEXED BY CONTENT IDENTITY, NOT BY TX ID, so the in-memory set matches the durable one key-for-key. Two
|
|
188
|
+
// submissions of the same anchor carry different tx ids and are the SAME state change; keying either side
|
|
189
|
+
// by the tx id makes them two of everything. See StateDiff.identity().
|
|
156
190
|
_recordDiff(diff) {
|
|
157
|
-
const
|
|
158
|
-
|
|
191
|
+
const id = diff.identity();
|
|
192
|
+
const at = this._diffIndex.get(id);
|
|
193
|
+
if (at === undefined) { this._diffIndex.set(id, this.diffs.length); this.diffs.push(diff); return true; }
|
|
159
194
|
this.diffs[at] = diff;
|
|
160
195
|
return false;
|
|
161
196
|
}
|
|
162
197
|
|
|
198
|
+
// The one place a diff's durable key is spelled. Every writer goes through it, or the re-key sweep in
|
|
199
|
+
// _loadDiffs is fighting a writer that still uses the block id.
|
|
200
|
+
_diffKey(diff) { return `diff:${diff.identity()}`; }
|
|
201
|
+
|
|
163
202
|
_stateChangesFor(block) {
|
|
164
203
|
const tx = block?.tx;
|
|
165
204
|
if (!tx || typeof tx !== 'object') return null;
|
|
@@ -196,6 +235,14 @@ export class StateMachine extends EventEmitter {
|
|
|
196
235
|
}
|
|
197
236
|
|
|
198
237
|
async _handleLedgerBlock(block) {
|
|
238
|
+
// JOIN THE STORE BEFORE TOUCHING IT. This handler is wired to `block:added` in the CONSTRUCTOR, so the
|
|
239
|
+
// ledger can deliver a block while `_initDb` is still opening the db and rehydrating the tree. Both
|
|
240
|
+
// writes below swallow their errors (by design — an in-memory fallback must not take the node down),
|
|
241
|
+
// which means an early block was applied to the in-memory tree and persisted NOWHERE: the root looked
|
|
242
|
+
// right for the life of the process and came back 64 zeros on the next boot, with nothing logged.
|
|
243
|
+
// MEASURED: applying two blocks before `ready()` resolved gave root ad778ca2… in-process and
|
|
244
|
+
// 0000000000… after a restart; awaiting `ready()` first, the same root survives.
|
|
245
|
+
await this.ready();
|
|
199
246
|
const changes = this._stateChangesFor(block);
|
|
200
247
|
if (!changes || !Object.keys(changes).length) return;
|
|
201
248
|
try {
|
|
@@ -208,7 +255,7 @@ export class StateMachine extends EventEmitter {
|
|
|
208
255
|
// StateDiff.serialize() ALREADY returns a JSON string — wrapping it in JSON.stringify again
|
|
209
256
|
// double-encodes, so _loadDiffs parses back a string instead of an object and `changes` comes out
|
|
210
257
|
// undefined, silently replaying nothing. Store the serialized form directly.
|
|
211
|
-
try { await this.db.put(
|
|
258
|
+
try { await this.db.put(this._diffKey(diff), diff.serialize()); }
|
|
212
259
|
catch { /* in-memory fallback */ }
|
|
213
260
|
}
|
|
214
261
|
} catch (error) {
|
|
@@ -252,7 +299,7 @@ export class StateMachine extends EventEmitter {
|
|
|
252
299
|
this._recordDiff(diff);
|
|
253
300
|
for (const [key, val] of Object.entries(changes)) await this.stateTree.insert(key, val);
|
|
254
301
|
if (this._dbOpen !== false) {
|
|
255
|
-
try { await this.db.put(
|
|
302
|
+
try { await this.db.put(this._diffKey(diff), diff.serialize()); } catch { /* in-memory fallback */ }
|
|
256
303
|
}
|
|
257
304
|
out.applied++;
|
|
258
305
|
} catch { out.failed++; }
|
|
@@ -273,6 +320,18 @@ export class StateMachine extends EventEmitter {
|
|
|
273
320
|
await this.stateTree.clear();
|
|
274
321
|
this.diffs = [];
|
|
275
322
|
this._diffIndex.clear();
|
|
323
|
+
// ⛔ THE DURABLE ROWS COME TOO. Clearing `this.diffs` in memory while 92,505 `diff:` rows stay on disk is
|
|
324
|
+
// not an authoritative rebuild — it is one that lasts until the next boot reads them back. The tree's
|
|
325
|
+
// `state:` keyspace is cleared above for exactly this reason; the diff keyspace is the same commitment
|
|
326
|
+
// written twice, and leaving half of it behind is what made the canonical set un-adoptable across a
|
|
327
|
+
// restart. The blocks themselves are untouched in the ledger: backfillFromLedger regenerates local
|
|
328
|
+
// history on demand, and the canonical rows are re-persisted below.
|
|
329
|
+
if (this._dbOpen) {
|
|
330
|
+
try {
|
|
331
|
+
if (typeof this.db.clear === 'function') await this.db.clear({ gte: 'diff:', lt: 'diff:\xFF' });
|
|
332
|
+
else for await (const [k] of this.db.iterator({ gte: 'diff:', lt: 'diff:\xFF' })) { try { await this.db.del(k); } catch { /* */ } }
|
|
333
|
+
} catch { /* best-effort */ }
|
|
334
|
+
}
|
|
276
335
|
// ⛔ RECORD A DIFF FOR EVERY ANCHOR APPLIED, or applied_tx_count IS ZERO BY CONSTRUCTION. This loop wrote
|
|
277
336
|
// straight into the tree and never touched `this.diffs`, which it had just emptied — and
|
|
278
337
|
// getStatistics().totalTransactions is transactionLog.length + diffs.length. So the moment a node runs the
|
|
@@ -288,7 +347,9 @@ export class StateMachine extends EventEmitter {
|
|
|
288
347
|
const key = `anchor:${a.event}:${a.hash}`;
|
|
289
348
|
const value = { ts: a.ts ?? null };
|
|
290
349
|
await this.stateTree.insert(key, value);
|
|
291
|
-
|
|
350
|
+
const diff = new StateDiff(key, { [key]: value });
|
|
351
|
+
this._recordDiff(diff);
|
|
352
|
+
if (this._dbOpen) { try { await this.db.put(this._diffKey(diff), diff.serialize()); } catch { /* in-memory fallback */ } }
|
|
292
353
|
out.applied++;
|
|
293
354
|
} catch { out.skipped++; }
|
|
294
355
|
}
|
|
@@ -10,10 +10,16 @@ import { join } from 'path';
|
|
|
10
10
|
let pass = 0, fail = 0;
|
|
11
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
12
|
const EMPTY = '0'.repeat(64);
|
|
13
|
-
// Portable: this suite runs on the laptop AND on every Linux box in the fleet
|
|
13
|
+
// Portable: this suite runs on the laptop AND on every Linux box in the fleet — so it JOINS on `ready()`
|
|
14
|
+
// rather than sleeping. A fixed timeout is a guess about someone else's scheduler: this suite waited
|
|
15
|
+
// 120/150/250/500ms for the store to open and the tree to rehydrate, which held on this laptop and LOST on
|
|
16
|
+
// ubuntu-latest, where the restart check read 64 zeros — the 150ms wait expired before `_initDb` had opened
|
|
17
|
+
// the db, so `_handleLedgerBlock` persisted no diff rows and the restart had nothing to replay.
|
|
18
|
+
// `StateMachine.ready()` resolves once the store is open, the tree is rehydrated from `state:` and the diff
|
|
19
|
+
// rows are swept; every write below it is already awaited. There is nothing left to sleep for.
|
|
14
20
|
const dir = join(tmpdir(), 'xvsm-verkle-test');
|
|
15
21
|
const fresh = async () => { rmSync(dir, { recursive: true, force: true });
|
|
16
|
-
const sm = new StateMachine({ dbPath: dir }); await
|
|
22
|
+
const sm = new StateMachine({ dbPath: dir }); await sm.ready(); return sm; };
|
|
17
23
|
|
|
18
24
|
const blk = (id, tx) => ({ id, tx });
|
|
19
25
|
const REAL_TRAFFIC = [
|
|
@@ -107,18 +113,40 @@ console.log('\n4. restart survival');
|
|
|
107
113
|
await check('verkle root survives a restart (diffs are REPLAYED, not just collected)', async () => {
|
|
108
114
|
const d = dir + '-restart';
|
|
109
115
|
rmSync(d, { recursive: true, force: true });
|
|
110
|
-
let sm = new StateMachine({ dbPath: d }); await
|
|
116
|
+
let sm = new StateMachine({ dbPath: d }); await sm.ready();
|
|
111
117
|
for (const b of REAL_TRAFFIC) await sm._handleLedgerBlock(b);
|
|
112
118
|
const before = sm.stateTree.getRoot();
|
|
113
119
|
assert.notStrictEqual(before, EMPTY);
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
// No sleep before the close: _handleLedgerBlock awaits both the tree insert (which awaits
|
|
121
|
+
// _saveState) and the diff `put`, so when the loop above returns, the bytes are already down.
|
|
122
|
+
await sm.db.close();
|
|
123
|
+
sm = new StateMachine({ dbPath: d }); await sm.ready();
|
|
116
124
|
const after = sm.stateTree.getRoot();
|
|
117
125
|
await sm.db.close(); rmSync(d, { recursive: true, force: true });
|
|
118
126
|
assert.strictEqual(after, before, 'root changed across restart');
|
|
119
127
|
assert.notStrictEqual(after, EMPTY, 'root reset to zeros on restart');
|
|
120
128
|
});
|
|
121
129
|
|
|
130
|
+
// The regression the sleeps above were hiding. `_handleLedgerBlock` is wired to `block:added` in the
|
|
131
|
+
// CONSTRUCTOR, so in production a block can arrive while `_initDb` is still opening the store — and both of
|
|
132
|
+
// that handler's writes swallow their errors, so the block landed in the in-memory tree and nowhere on disk.
|
|
133
|
+
// The root read correctly for the life of the process and came back 64 zeros on the next boot, silently.
|
|
134
|
+
// This check applies traffic with NO join at all, the way the ledger's event does.
|
|
135
|
+
await check('a block applied BEFORE the store finished opening still survives a restart', async () => {
|
|
136
|
+
const d = dir + '-race';
|
|
137
|
+
rmSync(d, { recursive: true, force: true });
|
|
138
|
+
let sm = new StateMachine({ dbPath: d }); // deliberately NOT awaiting ready()
|
|
139
|
+
for (const b of REAL_TRAFFIC) await sm._handleLedgerBlock(b);
|
|
140
|
+
const before = sm.stateTree.getRoot();
|
|
141
|
+
assert.notStrictEqual(before, EMPTY, 'control invalid: nothing was applied');
|
|
142
|
+
await sm.db.close();
|
|
143
|
+
sm = new StateMachine({ dbPath: d }); await sm.ready();
|
|
144
|
+
const after = sm.stateTree.getRoot();
|
|
145
|
+
await sm.db.close(); rmSync(d, { recursive: true, force: true });
|
|
146
|
+
assert.notStrictEqual(after, EMPTY, 'root came back as 64 zeros — the early block was never persisted');
|
|
147
|
+
assert.strictEqual(after, before, 'root changed across restart');
|
|
148
|
+
});
|
|
149
|
+
|
|
122
150
|
rmSync(dir, { recursive: true, force: true });
|
|
123
151
|
console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed\n`);
|
|
124
152
|
process.exit(fail === 0 ? 0 : 1);
|
package/src/verkle-tree.js
CHANGED
|
@@ -16,9 +16,10 @@ export class VerkleStateTree {
|
|
|
16
16
|
this.db = options.db || null;
|
|
17
17
|
this._dbOpen = false;
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// KEEP THE PROMISE. This was fire-and-forget, so XVSM's own _loadDiffs raced the tree's _loadState and
|
|
20
|
+
// could not tell "the tree is empty" from "the tree has not finished loading" — the exact distinction the
|
|
21
|
+
// replay decision below now turns on. `ready()` is the join point.
|
|
22
|
+
this._ready = this.db ? this._initDb().catch(() => {}) : Promise.resolve();
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
async _initDb() {
|
|
@@ -33,6 +34,9 @@ export class VerkleStateTree {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// Resolves once the `state:` keyspace has been read and the trie rebuilt from it.
|
|
38
|
+
ready() { return this._ready || Promise.resolve(); }
|
|
39
|
+
|
|
36
40
|
async _loadState() {
|
|
37
41
|
if (!this.db || !this._dbOpen) return;
|
|
38
42
|
|
|
@@ -45,6 +49,30 @@ export class VerkleStateTree {
|
|
|
45
49
|
} catch (error) {
|
|
46
50
|
// Ignore errors during load
|
|
47
51
|
}
|
|
52
|
+
// ⛔ REBUILD THE TRIE. This loop restored `this.state` — the key->value map — and stopped there, leaving
|
|
53
|
+
// `this.root` a fresh empty VerkleNode. So a node that had loaded EVERY key off disk answered getRoot()
|
|
54
|
+
// with 64 zeros: a tree that holds the whole state and commits to nothing. MEASURED 2026-09-15: after a
|
|
55
|
+
// canonical rebuild of 10 anchors, restart loaded all 10 keys and published root 0000…0000; across the
|
|
56
|
+
// fleet 39 of 44 reporting nodes published exactly that, including one with 11,691 blocks persisted. The
|
|
57
|
+
// only boxes showing a real root were showing it by accident, via XVSM's diff replay re-inserting keys
|
|
58
|
+
// through insert(). The state map is not the commitment — the hashed trie over it is, and it has to be
|
|
59
|
+
// reconstructed on open or every restart silently un-commits the node's entire state.
|
|
60
|
+
this._rebuildTrie();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Rebuild the hashed trie from `this.state` in ONE bottom-up pass. Per-key _updateHashPath would re-hash
|
|
64
|
+
// every ancestor once per key (32 levels x 8KB of child hashes each); this inserts every key first and
|
|
65
|
+
// hashes each node exactly once, so a 50k-key store rehydrates in a single walk instead of 1.6M of them.
|
|
66
|
+
_rebuildTrie() {
|
|
67
|
+
this.root = new VerkleNode();
|
|
68
|
+
for (const [k, v] of this.state) {
|
|
69
|
+
this._insertNode(this.root, this._hashKey(k), this._hashValue(v), 0, []);
|
|
70
|
+
}
|
|
71
|
+
const hashSubtree = (node) => {
|
|
72
|
+
for (const child of node.children.values()) hashSubtree(child);
|
|
73
|
+
this._updateHash(node);
|
|
74
|
+
};
|
|
75
|
+
hashSubtree(this.root);
|
|
48
76
|
}
|
|
49
77
|
|
|
50
78
|
async _saveState(key, value) {
|