Red team 4 Oct 2026: every attack suite against the 0.3.4 finality-fixes build (rule v3 on, fast time); 30 scenarios, ledger F25 M30 M31 new, F21 F23 F24 measured
Report docs/review/redteam-2026-10-04.md: finality attacks s1-s8 plus 34% withholding, 50/50 long partition, F23 and F24 custom runs, ordering harness, execution suite and EVM smoke, proving hostile tests and a proof flood, difficulty v2 timestamp forging in the simulator. New fails: the fast-time harnesses corrupt the u64::MAX sentinels of the override (F25), a block or transaction flood grows the node by hundreds of MB in a minute (M30), the coinbase does not fit the 204-byte limit on mainnet, testnet and simnet parameters (M31). F23 and F24 reproduced on this build; F21's bound measured at one window of the side's own DAA. Scenario scripts under tools/finality-attacks/redteam/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b06ba68a4d
commit
4b26d04bc0
5 changed files with 391 additions and 1 deletions
|
|
@ -1516,7 +1516,7 @@ the project lead's decision of 4 October 2026: the total-weight floor of Q3 is 2
|
|||
- **F16** (a lock can become uncertified after a heal). Status: rule unchanged (spec 3.11.4: a verified certificate is never withdrawn, O-3.17 implements the conflict report). What the floor changes is how the state F16 describes arises: two certificates at one index now need equivocators holding at least one third of total weight in every scenario (two certificates need 4/3 of weight in signatures), not 13.3% across a partition that outlasts the presence decay (`sim/results_v2.md` H at 2/3: 0 conflicts and no lock on either side through a 33% equivocator, conflicts from minute 0 at 34%). Ten days of 100% hashrate in public, or the long-partition case of F21.
|
||||
- **F18** ("a silent minority cannot freeze finality" is false under the floor). Status: Fixed again (4 October 2026). The litepaper now says a lock needs two thirds of all 30-day weight and that finality pauses whenever less than two thirds is connected and signing. The pause threshold moved from about 42% of weight silent to one third: in the model, whose keys are in outage 2.2% of the time, 30% silent locks every checkpoint, 32% locks 88%, 33% locks 11% and 34% locks none for as long as it stays silent (`sim/results_v2.md` L1). Was: Fixed (56.7% sentence).
|
||||
- **F9** (half the hashrate leaves and finality stalls for ten days). Status: Conceded, stated (4 October 2026). Under the 2/3 floor the critic's number is back: 50% churn pauses finality for 10 days and 35% churn for 1.4 days, until the departed weight ages out of the window (`sim/results_v2.md` D's total column, which the 2/3 floor equals arithmetically, and L2). The chain runs on proof of work meanwhile, the node reports the pause, and the litepaper says so. This is the price of the one-third safety bound and was taken knowingly. Was: Answered by design (the active denominator recovered in two hours).
|
||||
- **F21** (new, from attack scenario 6A). "Your floor is a fraction of a table each side computes for itself. Cut the network in half and leave it cut: after a while each half's window is full of its own blocks, each half holds two thirds of its own table, and both lock without any attacker at all. Your simulation never saw it because it kept the weights global." Status: Conceded, stated (4 October 2026): spec 3.3.1, 3.7 item 9, 3.9 guidance; `sim/results_v2.md` L4 (view-local weights). Correct. A side with pre-split share s holds s + (1 - s) t / 30 of its own table on day t and two thirds of it from day 30 (2/3 - s) / (1 - s): day 10 at 50/50 (day 4 under the old floor), day 5 for the 60 side of 60/40 (at once under the old floor). On the devnet the old floor fell at 84 s of a young 1,439-DAA window (`docs/bench-log.md`, "finality v2 attack harness", S6A); at 2/3 the same cut on a full 1,800-DAA window held for the whole 150-s split and fell at 205 s against a predicted W / (3R) = 200 s (`docs/bench-log.md`, "finality floor 2/3", 6A and 6A long heal). What the devnet adds to the simulation: after the heal the other side's blocks are merged red, so each side's own share jumps rather than drifts, both sides of a 50/50 split certify their own checkpoints within 10 s of each other, and F1 then pins each node to its own certified chain: 26 conflicting certificates and 23 disagreeing locked indices across three nodes, no equivocation, a finality fork that the network heal did not undo and that only an operator's trusted certificate (F5, not implemented) can resolve. No rule removes it, because a view cannot count blocks it has never seen; the floor at two thirds moved the day from 4 to 10, and the exchange guidance treats a node partitioned for more than a day as proof of work until it has rejoined. A rule option for gate 3, not adopted: evaluate the floor against the table of the last locked checkpoint while no newer lock exists, which trades F9's 10-day recovery for a manual override.
|
||||
- **F21** (new, from attack scenario 6A). "Your floor is a fraction of a table each side computes for itself. Cut the network in half and leave it cut: after a while each half's window is full of its own blocks, each half holds two thirds of its own table, and both lock without any attacker at all. Your simulation never saw it because it kept the weights global." Status: Conceded, stated (4 October 2026): spec 3.3.1, 3.7 item 9, 3.9 guidance; `sim/results_v2.md` L4 (view-local weights). Correct. A side with pre-split share s holds s + (1 - s) t / 30 of its own table on day t and two thirds of it from day 30 (2/3 - s) / (1 - s): day 10 at 50/50 (day 4 under the old floor), day 5 for the 60 side of 60/40 (at once under the old floor). On the devnet the old floor fell at 84 s of a young 1,439-DAA window (`docs/bench-log.md`, "finality v2 attack harness", S6A); at 2/3 the same cut on a full 1,800-DAA window held for the whole 150-s split and fell at 205 s against a predicted W / (3R) = 200 s (`docs/bench-log.md`, "finality floor 2/3", 6A and 6A long heal). What the devnet adds to the simulation: after the heal the other side's blocks are merged red, so each side's own share jumps rather than drifts, both sides of a 50/50 split certify their own checkpoints within 10 s of each other, and F1 then pins each node to its own certified chain: 26 conflicting certificates and 23 disagreeing locked indices across three nodes, no equivocation, a finality fork that the network heal did not undo and that only an operator's trusted certificate (F5, not implemented) can resolve. No rule removes it, because a view cannot count blocks it has never seen; the floor at two thirds moved the day from 4 to 10, and the exchange guidance treats a node partitioned for more than a day as proof of work until it has rejoined. A rule option for gate 3, not adopted: evaluate the floor against the table of the last locked checkpoint while no newer lock exists, which trades F9's 10-day recovery for a manual override. Red-team run on the 0.3.4 build with rule v3 on (4 October 2026, evening, `docs/review/redteam-2026-10-04.md` row 18): the frozen table of F21 holds for one weight window of the side's own DAA after the last common lock, then the sliding table decides and the side locks alone. Measured at fast time (window 120 DAA, about 2.5 to 3 blue blocks per second per side of a 3/3 split): first solo lock 36 s and 49 s after the cut against the old W/(3R) of about 13 s, which is W minus the age of the last lock at the cut; the 4 side of a 4/2 split locks at 24 s because 4/6 of the frozen table is two thirds; the 2 side locks only once its own window has drained the other four keys (near 50 s). After the heal, 8 and 5 conflicting certificates: the fork stands, as stated. On mainnet the same arithmetic is one full window of the side's own chain time, 30 days at 1 block/s after the difficulty settles, against the 10 days of the sliding table. Status unchanged: conceded, stated, bound tripled.
|
||||
|
||||
### M24. Your two-lane controller oscillates for an hour when a second miner joins mid-epoch
|
||||
"Watched your devnet this morning. The second 5090 came in at 10:12 and the difficulty never settled: 102M to 164M for forty minutes, 54 to 81 blocks a minute, three or four clamp steps stacked inside a second. Your fast lane and your slow lane disagree by a hair under the trigger and the rule flips between them every two minutes. One card joining is the mildest event a chain can see."
|
||||
|
|
@ -1691,6 +1691,8 @@ Answer: Correct. `ingest_evidence` (`processes/finality.rs:600-612`); `ingest_ce
|
|||
|
||||
Evidence: the files above. Experiment: `tools/finality-attacks` with one equivocation detected on node A by RPC and on node B from the carrying block 30 DAA later; count certificates refused with "names N voters" between the two expiries; after the fix, zero.
|
||||
|
||||
Red-team run, 4 October 2026 (evening, the 0.3.4 finality-fixes build with rule v3 on, `docs/review/redteam-2026-10-04.md` row 15): reproduced by the stock scenario 1 (two keys equivocating at every index, 4 honest voters, 3 nodes, fast time). The node that received the equivocators' votes by RPC re-detected at every index (46 detections) and held the ban until DAA 726; the two nodes that saw the evidence only in blocks detected it at indices 1 to 3 (8 detections, ban until 239) and let it expire. The first detection alone stamped `until` 174 and 176 on the RPC node against 176 on the others. From index 9 the voter lists differed by two keys and the nodes refused each other's certificates: 9 refusals "names 6 voters, this node counts 4" on the RPC node, 3 and 4 refusals "names 4 voters, this node counts 6" on the others. Every node still locked 15 of 15 only because each could aggregate its own certificate from the votes it held; with 8 named aggregators on a real network that fallback is `aggregator_fallback` later and a node whose certificate the rest refuse is one more aggregation round behind at every index. Rule v3 does not touch this path. Severity stays serious; the fix above stands.
|
||||
|
||||
### F24. A checkpoint determination is never revisited
|
||||
"After a reorg deeper than `checkpoint_depth`, the node's record for that index names a block off its chain. Every certificate the network forms for that index is refused as conflicting, with no equivocation anywhere, and the node voted for a block that is not on its chain."
|
||||
|
||||
|
|
@ -1700,6 +1702,17 @@ Answer: Correct. `on_virtual_changed` (`processes/finality.rs:404-440`) inserts
|
|||
|
||||
Evidence: the files above. Experiment: a 30 s cut on a 3-node devnet at d = 20; the losing side must accept the network's certificate at that index with no CONFLICTING line.
|
||||
|
||||
Red-team run, 5 October 2026, 00:56 (the 0.3.4 finality-fixes build, rule v3 on, fast time, `docs/review/redteam-2026-10-04.md` row 23b): reproduced on a clean merge. Two nodes with three voting keys each, cut for 16 s (about 50 blue blocks a side, under the 60-DAA merge depth), healed: sinks equal, 64 locks each, no conflicting certificate. Checkpoint 33 was determined during the cut on each side's own chain (two different blocks); after the reorg neither node re-determined it, neither block ever got a certificate (votes split 3/3), and finality went on from 34. A permanent one-index hole on every node, with no equivocation anywhere. The round-4 shape, a false CONFLICTING line, needs the other side's certificate to arrive, which a 3/3 split cannot form; the two 4/2 attempts (rows 22 and 23) showed the stuck indices and the refusals "this node's checkpoint is ..." but overshot merge depth, so they are confounded with F21. Rule v3 does not touch this path. The fix above stands; the two-node test is `rtfin.mjs f24c` in the red-team scratchpad (cut 16 s, 3/3), which should end with index 33 locked on both nodes.
|
||||
|
||||
### F25. The fast-time harnesses cannot start a node, and the timestamp probe tests the old rule
|
||||
"Both attack harnesses rebuild each node's override with `JSON.parse` and `JSON.stringify` of `infra/fast-time/override-60x.json`. That file now carries two `u64::MAX` sentinels (`difficulty_v2_activation_daa`, `proving_v0_activation_daa`); a JavaScript number cannot hold them, the round-trip writes `18446744073709552000`, and `igneumd` refuses the file as a floating point where a u64 is expected. Every `--fast-time` run of `tools/finality-attacks` and `tools/harness` fails at the first node. Scenario 2 of `tools/harness` still probes the 132 s future bound and reports FAIL against the 10 s rule the node has carried since the timestamp fix."
|
||||
|
||||
Status: Open (4 October 2026, red-team run). Tooling, low severity: no consensus effect, but every fast-time attack run is blind until it is fixed.
|
||||
|
||||
Answer: Correct, measured. The red-team run's first scenario errored on it (`docs/review/redteam-2026-10-04.md`, "Tooling defect"); `tools/proving-v0/run.mjs` already edits the file as text for this reason. Scenario 2 live probe: past floor pmt+1, future flip between +130.00 and +130.01 s of the probe's own offsets, every stamp from +10 s rejected; the node is right, the criterion is stale. Smallest fix: in `tools/finality-attacks/lib/net.mjs` and `tools/harness/lib/net.mjs` `overrideParams`, drop the two sentinel fields before `stringify` (absent means never) or splice the extra fields into the file text; in `tools/harness/scenarios/s2-timestamp.mjs`, probe `max(pmt + 1, parent - 10 s)` and the +10 s bound. Also stale: `tools/exec-attacks/scenario3_pgas.mjs` waits for an over-budget transaction to be included and skipped with `BlockProvingBudget`; since F-exec-B the mempool refuses it with the metered pgas, so the check should accept `ProvingGasAboveBlockLimit` from the pool (`docs/review/redteam-2026-10-04.md` row 28). And `tools/finality-attacks` scenario 5 compares the burster's share of the weight window with its share of the whole run, which only agree when the run is shorter than the window (row 17).
|
||||
|
||||
Evidence: the first run's `/tmp/igneum-redteam-fin/n0/node.log` parse line (kept in the session scratchpad `rt/logs/fa_s8/n0/node.log`), `rt/logs/ord_s2.log`.
|
||||
|
||||
### X19. Operational knobs and silences in the shipped node
|
||||
"A slow-clock node disconnects every peer on every relayed block and never says why; the handshake's `time_offset` is computed and unused; `IGNEUM_ATTACK_TS_OFFSET_MS` and `IGNEUM_POW_STRIKES` are compiled into the live binary; `timestamp_deviation_tolerance` is dead and still accepted."
|
||||
|
||||
|
|
@ -1799,6 +1812,24 @@ Answer: Correct. `site/litepaper.html:424`; the app's sources have no earnings,
|
|||
|
||||
Evidence: the files above.
|
||||
|
||||
### M30. A block or transaction flood grows the 0.3.4 node by hundreds of megabytes in a minute
|
||||
"On the 3 October ordering-layer node (no execution layer) the resource-exhaustion scenario grew RSS by 4, 11 and 14 MB and the 50x block flood by 30 MB. On the 0.3.4 build, same harness, same scenarios, same 60 s: template flood +6 MB, submit flood +269 MB, mempool flood +270 MB, block flood 302 to 1,082 MB on both nodes (567 MB at 10 s, 824 MB at 20 s). The harness calls it a pass because its bound is baseline + 512 MB; a peer that keeps going is not bounded by the harness."
|
||||
|
||||
Status: Open (4 October 2026, red-team run). Serious: a single peer at 50 blocks/s or 500 transactions/s is the devnet's own fast-miner event, and a node that grows 13 MB/s under it runs out of memory in minutes on the 2 to 4 GB cloud nodes.
|
||||
|
||||
Answer: Measured, cause not yet isolated. What changed between the two builds is the execution layer, which this node links: `igneum/exec/src/service.rs` keeps every `ChainBlockRecord` in `ExecState.records` (`:304`, `:419`, pushed and never truncated) plus `tx_index` and `inclusions` maps per transaction, and the mempool flood's 14,998 rejected transactions cost 270 MB, so rejected transactions are retained somewhere too. Smallest fix: bound `ExecState.records` to the record window plus the pruning depth and drop `tx_index`/`inclusions` entries with them; discard a rejected transaction's bytes at rejection; then re-run `tools/harness` s6 and s7 and require growth under 50 MB, the 3 October figure.
|
||||
|
||||
Evidence: `/tmp/igneum-redteam-ord/results/s6-exhaustion.json` and `s7-flood.json` (samples carry `rss_a`, `rss_b` every 10 s), kept in the session scratchpad `rt/logs/ord_s6`, `rt/logs/ord_s7`; the 3 October numbers in `docs/bench-log.md`, "consensus attack harness" entry.
|
||||
|
||||
### M31. The 0.3.4 node cannot produce a block template on mainnet, testnet or simnet parameters
|
||||
"`getBlockTemplate` on a `--simnet` node from the finality-fixes build answers every call with `Coinbase payload is above max length (204). Try to shorten the extra data.` and the network never makes a block. The coinbase of this build carries the vote-key reveal, the proof-record section and the finality section; only `DEVNET_PARAMS` was raised to `MAX_COINBASE_PAYLOAD_LEN_WITH_FINALITY` (16,384). `MAINNET_PARAMS`, `TESTNET_PARAMS` and `SIMNET_PARAMS` still carry Kaspa's 204 (`consensus/core/src/config/params.rs:705, 766, 828` against `:900`)."
|
||||
|
||||
Status: Open (4 October 2026, red-team run). Serious for anything that is not the devnet: a mainnet or testnet genesis on these parameters cannot be mined by a voting miner at all; harmless on the live devnet, whose parameters carry the raise.
|
||||
|
||||
Answer: Measured on the execution-layer attack network (`tools/exec-attacks/net.sh` runs `--simnet` with no override): three nodes up, 0 blocks, every template refused with that line (`docs/review/redteam-2026-10-04.md` row 27). Smallest fix: set `max_coinbase_payload_len: MAX_COINBASE_PAYLOAD_LEN_WITH_FINALITY` on the three other networks, and add a unit test that builds a coinbase with a key reveal, the maximum record section and a full certificate and checks it under every network's limit. The red-team run worked around it with `{"max_coinbase_payload_len": 16384}` in an override file.
|
||||
|
||||
Evidence: session scratchpad `rt/logs/exec_b/miner_node1.log` (the template error, repeated once per second), `rt/logs/exec_b/n1/node.log` (28 lines, genesis executed, nothing after).
|
||||
|
||||
### E17. Unlogged inputs behind the economics, minor
|
||||
"The cap's 110 MH/s and its draw are not in the bench-log; the Mac's draw is not logged; the economy sim's one measured input is a 229 MH/s card against today's 124; mining-versus-pool flips from 4.9x for mining on today's devnet to 930x for proving at 10,000 cards and no document says it depends on fleet size; the app-share text omits '100,000-gas calls' and the open base unit; emission ran at up to 2x schedule; shard-scale prover cost is unmeasured on any GPU; the iGPU default off is right."
|
||||
|
||||
|
|
|
|||
106
tools/finality-attacks/redteam/flood.mjs
Normal file
106
tools/finality-attacks/redteam/flood.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Red-team: flood of invalid proof records against the proof pool of the finality-fixes build (proving v0 active).
|
||||
// One redteam node (eth RPC), 3 vmine voters to reach activation and assign shards, then a flood of well-formed-length
|
||||
// garbage records through igneum_submitProofRecord. Measures reject throughput and node CPU per rejected record.
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, openSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { connectRpc } from '../lib/rpc.mjs';
|
||||
|
||||
const ROOT = '/Users/joshm/Projects/igneum/';
|
||||
const IGNEUMD = `${ROOT}vendor/igneum-node-redteam/target/release/igneumd`;
|
||||
const MINER = `${ROOT}vendor/igneum-node-fin-attacks/target/release/igneum-miner`;
|
||||
const OVERRIDE = '/tmp/igneum-redteam-override-prove-v3.json';
|
||||
const TMP = '/tmp/igneum-redteam-flood';
|
||||
const BASE = 29680, SUFFIX = 968;
|
||||
const RECLEN = 2 + 32 + 8 + 4 + 48 + 20 + 32 + 32 + 96; // 274
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const started = [];
|
||||
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
|
||||
class Node {
|
||||
constructor(i, connect = []) { this.i = i; this.grpc = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.evm = BASE + i * 10 + 3; this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpc}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--evm-rpclisten=127.0.0.1:${this.evm}`,
|
||||
`--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${OVERRIDE}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect.length) a.push(...this.connect.map(c => `--connect=${c}`)); 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(900); this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`);
|
||||
log(`n${this.i} up pid ${this.proc.pid} evm ${this.evm}`); return this;
|
||||
}
|
||||
async eth(method, params = []) {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
||||
const r = await fetch(`http://127.0.0.1:${this.evm}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
||||
const j = await r.json(); if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`); return j.result;
|
||||
}
|
||||
}
|
||||
function miner(node, label) {
|
||||
const a = ['vmine', `grpc://127.0.0.1:${node.grpc}`, '600', '--label', label, '--share', String(1 / 3), '--bps', '1'];
|
||||
const out = openSync(`${TMP}/miner-${label}.log`, 'a'); const p = spawn(MINER, a, { stdio: ['ignore', out, out] }); started.push(p); return p;
|
||||
}
|
||||
function cpuOf(pid) { try { return parseFloat(spawnSync('ps', ['-o', '%cpu=,time=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim().split(/\s+/)[0]) || 0; } catch { return 0; } }
|
||||
function cpuSecs(pid) { try { const t = spawnSync('ps', ['-o', 'time=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim(); const m = t.match(/(?:(\d+)-)?(\d+):(\d+):(\d+)|(\d+):(\d+)\.(\d+)/); if (!m) return 0; if (m[2] != null) return (+(m[1]||0))*86400 + (+m[2])*3600 + (+m[3])*60 + (+m[4]); return (+m[5])*60 + (+m[6]) + (+('0.'+m[7])); } catch { return 0; } }
|
||||
|
||||
// Build a well-formed-length record with controllable version and a matching/mismatching proof_hash.
|
||||
function craftRecord({ version = 1, proofMatches = false } = {}) {
|
||||
const proof = randomBytes(256);
|
||||
const rec = Buffer.alloc(RECLEN);
|
||||
let o = 0;
|
||||
rec.writeUInt16LE(version & 0xffff, o); o += 2; // version
|
||||
randomBytes(32).copy(rec, o); o += 32; // block
|
||||
rec.writeBigUInt64LE(BigInt(1 + Math.floor(Math.random() * 1000)), o); o += 8; // number
|
||||
rec.writeUInt32LE(0, o); o += 4; // shard
|
||||
randomBytes(48).copy(rec, o); o += 48; // pubkey
|
||||
randomBytes(20).copy(rec, o); o += 20; // payout
|
||||
randomBytes(32).copy(rec, o); o += 32; // statement
|
||||
const ph = proofMatches ? createHash('sha256').update(proof).digest() : randomBytes(32);
|
||||
ph.copy(rec, o); o += 32; // proof_hash
|
||||
randomBytes(96).copy(rec, o); o += 96; // signature
|
||||
return { record: '0x' + rec.toString('hex'), proof: '0x' + proof.toString('hex') };
|
||||
}
|
||||
|
||||
const out = { cases: [] };
|
||||
try {
|
||||
const n0 = await new Node(0).start();
|
||||
['v0', 'v1', 'v2'].forEach(l => miner(n0, l));
|
||||
const status0 = await n0.eth('igneum_getProvingStatus');
|
||||
log(`proving status: activationDaa=${parseInt(status0.activationDaa,16)} verifier=${status0.verifier}`);
|
||||
// wait for activation + a few assigned shards
|
||||
let daa = 0, waited = 0;
|
||||
while (daa < 55 && waited < 180) { await sleep(2000); waited += 2; const s = await n0.eth('igneum_getProvingStatus').catch(() => null); if (s) daa = parseInt(s.tipDaa, 16); if (waited % 10 === 0) log(`daa ${daa}`); }
|
||||
log(`reached daa ${daa}`);
|
||||
|
||||
async function floodCase(name, opts, n) {
|
||||
const c0 = cpuSecs(n0.proc.pid); const t0 = Date.now();
|
||||
let accepted = 0, rejected = 0; const reasons = {};
|
||||
for (let k = 0; k < n; k++) {
|
||||
const { record, proof } = craftRecord(opts);
|
||||
try { const r = await n0.eth('igneum_submitProofRecord', [{ record, proof }]); if (r.accepted) accepted++; else { rejected++; reasons[r.reason] = (reasons[r.reason] || 0) + 1; } }
|
||||
catch (e) { rejected++; const m = String(e.message).slice(0, 60); reasons[m] = (reasons[m] || 0) + 1; }
|
||||
}
|
||||
const wall = (Date.now() - t0) / 1000; const c1 = cpuSecs(n0.proc.pid);
|
||||
const row = { case: name, submitted: n, accepted, rejected, wallSecs: +wall.toFixed(2), rate: +(n / wall).toFixed(1), nodeCpuSecs: +(c1 - c0).toFixed(2), cpuMsPerRecord: +(((c1 - c0) * 1000) / n).toFixed(3), reasons };
|
||||
out.cases.push(row); log(`CASE ${name}: ${JSON.stringify(row)}`);
|
||||
}
|
||||
await floodCase('proof_hash-mismatch (cheapest)', { proofMatches: false, version: 1 }, 2000);
|
||||
await floodCase('bad-version (passes proof_hash)', { proofMatches: true, version: 0xbbbb }, 2000);
|
||||
await floodCase('v1-garbage (reaches record lookup)', { proofMatches: true, version: 1 }, 2000);
|
||||
|
||||
const up = await n0.eth('eth_blockNumber').catch(() => null);
|
||||
const status1 = await n0.eth('igneum_getProvingStatus').catch(() => null);
|
||||
out.nodeUpAfter = up != null;
|
||||
out.poolAfter = status1 && status1.pool;
|
||||
log(`node up after flood: ${out.nodeUpAfter}; pool ${JSON.stringify(out.poolAfter)}`);
|
||||
out.ok = out.nodeUpAfter && out.cases.every(c => c.accepted === 0);
|
||||
} catch (e) { out.error = e.message; log(`FAILED: ${e.message}`); }
|
||||
finally {
|
||||
writeFileSync(`${TMP}/flood.json`, JSON.stringify(out, null, 2));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
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.exit(out.ok ? 0 : 1);
|
||||
}
|
||||
1
tools/finality-attacks/redteam/override-60x-v3.json
Normal file
1
tools/finality-attacks/redteam/override-60x-v3.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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, "finality_v3_activation_daa": 0}
|
||||
251
tools/finality-attacks/redteam/rtfin.mjs
Normal file
251
tools/finality-attacks/redteam/rtfin.mjs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// Red-team custom finality scenarios against the finality-fixes build (v3 active), fast-time 60x.
|
||||
// node rtfin.mjs withhold34 | part5050 | f23 | f24 (run with IGNEUM_FAST_TIME=1)
|
||||
// Reuses the patched lib/net.mjs (node = vendor/igneum-node-redteam build, miner = fin-attacks, override = v3).
|
||||
import { Node, Miner, Proxy, stopAll, sleep, log, TMP, IGNEUMD, MINER } from '../lib/net.mjs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
|
||||
const maxLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').reduce((m, c) => Math.max(m, c.index), 0);
|
||||
const numLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').length;
|
||||
const hashAt = (cp, idx) => (cp.checkpoints.find(c => c.index === idx && c.state === 'locked') || {}).hash;
|
||||
const out = [];
|
||||
function record(o) { out.push(o); log(`RESULT ${o.scenario}: ${o.pass ? 'PASS' : 'FAIL'} :: ${o.observed}`); }
|
||||
|
||||
// 34% of weight silent (never signs); remaining 66% < 2/3 of total, so finality must PAUSE, not fork.
|
||||
async function withhold34() {
|
||||
const secs = 300;
|
||||
const n0 = await new Node(0).start();
|
||||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||||
const miners = [];
|
||||
// one silent producer at 34% share, five voters sharing 66%
|
||||
miners.push(new Miner(n0, { label: 'silent', share: 0.34, bps: 6, secs, vote: false }).start());
|
||||
for (const [nd, l, sh] of [[n0, 'v0', 0.132], [n0, 'v1', 0.132], [n1, 'v2', 0.132], [n1, 'v3', 0.132], [n1, 'v4', 0.132]])
|
||||
miners.push(new Miner(nd, { label: l, share: sh, bps: 6, secs }).start());
|
||||
await sleep(secs * 1000 + 3000);
|
||||
const cps = await Promise.all([n0, n1].map(n => n.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null)));
|
||||
const w = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({}));
|
||||
const locked = cps.map(numLocked);
|
||||
const maxIdx = cps.map(maxLocked);
|
||||
const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
// agreement on every commonly-locked index (no fork)
|
||||
const common = Math.min(...maxIdx);
|
||||
let agree = true;
|
||||
for (let i = 1; i <= common; i++) { const h = cps.map(c => hashAt(c, i)).filter(Boolean); if (new Set(h).size > 1) agree = false; }
|
||||
// finality should report paused / window not fully signed; locks should be few or none while 34% is silent
|
||||
const paused = cps.some(c => c && c.finalityActive === false) || locked.every(l => l === 0);
|
||||
const pass = conflicts.every(c => c === 0) && agree;
|
||||
for (const m of miners) await m.stop();
|
||||
record({ scenario: 'withhold34 (34% silent, pause not fork)',
|
||||
expected: 'finality pauses (signing weight 66% < 2/3 of total); 0 conflicting certs; no fork',
|
||||
observed: `locked per node ${locked.join('/')}, maxIdx ${maxIdx.join('/')}, conflicts ${conflicts.join('/')}, cross-node agree=${agree}, finalityActive ${cps.map(c=>c&&c.finalityActive).join('/')}, totalWeight ${w.totalWeight} activeWeight ${w.activeWeight}, pausedObserved=${paused}`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// 50/50 (3/3) partition kept longer than the old W/(3R) bound but within one weight window; F21 must hold 0 conflicting locks.
|
||||
async function part5050() {
|
||||
// fast-time: window 120 DAA, ~1 blk/s/side after split -> old bound W/(3R)=~80s. Keep the split ~105s (> old bound, < one window), then heal.
|
||||
const warm = 200, split = 105, healWin = 160;
|
||||
const secs = warm + split + healWin + 90;
|
||||
const n0 = await new Node(0).start();
|
||||
const proxy = await new Proxy(0, n0.p2pPort).start();
|
||||
const n1 = await new Node(1, { connect: [proxy.addr] }).start();
|
||||
const miners = [];
|
||||
for (const l of ['a0', 'a1', 'a2']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
for (const l of ['b0', 'b1', 'b2']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(warm * 1000);
|
||||
const w0 = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({}));
|
||||
const before0 = maxLocked(await n0.rpc.call('getFinalityCheckpoints', { last: 400 }));
|
||||
const before1 = maxLocked(await n1.rpc.call('getFinalityCheckpoints', { last: 400 }));
|
||||
log(`part5050 cut at warm ${warm}s: window daa ~${w0.daaScore}, voters ${w0.voters}, maxLocked ${before0}/${before1}`);
|
||||
proxy.cut();
|
||||
const t0 = Date.now(); let maxNew0 = before0, maxNew1 = before1, breach0 = null, breach1 = null;
|
||||
while (Date.now() - t0 < split * 1000) {
|
||||
const c0 = maxLocked(await n0.rpc.call('getFinalityCheckpoints', { last: 600 }).catch(() => null));
|
||||
const c1 = maxLocked(await n1.rpc.call('getFinalityCheckpoints', { last: 600 }).catch(() => null));
|
||||
if (c0 > maxNew0) maxNew0 = c0; if (c1 > maxNew1) maxNew1 = c1;
|
||||
if (breach0 == null && c0 > before0) breach0 = Math.round((Date.now() - t0) / 1000);
|
||||
if (breach1 == null && c1 > before1) breach1 = Math.round((Date.now() - t0) / 1000);
|
||||
await sleep(3000);
|
||||
}
|
||||
const newLocks = (maxNew0 - before0) + (maxNew1 - before1);
|
||||
proxy.heal();
|
||||
await sleep(healWin * 1000);
|
||||
const cpsA = await n0.rpc.call('getFinalityCheckpoints', { last: 900 });
|
||||
const cpsB = await n1.rpc.call('getFinalityCheckpoints', { last: 900 });
|
||||
const after0 = maxLocked(cpsA), after1 = maxLocked(cpsB);
|
||||
// disagreeing locked indices across nodes after heal (a finality fork)
|
||||
const common = Math.min(after0, after1);
|
||||
let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cpsA, i), b = hashAt(cpsB, i); if (a && b && a !== b) disagree++; }
|
||||
const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
for (const m of miners) await m.stop();
|
||||
const pass = newLocks === 0 && disagree === 0 && conflicts.every(c => c === 0) && after0 > maxNew0 && after1 > maxNew1;
|
||||
record({ scenario: 'part5050 (50/50 split > old bound, within one window)',
|
||||
expected: 'F21 frozen table: 0 new locks either side during the split, 0 disagreeing locked indices, 0 conflicting certs, locks resume after heal',
|
||||
observed: `split ${split}s (> old W/3R ~80s, window 120 DAA); new locks ${newLocks} (first new side0=${breach0??'none'}s side1=${breach1??'none'}s); disagreeing locked indices after heal ${disagree}; conflicting certs ${conflicts.join('/')}; resumed ${after0>maxNew0&&after1>maxNew1}`,
|
||||
pass });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// F23: a short equivocation burst, then the ban expires. Two honest nodes stamp the ban at different DAA, so their
|
||||
// voter lists differ by one key around the expiry and each refuses the other's certificate (voter_count mismatch).
|
||||
async function f23() {
|
||||
const secs = 360; // > ~3 windows so the ban (120 DAA) expires well inside the run
|
||||
const eqSecs = 40; // the equivocator stops early, so the ban has a definite expiry
|
||||
const n0 = await new Node(0).start();
|
||||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||||
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
|
||||
const miners = [];
|
||||
// one equivocator on n0 for a short burst, five honest voters for the whole run
|
||||
miners.push(new Miner(n0, { label: 'eq', share: 1 / 6, bps: 6, secs: eqSecs, equivocate: true }).start());
|
||||
for (const [nd, l] of [[n0, 'h0'], [n1, 'h1'], [n1, 'h2'], [n2, 'h3'], [n2, 'h4']])
|
||||
miners.push(new Miner(nd, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(secs * 1000 + 3000);
|
||||
const nodes = [n0, n1, n2];
|
||||
const ws = await Promise.all(nodes.map(n => n.rpc.call('getFinalityWeights', {}).catch(() => null)));
|
||||
const strippedUntil = ws.map(w => (w?.keys || []).filter(k => k.strippedUntilDaa > 0).map(k => k.strippedUntilDaa));
|
||||
// the F23 signature: per-node divergence in the ban expiry, and voter-count-mismatch refusals / CONFLICTING after the expiry
|
||||
const voterCountRefusals = nodes.map(n => n.grepLog(/names \d+ voters, this node counts \d+/).length);
|
||||
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length);
|
||||
const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 600 }).catch(() => null)));
|
||||
const maxIdx = cps.map(maxLocked);
|
||||
const common = Math.min(...maxIdx.filter(x => x > 0));
|
||||
let disagree = 0; for (let i = 1; i <= common; i++) { const h = cps.map(c => hashAt(c, i)).filter(Boolean); if (new Set(h).size > 1) disagree++; }
|
||||
// distinct ban-expiry values across nodes = node-local stamping divergence
|
||||
const flatUntil = strippedUntil.flat();
|
||||
const distinctUntil = new Set(flatUntil).size;
|
||||
for (const m of miners) await m.stop();
|
||||
const broke = voterCountRefusals.some(c => c > 0) || conflicts.some(c => c > 0) || disagree > 0;
|
||||
record({ scenario: 'F23 (equivocation ban node-local; honest nodes refuse each other\'s certs)',
|
||||
expected: 'ban expiry identical across honest nodes; 0 voter-count refusals; 0 CONFLICTING; 0 disagreeing locked indices',
|
||||
observed: `equiv detections ${equivDetections.join('/')}; stripped-until per node ${strippedUntil.map(a=>a.join(',')||'-').join(' | ')} (distinct values ${distinctUntil}); voter-count-mismatch refusals ${voterCountRefusals.join('/')}; CONFLICTING ${conflicts.join('/')}; disagreeing locked indices ${disagree}; maxLocked ${maxIdx.join('/')}`,
|
||||
pass: !broke });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
// F24: a deep reorg (> checkpoint_depth) moves a determined checkpoint's block off a node's chain. The node never
|
||||
// re-determines the index, so certificates for the new chain's determination hit cp.hash != cert.checkpoint and are
|
||||
// pushed to conflicting_certificates (false CONFLICTING) with no equivocation anywhere.
|
||||
async function f24() {
|
||||
// minority node determines checkpoints on its own chain during a split, then the majority chain reorgs it deep on heal.
|
||||
const warm = 160, split = 150, healWin = 200;
|
||||
const secs = warm + split + healWin + 90;
|
||||
const n0 = await new Node(0).start(); // majority (4 keys)
|
||||
const proxy = await new Proxy(0, n0.p2pPort).start();
|
||||
const n1 = await new Node(1, { connect: [proxy.addr] }).start(); // minority (2 keys)
|
||||
const miners = [];
|
||||
for (const l of ['p0', 'p1', 'p2', 'p3']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
for (const l of ['q0', 'q1']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(warm * 1000);
|
||||
const before1Det = (await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}))).nextIndex;
|
||||
proxy.cut();
|
||||
await sleep(split * 1000); // both sides advance and determine checkpoints independently
|
||||
const splitDet1 = (await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}))).nextIndex;
|
||||
proxy.heal(); // the heavier majority chain wins; n1 reorgs deep past its own determinations
|
||||
await sleep(healWin * 1000);
|
||||
const nodes = [n0, n1];
|
||||
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const certMismatch = nodes.map(n => n.grepLog(/certificate at index \d+ is for .*, this node's checkpoint is/).length);
|
||||
const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length);
|
||||
const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 900 }).catch(() => null)));
|
||||
const maxIdx = cps.map(maxLocked);
|
||||
const common = Math.min(...maxIdx.filter(x => x > 0));
|
||||
let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cps[0], i), b = hashAt(cps[1], i); if (a && b && a !== b) disagree++; }
|
||||
for (const m of miners) await m.stop();
|
||||
const broke = conflicts.some(c => c > 0) || certMismatch.some(c => c > 0) || disagree > 0;
|
||||
record({ scenario: 'F24 (checkpoint determination never revisited after deep reorg; false CONFLICTING)',
|
||||
expected: 'after the deep reorg the node re-determines the moved index; 0 false CONFLICTING without equivocation; 0 disagreeing locked indices',
|
||||
observed: `n1 nextIndex warm=${before1Det} split=${splitDet1}; CONFLICTING ${conflicts.join('/')}; cert-checkpoint-mismatch ${certMismatch.join('/')}; equivocation detections ${equivDetections.join('/')}; disagreeing locked indices ${disagree}; maxLocked ${maxIdx.join('/')}`,
|
||||
pass: !broke });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
|
||||
// F24b: the same cut held UNDER merge depth (60 DAA at 60x), long enough for the minority to determine one or two
|
||||
// checkpoints on its own chain (checkpoint_depth 20 blue), so the heal is a real reorg, not a permanent split.
|
||||
async function f24b() {
|
||||
const warm = 160, split = 24, healWin = 150;
|
||||
const secs = warm + split + healWin + 60;
|
||||
const n0 = await new Node(0).start();
|
||||
const proxy = await new Proxy(0, n0.p2pPort).start();
|
||||
const n1 = await new Node(1, { connect: [proxy.addr] }).start();
|
||||
const miners = [];
|
||||
for (const l of ['p0', 'p1', 'p2', 'p3']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
for (const l of ['q0', 'q1']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(warm * 1000);
|
||||
const c0 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}));
|
||||
proxy.cut(); log(`f24b cut: n1 nextIndex ${c0.nextIndex}`);
|
||||
await sleep(split * 1000);
|
||||
const c1 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}));
|
||||
const d1 = await n1.rpc.call('getBlockDagInfo').catch(() => ({}));
|
||||
proxy.heal(); log(`f24b heal: n1 nextIndex ${c1.nextIndex} daa ${d1.virtualDaaScore}`);
|
||||
await sleep(healWin * 1000);
|
||||
const nodes = [n0, n1];
|
||||
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const certMismatch = nodes.map(n => n.grepLog(/this node's checkpoint is/).length);
|
||||
const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length);
|
||||
const powRejected = nodes.map(n => n.grepLog(/PoW rejected/).length);
|
||||
const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 900 }).catch(() => null)));
|
||||
const sinks = await Promise.all(nodes.map(n => n.rpc.call('getBlockDagInfo').then(d => d.sink || (d.tipHashes||[])[0]).catch(() => null)));
|
||||
const maxIdx = cps.map(maxLocked);
|
||||
const common = Math.min(...maxIdx.filter(x => x > 0));
|
||||
let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cps[0], i), b = hashAt(cps[1], i); if (a && b && a !== b) disagree++; }
|
||||
// indices n1 determined during the split that it never locked while n0 did
|
||||
const stuck = (cps[1]?.checkpoints || []).filter(c => c.index >= (c0.nextIndex||0) && c.index < (c1.nextIndex||0) && c.state !== 'locked' && hashAt(cps[0], c.index)).map(c => c.index);
|
||||
for (const m of miners) await m.stop();
|
||||
const broke = certMismatch[1] > 0 || stuck.length > 0 || disagree > 0;
|
||||
record({ scenario: 'F24b (deep reorg under merge depth; determination never revisited)',
|
||||
expected: 'after a reorg deeper than checkpoint_depth the losing node re-determines the moved indices and accepts the network certificates; 0 false CONFLICTING, 0 stuck indices, 0 disagreeing locks',
|
||||
observed: `n1 determined ${c0.nextIndex}..${(c1.nextIndex||1)-1} during the ${split}s cut; after heal: cert-for-other-block refusals ${certMismatch.join('/')}, CONFLICTING ${conflicts.join('/')}, equivocation ${equivDetections.join('/')}, PoW-rejected ${powRejected.join('/')}, sinks equal ${sinks[0] && sinks[0] === sinks[1]}, disagreeing locked indices ${disagree}, n1 indices stuck unlocked that n0 locked [${stuck.join(',')}], maxLocked ${maxIdx.join('/')}`,
|
||||
pass: !broke });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
async function f24c() {
|
||||
const warm = 160, split = 16, healWin = 150;
|
||||
const secs = warm + split + healWin + 60;
|
||||
const n0 = await new Node(0).start();
|
||||
const proxy = await new Proxy(0, n0.p2pPort).start();
|
||||
const n1 = await new Node(1, { connect: [proxy.addr] }).start();
|
||||
const miners = [];
|
||||
for (const l of ['p0', 'p1', 'p2']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
for (const l of ['q0', 'q1', 'q2']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||||
await sleep(warm * 1000);
|
||||
const c0 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}));
|
||||
proxy.cut(); log(`f24c cut: n1 nextIndex ${c0.nextIndex}`);
|
||||
await sleep(split * 1000);
|
||||
const c1 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}));
|
||||
const d1 = await n1.rpc.call('getBlockDagInfo').catch(() => ({}));
|
||||
proxy.heal(); log(`f24c heal: n1 nextIndex ${c1.nextIndex} daa ${d1.virtualDaaScore}`);
|
||||
await sleep(healWin * 1000);
|
||||
const nodes = [n0, n1];
|
||||
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||||
const certMismatch = nodes.map(n => n.grepLog(/this node's checkpoint is/).length);
|
||||
const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length);
|
||||
const powRejected = nodes.map(n => n.grepLog(/PoW rejected/).length);
|
||||
const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 900 }).catch(() => null)));
|
||||
const sinks = await Promise.all(nodes.map(n => n.rpc.call('getBlockDagInfo').then(d => d.sink || (d.tipHashes||[])[0]).catch(() => null)));
|
||||
const maxIdx = cps.map(maxLocked);
|
||||
const common = Math.min(...maxIdx.filter(x => x > 0));
|
||||
let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cps[0], i), b = hashAt(cps[1], i); if (a && b && a !== b) disagree++; }
|
||||
// indices n1 determined during the split that it never locked while n0 did
|
||||
const loser = (cps[0] && cps[1] && maxIdx[0] >= maxIdx[1]) ? 1 : 0; const stuck = (cps[loser]?.checkpoints || []).filter(c => c.index >= (c0.nextIndex||0) && c.index < (c1.nextIndex||0) && c.state !== 'locked' && hashAt(cps[1 - loser], c.index)).map(c => c.index);
|
||||
for (const m of miners) await m.stop();
|
||||
const broke = certMismatch.some(x => x > 0) || stuck.length > 0 || disagree > 0;
|
||||
record({ scenario: 'F24c (3/3 split, 16 s cut under merge depth; determination never revisited)',
|
||||
expected: 'after a reorg deeper than checkpoint_depth the losing node re-determines the moved indices and accepts the network certificates; 0 false CONFLICTING, 0 stuck indices, 0 disagreeing locks',
|
||||
observed: `n1 determined ${c0.nextIndex}..${(c1.nextIndex||1)-1} during the ${split}s cut; after heal: cert-for-other-block refusals ${certMismatch.join('/')}, CONFLICTING ${conflicts.join('/')}, equivocation ${equivDetections.join('/')}, PoW-rejected ${powRejected.join('/')}, sinks equal ${sinks[0] && sinks[0] === sinks[1]}, disagreeing locked indices ${disagree}, n1 indices stuck unlocked that n0 locked [${stuck.join(',')}], maxLocked ${maxIdx.join('/')}`,
|
||||
pass: !broke });
|
||||
await stopAll();
|
||||
}
|
||||
|
||||
const which = process.argv[2];
|
||||
const map = { withhold34, part5050, f23, f24, f24b, f24c };
|
||||
if (!map[which]) { console.error('usage: node rtfin.mjs withhold34|part5050|f23|f24'); process.exit(2); }
|
||||
log(`=== ${which} starting (node ${IGNEUMD}, miner ${MINER}) ===`);
|
||||
map[which]().then(() => {
|
||||
writeFileSync(`${TMP}/rt-${which}.json`, JSON.stringify(out, null, 2));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
process.exit(out.every(r => r.pass) ? 0 : 1);
|
||||
}).catch(async (e) => { log(`${which} threw: ${e.stack || e}`); await stopAll(); process.exit(3); });
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"max_coinbase_payload_len": 16384, "skip_proof_of_work": true}
|
||||
Loading…
Reference in a new issue