diff --git a/infra/fast-time/README.md b/infra/fast-time/README.md index c02d3c0ca..fb89f3173 100644 --- a/infra/fast-time/README.md +++ b/infra/fast-time/README.md @@ -24,6 +24,12 @@ simulator takes the same file: `igneum-harness-sim --override-params-file infra/ 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). +Class switch gate (Counter ASIC 2.0 rollout G4): `node infra/fast-time/class-v3.mjs` (3 nodes on ports 29600 and up, +`igneum-devnet-960`, data `/tmp/igneum-fast-time-v3`, CPU difficulty `genesis_bits` 0x1f010000, real CPU mining on +every node, `program_class_v3_activation_daa` a few epochs ahead; reports blocks on both sides of the boundary, the +program id and class before and after, rejected blocks, forks, and every node's switch line; see +`docs/plans/counter-asic-2-node.md`). + 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`, @@ -51,6 +57,7 @@ Time parameters, divided by 60 (devnet value, 60x value): | `difficulty_v2_activation_daa` | never (`18446744073709551615`) | never | a height, not a clock: difficulty rule v2 (4 Oct 2026, `docs/analysis/difficulty-2026-10-04-oscillation.md`) switches on at this DAA score; a test network sets it in its merged file (`sim/difficulty/testnet_v2.py` uses 900) | | `proving_v0_activation_daa` | never | never | a height: proving v0 payouts (spec 7.7) switch on at this DAA score; `tools/proving-v0/run.mjs` sets 60 in its merged file | | `finality_v3_activation_daa` | never | never | a height, not a clock: finality rule v3 (the frozen weight table of ledger F21 and the certificate fold of F22, 4 Oct 2026 evening) applies to checkpoints at or above this DAA score; `tools/finality-attacks/v3.mjs` sets 0 in its merged file | +| `program_class_v3_activation_daa` | never | never | a height, not a clock: the lottery hash draws programs from class v3 (Counter ASIC 2.0, 5 Oct 2026) from the first EPOCH whose start is at or above this DAA score (rounded up to an epoch boundary: at 60 DAA per epoch, 150 means epoch 3 at DAA 180); `infra/fast-time/class-v3.mjs` sets it a few epochs ahead in its merged file | Unchanged, and why: diff --git a/infra/fast-time/class-v3.mjs b/infra/fast-time/class-v3.mjs new file mode 100644 index 000000000..58e0e03c9 --- /dev/null +++ b/infra/fast-time/class-v3.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +// Counter ASIC 2.0 rollout gate G4 (docs/plans/counter-asic-2-rollout.md section 7): the fast-time 3-node network +// mining across a program class v3 activation. A private network on 127.0.0.1 ports 29600 and up, data under +// /tmp/igneum-fast-time-v3, network id igneum-devnet-960, every node on infra/fast-time/override-60x.json merged with +// a CPU genesis difficulty (genesis_bits 0x1f010000, 2^16 hashes per block, as sim/difficulty/testnet_v2.py) and +// `program_class_v3_activation_daa` a few epochs ahead (default 150: inside epoch 2 at 60 DAA per epoch, so the +// switch rounds UP to epoch 3 at DAA 180, which is the boundary rule under test). One real CPU miner per node +// (igneum-miner --engine igneum-pow, 1 thread) follows its node's templates, so every block of the run is a real +// lottery-hash solution and every node verifies every block of the other two under the class of its epoch. +// +// Reports, from the nodes' RPC and the logs: blocks on each side of the boundary, the program class and id of every +// epoch (before and after), rejected blocks (the miners' submit answers and the nodes' "PoW rejected" lines), forks +// (every node's sink, selected tip and block count at the end), and every node's switch line. Exit 0 when every +// check passes. Never touches the live devnet (26610/26611, 26640/26641, 28640) or the simnet.mjs ports. +// +// node infra/fast-time/class-v3.mjs [--secs 420] [--activation 150] [--epochs-after 2] [--threads 1] +// IGNEUMD and IGNEUM_MINER name the binaries (default: the ca2 fork worktree's target-ca2/release). + +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 BIN = process.env.IGNEUM_CA2_BIN || `${ROOT}vendor/igneum-node-ca2/target-ca2/release`; +const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`; +const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`; +const TMP = '/tmp/igneum-fast-time-v3'; +const BASE = 29600, SUFFIX = 960; +const GENESIS_BITS = 0x1f010000; +const args = process.argv.slice(2); +const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; }; +const SECS = flag('secs', 420); +const ACTIVATION = flag('activation', 150); +const EPOCHS_AFTER = flag('epochs-after', 2); +const THREADS = flag('threads', 1); +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, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); } + +rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); +const base = JSON.parse(readFileSync(FILE, 'utf8')); +const EPOCH = base.pow_epoch_blocks; +const FIRST_V3_EPOCH = Math.ceil(ACTIVATION / EPOCH); +const BOUNDARY = FIRST_V3_EPOCH * EPOCH; +const override = `${TMP}/override.json`; +writeFileSync(override, JSON.stringify({ ...base, genesis_bits: GENESIS_BITS, skip_proof_of_work: false, program_class_v3_activation_daa: ACTIVATION })); +log(`activation ${ACTIVATION} at ${EPOCH} DAA per epoch: the first v3 epoch is ${FIRST_V3_EPOCH} (DAA ${BOUNDARY}); run ${SECS} s, CPU genesis bits 0x${GENESIS_BITS.toString(16)}`); + +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(1200); + 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 minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } }; + +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}`, `127.0.0.1:${n1.p2pPort}`]).start(); +const nodes = [n0, n1, n2]; +for (const n of nodes) { + const line = n.grepLog(/Program class v3 from the override file/)[0]; + log(`n${n.i} switch line: ${line ? line.replace(/^.*?(Program class v3)/, '$1') : '(none)'}`); +} +log(`n0 PoW schedule: ${n0.grepLog(/PoW schedule from the override file/).map(l => l.replace(/^.*?(PoW schedule)/, '$1')).join(' | ') || '(no line)'}`); +log(`n0 digest: ${n0.grepLog(/Consensus params digest/).map(l => l.replace(/^.*?digest: /, '').slice(0, 16)).join(' ')}`); + +// one real CPU miner per node, 1 thread, 2^16 expected hashes per block at genesis +nodes.forEach((n, i) => miner(CPU_MINER, ['mine', n.grpc, String(THREADS), String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', `cpu${i}`, '--status-secs', '30', '--no-vote'], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(base.pow_day_ms) })); +const pay = devAddress('fast-time-v3'); + +const epochs = new Map(); // epoch index -> { class, firstSeenDaa, at } +let firstV3 = null, lastEpoch = -1, lastReport = 0, lastDaa = 0, switchEnd = null; +const samples = []; +while (Date.now() - t0 < SECS * 1000) { + await sleep(1000); + let daa = null, epoch = null, cls = null, nextCls = null, era = null, eraSeed = null, act = 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; cls = pe.programClass; nextCls = pe.nextProgramClass; + era = pe.eraIndex; eraSeed = pe.eraSeed; act = pe.programClassV3ActivationDaa; + } catch (e) { log(`template: ${e.message}`); } + if (epoch != null && epoch !== lastEpoch) { + epochs.set(epoch, { class: cls, firstSeenDaa: daa, at: +since() }); + log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls} (generator), next epoch class ${nextCls}, era ${era} seed ${String(eraSeed).slice(0, 16)}, activation ${act}`); + if (firstV3 == null && cls === 3) { firstV3 = { epoch, daa, at: +since() }; log(`CLASS SWITCH: the template is class v3 from epoch ${epoch} (daa ${daa}) at ${since()} s wall`); } + lastEpoch = epoch; + } + lastDaa = daa ?? lastDaa; + if (Date.now() - lastReport > 15000) { + lastReport = Date.now(); + const counts = await Promise.all(nodes.map(async n => { try { const d = await n.rpc.call('getBlockDagInfo'); return `${d.blockCount}/${String(d.sink).slice(0, 8)}`; } catch { return '?'; } })); + log(`t=${since()} s daa ${daa} epoch ${epoch} class ${cls} blocks/sink per node ${counts.join(' ')}`); + samples.push({ t: +since(), daa, epoch, class: cls, nodes: counts }); + } + if (firstV3 != null && daa != null && daa >= BOUNDARY + EPOCHS_AFTER * EPOCH) { switchEnd = +since(); break; } +} +await sleep(3000); // let the last blocks relay before the end-of-run reads + +// ---- the end-of-run reads ----------------------------------------------------------------------------------------- +const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return { error: e.message }; } })); +const genesis = dag[0].pruningPointHash; +// every block the first node holds, by DAA score, from genesis +async function allBlocks(n) { + const out = []; let low = genesis; const seen = new Set(); + for (let round = 0; round < 500; round++) { + const r = await n.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false }); + const blocks = r.blocks || []; + let added = 0; + for (const b of blocks) { const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); out.push({ hash: h, daa: +b.header.daaScore, chain: !!b.verboseData?.isChainBlock, blue: +(b.verboseData?.blueScore ?? 0) }); added++; } + if (!blocks.length || added === 0) break; + low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break; + } + return out; +} +let blocks = []; +try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); } +const before = blocks.filter(b => b.daa < BOUNDARY), after = blocks.filter(b => b.daa >= BOUNDARY); +const chainBefore = before.filter(b => b.chain).length, chainAfter = after.filter(b => b.chain).length; + +// program ids and classes per epoch from the miners' "program and 256 MiB cache ready" lines +const programs = new Map(); // epoch seed -> { class, id, miners: Set } +for (const i of [0, 1, 2]) for (const l of minerLog(i)) { + const m = /epoch seed ([0-9a-f]{64}) day (\d+) \(daa (\d+)\): program and 256 MiB cache ready in [\d.]+ ms; class (v\d) program id ([0-9a-f]{16})/.exec(l); + if (!m) continue; + const k = m[1]; const e = programs.get(k) || { seed: k.slice(0, 16), epoch: Math.floor(+m[3] / EPOCH), class: m[4], id: m[5], miners: new Set() }; + if (e.id !== m[5] || e.class !== m[4]) e.disagree = true; + e.miners.add(i); programs.set(k, e); +} +const programRows = [...programs.values()].sort((a, b) => a.epoch - b.epoch).map(p => ({ epoch: p.epoch, class: p.class, program_id: p.id, seed: p.seed, miners: p.miners.size, disagree: !!p.disagree })); +const v2Ids = programRows.filter(p => p.class === 'v2').map(p => p.program_id), v3Ids = programRows.filter(p => p.class === 'v3').map(p => p.program_id); + +// rejections: the miners' submit answers and the nodes' PoW lines +const accepted = [0, 1, 2].map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length); +const rejectedMiner = [0, 1, 2].map(i => minerLog(i).filter(l => /rejected nonce=|submit error/.test(l))); +const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i)); +const switchLines = nodes.map(n => n.grepLog(/Program class v3 from the override file/).map(l => l.replace(/^.*?(Program class v3)/, '$1'))[0] || null); +const sinks = dag.map(d => String(d.sink || '?').slice(0, 16)); +const counts = dag.map(d => d.blockCount ?? '?'); +const tips = dag.map(d => (d.tipHashes || []).length); + +const checks = { + switch_line_on_every_node: switchLines.every(Boolean), + switch_line_names_the_rounded_epoch: switchLines.every(l => l && l.includes(`active from epoch ${FIRST_V3_EPOCH} `)), + template_switched_at_the_first_v3_epoch: firstV3 != null && firstV3.epoch === FIRST_V3_EPOCH, + blocks_before_the_boundary: before.length > 0, + blocks_after_the_boundary: after.length > 0, + v2_and_v3_programs_seen: v2Ids.length > 0 && v3Ids.length > 0, + program_ids_differ_across_the_switch: v2Ids.length > 0 && v3Ids.length > 0 && !v2Ids.some(id => v3Ids.includes(id)), + miners_agree_on_every_program: programRows.every(p => !p.disagree), + zero_rejected_by_miners: rejectedMiner.every(r => r.length === 0), + zero_rejected_by_nodes: rejectedNode.every(r => r.length === 0), + sinks_agree: new Set(sinks).size === 1, + block_counts_agree: new Set(counts.map(String)).size === 1, +}; +const pass = Object.values(checks).every(Boolean); +const summary = { + pass, checks, activation: ACTIVATION, epoch_blocks: EPOCH, first_v3_epoch: FIRST_V3_EPOCH, boundary_daa: BOUNDARY, secs: SECS, threads: THREADS, + node: IGNEUMD, miner: CPU_MINER, genesis_bits: `0x${GENESIS_BITS.toString(16)}`, + template_switch: firstV3, run_ended_at_s: switchEnd, final_daa: lastDaa, + blocks: { total: blocks.length, before_boundary: before.length, after_boundary: after.length, chain_before: chainBefore, chain_after: chainAfter }, + programs: programRows, accepted_per_miner: accepted, + rejected_by_miners: rejectedMiner.map(r => r.length), rejected_by_nodes: rejectedNode.map(r => r.length), + rejected_lines: [...rejectedMiner.flat(), ...rejectedNode.flat()].slice(0, 20), + sinks, block_counts: counts, tips_per_node: tips, switch_lines: switchLines, samples, +}; +writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2)); +log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: blocks ${before.length} before / ${after.length} after the boundary at DAA ${BOUNDARY} (chain ${chainBefore} / ${chainAfter}); programs ${programRows.map(p => `e${p.epoch}:${p.class}:${p.program_id}`).join(' ')}; rejected miners ${rejectedMiner.map(r => r.length).join('/')} nodes ${rejectedNode.map(r => r.length).join('/')}; sinks ${sinks.join(' ')} (${checks.sinks_agree ? 'agree' : 'DIFFER'}); block counts ${counts.join('/')}; switch lines ${switchLines.filter(Boolean).length}/3`); +for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`); +log(`summary: ${TMP}/summary.json`); +await stopAll(); +process.exit(pass ? 0 : 1); diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index d5db07547..7ec2e3b05 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -54,6 +54,7 @@ "difficulty_v2_activation_daa": 18446744073709551615, "proving_v0_activation_daa": 18446744073709551615, "finality_v3_activation_daa": 18446744073709551615, + "program_class_v3_activation_daa": 18446744073709551615, "fees_v1_activation_daa": 0, "fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8} } diff --git a/tools/finality-attacks/redteam/override-60x-v3.json b/tools/finality-attacks/redteam/override-60x-v3.json index d56775c68..af6a2ce37 100644 --- a/tools/finality-attacks/redteam/override-60x-v3.json +++ b/tools/finality-attacks/redteam/override-60x-v3.json @@ -1 +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} \ No newline at end of file +{"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, "program_class_v3_activation_daa": 18446744073709551615} \ No newline at end of file