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>
247 lines
11 KiB
Python
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()
|