Merge branch 'redteam' into fud-consensus
# Conflicts: # docs/fud-ledger.md
This commit is contained in:
commit
a32b10aad8
4 changed files with 139 additions and 0 deletions
|
|
@ -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}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"max_coinbase_payload_len": 16384, "skip_proof_of_work": true}
|
||||
Loading…
Reference in a new issue