@xmbl/simulator 0.1.0 → 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 +84 -0
- package/DEVNET-SEAM-FINDING.md +2 -2
- package/index.js +5 -2
- package/package.json +4 -1
- package/src/capabilities.js +5 -5
- package/src/devnet-rpc.js +2 -2
- package/src/devnet-rpc.test.mjs +1 -1
- package/src/devnet.js +3 -2
- package/src/devnet.test.mjs +55 -9
- package/src/simulator.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
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.
|
|
86
|
+
|
|
3
87
|
All notable changes to the `xsim` module will be documented in this file.
|
|
4
88
|
|
|
5
89
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
package/DEVNET-SEAM-FINDING.md
CHANGED
|
@@ -5,7 +5,7 @@ opts into ledger-side signature verification — it wires **both** `xid` and
|
|
|
5
5
|
`getPublicKeyByAddress` into the `Ledger` — which is precisely the caller configuration that
|
|
6
6
|
exercises defects no production code path currently reaches. Two of the three defects below are
|
|
7
7
|
now **FIXED**; the third is a genuine signature-domain decision left for the audit and documented
|
|
8
|
-
|
|
8
|
+
accurately rather than papered over. All are covered by `src/devnet.test.mjs`.
|
|
9
9
|
|
|
10
10
|
## Where ledger-side verification runs
|
|
11
11
|
|
|
@@ -92,7 +92,7 @@ architectural choices, **which is an audit-level signature-domain / block-identi
|
|
|
92
92
|
Both touch `block.js`, ledger dedup, and cross-node determinism, so neither rides in on a tooling
|
|
93
93
|
commit. Until one is made and audited, **ledger-side re-verification stays OFF in production**
|
|
94
94
|
(the `getPublicKeyByAddress` lookup is deliberately not wired into the `Ledger`), and consensus
|
|
95
|
-
remains the single verification point. This is the
|
|
95
|
+
remains the single verification point. This is the genuine, current posture — not a silent gap.
|
|
96
96
|
|
|
97
97
|
## Why the devnet drives the direct path
|
|
98
98
|
|
package/index.js
CHANGED
|
@@ -46,5 +46,8 @@ if (isMainModule || process.argv.includes('--run')) {
|
|
|
46
46
|
|
|
47
47
|
export { SystemSimulator, StructuredLogger, LocalDevnet, DevnetRpc };
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// THE VERSION OF THE CODE THIS PROCESS LOADED. Read once at import time from this package's own manifest, so a
|
|
50
|
+
// running node can report what it is actually executing — an install that lands on disk after this module was
|
|
51
|
+
// loaded changes the file, not this constant. Consumed by @xmbl/core's control socket (`status`.versions).
|
|
52
|
+
import { readFileSync as __readPkg } from 'node:fs';
|
|
53
|
+
export const VERSION = JSON.parse(__readPkg(new URL('./package.json', import.meta.url), 'utf8')).version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmbl/simulator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"description": "XMBL Simulator",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"chance": "^1.1.13"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
17
20
|
"publishConfig": {
|
|
18
21
|
"access": "public"
|
|
19
22
|
},
|
package/src/capabilities.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// zk / homomorphic-encryption / signature primitives in-page (they need node:crypto; an MV3 page
|
|
3
3
|
// CSP and WebCrypto's async sha256 make a faithful in-page port impossible), so the devnet node
|
|
4
4
|
// process — which already loads the REAL modules — runs each primitive end-to-end and returns a
|
|
5
|
-
// JSON-safe verdict. Each runner does the
|
|
5
|
+
// JSON-safe verdict. Each runner does the right thing and its NEGATIVE control in one call: it
|
|
6
6
|
// proves the capability verifies true input AND rejects tampered input, so a green card on screen
|
|
7
7
|
// is backed by a real refusal, not a fabricated pass. Every flow mirrors the headless
|
|
8
8
|
// reproductions in @xmbl/contracts (reproductions/contract-zk.mjs, contract-he.mjs,
|
|
@@ -25,7 +25,7 @@ const CUBE_CONTEXT = {
|
|
|
25
25
|
|
|
26
26
|
// ── Coordinate / curve zero-knowledge (@xmbl/zero-knowledge, FRI, ⛔ UNAUDITED) ──
|
|
27
27
|
// A prover shows a coordinate (derivedX, derivedY) lies on a curve through secret points, and a
|
|
28
|
-
// verifier checks it WITHOUT the secret points. We prove the
|
|
28
|
+
// verifier checks it WITHOUT the secret points. We prove the genuine coordinate and reject a
|
|
29
29
|
// tampered y (derivedY+1) — the exact pair reproductions/contract-zk.mjs gates a Verkle write on.
|
|
30
30
|
export function zkProof({ derivedX = 99 } = {}) {
|
|
31
31
|
const ctx = setup();
|
|
@@ -34,14 +34,14 @@ export function zkProof({ derivedX = 99 } = {}) {
|
|
|
34
34
|
const dX = bi(derivedX);
|
|
35
35
|
const { Pt, derivedY } = blindedCurve(ctx, { publicPoints, secretPoints, derivedX: dX });
|
|
36
36
|
const proof = prove(ctx, { Pt, publicPoints, derivedX: dX, derivedY });
|
|
37
|
-
const
|
|
37
|
+
const genuine = zkVerify(ctx, { proof, publicPoints, derivedX: dX, derivedY });
|
|
38
38
|
const tampered = zkVerify(ctx, { proof, publicPoints, derivedX: dX, derivedY: derivedY + 1n });
|
|
39
39
|
return {
|
|
40
|
-
ok:
|
|
40
|
+
ok: genuine === true && tampered === false,
|
|
41
41
|
scheme: 'xzk (FRI coordinate/curve proof, UNAUDITED)',
|
|
42
42
|
derivedX: str(dX), derivedY: str(derivedY),
|
|
43
43
|
publicAnchors: publicPoints.length,
|
|
44
|
-
|
|
44
|
+
genuineVerifies: genuine, tamperedRejected: tampered === false,
|
|
45
45
|
note: 'proof checked without the secret points; a coordinate off the curve is rejected',
|
|
46
46
|
};
|
|
47
47
|
}
|
package/src/devnet-rpc.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// It ALSO serves the node-side capability surface the extension cannot run in-page — each a REAL
|
|
18
18
|
// primitive run end-to-end in this node process, verdict returned JSON-safe (see capabilities.js):
|
|
19
19
|
// getStateRoot {type} → { root, pooled, landed } // live ledger state root
|
|
20
|
-
// zkProof {type, derivedX?} → { ok, derivedX, derivedY,
|
|
20
|
+
// zkProof {type, derivedX?} → { ok, derivedX, derivedY, genuineVerifies, tamperedRejected, … }
|
|
21
21
|
// heAdd {type, a?, b?} → { ok, a, b, sum, expected, … } // homomorphic add
|
|
22
22
|
// sigVerify {type, message?} → { ok, signedVerifies, tamperedRejected, … } // Cubic-SIG
|
|
23
23
|
// seal {type, secret?} → { ok, roundTrip, … } // PQ KEM seal
|
|
@@ -69,7 +69,7 @@ export class DevnetRpc {
|
|
|
69
69
|
}
|
|
70
70
|
default: {
|
|
71
71
|
// Node-side capability surface (zk / HE / signature / seal): a real primitive run to a
|
|
72
|
-
// verdict in this process. Unknown types still fall through to an
|
|
72
|
+
// verdict in this process. Unknown types still fall through to an genuine error.
|
|
73
73
|
const cap = CAPABILITIES[message && message.type];
|
|
74
74
|
if (cap) return cap(message || {});
|
|
75
75
|
return { error: `Unknown message type: ${message && message.type}` };
|
package/src/devnet-rpc.test.mjs
CHANGED
|
@@ -54,7 +54,7 @@ try {
|
|
|
54
54
|
|
|
55
55
|
// ── node-side capability surface: each a REAL primitive verified + negative-controlled ──
|
|
56
56
|
const zk = await call({ type: 'zkProof', derivedX: 99 });
|
|
57
|
-
ok('zkProof verifies the
|
|
57
|
+
ok('zkProof verifies the genuine coordinate and rejects the tampered one', zk.ok === true && zk.genuineVerifies === true && zk.tamperedRejected === true, JSON.stringify(zk));
|
|
58
58
|
|
|
59
59
|
const he1 = await call({ type: 'heAdd', a: 1, b: 0 });
|
|
60
60
|
ok('heAdd: ENC(1) ⊞ ENC(0) decrypts to 1 (blind add)', he1.ok === true && he1.sum === 1, JSON.stringify(he1));
|
package/src/devnet.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// getPublicKeyByAddress); the devnet opts it in, which is why the direct path is what it drives.
|
|
25
25
|
import { EventEmitter } from 'node:events';
|
|
26
26
|
import { Identity } from '../../identity/index.js';
|
|
27
|
-
import { Ledger } from '../../cubic-ledger/index.js';
|
|
27
|
+
import { Ledger, micromineTx } from '../../cubic-ledger/index.js';
|
|
28
28
|
|
|
29
29
|
export class LocalDevnet extends EventEmitter {
|
|
30
30
|
constructor(options = {}) {
|
|
@@ -120,7 +120,8 @@ export class LocalDevnet extends EventEmitter {
|
|
|
120
120
|
amount,
|
|
121
121
|
timestamp: Date.now(),
|
|
122
122
|
};
|
|
123
|
-
const
|
|
123
|
+
const typed = micromineTx(tx); // the xmbl type: xid micromined over the body, from final
|
|
124
|
+
const signed = await from.signTransaction(typed); // adds the MAYO sig over everything incl. the xid
|
|
124
125
|
this.metrics.submitted++;
|
|
125
126
|
try {
|
|
126
127
|
const result = await this.ledger.addTransaction(signed);
|
package/src/devnet.test.mjs
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// control so a regression fails loudly.
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { LocalDevnet } from './devnet.js';
|
|
12
|
+
import { micromineTx } from '../../cubic-ledger/index.js';
|
|
12
13
|
import { Identity } from '../../identity/index.js';
|
|
14
|
+
import { Ledger } from '@xmbl/cubic-ledger';
|
|
13
15
|
import { ConsensusWorkflow } from '../../consensus/index.js';
|
|
14
16
|
|
|
15
17
|
let pass = 0;
|
|
@@ -47,7 +49,7 @@ const ok = (name, cond, detail = '') => {
|
|
|
47
49
|
|
|
48
50
|
// NEGATIVE CONTROL — verification is actually ON: a tx tampered after signing is rejected.
|
|
49
51
|
const from = net.identities[0];
|
|
50
|
-
const signed = await from.signTransaction({ id: 'tamper_1', type: 'utxo', from: from.address, to: net.addressOf(1), amount: 5, timestamp: Date.now() });
|
|
52
|
+
const signed = await from.signTransaction(micromineTx({ id: 'tamper_1', type: 'utxo', from: from.address, to: net.addressOf(1), amount: 5, timestamp: Date.now() }));
|
|
51
53
|
const tampered = { ...signed, amount: 9999 }; // mutate a signed field
|
|
52
54
|
let threw = '';
|
|
53
55
|
try { await net.ledger.addTransaction(tampered); } catch (e) { threw = e.message; }
|
|
@@ -60,7 +62,7 @@ const ok = (name, cond, detail = '') => {
|
|
|
60
62
|
{
|
|
61
63
|
const net = await new LocalDevnet({ identities: 2 }).start();
|
|
62
64
|
const from = net.identities[0];
|
|
63
|
-
const signed = await from.signTransaction({ id: 'seam_client_id', type: 'utxo', from: from.address, to: net.addressOf(1), amount: 7, timestamp: Date.now() });
|
|
65
|
+
const signed = await from.signTransaction(micromineTx({ id: 'seam_client_id', type: 'utxo', from: from.address, to: net.addressOf(1), amount: 7, timestamp: Date.now() }));
|
|
64
66
|
|
|
65
67
|
// FIX(a): consensus finalizeTransaction now PRESERVES the originator's signed `id`. It used to
|
|
66
68
|
// overwrite it with validatedHash (workflow.js), corrupting the signed message so the ledger's
|
|
@@ -77,18 +79,62 @@ const ok = (name, cond, detail = '') => {
|
|
|
77
79
|
ok('FIX(a): finalize PRESERVES the signed id (not validatedHash)', !!emitted && emitted.txData.id === 'seam_client_id', `id=${emitted?.txData?.id}`);
|
|
78
80
|
ok('FIX(a): the finalized tx STILL verifies against the signer key', (await Identity.verifyTransaction(emitted.txData, from.publicKey)) === true);
|
|
79
81
|
ok('FIX(a): the consensus hash is carried separately as the event txId', emitted.txId === validatedHash);
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// so a real finalized tx does NOT re-verify against the signer key. This is why ledger-side
|
|
83
|
-
// re-verification stays OFF in production. If someone wires getPublicKeyByAddress into the
|
|
84
|
-
// Ledger without first resolving (c) (block-id-from-signed-body OR carrying validationTimestamp
|
|
85
|
-
// as a sibling), THIS assertion flips and the gate fails — which is the intended tripwire.
|
|
82
|
+
// WHY A TX MUST NOT CARRY THE CONSENSUS CLOCK: identity's signingMessage covers every field except
|
|
83
|
+
// sig/publicKey, so a `validationTimestamp` written into the body is a value the signer never saw.
|
|
86
84
|
const withVt = { ...signed, validationTimestamp: '123' };
|
|
87
|
-
ok('
|
|
85
|
+
ok('a tx carrying a validationTimestamp INSIDE the signed body does NOT re-verify (why A5 moved it out)',
|
|
88
86
|
(await Identity.verifyTransaction(withVt, from.publicKey)) === false);
|
|
89
87
|
try { await w.mempool?.db?.close?.(); } catch { /* in-memory / already closed */ }
|
|
90
88
|
}
|
|
91
89
|
|
|
90
|
+
// FIX(c) / A5 — DRIVEN AT THE REAL SITE, not asserted on a hand-built object. The previous version of this
|
|
91
|
+
// block pinned the DEFECT by checking a property of verifyTransaction itself, which no amount of fixing
|
|
92
|
+
// moveToProcessing could ever flip — a tripwire that could not trip. This runs the actual pipeline:
|
|
93
|
+
// addRawTransaction → quorum timestamps → moveToProcessing → finalizeTransaction → the ledger, with a key
|
|
94
|
+
// resolver wired exactly as the daemon wires it (B7). If consensus ever writes its clock back into the
|
|
95
|
+
// signed body, the finalized tx stops verifying and the ledger refuses it — here, in the gate.
|
|
96
|
+
{
|
|
97
|
+
const w = new ConsensusWorkflow({});
|
|
98
|
+
const rawTxId = await w.mempool.addRawTransaction('leaderA', { ...signed });
|
|
99
|
+
w.rawTxToId.set(rawTxId, 'leaderA');
|
|
100
|
+
const entry = w.mempool.rawTx.get('leaderA').get(rawTxId);
|
|
101
|
+
entry.validationTimestamps = [
|
|
102
|
+
{ nodeId: 'v1', timestamp: 1789470971116000000n },
|
|
103
|
+
{ nodeId: 'v2', timestamp: 1789470971117000000n },
|
|
104
|
+
{ nodeId: 'v3', timestamp: 1789470971118000000n },
|
|
105
|
+
];
|
|
106
|
+
await w.moveToProcessing(rawTxId);
|
|
107
|
+
const [key, processing] = [...w.mempool.processingTx.entries()][0];
|
|
108
|
+
ok('A5: the processing tx body carries NO validationTimestamp (the clock is a sibling)',
|
|
109
|
+
processing.txData.validationTimestamp === undefined, `body=${JSON.stringify(Object.keys(processing.txData))}`);
|
|
110
|
+
ok('A5: the clock travels BESIDE the tx and is non-null', processing.validationTimestamp != null);
|
|
111
|
+
ok('A5: the processing tx STILL verifies against the signer key (the whole point)',
|
|
112
|
+
(await Identity.verifyTransaction(processing.txData, from.publicKey)) === true);
|
|
113
|
+
|
|
114
|
+
let fin = null;
|
|
115
|
+
w.on('tx:finalized', (d) => { fin = d; });
|
|
116
|
+
await w.finalizeTransaction(key);
|
|
117
|
+
ok('A5: the FINALIZED tx verifies against the signer key', !!fin && (await Identity.verifyTransaction(fin.txData, from.publicKey)) === true);
|
|
118
|
+
ok('A5: the finalized event carries the clock as its own field', fin.validationTimestamp != null && fin.txData.validationTimestamp === undefined);
|
|
119
|
+
|
|
120
|
+
// B7 — the ledger, with the resolver the daemon now wires, verifies the same tx a SECOND time.
|
|
121
|
+
const led = new Ledger({});
|
|
122
|
+
led.xid = from; // enables the ledger's signature check
|
|
123
|
+
led.getPublicKeyByAddress = (addr) => (addr === from.address ? from.publicKey : null);
|
|
124
|
+
const res = await led.addTransaction(fin.txData, { validationTimestamp: fin.validationTimestamp });
|
|
125
|
+
ok('B7: a signed finalized tx is ACCEPTED at the ledger with re-verification ON', !!res && res.duplicate !== true);
|
|
126
|
+
const block = [...led._membershipPool, ...led.blocks.values()].find((b) => b.tx && b.tx.id === 'seam_client_id');
|
|
127
|
+
ok('B7: the block carries the consensus clock as its own field, and its tx does not',
|
|
128
|
+
!!block && block.validationTimestamp != null && block.tx.validationTimestamp === undefined);
|
|
129
|
+
let refused = '';
|
|
130
|
+
try { await led.addTransaction({ ...fin.txData, amount: 424242 }, { validationTimestamp: fin.validationTimestamp }); }
|
|
131
|
+
catch (e) { refused = e.message; }
|
|
132
|
+
ok('B7: a tx tampered AFTER consensus is REFUSED at the ledger (the defense-in-depth layer is live)',
|
|
133
|
+
/Invalid transaction signature|address mismatch/.test(refused), `threw="${refused}"`);
|
|
134
|
+
try { await w.mempool?.db?.close?.(); } catch { /* in-memory / already closed */ }
|
|
135
|
+
try { await led.close?.(); } catch { /* in-memory */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
92
138
|
// FIX(a) negative control: mutating `id` AFTER signing (what the old overwrite effectively did)
|
|
93
139
|
// is REJECTED by the ledger — `id` is inside the signed domain, which is exactly why consensus
|
|
94
140
|
// must not overwrite it.
|
package/src/simulator.js
CHANGED
|
@@ -613,7 +613,7 @@ export class SystemSimulator extends EventEmitter {
|
|
|
613
613
|
// Compute operations can trigger state changes. This used to call a fake
|
|
614
614
|
// xvsm.executeTransaction with a comment string as "WASM"; that path is gone (real
|
|
615
615
|
// contract execution is @xmbl/contracts' ContractHost over @xmbl/storage-compute). The
|
|
616
|
-
// simulator's
|
|
616
|
+
// simulator's genuine job here is just to drive a state change, so it uses the real
|
|
617
617
|
// state-diff path directly.
|
|
618
618
|
if (this.modules.xvsm && chance.bool({ likelihood: 70 })) {
|
|
619
619
|
setTimeout(() => {
|