igneum/tools/fleet/base-unit-gate.sh
igneum-labs 211ab85a96 base-unit gate: the miner takes --exec-rpc for node A's exec port, and block one on a fresh class v5 chain is printed and required (the missing test); go checklist: row 9v's fix landed, 6e04f7fc is the go commit
The gate's node A serves its exec RPC on 28190 while the miner's default is the network's port (26790 on the 0.3.24 line
everywhere, 26890 on the testnet from f41f48a7), so the miner never reached it; the pod run of 15:0x UK needed a relay. The
block-one lines: node A's genesis-stream line, the miner's first block, the first class v5 refusal if any, node A's exec
tip after mining; tip 0 fails the run (the known-failed case of 6e04f7fc, ba294c98's pair, must read FAIL here).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:22:01 +00:00

177 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
# B10 of the base-unit widening (O-2.6, docs/design/base-unit.md section 8): two nodes on the testnet params (18 decimals)
# mine for N seconds on one box, then the coinbase, the gRPC and the execution layer must read one number.
#
# Runs ON igneum-build-1 (ssh build@188.40.146.49 'bash -s' < tools/fleet/base-unit-gate.sh [seconds]), against the
# binaries of the decimals worktree's fork (target/release of /srv/builds/igneum-wt-decimals/vendor/igneum-node-decimals).
# Ports 28110 to 28191 (nothing else on the box uses them); data under /srv/builds/_gate-decimals, wiped at the start; the
# processes it starts are the only ones it stops (a pid file each, never a pattern). PASS is the last line.
#
# What it checks (each a FAIL line otherwise):
# 1. both nodes answer and B reaches the same sink and block count as A (the p2p wire carries the wide amounts)
# 2. at least MIN_BLOCKS blocks were mined
# 3. igneum-miner inspect over the last 30 blocks: every coinbase's payload subsidy is above u64::MAX (18 decimals),
# the outputs split 80/20 exactly (the UTXO side), identical on both nodes
# 4. the execution layer: eth_getBalance of the miner's EVM address is equal on both nodes and equals the sum of the
# producer shares the segments paid (igneum_getSegment over every chain block), which is the identity bridge
# 5. every reward in a segment equals producer_share(block_subsidy(daa of the rewarded block itself)) under the testnet
# schedule at 18 decimals (100 IGN a second, the 90-day ramp from 10%), computed here in exact integers (each merged
# block its own DAA: subsidy_per_block_activation_daa 0 on the testnet since the re-cut of 7 October 2026)
#
# CPU note (7 October 2026, 01:5x UK): the testnet genesis bits are 2^28 expected hashes a block and the box's CPU engine
# does 0.147 MH/s on 32 threads, so a block takes 10 to 30 minutes on CPU; the ten-minute, hundreds-of-blocks form of
# this gate needs a GPU wave box (the fleet lane's); on the box alone run it for an hour with MIN_BLOCKS=3.
#
# The nodes run --nodnsseed: a gate never dials the public testnet seeds (the pod run of 8 October 2026 did, and was refused on
# the digest; harmless, and wrong).
# A fresh chain is never "synced" by the mining rule (its sink is the two-day-old genesis), so both nodes run with
# --enable-unsynced-mining, as a devnet's first node does; without it every found block is Reject(IsInIBD) (seen 00:2x UK).
#
# Known-failed case: run with GATE_EXPECT_DECIMALS=8 against the same nodes and check 3 and 5 fail (the schedule at 8
# does not match an 18-decimal chain). The self-test target below does that on the recorded output.
set -euo pipefail
SECS="${1:-600}"; MIN_BLOCKS="${MIN_BLOCKS:-60}"; THREADS="${THREADS:-48}"
BIN="${BIN:-/srv/builds/igneum-wt-decimals/vendor/igneum-node-decimals/target/release}"
ROOT="${ROOT:-/srv/builds/_gate-decimals}"; A_RPC=28110; A_P2P=28111; A_EVM=28190; B_RPC=28120; B_P2P=28121; B_EVM=28191
# WORKER=<path to igneum-worker-cuda|igneum-worker-opencl>: the GPU form (a wave box; the fleet lane rents it): the first
# pack is exported from node A, the worker serves it and the miner prepares the next seeds; THREADS is then ignored
WORKER="${WORKER:-}"
EVM_ADDR="00000000000000000000000000000000000000aa"
fail=0
say() { echo "$(date -u +%H:%M:%SZ) gate: $*"; }
die() { say "FAIL: $*"; fail=1; }
stop_all() {
for p in "$ROOT"/*.pid; do [ -f "$p" ] && kill "$(cat "$p")" 2>/dev/null || true; done
sleep 2
}
trap stop_all EXIT
rm -rf "$ROOT"; mkdir -p "$ROOT/a" "$ROOT/b"
[ -x "$BIN/igneumd" ] && [ -x "$BIN/igneum-miner" ] || { echo "FAIL: binaries missing in $BIN"; exit 2; }
say "starting node A (testnet params, 18 decimals)"
"$BIN/igneumd" --testnet --netsuffix=1 --nodnsseed --enable-unsynced-mining --appdir="$ROOT/a" --listen=127.0.0.1:$A_P2P --rpclisten=127.0.0.1:$A_RPC --evm-rpclisten=127.0.0.1:$A_EVM --outpeers=1 --loglevel=info > "$ROOT/a.log" 2>&1 &
echo $! > "$ROOT/a.pid"
sleep 3
grep -m1 "Base unit" "$ROOT/a.log" || true
if grep -q -E "refusing to start|panicked" "$ROOT/a.log"; then die "node A did not start: $(grep -m1 -E 'refusing|panicked' "$ROOT/a.log")"; exit 1; fi
say "starting node B, connected to A"
"$BIN/igneumd" --testnet --netsuffix=1 --nodnsseed --enable-unsynced-mining --appdir="$ROOT/b" --listen=127.0.0.1:$B_P2P --rpclisten=127.0.0.1:$B_RPC --evm-rpclisten=127.0.0.1:$B_EVM --connect=127.0.0.1:$A_P2P --loglevel=info > "$ROOT/b.log" 2>&1 &
echo $! > "$ROOT/b.pid"
for i in $(seq 1 60); do
if curl -s -m 2 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:$A_EVM | grep -q result \
&& curl -s -m 2 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:$B_EVM | grep -q result; then break; fi
sleep 2
done
if [ -n "$WORKER" ]; then
say "nodes up; exporting the first pack from A, then mining $SECS s on the GPU worker $WORKER, payout to 0x$EVM_ADDR"
mkdir -p "$ROOT/packs/first" "$ROOT/packs/prepare"
"$BIN/igneum-miner" export-pack grpc://127.0.0.1:$A_RPC "$ROOT/packs/first" > "$ROOT/pack.log" 2>&1 || say "export-pack exit $? (the testnet address prefix; the miner prepares the pack itself)"
"$BIN/igneum-miner" mine grpc://127.0.0.1:$A_RPC 1 "$SECS" gate --worker "$WORKER" --worker-args "--pack $ROOT/packs/first" --prepare-packs "$ROOT/packs/prepare" --network testnet --exec-rpc "http://127.0.0.1:$A_EVM" --payout-label gate --evm-address "$EVM_ADDR" --dev-fee 0 --status-secs 60 > "$ROOT/miner.log" 2>&1 || say "miner exit $?"
else
say "nodes up; mining $SECS s with $THREADS CPU threads on A, payout to 0x$EVM_ADDR"
nice -n 19 "$BIN/igneum-miner" mine grpc://127.0.0.1:$A_RPC "$THREADS" "$SECS" gate --engine igneum-pow --network testnet --exec-rpc "http://127.0.0.1:$A_EVM" --payout-label gate --evm-address "$EVM_ADDR" --dev-fee 0 --status-secs 60 > "$ROOT/miner.log" 2>&1 || say "miner exit $?"
fi
sleep 5
say "miner done; last status: $(grep -E "blocks|found|accepted" "$ROOT/miner.log" | tail -1 | cut -c1-200)"
# block one on a fresh class v5 chain (the test that was missing, 8 October 2026: with class v5 from genesis the miner needs the
# state stream after the genesis seed block, which an executor that has executed nothing never published; 6e04f7fc publishes it
# at start). The lines printed: the node's genesis-stream line, the miner's first found block, and the first class v5 refusal if
# any; no block at all is the known-failed case of that fix and fails here, never a silent pass
say "block one: node A genesis stream: $(grep -m1 -E "state stream after genesis" "$ROOT/a.log" | cut -c1-200)"
say "block one: miner first block: $(grep -m1 -iE "found|accepted|block [0-9a-f]{8}" "$ROOT/miner.log" | cut -c1-200)"
say "block one: first class v5 refusal: $(grep -m1 -E "class v5 needs the execution state" "$ROOT/miner.log" | cut -c1-200)"
BLOCK_ONE=$(evm0() { curl -s -m 5 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' "http://127.0.0.1:$A_EVM"; }; evm0 | python3 -c "import sys,json; print(int(json.load(sys.stdin).get('result','0x0'),16))" 2>/dev/null || echo 0)
say "block one: node A exec tip after mining: $BLOCK_ONE"
[ "$BLOCK_ONE" -gt 0 ] || die "block one was never mined on a fresh class v5 chain (exec tip 0): the genesis state stream is not published (row 9v of docs/plans/testnet-go.md)"
say "check 1: both nodes agree"
"$BIN/igneum-miner" watch 6 grpc://127.0.0.1:$A_RPC grpc://127.0.0.1:$B_RPC > "$ROOT/watch.log" 2>&1 || true
tail -2 "$ROOT/watch.log" | cut -c1-240
evm() { curl -s -m 5 -X POST -H 'content-type: application/json' --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$2\",\"params\":$3}" "http://127.0.0.1:$1" ; }
TIP_A=$(evm $A_EVM eth_blockNumber '[]' | jq -r .result); TIP_B=$(evm $B_EVM eth_blockNumber '[]' | jq -r .result)
say "exec tips: A $TIP_A B $TIP_B"
[ "$TIP_A" = "$TIP_B" ] || die "exec tips differ (A $TIP_A, B $TIP_B)"
BLOCKS=$((TIP_A))
[ "$BLOCKS" -ge "$MIN_BLOCKS" ] || die "only $BLOCKS chain blocks, wanted $MIN_BLOCKS"
say "check 3: the UTXO side over the last 30 blocks on both nodes"
"$BIN/igneum-miner" inspect 30 grpc://127.0.0.1:$A_RPC grpc://127.0.0.1:$B_RPC > "$ROOT/inspect.log" 2>&1 || true
grep -c "same_on_all_nodes=true" "$ROOT/inspect.log" | sed 's/^/ blocks identical on both nodes: /'
if grep -q "MISMATCH" "$ROOT/inspect.log"; then die "80/20 mismatch: $(grep -m1 MISMATCH "$ROOT/inspect.log")"; fi
if grep -q "same_on_all_nodes=false" "$ROOT/inspect.log"; then die "a block differs between the nodes"; fi
python3 - "$ROOT/inspect.log" "${GATE_EXPECT_DECIMALS:-18}" <<'PY' || die "a coinbase payload subsidy is not the 18-decimal schedule (see above)"
import re, sys
decimals = int(sys.argv[2]); UNIT = 10 ** decimals
launch_rate = 100 * UNIT; ramp_seconds = 90 * 86400; start = 10; bps = 1
def subsidy(daa):
s = daa // bps; full = launch_rate // bps
return full if s >= ramp_seconds else full * (start * ramp_seconds + (100 - start) * s) // (100 * ramp_seconds)
n = bad = 0
for line in open(sys.argv[1]):
m = re.search(r" daa=(\d+) .*subsidy_in_payload=(\d+)", line)
if not m: continue
n += 1; daa, got = int(m.group(1)), int(m.group(2))
if got != subsidy(daa):
bad += 1
if bad <= 3: print(f" daa {daa}: payload subsidy {got} != schedule {subsidy(daa)}")
print(f" payload subsidies checked {n}, wrong {bad}; one IGN = {UNIT}; day-0 block = {subsidy(0)} base units")
assert n > 0 and bad == 0
PY
tail -1 "$ROOT/inspect.log" | cut -c1-200
say "check 4 and 5: the execution layer, both nodes, against the schedule"
BAL_A=$(evm $A_EVM eth_getBalance "[\"0x$EVM_ADDR\",\"latest\"]" | jq -r .result); BAL_B=$(evm $B_EVM eth_getBalance "[\"0x$EVM_ADDR\",\"latest\"]" | jq -r .result)
say "balances: A $BAL_A B $BAL_B"
[ "$BAL_A" = "$BAL_B" ] || die "balances differ between the nodes"
: > "$ROOT/segments.jsonl"
for n in $(seq 1 "$BLOCKS"); do evm $A_EVM igneum_getSegment "[\"$(printf '0x%x' "$n")\"]" >> "$ROOT/segments.jsonl"; echo >> "$ROOT/segments.jsonl"; done
python3 - "$ROOT/segments.jsonl" "$BAL_A" "$EVM_ADDR" "${GATE_EXPECT_DECIMALS:-18}" <<'PY' || die "the execution layer does not match the schedule (see above)"
import json, sys
path, bal_hex, addr, decimals = sys.argv[1], sys.argv[2], sys.argv[3].lower(), int(sys.argv[4])
UNIT = 10 ** decimals
# the testnet schedule (EmissionSchedule::TESTNET_1 rescaled to the unit): 100 IGN a second, a 90-day ramp from 10 percent,
# the first monthly glide step is far beyond a ten-minute gate
launch_rate = 100 * UNIT; ramp_seconds = 90 * 86400; start = 10; bps = 1
def ramp(full, s):
if s >= ramp_seconds: return full
return full * (start * ramp_seconds + (100 - start) * s) // (100 * ramp_seconds)
def subsidy(daa): return ramp(launch_rate // bps, daa // bps)
def producer(a):
q, r = divmod(a, 100); pool = q * 20 + r * 20 // 100
return a - pool
total = 0; checked = 0; bad = 0
for line in open(path):
line = line.strip()
if not line: continue
r = json.loads(line).get("result")
if not r:
print(" segment query failed:", line[:120]); bad += 1; continue
# each blue block of the mergeset is credited the subsidy of ITS OWN DAA (subsidy_per_block_activation_daa 0 on
# the testnet, ledger N8, 7 October 2026); the rewards list runs in the mergeset's blue order, one entry per blue
# block. Before N8 every reward was the chain block's subsidy and 165 of 561 segments read one ramp step low
# (the pod run of 09:4x UK, 7 October 2026: the known-failed shape of this check).
def daa_of(m):
return int(m["daaScore"], 16) if isinstance(m["daaScore"], str) else int(m["daaScore"])
blues = [m for m in r["mergeset"] if m["blue"]]
if len(blues) != len(r["rewards"]):
print(f" segment {r['number']}: {len(blues)} blue blocks but {len(r['rewards'])} rewards"); bad += 1; continue
for m, w in zip(blues, r["rewards"]):
if w["miner"].lower() != m["miner"].lower():
print(f" segment {r['number']}: reward {w['miner']} is not the blue block's miner {m['miner']}"); bad += 1; continue
daa = daa_of(m)
expect = producer(subsidy(daa))
wei = int(w["wei"], 16) if isinstance(w["wei"], str) else int(w["wei"])
checked += 1
if wei != expect:
bad += 1
if bad <= 3: print(f" segment {r['number']}: reward {wei} != expected {expect} (block daa {daa})")
if w["miner"].lower().removeprefix("0x") == addr: total += wei
bal = int(bal_hex, 16)
print(f" rewards checked {checked}, wrong {bad}; sum of our rewards {total}; eth_getBalance {bal}; one IGN = {UNIT}")
print(f" balance in IGN (8 visible digits): {bal // UNIT}.{(bal % UNIT) // (UNIT // 10**8):08d}")
assert bad == 0, "a reward disagrees with the schedule"
assert total == bal, "the balance is not the sum of the rewards (the bridge is not the identity)"
assert bal > 18446744073709551615, "the balance fits a u64: not an 18-decimal chain"
PY
if [ "$fail" = 0 ]; then say "PASS: two nodes at 18 decimals, $BLOCKS chain blocks, the coinbase, the gRPC and the execution layer read one number"; else say "FAIL (see above)"; exit 1; fi