igneum/sim/difficulty/testnet_v2.py
igneum-labs e17d3816ba Difficulty: the live oscillation of 4 October, its cause, the DAG replay and rule v2 (reference window 600 behind a height switch)
Live devnet v4: a second RTX 5090 joining 7 minutes into an epoch left the whole-epoch reference lane polluted for the hour; the short lane read 11 to 25% above it and the 25% trigger flipped between the two for 40 minutes (102M to 164M, 54 to 81 blocks a minute). Record and hash-rate truth under sim/difficulty/records/. sim.py gains a DAG model (miners on nodes with igneum-miner's template staleness, GHOSTDAG, the rule as the node runs it) and --live replay: std of log difficulty 0.115 against the record's 0.134, 4.3 peaks of 1.31x against 4 of 1.37x. The brief's candidates (short lane 240/360, ease clamp 3%, clamp once per DAA second, hysteresis, median of three) leave 0.09 to 0.13; capping the reference lane at the newest 600 blocks of the epoch gives 0.026 with no flips. Rule v2 = that cap, epoch lane only, behind difficulty_v2_activation_daa (devnet-v4 fork). Attack suite and synthetic set before and after, 3-node test network of the switch (testnet_v2.py), analysis document, spec 2.3, bench-log entry, ledger M24, fast-time file carries the new field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 13:26:23 +00:00

247 lines
11 KiB
Python

#!/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()