igneum/tools/fleet/base-unit-gate.sh

165 lines
11 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.
#
# 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 --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 --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 --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 --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)"
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