@xmbl/state-machine 0.1.5 → 0.1.12

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 ADDED
@@ -0,0 +1,85 @@
1
+ # @xmbl/state-machine
2
+
3
+ ## 0.1.12
4
+
5
+ ## 0.1.11
6
+
7
+ ### Patch Changes
8
+
9
+ - Return every `@xmbl/*` protocol package to ONE version line (0.1.11) after a night of per-package
10
+ hotfix publishes (cubic-ledger reached 0.1.10 while zero-knowledge sat at 0.1.1, crates at 0.1.0).
11
+
12
+ 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:
13
+
14
+ - cubic-ledger: block ids address CONSENSUS CONTENT (`consensusBody`) instead of the whole tx envelope; an
15
+ anchor's `hash` must be a sha-256 digest; invalid txs are evicted for good (`evict`, `evicted:` keyspace);
16
+ legacy envelope-keyed rows converge to content ids on the next boot; a canonical rebuild preserves every
17
+ non-anchor block and reports what it wiped; `ready()` is the boot join point. `capabilities()` (hardcoded
18
+ flags) is gone; `census.mjs` (an operator one-off) no longer ships in the tarball. A FORGERY CAN NO LONGER
19
+ DELETE THE TRANSACTION IT IMPERSONATES: an invalid typed datum is evicted under a digest of its own bytes,
20
+ never under the xid it claims (a datum fails `validateXid` precisely when that xid is somebody else's), and
21
+ the anchor dedup key claimed before validation is released on every failure. Both doors are closed on BOTH
22
+ entry points — `addTransaction` and `addSealedBatch`, the path a finalized transaction actually takes — and
23
+ one invalid entry in a batch no longer discards the genuine transactions behind it.
24
+ - consensus: a content-addressed type-6 value tx is admitted on its content address (it carries no in-body
25
+ signature by design); a malformed anchor is refused at the door by the ledger's own `validateTransaction`;
26
+ `finalizeTransaction` preserves the signed `id`.
27
+ - identity: one canonical `signingMessage`; `signingStatus()` / `verifySigning()`; `Identity.fromPrivateKey`
28
+ (a stub that only threw) is removed.
29
+ - state-machine: the verkle trie is rebuilt on load; diffs are keyed by content identity; `ready()`.
30
+ - networking: self-elected circuit-relay server; NO_FATAL transport tolerance; throttled bootstrap warnings.
31
+ - storage-compute: `CoordinateDelivery` (broken: `require` in ESM, keyed on the public key) is removed.
32
+ - core: ships the node daemon as the `xmbl-node` bin; the control socket implements the coordinator
33
+ contract (`xsc`, `submit_batch`, `ledger_capabilities`, `identity_status`, a signed `chain` claim, genuine
34
+ `submit_tx` rejections) and reports the RUNNING versions; boot waits for the stores.
35
+ - every package exports a load-time `VERSION`.
36
+ - PROTOCOL (operator, 2026-09-16): every transaction is typed by its xid (tokens.json type codes; `micromineTx`/
37
+ `validateXid`; untyped rows deleted on boot, skipped by a canonical rebuild; an anchor's wire tx carries `prior`);
38
+ consensus validates in order — can it happen, is the xid correct, is the placement right (`validate.js`,
39
+ `verifyPlacement`); block hashes are content-only so every node seals the same cubes.
40
+ - ROLLOUT (operator, 2026-09-16): a node proves its version (`build` digest of the loaded code, signed into the
41
+ `chain` claim; `release` op), suspends itself when behind the npm `latest` of @xmbl/core (no submits, validations
42
+ or seals), installs the latest over the air and restarts (exit 75 under a supervisor).
43
+ - lng: ships its BROWSER build — `dist/lng.browser.js` (`@xmbl/lng/browser`), one dependency-free ES module
44
+ generated from the same `src/*.js` the node runs and byte-checked by the gate (same surface, same bytes, runs
45
+ with no Node globals); the sources no longer assume `process`/`Buffer`.
46
+ - lng: the `~bytes` BYTE-STRING TYPE reaches the WASM backend — a (pointer, length) pair on the operand
47
+ stack, never a 256-bit word: a literal's bytes live in a data segment with a compile-time length, a runtime
48
+ value (a UTXO id from `xmbl_input_id`) is host-written into fresh memory with its length in a local. A
49
+ `~bytes` FIELD or PARAM is now REFUSED — committed state and the call ABI are both 32-byte words with
50
+ nowhere to put a length, and until now both compiled SILENTLY as words. New opt-in host modes
51
+ `compile(src, { crypto: true })` (`xmbl.mayo.verify` / `xmbl.cubic.verify`) and `{ utxo: true }` (the
52
+ five-entry value ABI), so a contract written in LNG verifies a signature and spends a UTXO the caller
53
+ presented. The value ABI's `-1` sentinel TRAPS instead of widening to 2^256-1. The typechecker refuses
54
+ arithmetic/bitwise/ordering on `~bytes` and now walks `~contract` method bodies at all, which it never did.
55
+ A contract with no byte literals emits a byte-identical, import-free module.
56
+ - storage-compute: THE ERASURE CODER RETURNED CORRUPTED DATA WITHOUT SAYING SO. `StorageShard.decode`
57
+ recovers a lost data shard by XOR-ing its parity group, and XOR parity recovers AT MOST ONE loss per
58
+ group — but when two members of one group were missing it filled the hole with zeros and returned the
59
+ buffer as if decoding had succeeded, with no error, no flag and no short read. MEASURED on k=4, m=2:
60
+ losing data shards 0 and 2 handed back a buffer that differed from the original and nothing downstream
61
+ could tell. Two independent causes are fixed: the group is now checked for completeness before the XOR
62
+ is trusted, and `m` — the encoding's PARITY DEGREE — is carried on every shard as the new optional
63
+ `parityCount` field instead of being inferred from however many parity shards happened to survive (that
64
+ inference was wrong exactly when a parity shard was among the losses: given data 0,1,2 and parity 4 only,
65
+ the inferred m was 1, the recovery group became {0,1,2,3} instead of {0,2}, and decode returned wrong
66
+ bytes). An unrecoverable decode now THROWS and names every missing shard. Proven exhaustively over all
67
+ 63 non-empty subsets of a k=4/m=2 encoding: every subset either decodes to the exact original or throws,
68
+ and none returns wrong bytes. COMPAT: `parityCount` is additive and optional, so a shard written by an
69
+ older node reads back fine — a new node treats it as legacy and REFUSES parity recovery rather than
70
+ guessing, which fails loudly where the old code failed silently. Shard metadata persists as JSON
71
+ (`meta:<id>`), so an old reader ignores the extra field.
72
+ - core: a boot crash. `Config._applyEnvOverrides()` assigned into `config.network` / `config.logging`
73
+ without creating them, so a node started with `XN_PORT` or `LOG_LEVEL` set against a config that omitted
74
+ those sections died on `undefined.port` before it could log why. The sections are created on demand.
75
+ - the protocol gate is 75 suites (was 67), and line coverage across the twelve protocol packages is 85.5%
76
+ (was 80.6%). `scripts/coverage-report.mjs` is the instrument: `NODE_V8_COVERAGE` + a V8-range reducer,
77
+ since nothing in the tree measured coverage at all. Ten protocol files that no suite had ever loaded now
78
+ have one; four remain, all process entry points.
79
+ - NODE 22 IS NOW DECLARED, because it was already REQUIRED. Every published package gains
80
+ `engines: { node: ">=22" }`; the workspace root's `">=20"` was simply false. `@xmbl/identity` imports
81
+ `node:sqlite` (Node 22.5+) for the durable nonce registry, `@xmbl/storage-compute` meters jobs with
82
+ `process.threadCpuUsage` (22.10+), and libp2p's own dependency chain calls `Promise.withResolvers`
83
+ (22.0). On Node 20 a consumer installed cleanly and crashed at import instead of being told at install
84
+ time. MEASURED: the protocol gate scores 54/75 on Node 20.20.2 and 75/75 on Node 22 — the twenty-one
85
+ 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.5",
3
+ "version": "0.1.12",
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
  },
@@ -1,4 +1,4 @@
1
- // OUTCOME TEST: applied_tx_count after the convergence primitive. The metric the whole fleet reads.
1
+ // OUTCOME TEST: applied_tx_count after the convergence primitive. The metric every node reads.
2
2
  const { StateMachine } = await import("../index.js");
3
3
  const sm = new StateMachine({ dbPath: null });
4
4
  sm._dbOpen = false;
@@ -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
- 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
- }));
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-'));
@@ -46,7 +50,7 @@ const close = async ({ dir, xclt, xvsm }) => {
46
50
  console.log('\n1. a SEALED face reaches the state tree (the missing block:added doorbell)');
47
51
  {
48
52
  const env = await fresh();
49
- await check('the tree starts empty — the honest baseline, not an assumption', async () => {
53
+ await check('the tree starts empty — the true baseline, not an assumption', async () => {
50
54
  eq(env.xvsm.getStateRoot(), ZERO, 'initial root');
51
55
  });
52
56
  await check('sealing 9 txs moves state_root off zero and applies 9 diffs', async () => {
@@ -1,4 +1,4 @@
1
- // THE THREE DEFECTS THAT KEPT NODES OFF A SHARED ROOT, each asserted by the symptom the fleet showed,
1
+ // THE THREE DEFECTS THAT KEPT NODES OFF A SHARED ROOT, each asserted by the symptom the nodes showed,
2
2
  // not by the mechanism. All three were measured live on 2026-09-15 before being fixed here.
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert';
@@ -25,7 +25,7 @@ test('a restarted node publishes a REAL root, not 64 zeros, from the state: keys
25
25
  await sm.db.close();
26
26
 
27
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.
28
+ // surviving source for the trie is `state:`. 39 of 44 reporting nodes published ZERO here.
29
29
  sm = await open(dir);
30
30
  await sm.db.clear({ gte: 'diff:', lt: 'diff:\xFF' });
31
31
  await sm.db.close();
@@ -73,7 +73,7 @@ test('legacy rows keyed by block id are re-keyed to content identity on the next
73
73
  const dir = `/tmp/xvsm-rehydrate-${process.pid}-d`;
74
74
  rmSync(dir, { recursive: true, force: true });
75
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.
76
+ // Write rows the way every node on the nodes already has them: one per submission, keyed by block id.
77
77
  const set = anchors(40);
78
78
  let written = 0;
79
79
  for (let pass = 0; pass < 3; pass++) for (const a of set) {
@@ -34,7 +34,7 @@ export class StateMachine extends EventEmitter {
34
34
 
35
35
  // Initialize database. KEEP THE PROMISE: `_dbOpen` flips true the moment Level opens, long before the
36
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.
37
+ // count is reading a store mid-rebuild. `ready()` is the only genuine join point.
38
38
  this._ready = this._initDb().catch(() => {});
39
39
 
40
40
  // Listen to ledger events if available
@@ -235,6 +235,14 @@ export class StateMachine extends EventEmitter {
235
235
  }
236
236
 
237
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();
238
246
  const changes = this._stateChangesFor(block);
239
247
  if (!changes || !Object.keys(changes).length) return;
240
248
  try {
@@ -327,9 +335,9 @@ export class StateMachine extends EventEmitter {
327
335
  // ⛔ RECORD A DIFF FOR EVERY ANCHOR APPLIED, or applied_tx_count IS ZERO BY CONSTRUCTION. This loop wrote
328
336
  // straight into the tree and never touched `this.diffs`, which it had just emptied — and
329
337
  // getStatistics().totalTransactions is transactionLog.length + diffs.length. So the moment a node runs the
330
- // convergence primitive, the number the whole fleet reads as "is this node applying anything" resets to 0
338
+ // convergence primitive, the number every node reads as "is this node applying anything" resets to 0
331
339
  // and STAYS 0 no matter how many anchors it applied. MEASURED 2026-09-15: this node held a correct root
332
- // over 3,941 applied anchors and published applied_tx_count 0; across the fleet 42 of 46 reporting nodes
340
+ // over 3,941 applied anchors and published applied_tx_count 0; across the nodes 42 of 46 reporting nodes
333
341
  // read 0, and a design ruling was written on the premise that 41 of them had "applied nothing". They had.
334
342
  // The diff is not bookkeeping — it is the same StateDiff the block path records, keyed by the anchor's own
335
343
  // content, so a rebuild and a live apply of the same anchor upsert to ONE row rather than two.
@@ -9,7 +9,7 @@
9
9
  // leaf hash = sha256(value canonicalised) (value node at depth 32)
10
10
  // internal node hash = sha256( concat of 256 child slots, each 32 bytes; empty slot = 32 zeros )
11
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
12
+ // A proof carries, per level, the sibling hashes at that node; the correct verifier fills the key's own
13
13
  // slot with the hash carried up from below and every other slot from the siblings (or zeros), hashes,
14
14
  // and repeats to the root. The proof is SOUND iff the reconstructed root equals the tree's real root —
15
15
  // which the verifier learns independently (tree.getRoot()), never from the attacker-supplied proof.root.
@@ -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 among the nodes — 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 new Promise(r => setTimeout(r, 120)); return sm; };
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 new Promise(r => setTimeout(r, 150));
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
- await new Promise(r => setTimeout(r, 250)); await sm.db.close();
115
- sm = new StateMachine({ dbPath: d }); await new Promise(r => setTimeout(r, 500));
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);
@@ -53,7 +53,7 @@ export class VerkleStateTree {
53
53
  // `this.root` a fresh empty VerkleNode. So a node that had loaded EVERY key off disk answered getRoot()
54
54
  // with 64 zeros: a tree that holds the whole state and commits to nothing. MEASURED 2026-09-15: after a
55
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
56
+ // nodes 39 of 44 reporting nodes published exactly that, including one with 11,691 blocks persisted. The
57
57
  // only boxes showing a real root were showing it by accident, via XVSM's diff replay re-inserting keys
58
58
  // through insert(). The state map is not the commitment — the hashed trie over it is, and it has to be
59
59
  // reconstructed on open or every restart silently un-commits the node's entire state.
@@ -201,7 +201,7 @@ export class VerkleStateTree {
201
201
  // transactions instead of the structure that actually commits them. This returns the real nodes and the
202
202
  // real parent->child edges, bounded, with the counts needed to say what was left out.
203
203
  //
204
- // Bounded on BOTH axes and honest about it: `maxDepth` limits how far down, `maxNodes` caps the total, and
204
+ // Bounded on BOTH axes and genuine about it: `maxDepth` limits how far down, `maxNodes` caps the total, and
205
205
  // the result carries `truncated` plus each node's own `descendants` so a cut branch reports its real size
206
206
  // rather than looking like a leaf. A viewer that silently stopped at 500 nodes would draw a tree that is
207
207
  // simply the wrong shape.
@@ -217,7 +217,7 @@ export class VerkleStateTree {
217
217
  }
218
218
  if (!start.hash) this._updateHash(start);
219
219
 
220
- // Subtree size is what makes a truncated branch honest, so it is computed for every node we emit.
220
+ // Subtree size is what makes a truncated branch genuine, so it is computed for every node we emit.
221
221
  const sizeOf = (node) => {
222
222
  let n = 1;
223
223
  for (const c of node.children.values()) n += sizeOf(c);