#!/usr/bin/env python3 """3-node CPU test network for difficulty rule v2 and its height switch (4 October 2026). Ports 29600 and above, appdir /tmp/igneum-diffv2, binaries from vendor/igneum-node-v4/target/release (the devnet-v4 line with `difficulty_v2_activation_daa`). Never touches the live devnet (26610/26611, 26640/26641, 28640). Profile: infra/fast-time/override-60x.json plus genesis bits 0x1f010000 (2^16 hashes per block, the CPU difficulty), the devnet's program epoch (pow_epoch_blocks 3,600, lead 600: the 60x file's one-minute epoch would empty the reference window every minute and hide the pollution this test is about), and `difficulty_v2_activation_daa` at the given height. Node 1 (gRPC 29600, P2P 29601, wRPC JSON 29602) and node 2 (29610/29611) carry the activation; node 3 (29620/29621) is started WITHOUT it (rule v1 for ever) and follows only: it must stall at the activation height, which shows the switch is consensus-critical and takes effect at that block and no other. Schedule (minutes from the first miner): A (4 CPU threads) on node 1 all run; B (4 threads) on node 2 from `--join` (a 2x step inside epoch 0, the live devnet's case), off at `--leave`, back at `--rejoin`. The activation height is chosen so the first step runs under rule v1 and the leave and rejoin under v2. python3 testnet_v2.py --minutes 28 --join 4 --activation 900 --leave 19 --rejoin 23 --out /tmp/igneum-diffv2/run1 python3 testnet_v2.py --analyse /tmp/igneum-diffv2/run1 """ import argparse import csv import json import math import os import subprocess import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "attacks")) import testnet as TN # noqa: E402 (node/miner helpers, wRPC export) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) BIN = os.environ.get("IGNEUM_BIN", os.path.join(ROOT, "vendor", "igneum-node-v4", "target", "release")) FAST = os.path.join(ROOT, "infra", "fast-time", "override-60x.json") GENESIS_BITS = 0x1f010000 NODES = [ {"name": "node1", "grpc": 29600, "p2p": 29601, "json": 29602}, {"name": "node2", "grpc": 29610, "p2p": 29611, "json": 29612}, {"name": "node3", "grpc": 29620, "p2p": 29621, "json": 29622}, ] def write_override(out, name, activation): o = json.load(open(FAST)) o["genesis_bits"] = GENESIS_BITS o["pow_epoch_blocks"] = 3600 o["pow_epoch_lead"] = 600 if activation is not None: o["difficulty_v2_activation_daa"] = activation else: o.pop("difficulty_v2_activation_daa", None) path = os.path.join(out, name) with open(path, "w") as f: json.dump(o, f) return path def start_node(n, out, override): appdir = os.path.join(out, n["name"]) args = [os.path.join(BIN, "igneumd"), "--devnet", "--devnet-suffix=960", "--nodnsseed", "--disable-upnp", "--enable-unsynced-mining", "--nologfiles", "--override-params-file=" + override, "--appdir=" + appdir, "--rpclisten=127.0.0.1:%d" % n["grpc"], "--listen=127.0.0.1:%d" % n["p2p"], "--rpclisten-json=127.0.0.1:%d" % n["json"], "--loglevel=info"] if n["name"] != "node1": args.append("--connect=127.0.0.1:%d" % NODES[0]["p2p"]) else: args.append("--outpeers=0") lf = open(os.path.join(out, n["name"] + ".log"), "w") p = subprocess.Popen(["nice", "-n", "19"] + args, stdout=lf, stderr=subprocess.STDOUT) TN.started.append(p) TN.log(out, "%s pid %d gRPC %d override %s" % (n["name"], p.pid, n["grpc"], os.path.basename(override))) return p def start_miner(out, label, grpc, threads, secs): lf = open(os.path.join(out, "miner-%s.log" % label), "a") args = ["nice", "-n", "19", os.path.join(BIN, "igneum-miner"), "mine", "grpc://127.0.0.1:%d" % grpc, str(threads), str(secs), label, "--engine", "igneum-pow", "--payout-label", label, "--status-secs", "10"] env = dict(os.environ) env["IGNEUM_POW_DAY_MS"] = "1440000" p = subprocess.Popen(args, stdout=lf, stderr=subprocess.STDOUT, env=env) TN.started.append(p) TN.log(out, "miner %s pid %d on %d threads %d for %d s" % (label, p.pid, grpc, threads, secs)) return p def daa_of(port): try: return int(TN.dag_info(port)["virtualDaaScore"]) except Exception: return None def run(args): out = args.out os.makedirs(out, exist_ok=True) total = args.minutes * 60 TN.log(out, "start minutes=%d join=%d leave=%d rejoin=%d activation=%d genesis_bits=%#x bin=%s" % ( args.minutes, args.join, args.leave, args.rejoin, args.activation, GENESIS_BITS, BIN)) ov_v2 = write_override(out, "override-v2.json", args.activation) ov_v1 = write_override(out, "override-v1.json", None) try: start_node(NODES[0], out, ov_v2) TN.wait_grpc(out, NODES[0]) start_node(NODES[1], out, ov_v2) start_node(NODES[2], out, ov_v1) for n in NODES[1:]: TN.wait_grpc(out, n) time.sleep(3) t0 = time.time() TN.log(out, "miners from now") a = start_miner(out, "A", NODES[0]["grpc"], args.threads, total) b = None phase = "solo" last_daa = {} switched = None while time.time() - t0 < total: m = (time.time() - t0) / 60.0 if phase == "solo" and m >= args.join: b = start_miner(out, "B", NODES[1]["grpc"], args.threads, int((args.leave - args.join) * 60)) phase = "joined" TN.log(out, "B joins (2x step under rule v1)") elif phase == "joined" and m >= args.leave: TN.stop(b, out, "miner B (leave)") phase = "left" TN.log(out, "B leaves (step down under rule v2)") elif phase == "left" and m >= args.rejoin: b = start_miner(out, "B", NODES[1]["grpc"], args.threads, int(total - (time.time() - t0)) + 5) phase = "rejoined" TN.log(out, "B rejoins (step up under rule v2)") d = {n["name"]: daa_of(n["json"]) for n in NODES} if d != last_daa and int(m * 6) % 3 == 0: last_daa = d if switched is None and d.get("node1") is not None and d["node1"] >= args.activation: switched = time.time() - t0 TN.log(out, "node1 virtual DAA %d reached the activation %d at %.0f s; node3 (v1) at DAA %s" % (d["node1"], args.activation, switched, d.get("node3"))) if int(time.time() - t0) % 30 == 0: try: info = TN.dag_info(NODES[0]["json"]) TN.log(out, "t=%.0f s daa node1/2/3 %s/%s/%s difficulty %.0f blocks %s" % ( time.time() - t0, d.get("node1"), d.get("node2"), d.get("node3"), float(info["difficulty"]), info["blockCount"])) except Exception as e: TN.log(out, "dag info failed: %s" % e) time.sleep(1) if b is not None: TN.stop(b, out, "miner B") TN.stop(a, out, "miner A") time.sleep(2) for n in NODES: try: rows, info = TN.export(n["json"], os.path.join(out, "record-%s.csv" % n["name"])) TN.log(out, "%s: %s, %d rows" % (n["name"], json.dumps({k: info.get(k) for k in ("blockCount", "difficulty", "virtualDaaScore", "sink")}), len(rows))) except Exception as e: TN.log(out, "%s export failed: %s" % (n["name"], e)) finally: for p in reversed(TN.started): TN.stop(p, out, "pid") TN.log(out, "end") def analyse(out): sched = open(os.path.join(out, "schedule.log")).read().splitlines() rows = list(csv.DictReader(open(os.path.join(out, "record-node1.csv")))) rows = [r for r in rows if int(r["blue_score"]) > 0] chain = sorted([r for r in rows if r["is_chain_block"] == "1"], key=lambda r: int(r["blue_score"])) t0 = int(chain[0]["timestamp_ms"]) / 1000.0 marks = {} for line in sched: for key, pat in (("join", "B joins"), ("leave", "B leaves"), ("rejoin", "B rejoins"), ("switch", "reached the activation")): if pat in line: hms = line.split()[0] marks[key] = hms print("blocks %d, chain blocks %d; schedule marks %s" % (len(rows), len(chain), marks)) print("\n| from s | chain blocks | chain/s | all blocks | mean difficulty | min | max | DAA at end |") print("|---|---|---|---|---|---|---|---|") bins = {} for r in rows: b = int((int(r["timestamp_ms"]) / 1000.0 - t0) // 30) d = bins.setdefault(b, {"n": 0, "c": 0, "D": [], "daa": 0}) d["n"] += 1 if r["is_chain_block"] == "1": d["c"] += 1 d["D"].append(float(r["difficulty"])) d["daa"] = max(d["daa"], int(r["daa_score"])) for b in sorted(bins): d = bins[b] if not d["D"]: continue print("| %d | %d | %.2f | %d | %s | %s | %s | %d |" % (b * 30, d["c"], d["c"] / 30.0, d["n"], "{:,.0f}".format(sum(d["D"]) / len(d["D"])), "{:,.0f}".format(min(d["D"])), "{:,.0f}".format(max(d["D"])), d["daa"])) # phases by DAA score of the chain: std of log difficulty and peaks over 15% def phase_stats(lo_s, hi_s): seg = [float(r["difficulty"]) for r in chain if lo_s <= int(r["timestamp_ms"]) / 1000.0 - t0 < hi_s] if len(seg) < 10: return None ld = [math.log(x) for x in seg] m = sum(ld) / len(ld) std = math.sqrt(sum((x - m) ** 2 for x in ld) / len(ld)) peaks = 0 trough = seg[0] up = False peak = None for x in seg: if not up: trough = min(trough, x) if x >= trough * 1.15: up, peak = True, x else: peak = max(peak, x) if x <= peak / 1.15: peaks += 1 up, trough = False, x return len(seg), std, peaks, min(seg), max(seg) return phase_stats def main(): ap = argparse.ArgumentParser() ap.add_argument("--minutes", type=int, default=28) ap.add_argument("--join", type=int, default=4) ap.add_argument("--leave", type=int, default=19) ap.add_argument("--rejoin", type=int, default=23) ap.add_argument("--activation", type=int, default=900) ap.add_argument("--threads", type=int, default=4) ap.add_argument("--out", default="/tmp/igneum-diffv2/run") ap.add_argument("--analyse", default="") ap.add_argument("--phases", default="", help="analyse: name=lo_s-hi_s,... windows for std of log difficulty") args = ap.parse_args() if args.analyse: ps = analyse(args.analyse) if args.phases: print("\n| phase | chain blocks | std log D | peaks over 15% | min | max |") print("|---|---|---|---|---|---|") for w in args.phases.split(","): name, span = w.split("=") lo, hi = span.split("-") st = ps(float(lo), float(hi)) if st: print("| %s | %d | %.3f | %d | %s | %s |" % (name, st[0], st[1], st[2], "{:,.0f}".format(st[3]), "{:,.0f}".format(st[4]))) return run(args) if __name__ == "__main__": main()