@xmbl/simulator 0.1.0
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 +8 -0
- package/DEVNET-SEAM-FINDING.md +102 -0
- package/index.js +50 -0
- package/instructions.md +784 -0
- package/package.json +28 -0
- package/readme.md +7 -0
- package/src/capabilities.js +110 -0
- package/src/devnet-rpc.js +114 -0
- package/src/devnet-rpc.test.mjs +91 -0
- package/src/devnet-run.mjs +46 -0
- package/src/devnet.js +180 -0
- package/src/devnet.test.mjs +117 -0
- package/src/logger.js +80 -0
- package/src/simulator.js +846 -0
- package/status.md +167 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xmbl/simulator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"description": "XMBL Simulator",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"devnet": "node src/devnet-run.mjs",
|
|
9
|
+
"soak": "node index.js --run",
|
|
10
|
+
"test": "node src/devnet.test.mjs && node src/devnet-rpc.test.mjs"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@faker-js/faker": "^8.4.1",
|
|
14
|
+
"chance": "^1.1.13"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/34r7h/xmbl-mainnet.git",
|
|
23
|
+
"directory": "packages/simulator"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./index.js"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# XSIM - XMBL Simulator
|
|
2
|
+
|
|
3
|
+
XMBL's Simulator module.
|
|
4
|
+
|
|
5
|
+
This simulator runs infinitely. It has two modes, a deterministic mode where we know exactly what to expect and will be used for system e2e tests then also a random mode with chaotic qualities.
|
|
6
|
+
|
|
7
|
+
every type of interaction and activity in the total xmbl system, including creation of identities, posting txs, validations, storage, compute, state machine diff txs, assembling app-centric diffs into current state, etc
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Node-side capability runners for the browser extension. The extension popup cannot run xmbl's
|
|
2
|
+
// zk / homomorphic-encryption / signature primitives in-page (they need node:crypto; an MV3 page
|
|
3
|
+
// CSP and WebCrypto's async sha256 make a faithful in-page port impossible), so the devnet node
|
|
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 honest thing and its NEGATIVE control in one call: it
|
|
6
|
+
// proves the capability verifies true input AND rejects tampered input, so a green card on screen
|
|
7
|
+
// is backed by a real refusal, not a fabricated pass. Every flow mirrors the headless
|
|
8
|
+
// reproductions in @xmbl/contracts (reproductions/contract-zk.mjs, contract-he.mjs,
|
|
9
|
+
// agentic-contract-e2e.mjs) so the extension and the audit prove the same thing.
|
|
10
|
+
import { setup, blindedCurve, prove, verify as zkVerify } from '../../zero-knowledge/index.js';
|
|
11
|
+
import {
|
|
12
|
+
cubicSigKeyGen, cubicSigSign, cubicSigVerify,
|
|
13
|
+
cubicLweKeyGen, encryptBit, decryptBit, addCiphertexts,
|
|
14
|
+
sealSecret, openSecret, sealKeyPair,
|
|
15
|
+
} from '../../identity/index.js';
|
|
16
|
+
|
|
17
|
+
const bi = (v) => BigInt(v);
|
|
18
|
+
const str = (v) => (typeof v === 'bigint' ? v.toString() : v);
|
|
19
|
+
|
|
20
|
+
// A real, non-collinear cube plane for the spatial binding (normal ≠ 0).
|
|
21
|
+
const CUBE_CONTEXT = {
|
|
22
|
+
cubeAddress: 'cube-xbe-demo',
|
|
23
|
+
coordinates: [{ x: 1, y: 0, z: 0 }, { x: 0, y: 1, z: 0 }, { x: 0, y: 0, z: 1 }],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Coordinate / curve zero-knowledge (@xmbl/zero-knowledge, FRI, ⛔ UNAUDITED) ──
|
|
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 honest coordinate and reject a
|
|
29
|
+
// tampered y (derivedY+1) — the exact pair reproductions/contract-zk.mjs gates a Verkle write on.
|
|
30
|
+
export function zkProof({ derivedX = 99 } = {}) {
|
|
31
|
+
const ctx = setup();
|
|
32
|
+
const publicPoints = [{ x: 11n, y: 101n }, { x: 12n, y: 205n }, { x: 13n, y: 313n }, { x: 14n, y: 419n }];
|
|
33
|
+
const secretPoints = [{ x: 21n, y: 55555n }, { x: 22n, y: 66666n }, { x: 23n, y: 77777n }];
|
|
34
|
+
const dX = bi(derivedX);
|
|
35
|
+
const { Pt, derivedY } = blindedCurve(ctx, { publicPoints, secretPoints, derivedX: dX });
|
|
36
|
+
const proof = prove(ctx, { Pt, publicPoints, derivedX: dX, derivedY });
|
|
37
|
+
const honest = zkVerify(ctx, { proof, publicPoints, derivedX: dX, derivedY });
|
|
38
|
+
const tampered = zkVerify(ctx, { proof, publicPoints, derivedX: dX, derivedY: derivedY + 1n });
|
|
39
|
+
return {
|
|
40
|
+
ok: honest === true && tampered === false,
|
|
41
|
+
scheme: 'xzk (FRI coordinate/curve proof, UNAUDITED)',
|
|
42
|
+
derivedX: str(dX), derivedY: str(derivedY),
|
|
43
|
+
publicAnchors: publicPoints.length,
|
|
44
|
+
honestVerifies: honest, tamperedRejected: tampered === false,
|
|
45
|
+
note: 'proof checked without the secret points; a coordinate off the curve is rejected',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Homomorphic add (post-quantum cubic-LWE) ──
|
|
50
|
+
// ENC(a) ⊞ ENC(b) decrypts to a+b with NO secret key present during the add. Single-bit message
|
|
51
|
+
// space wraps mod 2 (the documented behavior in reproductions/contract-he.mjs). a,b ∈ {0,1}.
|
|
52
|
+
export function heAdd({ a = 1, b = 1 } = {}) {
|
|
53
|
+
// The message space is a single bit; reject anything else rather than silently coercing (a
|
|
54
|
+
// coerced 3→1 would return ok:true for an operation nobody asked for).
|
|
55
|
+
if (![0, 1].includes(a) || ![0, 1].includes(b)) {
|
|
56
|
+
return { ok: false, scheme: 'cubic-LWE (single-bit message space)', a, b, error: 'heAdd: a and b must each be 0 or 1' };
|
|
57
|
+
}
|
|
58
|
+
const bitA = a, bitB = b;
|
|
59
|
+
const { sk, pk } = cubicLweKeyGen();
|
|
60
|
+
const ctA = encryptBit(pk, bitA);
|
|
61
|
+
const ctB = encryptBit(pk, bitB);
|
|
62
|
+
const ctSum = addCiphertexts(ctA, ctB); // NO secret key here — the add is blind
|
|
63
|
+
const sum = decryptBit(sk, ctSum); // decrypt OFF to the side, with sk
|
|
64
|
+
const expected = (bitA + bitB) % 2;
|
|
65
|
+
return {
|
|
66
|
+
ok: sum === expected,
|
|
67
|
+
scheme: 'cubic-LWE (post-quantum, single-bit message space mod 2)',
|
|
68
|
+
a: bitA, b: bitB, sum, expected,
|
|
69
|
+
note: 'ENC(a) ⊞ ENC(b) added with no secret key; decryption needs sk and is on no contract ABI',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Cubic-SIG signature verify (spatially bound to a cube plane) ──
|
|
74
|
+
// Sign a message, verify it, then prove a tampered message is rejected.
|
|
75
|
+
export function sigVerify({ message = 'xmbl-extension' } = {}) {
|
|
76
|
+
const { sk, pk } = cubicSigKeyGen();
|
|
77
|
+
const sig = cubicSigSign(message, sk, pk, CUBE_CONTEXT);
|
|
78
|
+
const good = cubicSigVerify(message, sig, pk, CUBE_CONTEXT);
|
|
79
|
+
const bad = cubicSigVerify(message + '!', sig, pk, CUBE_CONTEXT);
|
|
80
|
+
return {
|
|
81
|
+
ok: good === true && bad === false,
|
|
82
|
+
scheme: 'Cubic-SIG (Schnorr bound to a 3-point cube plane)',
|
|
83
|
+
message, signedVerifies: good, tamperedRejected: bad === false,
|
|
84
|
+
note: '[s]G == R + [e]pk over the cube plane; a changed message fails verification',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Seal (post-quantum KEM envelope, MAINNET_N) ──
|
|
89
|
+
// Seal a secret to a receiver's public key; only the receiver's secret key opens it.
|
|
90
|
+
export function sealRoundTrip({ secret = 'authorizing-key' } = {}) {
|
|
91
|
+
const receiver = sealKeyPair();
|
|
92
|
+
const bytes = new TextEncoder().encode(secret);
|
|
93
|
+
const env = sealSecret(receiver.pk, bytes);
|
|
94
|
+
const opened = openSecret(receiver.sk, env);
|
|
95
|
+
const back = new TextDecoder().decode(Uint8Array.from(opened));
|
|
96
|
+
return {
|
|
97
|
+
ok: back === secret,
|
|
98
|
+
scheme: 'cubic-LWE KEM seal (post-quantum, MAINNET_N)',
|
|
99
|
+
roundTrip: back === secret,
|
|
100
|
+
note: 'sealed to the receiver’s public key; opens only with their secret key, never custodied',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Dispatch table: message.type → runner. Kept here so DevnetRpc.handle stays a thin router.
|
|
105
|
+
export const CAPABILITIES = {
|
|
106
|
+
zkProof: (m) => zkProof(m),
|
|
107
|
+
heAdd: (m) => heAdd(m),
|
|
108
|
+
sigVerify: (m) => sigVerify(m),
|
|
109
|
+
seal: (m) => sealRoundTrip(m),
|
|
110
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// DevnetRpc — serves the browser-extension's background message contract over loopback HTTP,
|
|
2
|
+
// backed by a real LocalDevnet. This is the drop-in replacement for the extension's stub
|
|
3
|
+
// BackgroundNode (packages/browser-extension/src/background.js): POST JSON `{type, ...}` and get
|
|
4
|
+
// back a response IDENTICAL in shape to what that stub returns — but computed from REAL devnet
|
|
5
|
+
// state, not mocked.
|
|
6
|
+
//
|
|
7
|
+
// getBalance {type, address?} → { balance, address } // net of APPLIED utxo deltas
|
|
8
|
+
// sendTransaction {type, tx:{to,amount}} → { txId } | { error } // a real signed+verified tx lands
|
|
9
|
+
// getNodeStatus {type} → { running, peers, height }
|
|
10
|
+
// startNode {type} → { success }
|
|
11
|
+
// stopNode {type} → { success }
|
|
12
|
+
//
|
|
13
|
+
// Contract deploy/call are intentionally NOT served here — the in-page contract path is already
|
|
14
|
+
// node-parity-proven in the extension, and a deploy route pulls in the delegation gate + worker
|
|
15
|
+
// isolation questions, which are a separate piece.
|
|
16
|
+
//
|
|
17
|
+
// It ALSO serves the node-side capability surface the extension cannot run in-page — each a REAL
|
|
18
|
+
// primitive run end-to-end in this node process, verdict returned JSON-safe (see capabilities.js):
|
|
19
|
+
// getStateRoot {type} → { root, pooled, landed } // live ledger state root
|
|
20
|
+
// zkProof {type, derivedX?} → { ok, derivedX, derivedY, honestVerifies, tamperedRejected, … }
|
|
21
|
+
// heAdd {type, a?, b?} → { ok, a, b, sum, expected, … } // homomorphic add
|
|
22
|
+
// sigVerify {type, message?} → { ok, signedVerifies, tamperedRejected, … } // Cubic-SIG
|
|
23
|
+
// seal {type, secret?} → { ok, roundTrip, … } // PQ KEM seal
|
|
24
|
+
import { createServer } from 'node:http';
|
|
25
|
+
import { CAPABILITIES } from './capabilities.js';
|
|
26
|
+
|
|
27
|
+
export class DevnetRpc {
|
|
28
|
+
constructor(devnet, options = {}) {
|
|
29
|
+
this.net = devnet;
|
|
30
|
+
this.walletIndex = options.walletIndex ?? 0; // identity[0] is the "wallet" the extension speaks for
|
|
31
|
+
this.server = null;
|
|
32
|
+
this.port = null;
|
|
33
|
+
this.host = options.host ?? '127.0.0.1';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map one extension message to a real devnet response. */
|
|
37
|
+
async handle(message) {
|
|
38
|
+
switch (message && message.type) {
|
|
39
|
+
case 'getBalance': {
|
|
40
|
+
const address = message.address || this.net.addressOf(this.walletIndex);
|
|
41
|
+
return { balance: this.net.balanceOf(address), address };
|
|
42
|
+
}
|
|
43
|
+
case 'sendTransaction': {
|
|
44
|
+
const tx = message.tx || {};
|
|
45
|
+
const amount = Number(tx.amount);
|
|
46
|
+
if (!tx.to || !Number.isFinite(amount)) return { error: 'sendTransaction needs tx.to and a numeric tx.amount' };
|
|
47
|
+
// Resolve a known participant by address, else send to the raw external address.
|
|
48
|
+
const toIndex = this.net.identities.findIndex((i) => i.address === tx.to);
|
|
49
|
+
const r = toIndex >= 0
|
|
50
|
+
? await this.net.submitTransfer(this.walletIndex, toIndex, amount)
|
|
51
|
+
: await this.net.submitTransferToAddress(this.walletIndex, tx.to, amount);
|
|
52
|
+
return r.ok ? { txId: r.id } : { error: r.error };
|
|
53
|
+
}
|
|
54
|
+
case 'getNodeStatus':
|
|
55
|
+
return {
|
|
56
|
+
running: this.net.isRunning(),
|
|
57
|
+
peers: Math.max(0, this.net.identities.length - 1),
|
|
58
|
+
height: this.net.height(),
|
|
59
|
+
};
|
|
60
|
+
case 'startNode':
|
|
61
|
+
if (!this.net.isRunning()) await this.net.start();
|
|
62
|
+
return { success: true };
|
|
63
|
+
case 'stopNode':
|
|
64
|
+
if (this.net.isRunning()) await this.net.stop();
|
|
65
|
+
return { success: true };
|
|
66
|
+
case 'getStateRoot': {
|
|
67
|
+
const m = await this.net.getMetrics();
|
|
68
|
+
return { root: m.root ?? null, pooled: m.pooled ?? 0, landed: m.landed ?? this.net.height() };
|
|
69
|
+
}
|
|
70
|
+
default: {
|
|
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 honest error.
|
|
73
|
+
const cap = CAPABILITIES[message && message.type];
|
|
74
|
+
if (cap) return cap(message || {});
|
|
75
|
+
return { error: `Unknown message type: ${message && message.type}` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Start listening on loopback. port 0 → an OS-assigned port (returned). */
|
|
81
|
+
async listen(port = 0) {
|
|
82
|
+
this.server = createServer((req, res) => {
|
|
83
|
+
// CORS preflight / permissive origin so a page or service worker on any origin can call it.
|
|
84
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
85
|
+
res.setHeader('access-control-allow-headers', 'content-type');
|
|
86
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
87
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('POST JSON only'); return; }
|
|
88
|
+
let body = '';
|
|
89
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 1e6) req.destroy(); });
|
|
90
|
+
req.on('end', async () => {
|
|
91
|
+
let message;
|
|
92
|
+
try { message = JSON.parse(body || '{}'); } catch { res.writeHead(400, { 'content-type': 'application/json' }); res.end('{"error":"invalid json"}'); return; }
|
|
93
|
+
try {
|
|
94
|
+
const response = await this.handle(message);
|
|
95
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
96
|
+
res.end(JSON.stringify(response));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
99
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
await new Promise((resolve) => this.server.listen(port, this.host, resolve));
|
|
104
|
+
this.port = this.server.address().port;
|
|
105
|
+
return this.port;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
url() { return this.port ? `http://${this.host}:${this.port}` : null; }
|
|
109
|
+
|
|
110
|
+
async close() {
|
|
111
|
+
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
|
112
|
+
this.server = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Gate test for DevnetRpc: drives the REAL loopback HTTP surface through the browser-extension's
|
|
2
|
+
// five background message types and asserts responses computed from REAL devnet state. Proves the
|
|
3
|
+
// RPC is a faithful, non-mocked drop-in for the extension's stub BackgroundNode. Self-contained;
|
|
4
|
+
// exits non-zero on failure.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { LocalDevnet } from './devnet.js';
|
|
7
|
+
import { DevnetRpc } from './devnet-rpc.js';
|
|
8
|
+
|
|
9
|
+
let pass = 0;
|
|
10
|
+
const ok = (name, cond, detail = '') => { assert.ok(cond, `${name}${detail ? ' — ' + detail : ''}`); console.log(' ok ' + name); pass++; };
|
|
11
|
+
|
|
12
|
+
const net = await new LocalDevnet({ identities: 3 }).start();
|
|
13
|
+
const rpc = new DevnetRpc(net, { walletIndex: 0 });
|
|
14
|
+
const port = await rpc.listen(0);
|
|
15
|
+
ok('RPC listens on a loopback port', Number.isInteger(port) && port > 0, `port=${port}`);
|
|
16
|
+
|
|
17
|
+
const call = async (msg) => {
|
|
18
|
+
const res = await fetch(rpc.url(), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(msg) });
|
|
19
|
+
return res.json();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// status: real running flag, peers = other identities, height starts at 0
|
|
24
|
+
const s0 = await call({ type: 'getNodeStatus' });
|
|
25
|
+
ok('getNodeStatus reports running', s0.running === true);
|
|
26
|
+
ok('getNodeStatus peers = other identities', s0.peers === 2, `peers=${s0.peers}`);
|
|
27
|
+
ok('getNodeStatus height starts at 0', s0.height === 0, `height=${s0.height}`);
|
|
28
|
+
|
|
29
|
+
// balance: wallet (identity 0) starts at 0 — real, not fabricated
|
|
30
|
+
const b0 = await call({ type: 'getBalance' });
|
|
31
|
+
ok('getBalance defaults to the wallet address', b0.address === net.addressOf(0));
|
|
32
|
+
ok('wallet balance starts at 0', b0.balance === 0, `balance=${b0.balance}`);
|
|
33
|
+
|
|
34
|
+
// send a real signed+verified tx from the wallet to another participant
|
|
35
|
+
const recipient = net.addressOf(1);
|
|
36
|
+
const sent = await call({ type: 'sendTransaction', tx: { to: recipient, amount: 25 } });
|
|
37
|
+
ok('sendTransaction returns a real landed txId', typeof sent.txId === 'string' && /^dtx_/.test(sent.txId), JSON.stringify(sent));
|
|
38
|
+
|
|
39
|
+
// balances reflect the landed transfer (REAL applied delta), height advanced
|
|
40
|
+
const br = await call({ type: 'getBalance', address: recipient });
|
|
41
|
+
ok('recipient balance reflects the landed transfer', br.balance === 25, `balance=${br.balance}`);
|
|
42
|
+
const bw = await call({ type: 'getBalance' });
|
|
43
|
+
ok('wallet balance is negated by the send', bw.balance === -25, `balance=${bw.balance}`);
|
|
44
|
+
const s1 = await call({ type: 'getNodeStatus' });
|
|
45
|
+
ok('height advanced after the send', s1.height === 1, `height=${s1.height}`);
|
|
46
|
+
|
|
47
|
+
// a malformed send is reported, not crashed
|
|
48
|
+
const bad = await call({ type: 'sendTransaction', tx: { amount: 5 } });
|
|
49
|
+
ok('sendTransaction without a recipient returns an error', typeof bad.error === 'string', JSON.stringify(bad));
|
|
50
|
+
|
|
51
|
+
// live ledger state root (real, moves as faces seal)
|
|
52
|
+
const sr = await call({ type: 'getStateRoot' });
|
|
53
|
+
ok('getStateRoot returns landed count and pooled', sr.landed === 1 && typeof sr.pooled === 'number', JSON.stringify(sr));
|
|
54
|
+
|
|
55
|
+
// ── node-side capability surface: each a REAL primitive verified + negative-controlled ──
|
|
56
|
+
const zk = await call({ type: 'zkProof', derivedX: 99 });
|
|
57
|
+
ok('zkProof verifies the honest coordinate and rejects the tampered one', zk.ok === true && zk.honestVerifies === true && zk.tamperedRejected === true, JSON.stringify(zk));
|
|
58
|
+
|
|
59
|
+
const he1 = await call({ type: 'heAdd', a: 1, b: 0 });
|
|
60
|
+
ok('heAdd: ENC(1) ⊞ ENC(0) decrypts to 1 (blind add)', he1.ok === true && he1.sum === 1, JSON.stringify(he1));
|
|
61
|
+
const he2 = await call({ type: 'heAdd', a: 1, b: 1 });
|
|
62
|
+
ok('heAdd: ENC(1) ⊞ ENC(1) decrypts to 0 (mod-2 wrap)', he2.ok === true && he2.sum === 0, JSON.stringify(he2));
|
|
63
|
+
// the message space is a single bit: a non-bit operand is rejected, NOT silently coerced to a
|
|
64
|
+
// green verdict for an operation nobody asked for
|
|
65
|
+
const heBad = await call({ type: 'heAdd', a: 3, b: 5 });
|
|
66
|
+
ok('heAdd rejects non-bit operands (no coerced false pass)', heBad.ok === false && /0 or 1/.test(heBad.error || ''), JSON.stringify(heBad));
|
|
67
|
+
|
|
68
|
+
const sig = await call({ type: 'sigVerify', message: 'xbe' });
|
|
69
|
+
ok('sigVerify signs+verifies and rejects a tampered message', sig.ok === true && sig.signedVerifies === true && sig.tamperedRejected === true, JSON.stringify(sig));
|
|
70
|
+
|
|
71
|
+
const seal = await call({ type: 'seal', secret: 'authorizing-key' });
|
|
72
|
+
ok('seal round-trips a secret through a PQ KEM envelope', seal.ok === true && seal.roundTrip === true, JSON.stringify(seal));
|
|
73
|
+
|
|
74
|
+
// unknown type is reported
|
|
75
|
+
const unk = await call({ type: 'frobnicate' });
|
|
76
|
+
ok('unknown message type returns an error', /Unknown message type/.test(unk.error || ''), JSON.stringify(unk));
|
|
77
|
+
|
|
78
|
+
// node lifecycle toggles real state
|
|
79
|
+
const stop = await call({ type: 'stopNode' });
|
|
80
|
+
ok('stopNode succeeds', stop.success === true);
|
|
81
|
+
ok('devnet actually stopped', net.isRunning() === false);
|
|
82
|
+
const start = await call({ type: 'startNode' });
|
|
83
|
+
ok('startNode succeeds', start.success === true);
|
|
84
|
+
ok('devnet actually running again', net.isRunning() === true);
|
|
85
|
+
|
|
86
|
+
console.log(`\n✅ devnet-rpc: ${pass} checks passed`);
|
|
87
|
+
} finally {
|
|
88
|
+
await rpc.close();
|
|
89
|
+
await net.stop();
|
|
90
|
+
}
|
|
91
|
+
process.exit(0);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Runner for the XMBL LocalDevnet + its RPC surface. `npm run devnet -w packages/simulator`
|
|
2
|
+
//
|
|
3
|
+
// Boots a real local network (real MAYO identities + real cubic ledger, signature verification
|
|
4
|
+
// ON), seeds a little activity so balances/height are non-trivial, then serves the browser
|
|
5
|
+
// -extension message contract over loopback HTTP and stays up until Ctrl-C. The printed URL is
|
|
6
|
+
// the one a node bridge (replacing the extension's stub BackgroundNode) would POST to.
|
|
7
|
+
//
|
|
8
|
+
// PORT=8645 npm run devnet -w packages/simulator # pin a different RPC port
|
|
9
|
+
// IDENTITIES=6 SEED=12 npm run devnet -w packages/simulator
|
|
10
|
+
//
|
|
11
|
+
// The default port is 8646 — the browser extension's documented default endpoint (its Config tab
|
|
12
|
+
// and `xmbl:devnetUrl` default to http://127.0.0.1:8646), so `npm run devnet` serves the loaded
|
|
13
|
+
// extension with no reconfiguration. Override with PORT to run elsewhere (then re-point the
|
|
14
|
+
// extension's Config tab to match).
|
|
15
|
+
import { LocalDevnet } from './devnet.js';
|
|
16
|
+
import { DevnetRpc } from './devnet-rpc.js';
|
|
17
|
+
|
|
18
|
+
const identities = Number(process.env.IDENTITIES || 4);
|
|
19
|
+
const seed = Number(process.env.SEED || 9);
|
|
20
|
+
const port = Number(process.env.PORT || 8646);
|
|
21
|
+
|
|
22
|
+
const net = await new LocalDevnet({ identities }).start();
|
|
23
|
+
console.log(`[devnet] booted ${net.metrics.identities} real identities; wallet = ${net.addressOf(0)}`);
|
|
24
|
+
|
|
25
|
+
// Seed: wallet → participant 1, varied amounts, through the real verified direct path.
|
|
26
|
+
for (let k = 0; k < seed; k++) {
|
|
27
|
+
const r = await net.submitTransfer(0, 1, 1 + k);
|
|
28
|
+
if (!r.ok) console.warn(`[devnet] seed tx ${k} rejected: ${r.error}`);
|
|
29
|
+
}
|
|
30
|
+
const m = await net.getMetrics();
|
|
31
|
+
console.log(`[devnet] seeded ${m.landed} landed tx, ${m.facesCompleted} face(s) sealed, pooled ${m.pooled}, root ${m.root ?? '(none)'}`);
|
|
32
|
+
|
|
33
|
+
const rpc = new DevnetRpc(net, { walletIndex: 0 });
|
|
34
|
+
await rpc.listen(port);
|
|
35
|
+
console.log(`[devnet] RPC (extension message contract) listening at ${rpc.url()}`);
|
|
36
|
+
console.log('[devnet] POST JSON {type:"getNodeStatus"} | {"getBalance"} | {"sendTransaction",tx:{to,amount}} | {"startNode"} | {"stopNode"}');
|
|
37
|
+
console.log('[devnet] Ctrl-C to stop.');
|
|
38
|
+
|
|
39
|
+
const shutdown = async () => {
|
|
40
|
+
console.log('\n[devnet] shutting down…');
|
|
41
|
+
await rpc.close();
|
|
42
|
+
await net.dispose();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
};
|
|
45
|
+
process.on('SIGINT', shutdown);
|
|
46
|
+
process.on('SIGTERM', shutdown);
|
package/src/devnet.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// LocalDevnet — a light local XMBL network you can run and verify against "in reality".
|
|
2
|
+
//
|
|
3
|
+
// This is the "hardhat for XMBL": a SCRIPTED, BOUNDED driver over the REAL protocol modules
|
|
4
|
+
// (@xmbl/identity + @xmbl/cubic-ledger), with the ledger's signature verification turned ON
|
|
5
|
+
// (it wires BOTH `xid` and `getPublicKeyByAddress`, the combination that activates
|
|
6
|
+
// Identity.verifyTransaction on every tx). Every operation is an awaited, discrete step — no
|
|
7
|
+
// setInterval, no Math.random in control flow — so counts and block/face formation are
|
|
8
|
+
// repeatable run to run. That repeatability, not byte-identical faker output, is what makes a
|
|
9
|
+
// gate test possible (see devnet.test.mjs).
|
|
10
|
+
//
|
|
11
|
+
// WHY THE DIRECT LEDGER PATH (ledger.addTransaction), NOT CONSENSUS:
|
|
12
|
+
// A signed node-authored tx, submitted directly, is verified by the ledger as-signed and
|
|
13
|
+
// lands — proven here with real MAYO keys + real verification. Two ledger-entry defects that
|
|
14
|
+
// the direct path never hit but the consensus finalize route did are now FIXED (proven by
|
|
15
|
+
// devnet.test.mjs, documented in ../DEVNET-SEAM-FINDING.md):
|
|
16
|
+
// (a) FIXED — ConsensusWorkflow.finalizeTransaction no longer overwrites the signed `id`
|
|
17
|
+
// with `validatedHash`; the signed id survives the handoff so re-verification matches.
|
|
18
|
+
// (b) FIXED — ledger.addSealedBatch now verifies via the static Identity.verifyTransaction
|
|
19
|
+
// (it used to call the nonexistent this.xid.verify → TypeError, so it verified nothing).
|
|
20
|
+
// ONE seam remains OPEN and audit-scoped (defect (c) in the finding): moveToProcessing injects
|
|
21
|
+
// `validationTimestamp` INTO the signed body, so the FULL consensus path still cannot re-verify
|
|
22
|
+
// at the ledger without a signature-domain / block-id decision. Ledger-side re-verification is
|
|
23
|
+
// therefore still OFF in the production daemon (core/index.js constructs the Ledger without
|
|
24
|
+
// getPublicKeyByAddress); the devnet opts it in, which is why the direct path is what it drives.
|
|
25
|
+
import { EventEmitter } from 'node:events';
|
|
26
|
+
import { Identity } from '../../identity/index.js';
|
|
27
|
+
import { Ledger } from '../../cubic-ledger/index.js';
|
|
28
|
+
|
|
29
|
+
export class LocalDevnet extends EventEmitter {
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
super();
|
|
32
|
+
this.options = {
|
|
33
|
+
identities: options.identities ?? 4,
|
|
34
|
+
scheme: options.scheme ?? 'mayo',
|
|
35
|
+
dbPath: options.dbPath ?? null, // null → in-memory ledger
|
|
36
|
+
...options,
|
|
37
|
+
};
|
|
38
|
+
this.identities = []; // real Identity instances (hold private keys)
|
|
39
|
+
this.byAddress = new Map(); // address → Identity
|
|
40
|
+
this.ledger = null;
|
|
41
|
+
this._applied = []; // every utxo tx the REAL ledger accepted (post-verification)
|
|
42
|
+
this._seq = 0;
|
|
43
|
+
this.running = false;
|
|
44
|
+
this.metrics = {
|
|
45
|
+
identities: 0,
|
|
46
|
+
submitted: 0,
|
|
47
|
+
landed: 0,
|
|
48
|
+
rejected: 0,
|
|
49
|
+
facesCompleted: 0,
|
|
50
|
+
cubesCompleted: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Start accepting work. The FIRST call bootstraps (mints real identities + a real ledger with
|
|
56
|
+
* signature verification ON); later calls after a stop() just resume — identities, ledger and
|
|
57
|
+
* applied balances persist across a stop/start, as a real node's chain would. Full teardown is
|
|
58
|
+
* dispose().
|
|
59
|
+
*/
|
|
60
|
+
async start() {
|
|
61
|
+
if (this.running) return this;
|
|
62
|
+
if (this.ledger) { this.running = true; this.emit('started', { identities: this.identities.length, resumed: true }); return this; }
|
|
63
|
+
this.ledger = new Ledger({
|
|
64
|
+
dbPath: this.options.dbPath,
|
|
65
|
+
// Wiring this lookup is what activates ledger-side signature verification.
|
|
66
|
+
getPublicKeyByAddress: (address) => {
|
|
67
|
+
const id = this.byAddress.get(address);
|
|
68
|
+
return id ? id.publicKey : null;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
this.ledger.on('face:complete', () => { this.metrics.facesCompleted++; this.emit('face:complete'); });
|
|
72
|
+
this.ledger.on('cube:complete', () => { this.metrics.cubesCompleted++; this.emit('cube:complete'); });
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < this.options.identities; i++) {
|
|
75
|
+
const id = await Identity.create(this.options.scheme);
|
|
76
|
+
this.identities.push(id);
|
|
77
|
+
this.byAddress.set(id.address, id);
|
|
78
|
+
this.metrics.identities++;
|
|
79
|
+
this.emit('identity:created', { index: i, address: id.address });
|
|
80
|
+
}
|
|
81
|
+
// ledger.xid must be truthy for the verification branch to run; the static
|
|
82
|
+
// Identity.verifyTransaction does the actual work, so any identity instance serves.
|
|
83
|
+
this.ledger.xid = this.identities[0] || Identity;
|
|
84
|
+
this.running = true;
|
|
85
|
+
this.emit('started', { identities: this.identities.length });
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Address of a devnet participant by index (0 is the default "wallet"). */
|
|
90
|
+
addressOf(index = 0) {
|
|
91
|
+
const id = this.identities[index];
|
|
92
|
+
return id ? id.address : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Submit a SIGNED utxo transfer between two devnet participants (by index).
|
|
97
|
+
* Returns { ok, id, result?|error? }; the signature is verified by the ledger before it lands.
|
|
98
|
+
*/
|
|
99
|
+
async submitTransfer(fromIndex, toIndex, amount) {
|
|
100
|
+
const to = this.identities[toIndex];
|
|
101
|
+
if (!to) throw new Error(`bad recipient index (have ${this.identities.length})`);
|
|
102
|
+
return this.submitTransferToAddress(fromIndex, to.address, amount);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Submit a SIGNED utxo transfer FROM a devnet participant TO an arbitrary address
|
|
107
|
+
* (the recipient need not be a known identity — the ledger only verifies the signer's
|
|
108
|
+
* ownership of `from`). This is what the RPC `sendTransaction` maps to.
|
|
109
|
+
*/
|
|
110
|
+
async submitTransferToAddress(fromIndex, toAddress, amount) {
|
|
111
|
+
if (!this.running) throw new Error('devnet not started');
|
|
112
|
+
const from = this.identities[fromIndex];
|
|
113
|
+
if (!from) throw new Error(`bad sender index (have ${this.identities.length})`);
|
|
114
|
+
if (!toAddress || typeof toAddress !== 'string') throw new Error('toAddress required');
|
|
115
|
+
const tx = {
|
|
116
|
+
id: `dtx_${++this._seq}`,
|
|
117
|
+
type: 'utxo',
|
|
118
|
+
from: from.address,
|
|
119
|
+
to: toAddress,
|
|
120
|
+
amount,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
};
|
|
123
|
+
const signed = await from.signTransaction(tx); // sets from=from.address, adds MAYO sig
|
|
124
|
+
this.metrics.submitted++;
|
|
125
|
+
try {
|
|
126
|
+
const result = await this.ledger.addTransaction(signed);
|
|
127
|
+
this.metrics.landed++;
|
|
128
|
+
this._applied.push({ from: signed.from, to: signed.to, amount: Number(amount) });
|
|
129
|
+
this.emit('tx:landed', { id: signed.id, from: signed.from, to: signed.to, amount: Number(amount) });
|
|
130
|
+
return { ok: true, id: signed.id, result };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.metrics.rejected++;
|
|
133
|
+
this.emit('tx:rejected', { id: signed.id, error: error.message });
|
|
134
|
+
return { ok: false, id: signed.id, error: error.message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* REAL balance of an address: the net of every utxo tx the ledger actually accepted
|
|
140
|
+
* (each passed signature verification). Not a fabricated figure — it is the sum of applied
|
|
141
|
+
* deltas, so a fresh devnet reports 0 and only submitted+landed transfers move it.
|
|
142
|
+
*/
|
|
143
|
+
balanceOf(address) {
|
|
144
|
+
let bal = 0;
|
|
145
|
+
for (const tx of this._applied) {
|
|
146
|
+
if (tx.to === address) bal += tx.amount;
|
|
147
|
+
if (tx.from === address) bal -= tx.amount;
|
|
148
|
+
}
|
|
149
|
+
return bal;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Current height: number of blocks the ledger is holding/has sealed this session. */
|
|
153
|
+
height() {
|
|
154
|
+
return this.metrics.landed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getMetrics() {
|
|
158
|
+
let root = null;
|
|
159
|
+
try { root = await this.ledger?.getStateRoot?.(); } catch { /* root optional */ }
|
|
160
|
+
return { ...this.metrics, pooled: this.ledger?.getMembershipPool?.().length ?? 0, root };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
isRunning() { return this.running; }
|
|
164
|
+
|
|
165
|
+
/** Halt accepting work but KEEP state (identities, ledger, balances) so start() can resume. */
|
|
166
|
+
async stop() {
|
|
167
|
+
if (!this.running) return;
|
|
168
|
+
this.running = false;
|
|
169
|
+
this.emit('stopped', { ...this.metrics });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Final teardown: stop and release the ledger. After this, start() bootstraps afresh. */
|
|
173
|
+
async dispose() {
|
|
174
|
+
await this.stop();
|
|
175
|
+
try { await this.ledger?.close?.(); } catch { /* in-memory has no close */ }
|
|
176
|
+
this.ledger = null;
|
|
177
|
+
this.identities = [];
|
|
178
|
+
this.byAddress.clear();
|
|
179
|
+
}
|
|
180
|
+
}
|