Fast time: the devnet at 60x for test networks (override-60x.json, --fast-time in both harnesses, proof script)

infra/fast-time/override-60x.json is the devnet with every clock-like consensus parameter divided by 60 and every
block count unchanged (finality window, ban and min_daa 120 DAA; merge depth 60; Kaspa finality depth 720; pruning
depth at the anticone bound 13,838; coinbase maturity 2; the hourly program epoch 60 blocks with a 10-block lead;
the dataset day 24 minutes). The epoch length, lead and day are consensus parameters of the node since devnet-v4
a5ef8b07, carried by the override file. README lists each field, why it scales or not, the flags and the numbers.

Measured (simnet.mjs, three devnet-v4 nodes, three vmine voters at 1 block/s, one real-hash CPU miner): next
epoch seed in the template at 56.1 s, program swap at 65.1 s wall (DAA 60), first finality lock at 185.5 s wall
(checkpoint 5, DAA 149). Both harnesses take --fast-time: finality-attacks s3 PASS in 113 s wall with 16 locks
per node (the devnet rule needs 20 min of warm-up at 6 blocks/s before any lock); harness s3 partition and heal
43 s wall for three cuts against 983 s for four on the devnet profile with the same binary. Bench-log entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 10:34:19 +00:00
parent bd4a64d559
commit d783b13733
15 changed files with 402 additions and 24 deletions

View file

@ -690,3 +690,22 @@ CPU proofs (`SP1_PROVER=cpu`), block-56-transfers-3shards:
| pending | | | |
Reading: the shard at `S_p` is 60 M cycles on the prototype table, 9 cycles per pgas against the unit's 1,000 (the modexp entry is about 100x its SP1 cost: R1, one number); a plain transfer shard is 1,400 to 1,600 cycles per pgas because the 200-pgas intrinsic charge carries the fixed cost of the witness check and the two root computations. The aggregator statement is 1.5 to 1.7 M cycles (bincode, an unpatched sha256 of each shard's public values, the keccaks), small next to a shard. The CPU proof times are 3.8x and 4.9x the 3 October v0 numbers on a comparable statement, on a machine three times as loaded; the GPU row stays empty until the PC runs. The devnet was not touched; the simnet ran on ports 29300, 29301 and 29390 and was stopped.
## 2026-10-04, fast time (60x test profile), Linux cross-compile from the Mac, CI on every push (consensus-engineer)
Machine: Apple M5 Max, 64 GB, shared with four other agents and the live devnet (load 40 to 76 throughout), every job at nice 19 with at most 4 cargo jobs. The live devnet (26610/26611, 26640/26641, 28640), the Metal worker and the seed's `/opt/igneum/v4/bin` were not touched.
Fast time. `infra/fast-time/override-60x.json` is the devnet with every clock-like consensus parameter divided by 60 and every block count unchanged (`infra/fast-time/README.md` lists each field and why it scales or not). The hourly program epoch and its lead are now consensus parameters carried by the override file (`pow_epoch_blocks`, `pow_epoch_lead`, plus `pow_day_ms` for the dataset day; devnet-v4 `a5ef8b07`, devnet defaults unchanged: kaspa-consensus-core 79 tests, kaspa-pow 7, igneum-pow 39 pass, and a new test reads the 60x file and checks every rule against `DEVNET_PARAMS`). Proof (`infra/fast-time/simnet.mjs`, three devnet-v4 nodes on ports 29500+, `igneum-devnet-950`, three vmine voters sharing 1 block/s, one real-hash CPU miner thread): next epoch seed in the template at 56.1 s (DAA 53), program swap at 65.1 s wall (DAA 60; the CPU miner's new program and cache ready 397 ms later), first finality lock at 185.5 s wall (checkpoint 5, blue score 150, DAA 149; checkpoint 4 sat one DAA under `min_daa` 120), sinks identical on all three nodes. The devnet reaches the same two events at DAA 3,600 and DAA 7,200 plus a checkpoint.
| Harness run, same binaries | Devnet profile | 60x profile (`--fast-time`) |
|---|---|---|
| `tools/finality-attacks` s3, dishonest aggregators | catalogue 32 min at SCALE 0.6 on the 3 Oct node (above); on today's rule the window fills at DAA 7,200 = 20 min at 6 blocks/s before any lock | 113 s wall: 16 locks per node, 0 conflicting certificates, lock hashes agree, median lock latency 1,018 ms, PASS |
| `tools/harness` s3, partition and heal (in-process simulator of the devnet-v4 line) | 983 s wall, four cuts of 120 / 600 / 1,800 / 3,700 virtual s (46 / 151 / 400 / 831 s wall), all PASS | 43 s wall, three cuts of 10 / 30 / 62 virtual s (18 / 20 / 26 s wall), all PASS; the 62-s cut beyond the 60-s merge depth converged with a 34-block reorg |
Both harnesses take `--fast-time` (`tools/harness/lib/net.mjs`, `tools/finality-attacks/lib/net.mjs`): the node and the simulator then come from `vendor/igneum-node/target-integration` (the file carries fields only the devnet-v4 line knows), the merge-depth scenarios scale their cuts with the profile, and the finality runs default to the `--quick` scale.
Linux cross-compile. `infra/cross/build-linux.sh`: cargo-zigbuild 0.23.4 with zig 0.17.0 (`brew install zig`, `cargo install cargo-zigbuild`, `rustup target add x86_64-unknown-linux-gnu`), target `x86_64-unknown-linux-gnu.2.36` (Debian 12 on the servers), `-p kaspad -p igneum-miner --features kaspad/igneum-pow`, target dir `vendor/igneum-node/target-linux`. Cold build: 1,856 s (30 min 56 s) at 4 jobs, nice 19, on this loaded machine; rocksdb (librocksdb-sys C++), lz4, blst, secp256k1 and the execution layer's crates all linked through zig; no crate failed, so `cross` was not needed (Docker is not installed here anyway). Output: ELF x86-64 PIE, igneumd 46,883,304 B and igneum-miner 8,978,424 B, dynamically linked against libc and libm only. Verified on igneum-seed-1 (Debian 12, glibc 2.36, 2 vCPU) in `/root/xbuild-test`: `igneumd --version` = igneumd 2.1.0, `igneum-miner --help` prints its usage, and a 60-s run of the cross-compiled node on `igneum-devnet-951` (the 60x profile at genesis bits 2^16, real proof of work) with the cross-compiled miner on one CPU thread: the miner adopted the node's 60-block epoch from the template, built its 256 MiB cache in 395 ms, found 7 blocks at 0.011 MH/s, all 7 accepted by the node's own PoW check, 0 rejected (the x86 build of the lottery hash agrees between miner and node; neither binary exposes a standalone vector check). Against the alternatives: the seed's own build took 55 min on its 2 vCPU (4 Oct 2026, above), the builder VM 10 to 25 min on 8 vCPU plus its creation and deletion. `BIN_SOURCE=mac` is wired into `infra/cloud-devnet/provision.sh` (no builder VM, build/bin from the cross-compile) and `infra/seed-nodes/stage-v4.sh` (upload to /root/v4/out, install as before).
CI. `.github/workflows/ci.yml` runs on every push and pull request of the private repository: igneum-pow `cargo test --release` and the igneum-census build (49 s), the two simulators' `--quick` modes under a 120-s timeout (49 s for the job; `sim/finality_v2.py --quick` is now a true smoke run, 149 s at nice 19 on this loaded Mac and under 40 s on the runner, was 745 s; `sim/difficulty/sim.py --quick` is new, 36 s here), the site build, an internal link check of `site/*.html` (151 links, 0 broken) and a gh-free identity grep of the public export list after the generic scrub (`tools/ci/identity-check.sh`, `tools/ci/forbidden-strings.txt`: 157 files, 0 hits). First run green: https://github.com/igneum-network/igneum/actions/runs/37193811336, 54 s from trigger to completion. The node fork is gitignored and too big for the free runners today; the workflow says so.
Not done: `igneum-harness-sim`'s per-block cost on the devnet-v4 line (about 70 ms here against 3 ms on the ordering-layer branch, the execution layer's follower) is what still bounds the harness, not the clocks; the fast-time presence window floors at one checkpoint; the GPU workers were not run on fast time (the CPU miner proved the swap).

101
infra/fast-time/README.md Normal file
View file

@ -0,0 +1,101 @@
# Fast time: the devnet at 60x for test networks
`override-60x.json` is the Igneum devnet with every clock-like consensus parameter divided by 60 and every block
count unchanged. A test network on it reaches its first finality lock in about three minutes instead of two hours and
crosses an hourly program epoch every minute, so a scenario that took hours of wall time takes minutes. 4 October 2026.
## Flags
Node (devnet-v4 line, `vendor/igneum-node-v4`, built in `vendor/igneum-node/target-integration`):
```
igneumd --devnet --devnet-suffix=950 --override-params-file=infra/fast-time/override-60x.json ...
```
The file is complete: every field `OverrideParams` accepts is present (`consensus/core/src/config/params.rs`), so a
reader sees the whole profile in one place. Merge it with `skip_proof_of_work: true` for a network without miners
(the harnesses do this), or lower `genesis_bits` for CPU miners (the devnet value 0x1d100000 is the GPU difficulty).
Harnesses: `node tools/finality-attacks/run.mjs s3 --fast-time`, `node tools/harness/run.mjs s3 s4 --fast-time`
(or `IGNEUM_FAST_TIME=1`). Both then take the node from `vendor/igneum-node/target-integration/release` because the
file carries the PoW schedule fields that only the devnet-v4 line knows; `IGNEUMD=...` overrides. The in-process
simulator takes the same file: `igneum-harness-sim --override-params-file infra/fast-time/override-60x.json`.
Proof script: `node infra/fast-time/simnet.mjs` (3 nodes on ports 29500 and up, `igneum-devnet-950`, data
`/tmp/igneum-fast-time`; records the first lock and the first program swap; see "Measured" below).
Miners: `igneum-miner` follows the epoch length and lead its node reports in every template (`pow_epoch`), no flag.
The dataset day is not in the template, so a real-hash miner on a fast-time network takes `IGNEUM_POW_DAY_MS=1440000`
in its environment (the node reads it from the file). The environment variables `IGNEUM_POW_EPOCH_BLOCKS`,
`IGNEUM_POW_EPOCH_LEAD` and `IGNEUM_POW_DAY_MS` remain the fallback for a node without a params file; the file wins.
## What scales and what does not
Time parameters, divided by 60 (devnet value, 60x value):
| Parameter | Devnet | 60x | Note |
|---|---|---|---|
| `finality.weight_window` (W2, DAA s) | 7,200 | 120 | the 2-hour weight window becomes 2 minutes |
| `finality.min_daa` (C5, F1, DAA s) | 7,200 | 120 | equal to the window on every network, so the first lock waits for a full window |
| `finality.equivocation_ban` (3.6, DAA s) | 7,200 | 120 | |
| `finality.aggregator_fallback` (DAA s) | 15 | 1 | 15/60 rounds up to the floor of one second |
| `finality.presence_window` (Q1, checkpoints) | 20 | 1 | a count of checkpoints, but it stands for 20 x 30 = 600 DAA s of presence; 10 s is under one checkpoint, so the floor of one |
| `pow_epoch_blocks` (DAA s) | 3,600 | 60 | the hourly program epoch becomes a minute (new in the node, 4 Oct 2026: a consensus param carried by the override file; before, only an environment variable) |
| `pow_epoch_lead` (DAA s) | 600 | 10 | the seed is knowable one lead before the boundary; the miner confirms it a quarter of the lead later (3 DAA s at 60x, 150 on the devnet) |
| `pow_day_ms` (ms) | 86,400,000 | 1,440,000 | the dataset day of the 256 MiB cache becomes 24 minutes (new consensus param, same change) |
| `blockrate.merge_depth` (DAA s) | 3,600 | 60 | Kaspa's merge-depth bound, one hour of DAA score |
| `blockrate.finality_depth` (DAA s) | 43,200 | 720 | Kaspa's pruning-side finality, 12 hours |
| `blockrate.pruning_depth` (DAA s) | 108,000 | 13,838 | 30 hours / 60 = 1,800 is under Kaspa's anticone finalisation bound (finality + 2 x merge + 4 x mergeset x k + 2k + 2 = 13,838 at k 18, mergeset 180), and `Bps::pruning_depth` takes the larger, so this does |
| `blockrate.coinbase_maturity` (s) | 100 | 2 | 100 s / 60 rounds up |
| `deflationary_phase_daa_score` | 0 | 0 | the devnet has no deflationary phase |
Unchanged, and why:
| Parameter | Value | Why it stays |
|---|---|---|
| `blockrate.target_time_per_block` | 1,000 ms | the unit: a 60x network still makes one block per second; 60 blocks per second is a different DAG (k, parents, mergeset) and not what is being tested |
| `blockrate.ghostdag_k`, `max_block_parents`, `mergeset_size_limit` | 18, 10, 180 | DAG shape at 1 block/s |
| `finality.checkpoint_interval`, `checkpoint_depth` | 30, 20 | blue-score counts: a checkpoint every 30 blocks, determined 20 blocks later |
| `finality.dust`, `aggregators` | 5, 8 | a block count and a count of keys |
| `difficulty_window_size`, `min_difficulty_window_size`, `difficulty_sample_rate` | 661, 150, 4 | sample counts of Kaspa's sampled DAA (the reference lane of the dual rule); the controller needs its samples to estimate hash rate, and the 120-block LWMA lane, the 8-block warm-up, the 600-block reference start and the per-block clamps (`igneum::difficulty`) are block counts by design |
| `past_median_time_window_size`, `past_median_time_sample_rate`, `timestamp_deviation_tolerance` | 27, 10, 132 | the past-median rule's window in samples; Igneum's own 10 s future and back bounds are constants of the rule |
| `genesis_bits` | 0x1d100000 | the devnet's GPU difficulty; a CPU test network sets its own (0x1f010000 = 2^16 hashes per block) |
| `skip_proof_of_work` | false | the harnesses turn it on themselves |
| mass, lane, script and payload limits, `pruning_proof_m`, `max_block_level`, `crescendo_activation`, `storage_mass_parameter`, `pre_deflationary_phase_base_subsidy` | devnet | sizes and rule switches, not clocks |
Consequences to remember when reading a fast-time result: the merge depth (60 s) is now far under the difficulty
window (2,641 s), so a low-difficulty side chain older than a minute cannot merge, which the devnet tolerates for an
hour; the presence window of one checkpoint means "finality active" needs a lock within the last checkpoint; the
partition bound of spec 3.3.1 is W / (3R) = 13 s at 1 block/s per side (200 s on the devnet), so a split longer than
that locks on both sides as the arithmetic says (`docs/bench-log.md`, 4 Oct 2026, 6A long heal).
The node unit test `fast_time_60x_file_is_the_devnet_at_60x` (`consensus/core/src/config/params.rs`) reads this
file and checks every rule above against `DEVNET_PARAMS`; `override_params_carry_the_pow_schedule` and
`pow_schedule_defaults_and_clamps` check the new fields and that the devnet defaults are untouched.
## Measured (4 October 2026, Apple M5 Max shared with four other agents and the live devnet, load 40 to 60)
`node infra/fast-time/simnet.mjs --secs 240`: three devnet-v4 nodes (`target-integration/release/igneumd`, built
from devnet-v4 `a5ef8b07`), three vmine voters sharing 1 block/s, one real-hash CPU miner thread on node 0.
| Event | Wall time from the first node's start | Chain position |
|---|---|---|
| next epoch seed reported in the template | 56.1 s | DAA 53 (boundary 60, lead 10, quarter-lead confirm 3) |
| first hourly program swap: template epoch index 0 to 1 | 65.1 s | DAA 60 |
| CPU miner's new program and 256 MiB cache ready for the new seed | 66.1 s (397 ms compile) | DAA 60 |
| first finality lock | 185.5 s | checkpoint 5, blue score 150, DAA 149 (checkpoint 4 sat at DAA 119, one under `min_daa` 120, so it could not certify) |
Sinks agreed on all three nodes at the end (DAA 171). On the devnet the same two events come at DAA 3,600 (one hour)
and DAA 7,200 plus a checkpoint (two hours and change).
Harness scenarios, same binaries, before and after:
| Run | Devnet profile | 60x profile |
|---|---|---|
| `tools/finality-attacks` s3 (dishonest aggregators, six voters at 6 blocks/s) | 32 min for the seven-scenario catalogue at SCALE 0.6 on the 3 Oct node (bench log 4 Oct 2026, s3 alone 180 s plus bring-up); on today's rule (`min_daa` = window) the window fills at DAA 7,200 = 20 min at 6 blocks/s, so s3 at full scale shows no lock inside its 300 s | 113 s wall, 16 locks per node, 0 conflicting certificates, lock hashes agree across nodes, median lock latency 1,018 ms: PASS |
| `tools/harness` s3 (partition and heal in the in-process simulator, `igneum-harness-sim` of the devnet-v4 line) | 983 s wall for the four cuts (120, 600, 1,800 and 3,700 s virtual: 46, 151, 400 and 831 s wall, two at a time), all PASS | 43 s wall for the three cuts (10, 30 and 62 s virtual: 18, 20 and 26 s wall), all PASS, the 62-s cut beyond the 60-s merge depth converged with a 34-block reorg |
The 3 Oct catalogue run reported 3 to 47 s wall per s3 cut on the ordering-layer-only `harness` branch; the
devnet-v4 simulator carries the execution layer and costs about 70 ms per block on this loaded machine, hence the
983 s. The before and after above are the same binary on the same day.

View file

@ -0,0 +1,53 @@
{
"timestamp_deviation_tolerance": 132,
"past_median_time_window_size": 27,
"difficulty_window_size": 661,
"min_difficulty_window_size": 150,
"difficulty_rule": "igneum-dual",
"coinbase_payload_script_public_key_max_len": 150,
"max_coinbase_payload_len": 16384,
"max_tx_inputs": 1000,
"max_tx_outputs": 1000,
"max_signature_script_len": 250000,
"max_script_public_key_len": 10000,
"mass_per_tx_byte": 1,
"mass_per_script_pub_key_byte": 10,
"mass_per_sig_op": 1000,
"block_mass_limits": { "compute": 500000, "storage": 500000, "transient": 1000000 },
"block_lane_limits": { "lanes_per_block": 50, "gas_per_lane": 1000000000 },
"storage_mass_parameter": 1000000000000,
"deflationary_phase_daa_score": 0,
"pre_deflationary_phase_base_subsidy": 50000000000,
"skip_proof_of_work": false,
"max_block_level": 250,
"pruning_proof_m": 1000,
"blockrate": {
"target_time_per_block": 1000,
"ghostdag_k": 18,
"past_median_time_sample_rate": 10,
"difficulty_sample_rate": 4,
"max_block_parents": 10,
"mergeset_size_limit": 180,
"merge_depth": 60,
"finality_depth": 720,
"pruning_depth": 13838,
"coinbase_maturity": 2
},
"pre_crescendo_target_time_per_block": 1000,
"crescendo_activation": 0,
"genesis_bits": 487587840,
"finality": {
"checkpoint_interval": 30,
"checkpoint_depth": 20,
"weight_window": 120,
"dust": 5,
"presence_window": 1,
"aggregators": 8,
"equivocation_ban": 120,
"min_daa": 120,
"aggregator_fallback": 1
},
"pow_epoch_blocks": 60,
"pow_epoch_lead": 10,
"pow_day_ms": 1440000
}

126
infra/fast-time/simnet.mjs Normal file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env node
// Proof of the 60x fast-time profile: a 3-node private network on 127.0.0.1 ports 29500 and up, data under
// /tmp/igneum-fast-time, network id igneum-devnet-950, every node on infra/fast-time/override-60x.json with
// skip_proof_of_work. Three voting vmine miners (the fin-attacks igneum-miner, one per node) share 1 block/s, the
// devnet rate, so DAA score advances one per second as on the devnet. One real CPU miner (devnet-v4 igneum-miner,
// --engine igneum-pow, 1 thread) follows node 0's templates and prints its program swap at every epoch boundary;
// at the devnet genesis difficulty a CPU thread finds no block, so it adds nothing to the chain.
//
// Records, in wall seconds from the first node's start: the first finality lock (getFinalityCheckpoints) and the
// first hourly program swap (template epoch index 0 -> 1 at DAA 60, and the CPU miner's "program and cache ready"
// line for the new seed). Expected: lock within about 3 minutes (window 120 DAA + checkpoint depth), swap within
// about 2 minutes. Never touches the live devnet (26610/26611, 26640/26641, 28640).
//
// node infra/fast-time/simnet.mjs [--secs 300] [--no-cpu-miner]
import { spawn } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
import { devAddress } from '../../tools/harness/lib/address.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const IGNEUMD = process.env.IGNEUMD || `${ROOT}vendor/igneum-node/target-integration/release/igneumd`;
const VMINE = process.env.IGNEUM_VMINE || `${ROOT}vendor/igneum-node-fin-attacks/target/release/igneum-miner`;
const CPU_MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-integration/release/igneum-miner`;
const TMP = '/tmp/igneum-fast-time';
const BASE = 29500, SUFFIX = 950;
const args = process.argv.slice(2);
const SECS = +(args[args.indexOf('--secs') + 1] || 300);
const CPU = !args.includes('--no-cpu-miner');
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [IGNEUMD, VMINE]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const override = `${TMP}/override.json`;
writeFileSync(override, JSON.stringify({ ...JSON.parse(readFileSync(FILE, 'utf8')), skip_proof_of_work: true }));
class Node {
constructor(i, connect = []) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
started.push(this.proc);
await sleep(800);
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
function miner(bin, argv, name, env = {}) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
started.push(p);
return p;
}
async function stopAll() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const n0 = await new Node(0).start();
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
const nodes = [n0, n1, n2];
log(`override: ${readFileSync(override, 'utf8').length} bytes; node log says: ${n0.grepLog(/PoW schedule/).join(' | ') || '(no PoW schedule line)'}`);
// three voters sharing 1 block/s (the devnet rate): DAA advances 1 per second
nodes.forEach((n, i) => miner(VMINE, ['vmine', n.grpc, String(SECS), '--share', String(1 / 3), '--bps', '1', '--label', `v${i}`], `vmine-v${i}`));
if (CPU) miner(CPU_MINER, ['mine', n0.grpc, '1', String(SECS), 'cpu0', '--engine', 'igneum-pow', '--status-secs', '30'], 'cpu-miner', { IGNEUM_POW_DAY_MS: '1440000' });
const pay = devAddress('fast-time');
let firstLock = null, firstSwap = null, firstPrepare = null, cpuSwap = null, lastDaa = 0, lastEpoch = 0, lastReport = 0;
const samples = [];
while (Date.now() - t0 < SECS * 1000) {
await sleep(1000);
let daa = null, epoch = null, nextSeed = null, cps = null;
try {
const t = await n0.rpc.call('getBlockTemplate', { payAddress: pay, extraData: [] });
const pe = t.powEpoch || t.pow_epoch || {};
daa = pe.virtualDaaScore ?? t.block?.header?.daaScore; epoch = pe.epochIndex; nextSeed = pe.nextEpochSeed;
} catch (e) { log(`template: ${e.message}`); }
try { cps = await n0.rpc.call('getFinalityCheckpoints', { last: 20 }); } catch { }
const locked = (cps?.checkpoints || []).filter(c => c.state === 'locked');
if (firstPrepare == null && nextSeed) { firstPrepare = +since(); log(`next epoch seed known at ${firstPrepare} s (daa ${daa})`); }
if (firstSwap == null && epoch >= 1) { firstSwap = +since(); log(`PROGRAM SWAP: template epoch index ${epoch} at ${firstSwap} s wall (daa ${daa})`); }
if (firstLock == null && locked.length) {
firstLock = +since();
const c = locked[0];
log(`FIRST LOCK: checkpoint ${c.index} (blue score ${c.blueScore ?? c.blue_score}, daa ${c.daaScore ?? c.daa_score}) locked at ${firstLock} s wall`);
}
if (cpuSwap == null && CPU) {
const lines = (() => { try { return readFileSync(`${TMP}/cpu-miner.log`, 'utf8').split('\n').filter(l => /program and 256 MiB cache ready/.test(l)); } catch { return []; } })();
if (lines.length >= 2) { cpuSwap = +since(); log(`CPU MINER SWAP at ${cpuSwap} s wall: ${lines[1].trim()}`); }
}
if (epoch != null && epoch !== lastEpoch) { log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s`); lastEpoch = epoch; }
lastDaa = daa ?? lastDaa;
if (Date.now() - lastReport > 15000) {
lastReport = Date.now();
const lockedPer = await Promise.all(nodes.map(async n => { try { const c = await n.rpc.call('getFinalityCheckpoints', { last: 400 }); return (c.checkpoints || []).filter(x => x.state === 'locked').length; } catch { return '?'; } }));
log(`t=${since()} s daa ${daa} epoch ${epoch} nextSeed ${nextSeed ? 'known' : 'no'} locked per node ${lockedPer.join('/')}`);
samples.push({ t: +since(), daa, epoch, locked: lockedPer });
}
if (firstLock != null && firstSwap != null && (cpuSwap != null || !CPU) && Date.now() - t0 > 150_000) break;
}
const sinks = await Promise.all(nodes.map(async n => { try { return (await n.rpc.call('getBlockDagInfo')).sink.slice(0, 12); } catch { return '?'; } }));
const summary = { first_lock_s: firstLock, first_swap_s: firstSwap, next_seed_known_s: firstPrepare, cpu_miner_swap_s: cpuSwap, final_daa: lastDaa, sinks_agree: new Set(sinks).size === 1, sinks, samples, secs: SECS, node: IGNEUMD };
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY first lock ${firstLock} s, first program swap ${firstSwap} s (next seed known at ${firstPrepare} s), CPU miner swap ${cpuSwap} s, final daa ${lastDaa}, sinks agree ${summary.sinks_agree}`);
await stopAll();
process.exit(firstLock != null && firstSwap != null ? 0 : 1);

File diff suppressed because one or more lines are too long

View file

@ -50,6 +50,11 @@
}
],
"log": [
{
"date": "2026-10-04",
"text": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine",
"short": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine"
},
{
"date": "2026-10-04",
"text": "One-click Windows workers: what the Mac could measure",

View file

@ -47,8 +47,15 @@ node tools/finality-attacks/run.mjs # the achievable catalogue,
node tools/finality-attacks/run.mjs s1 s6 # named scenarios
node tools/finality-attacks/run.mjs --quick # short durations (smoke)
SCALE=0.7 node tools/finality-attacks/run.mjs # scale every duration
node tools/finality-attacks/run.mjs s3 --fast-time # the 60x fast-time profile (infra/fast-time/README.md): the weight
# window and min_daa are 120 DAA, so the first lock comes at about
# DAA 150 instead of 7,200; the node is the devnet-v4 integration build
```
Under the devnet parameters of 4 October 2026 (`min_daa` = window = 7,200 DAA) a scenario cannot see a lock before
DAA 7,200, which at the six miners' 6 blocks/s is 20 minutes of warm-up per scenario; `--fast-time` brings that to
20 s (s3 measured: 16 locks per node and PASS in 113 s wall, 4 Oct 2026).
Results print as a table and are written to `/tmp/igneum-fin-attacks/results.{txt,json}`; the S8 transcript is at
`/tmp/igneum-fin-attacks/s8-fin-rpc-attack.out`. Exit code is non-zero if any scenario failed.

View file

@ -13,9 +13,15 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, openSync }
import { createServer, connect as tcpConnect } from 'node:net';
import { connectRpc } from './rpc.mjs';
export const ROOT = new URL('../../../', import.meta.url).pathname; // /Users/joshm/Projects/igneum/
export const ROOT = new URL('../../../', import.meta.url).pathname; // the repository root
export const TARGET = process.env.IGNEUM_FIN_TARGET || `${ROOT}vendor/igneum-node-fin-attacks/target/release`;
export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`;
// --fast-time (or IGNEUM_FAST_TIME=1): the 60x profile (infra/fast-time/README.md): weight window, ban and min_daa
// 120 DAA instead of 7,200, 60-block epochs. The file carries the PoW schedule fields that only the devnet-v4 node
// knows, so the node then defaults to the integration build of that line; the hostile vmine miner stays the
// fin-attacks one (its RPCs are additive, it drove the v4 node before: docs/bench-log.md, 4 Oct 2026).
export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGNEUM_FAST_TIME === '1';
export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`;
export const IGNEUMD = process.env.IGNEUMD || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release/igneumd` : `${TARGET}/igneumd`);
export const MINER = process.env.IGNEUM_MINER || `${TARGET}/igneum-miner`;
export const TMP = '/tmp/igneum-fin-attacks';
export const BASE_PORT = 27800; // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2)
@ -29,7 +35,8 @@ export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
export function overrideParams() {
mkdirSync(TMP, { recursive: true });
const file = `${TMP}/override.json`;
writeFileSync(file, JSON.stringify({ skip_proof_of_work: true }));
const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {};
writeFileSync(file, JSON.stringify({ ...base, skip_proof_of_work: true }));
return file;
}

View file

@ -7,12 +7,15 @@
// node tools/finality-attacks/run.mjs s1 s6 # named scenarios
// node tools/finality-attacks/run.mjs --quick # short durations (smoke)
// SCALE=0.5 node tools/finality-attacks/run.mjs # scale every duration
// node tools/finality-attacks/run.mjs s3 --fast-time # the 60x profile (infra/fast-time): the weight window fills
// # at DAA 120 instead of 7,200, so locks start within a minute;
// # durations default to the --quick scale
import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP } from './lib/net.mjs';
import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, FAST_TIME, IGNEUMD } from './lib/net.mjs';
import { mkdirSync, writeFileSync } from 'node:fs';
const QUICK = process.argv.includes('--quick');
const SCALE = QUICK ? 0.35 : (parseFloat(process.env.SCALE || '1') || 1);
const SCALE = process.env.SCALE ? (parseFloat(process.env.SCALE) || 1) : (QUICK || FAST_TIME ? 0.35 : 1);
const dur = (s) => Math.max(20, Math.round(s * SCALE));
const results = [];
mkdirSync(TMP, { recursive: true });
@ -323,6 +326,7 @@ const ORDER = ['s3', 's2', 's1', 's6', 's4', 's8', 's5'];
async function main() {
assertBinaries();
if (FAST_TIME) log(`fast-time 60x profile: node ${IGNEUMD}`);
const asked = process.argv.slice(2).filter(a => !a.startsWith('--'));
const run = asked.length ? asked : ORDER;
for (const key of run) {

View file

@ -48,6 +48,9 @@ node tools/harness/run.mjs # the full catalogue, priority order 5
node tools/harness/run.mjs s5 s2 --quick # named scenarios, short durations
node tools/harness/run.mjs --no-bench-log # do not append to docs/bench-log.md
node tools/harness/scenarios/s1-withhold.mjs --quick # one scenario on its own
node tools/harness/run.mjs s3 s4 --fast-time # the 60x fast-time profile (infra/fast-time): merge depth 60 s, so the
# partition and eclipse cuts are 10, 30 and 62 s instead of 600, 1,800 and
# 3,700; nodes and the simulator come from vendor/igneum-node/target-integration
```
Each run appends one dated entry to `docs/bench-log.md` with a row per scenario (criterion, measured result, pass

View file

@ -14,8 +14,13 @@ import { execSync } from 'node:child_process';
import { connectRpc } from './rpc.mjs';
export const ROOT = new URL('../../../', import.meta.url).pathname;
// --fast-time (or IGNEUM_FAST_TIME=1): every clock-like consensus parameter divided by 60 (infra/fast-time/README.md).
// The 60x file carries the PoW schedule fields, which only the devnet-v4 node and later know, so the binaries then
// default to the integration build of that line (vendor/igneum-node/target-integration) instead of the harness worktree.
export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGNEUM_FAST_TIME === '1';
export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`;
export const WORKTREE = `${ROOT}vendor/igneum-node-harness`;
export const TARGET = process.env.IGNEUM_HARNESS_TARGET || `${WORKTREE}/target/release`;
export const TARGET = process.env.IGNEUM_HARNESS_TARGET || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release` : `${WORKTREE}/target/release`);
export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`;
export const PROBE = process.env.IGNEUM_P2P_PROBE || `${TARGET}/igneum-p2p-probe`;
export const SIM = process.env.IGNEUM_HARNESS_SIM || `${TARGET}/igneum-harness-sim`;
@ -31,11 +36,22 @@ export function overrideParams(extra = {}) {
mkdirSync(TMP, { recursive: true });
const file = `${TMP}/override-params.json`;
// skip_proof_of_work: the harness miner never hashes; every other rule (timestamps, DAA, GHOSTDAG, merge
// depth, mass, coinbase) runs unchanged. Devnet parameters otherwise (1 BPS, k 18, merge depth 3,600).
writeFileSync(file, JSON.stringify({ skip_proof_of_work: true, ...extra }));
// depth, mass, coinbase) runs unchanged. Devnet parameters otherwise (1 BPS, k 18, merge depth 3,600), or the
// 60x fast-time profile under --fast-time (merge depth 60, finality window 120 DAA, 60-block epochs).
const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {};
writeFileSync(file, JSON.stringify({ ...base, skip_proof_of_work: true, ...extra }));
return file;
}
// Consensus clock parameters the scenarios scale their durations by: merge depth in DAA seconds (3,600 on the
// devnet, 60 under --fast-time). A scenario cut "beyond merge depth" is mergeDepth + 100 s on the devnet and
// mergeDepth + 2 s at 60x.
export function clockParams() {
const o = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {};
const mergeDepth = o.blockrate?.merge_depth ?? 3600;
return { fastTime: FAST_TIME, mergeDepth, scale: 3600 / mergeDepth };
}
export class Node {
constructor(index, { connect = [], extraArgs = [], name } = {}) {
this.index = index;

View file

@ -1,13 +1,16 @@
// Runs the in-process simulator (vendor worktree crate igneum/harness-sim) and reads its JSON report.
import { spawnSync, spawn } from 'node:child_process';
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
import { SIM, TMP, log } from './net.mjs';
import { SIM, TMP, log, FAST_TIME, FAST_TIME_FILE } from './net.mjs';
// Under --fast-time the simulator runs the 60x profile too (igneum-harness-sim --override-params-file).
const fastArgs = () => (FAST_TIME ? ['--override-params-file', FAST_TIME_FILE] : []);
export function runSim(name, args, { timeoutMs = 60 * 60_000 } = {}) {
if (!existsSync(SIM)) throw new Error(`missing ${SIM}; build igneum-harness-sim in the harness worktree`);
mkdirSync(`${TMP}/sim`, { recursive: true });
const out = `${TMP}/sim/${name}.json`;
const argv = [...args, '--out', out];
const argv = [...args, ...fastArgs(), '--out', out];
log(`sim ${name}: igneum-harness-sim ${argv.join(' ')}`);
const t0 = Date.now();
const r = spawnSync('nice', ['-n', '19', SIM, ...argv], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
@ -28,7 +31,7 @@ export async function runSims(jobs, { parallel = 3 } = {}) {
const { name, args } = jobs[i];
mkdirSync(`${TMP}/sim`, { recursive: true });
const out = `${TMP}/sim/${name}.json`;
const argv = [...args, '--out', out];
const argv = [...args, ...fastArgs(), '--out', out];
log(`sim ${name}: igneum-harness-sim ${argv.join(' ')}`);
const t0 = Date.now();
await new Promise((resolve) => {

View file

@ -3,13 +3,16 @@
// 127.0.0.1 ports 27200+ and /tmp/igneum-harness, writes a results table per run to docs/bench-log.md, and leaves
// the test network stopped. The live devnet (26610/26611, 26640/26641, 28640) and the PC node are never touched.
//
// node tools/harness/run.mjs [scenario ...] [--quick] [--no-bench-log]
// node tools/harness/run.mjs [scenario ...] [--quick] [--no-bench-log] [--fast-time]
// scenarios: s5 s2 s1 s3 s6 s4 s7 (default: priority order 5,2,1,3,6,4,7)
// --quick runs shorter block counts and durations for a smoke run.
// --fast-time runs the network and the simulator on infra/fast-time/override-60x.json (every clock-like consensus
// parameter divided by 60: merge depth 60, finality window 120 DAA, 60-block epochs) with the devnet-v4 binaries
// from vendor/igneum-node/target-integration; the merge-depth scenarios (3, 4) scale their cuts with it.
//
// See tools/harness/README.md.
import { stopAll, assertBinaries } from './lib/net.mjs';
import { stopAll, assertBinaries, FAST_TIME, TARGET } from './lib/net.mjs';
import { benchLogEntry, appendBenchLog } from './lib/report.mjs';
import { stubRows } from './scenarios/stubs.mjs';
import { execSync } from 'node:child_process';
@ -30,7 +33,8 @@ function machineLine() {
try { cpu = execSync('sysctl -n machdep.cpu.brand_string').toString().trim(); } catch { }
try { mem = (parseInt(execSync('sysctl -n hw.memsize').toString().trim(), 10) / 2 ** 30).toFixed(0) + ' GB'; } catch { }
let load = ''; try { load = execSync('uptime').toString().match(/load averages?: ([\d. ]+)/)?.[1] || ''; } catch { }
return `Machine: ${cpu}, ${mem}, load ${load.trim()}. Private test network of igneumd (release, skip_proof_of_work devnet) on 127.0.0.1 ports 27200+, data /tmp/igneum-harness; the live devnet and the PC node were not touched. Harness: tools/harness/, node fork worktree vendor/igneum-node-harness.`;
const profile = FAST_TIME ? 'skip_proof_of_work devnet on the 60x fast-time profile, infra/fast-time/override-60x.json' : 'skip_proof_of_work devnet';
return `Machine: ${cpu}, ${mem}, load ${load.trim()}. Private test network of igneumd (release, ${profile}) on 127.0.0.1 ports 27200+, data /tmp/igneum-harness; the live devnet and the PC node were not touched. Harness: tools/harness/, binaries ${TARGET}.`;
}
// Assemble one bench-log entry from result JSONs already written under /tmp/igneum-harness/results, without
@ -82,7 +86,7 @@ async function main() {
const allRows = [];
for (const key of picks) {
if (!SCENARIOS[key]) { console.error(`unknown scenario ${key}`); continue; }
console.log(`\n==== scenario ${key}${quick ? ' (quick)' : ''} ====`);
console.log(`\n==== scenario ${key}${quick ? ' (quick)' : ''}${FAST_TIME ? ' (fast-time 60x)' : ''} ====`);
try {
const mod = await SCENARIOS[key]();
const { rows } = await mod.run({ quick });
@ -102,7 +106,7 @@ async function main() {
const date = new Date().toISOString().slice(0, 10);
const entry = benchLogEntry({
date: `${date}, consensus attack harness (consensus-engineer)`,
title: `catalogue run${quick ? ' (quick)' : ''} on the ordering-layer node`,
title: `catalogue run${quick ? ' (quick)' : ''}${FAST_TIME ? ' (fast-time 60x)' : ''} on the ordering-layer node`,
machine: machineLine(),
rows: allRows,
notes: [

View file

@ -8,11 +8,19 @@
// Runs in the in-process simulator.
import { runSims, hist, maxDepth } from '../lib/sim.mjs';
import { clockParams } from '../lib/net.mjs';
import { saveResult } from '../lib/report.mjs';
export async function run({ quick = false } = {}) {
const cuts = quick ? [120, 600] : [120, 600, 1800, 3700];
const jobs = cuts.map(c => ({ name: `s3-cut${c}`, args: ['--scenario', 'partition', '--cut-at', '300', '--cut-secs', String(c), '--run-after', '600', '--sample-secs', '10', '--seed', '3'] }));
// Cuts as fractions of the merge depth: 1/30, 1/6, 1/2 and one beyond it (devnet 120, 600, 1,800, 3,700 s). Under
// --fast-time (merge depth 60) the same fractions are 2, 10, 30 and 62 s, with the 2-s cut dropped (it equals the
// 2-s network delay), and the warm-up and run-after shrink with the clock.
const { fastTime, mergeDepth } = clockParams();
const fractions = quick ? [1 / 30, 1 / 6] : [1 / 30, 1 / 6, 1 / 2, null];
let cuts = fractions.map(f => (f == null ? mergeDepth + (fastTime ? 2 : 100) : Math.round(mergeDepth * f)));
if (fastTime) cuts = cuts.filter(c => c > 2);
const cutAt = fastTime ? '120' : '300', runAfter = fastTime ? '120' : '600', sample = fastTime ? '2' : '10';
const jobs = cuts.map(c => ({ name: `s3-cut${c}`, args: ['--scenario', 'partition', '--cut-at', cutAt, '--cut-secs', String(c), '--run-after', runAfter, '--sample-secs', sample, '--seed', '3'] }));
const reports = await runSims(jobs, { parallel: 2 });
const rows = []; const data = [];
reports.forEach((r, i) => {
@ -26,7 +34,7 @@ export async function run({ quick = false } = {}) {
// one-block tip churn at 4 miners and a 2 s delay; the instantaneous sink snapshot (one_chain) is noisier.
const pass = r.blue_converged && timeToHeal != null;
data.push({ cut_s: c, heal_at_s: healS, converged_blue_at_s: convS, time_to_heal_s: timeToHeal, final_blue_spread: r.final_blue_spread, reorg_depth_at_heal: depths, max_reorg_per_node: r.reorg_hist.map(maxDepth), reorg_hist: r.reorg_hist, rejects: r.rejects, one_chain: r.one_chain, blue_converged: r.blue_converged, final_sinks: r.final_sinks.map(s => s.slice(0, 12)), counts: r.counts, wall_s: r.wall_s });
rows.push({ scenario: `3 partition ${c} s${c > 3600 ? ' (beyond merge depth)' : ''}`, criterion: 'one chain after the merge-depth rule; reorg depth and time to heal recorded', result: `one chain: ${r.blue_converged} (blue scores within ${r.final_blue_spread} at the end); healed in ${timeToHeal ?? 'never'} s; losing-side reorg at heal ${Math.max(...depths)} chain blocks (per node ${depths.join('/')}); rejects ${rejects}`, pass });
rows.push({ scenario: `3 partition ${c} s${c > mergeDepth ? ' (beyond merge depth)' : ''}${fastTime ? ' [fast-time 60x]' : ''}`, criterion: 'one chain after the merge-depth rule; reorg depth and time to heal recorded', result: `one chain: ${r.blue_converged} (blue scores within ${r.final_blue_spread} at the end); healed in ${timeToHeal ?? 'never'} s; losing-side reorg at heal ${Math.max(...depths)} chain blocks (per node ${depths.join('/')}); rejects ${rejects}`, pass });
});
saveResult('s3-partition', { rows, data });
return { rows, data };

View file

@ -6,11 +6,15 @@
// Runs in the in-process simulator.
import { runSims, maxDepth } from '../lib/sim.mjs';
import { clockParams } from '../lib/net.mjs';
import { saveResult } from '../lib/report.mjs';
export async function run({ quick = false } = {}) {
const cuts = quick ? [600] : [600, 1800];
const jobs = cuts.map(c => ({ name: `s4-eclipse${c}`, args: ['--scenario', 'eclipse', '--cut-at', '60', '--cut-secs', String(c), '--run-after', '600', '--sample-secs', '10', '--seed', '4'] }));
// Eclipses of 1/6 and 1/2 of the merge depth (devnet 600 and 1,800 s; 10 and 30 s under --fast-time).
const { fastTime, mergeDepth } = clockParams();
const cuts = (quick ? [1 / 6] : [1 / 6, 1 / 2]).map(f => Math.round(mergeDepth * f));
const runAfter = fastTime ? '120' : '600', sample = fastTime ? '2' : '10';
const jobs = cuts.map(c => ({ name: `s4-eclipse${c}`, args: ['--scenario', 'eclipse', '--cut-at', '60', '--cut-secs', String(c), '--run-after', runAfter, '--sample-secs', sample, '--seed', '4'] }));
const reports = await runSims(jobs, { parallel: 2 });
const rows = []; const data = [];
reports.forEach((r, i) => {
@ -26,7 +30,7 @@ export async function run({ quick = false } = {}) {
const onHonestSink = lastSample.sinks[3] === lastSample.sinks[0];
const pass = victimGap <= 18 && rejoin != null;
data.push({ eclipse_s: c, heal_at_s: r.heal_at_s, victim_rejoin_after_s: rejoin, victim_reorg_depth: victimDepth, victim_blue_gap_end: victimGap, victim_on_honest_sink: onHonestSink, reorg_depth_at_heal: r.heal_reorg_depth, victim_max_reorg: maxDepth(r.reorg_hist[3]), counts: r.counts, rejects: r.rejects, final_sinks: r.final_sinks.map(s => s.slice(0, 12)), wall_s: r.wall_s });
rows.push({ scenario: `4 eclipse ${c} s`, criterion: 'victim rejoins the honest chain on reconnection within the merge-depth bound; reorg depth recorded', result: `victim rejoined ${rejoin ?? 'never'} s after reconnection (blue-score gap to honest ${victimGap} at the end); victim reorg depth ${victimDepth} chain blocks; adversary built ${r.counts[2].created} blocks that never entered the honest chain`, pass });
rows.push({ scenario: `4 eclipse ${c} s${fastTime ? ' [fast-time 60x]' : ''}`, criterion: 'victim rejoins the honest chain on reconnection within the merge-depth bound; reorg depth recorded', result: `victim rejoined ${rejoin ?? 'never'} s after reconnection (blue-score gap to honest ${victimGap} at the end); victim reorg depth ${victimDepth} chain blocks; adversary built ${r.counts[2].created} blocks that never entered the honest chain`, pass });
});
saveResult('s4-eclipse', { rows, data });
return { rows, data };