Base unit (O-2.6) phase B, the Igneum side: tools/fleet/base-unit-gate.sh (the B10 gate: two testnet-params nodes on igneum-build-1, CPU mining, then the coinbase payload above u64, the 80/20 split identical on both nodes, eth_getBalance equal on both nodes and equal to the sum of the segments' producer shares under the 18-decimal testnet schedule, computed in exact integers; PASS is the last line); tools/ci/base-unit-pending-check.sh recognises EVM_DECIMALS and excludes the inherited Kaspa wallet, cli and rothschild by rule (Igneum ships none of them); tools/observer/observer.mjs reads the coinbase subsidy at the network's width and keeps it in numeric columns (an 18-decimal block is above bigint); pool/src reads the subsidy from the installed emission table in u128 and bridges to wei by the installed unit (WEI_PER_SOMPI gone; the pool builds against vendor/igneum-node, so it compiles once the fork lands there: flagged); docs/design/base-unit.md sections 3, 5 and 7 as built (the unit-keyed width rule, no protocol or serializer version steps, the p2p and gRPC pairs, the genesis re-lay, the script numbers decision, the measured wire sizes)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 23:58:55 +00:00
parent 9ec98e0492
commit 39b8e599eb
6 changed files with 212 additions and 37 deletions

View file

@ -63,21 +63,27 @@ remainder), `to_wei(decimals)`, `format_ign` / `parse_ign` (section 6). Helpers:
`decimals_valid` (0 to 18), `IMPLEMENTED_DECIMALS` (section 8). A NewType, not a bare `u128`, so a unit is never
added to a count and every serialiser sees one type.
## 3. Serialisation and versioning
## 3. Serialisation and versioning (as built, 7 October 2026)
| Format | Change | Versioned by |
|---|---|---|
| Transaction hash and id (`hashing/tx.rs`), sighash | `value` as 16 LE bytes | `TX_VERSION` 2 (new); versions 0 and 1 keep 8 bytes, so every devnet hash is unchanged; a network at 18 refuses versions 0 and 1 at genesis |
| Coinbase payload | `LENGTH_OF_SUBSIDY` 16 | the coinbase transaction's version |
| UTXO set store, UTXO diffs, muhash | bincode of `UtxoEntry` with a 16-byte amount | a store schema version with the u64 legacy decode (`CachedDbAccess` already carries one for pre-Toccata entries); a fresh network has no legacy rows |
| P2P (`p2p.proto`) | `uint64 value = 1` stays as the low word, `uint64 value_hi` added (same for `amount`); a wide amount sets both | `PROTOCOL_VERSION` 16 to 17; an amount under 2^64 encodes exactly as today, so the digest, not the wire, is what separates networks |
| gRPC (`rpc.proto`) | the same low and high pair | the gRPC message is additive; old clients read the low word, which is exact under 2^64 |
| wRPC borsh, RPC model | `Amount` (16 LE) | the RPC serializer version |
| JSON RPC, wasm | string / `BigInt` | the JSON shape accepts numbers on the way in |
| Coinbase payload in the observer, `/api/supply` | `numeric`, strings | the observer's table migration |
The rule that replaced the version fields of the first draft: the byte width of an amount follows the network's unit,
not a transaction or store version. On a network at 8 decimals every form is today's 8-byte u64 form, so every devnet
hash, database row, p2p message and JSON field is unchanged byte for byte and no migration or version step exists; on
a network at any other unit the forms are 16 bytes (or a decimal string in JSON). The unit is installed process-wide
by the daemon from the params (`unit::install_base_unit`, read by `unit::amount_wire_len`); a fixed width per network
keeps every hash injective (a width that varied with the value could collide in the UTXO commitment). The consensus
digest, which carries the unit once it leaves 8, is what separates the networks at the handshake.
Two varints were measured against a 16-byte `bytes` field: 12.4 B against 18 B per output at the schedule's amounts
(section 5), and the low word reuses the field number so a message under 2^64 is byte-identical to today's.
| Format | At 8 decimals | At 18 decimals | Versioned by |
|---|---|---|---|
| Transaction hash and id, sighash, covenant id (`Amount::consensus_bytes`) | 8 LE bytes, today's | 16 LE bytes | the unit; no tx version (a network at 18 has no 8-byte history) |
| Coinbase payload subsidy field | 8 bytes, today's layout | 16 bytes; the mainnet and testnet genesis payloads are laid out so (one IGN = 10^18) and their hashes recomputed (GENESIS c93c6757..., TESTNET_GENESIS 494fc9a3...); the daemon refuses a genesis whose payload does not parse at the unit | the unit |
| UTXO commitment (muhash) | 8 bytes | 16 bytes | the unit |
| Stores (bincode of `Amount`: UTXO set, diffs, block transactions, virtual state fees, the UTXO index and its supply row) | 8 bytes, today's rows; the pre-Toccata fixtures unchanged | 16 bytes; a fresh network has no legacy rows, so no schema version (B3 found the draft's "store schema version" unnecessary) | the unit |
| p2p (`p2p.proto`) | `uint64 value = 1` / `amount = 1` as today, byte-identical (a fixture encoded on the base tree is asserted) | the low word on field 1 plus `value_hi` (field 4) and `amount_hi` (field 6), absent when zero; a non-zero high word on an 8-decimal network is a conversion error | no PROTOCOL_VERSION bump (16 stays; the draft's 17 would have split the live devnet for nothing) |
| gRPC (`rpc.proto`) | as today | `amountHi`, `feeHi`, `valueHi`, `rewardAmountHi` optional pairs; the same high-word rule | additive fields |
| wRPC borsh | 8 bytes, today's | 16 bytes | no serializer version step: each struct's own version byte stays; there is no central RPC serializer version (B5) |
| JSON RPC, wasm | a JSON number, today's shape | a decimal string; wasm getters hand out `BigInt` | the unit |
| Script numbers (KIP-10 introspection) | 8-byte numbers, today's errors | 16-byte numbers for the whole numeric family (i128 inside), so a 10 IGN output introspects | the unit (B8) |
## 4. The EVM side
@ -96,6 +102,8 @@ at the testnet genesis, where it is pinned fresh anyway; the devnet's pinned id
| Resident memory, 1,000,000 `Arc` entries | 151 B per entry, 152 MB | 168 B per entry, 168 MB | +17 B, +10.6 percent |
| Wire, per output (protobuf overhead included) | 6.0 B (varint) | 12.4 B (two varints) or 18 B (16-byte bytes) | +6.4 B or +12 B |
| Wire, a 2-output transaction | | | +12.8 B or +24 B |
| Wire as built (B4, two uint64 words, a fixed two-output transaction message) | 233 B | 245 B | +12 B (+6.0 per output; +7 B above 2^64, +5 B below it) |
| Wire as built, a UTXO entry message | 48 B | 55 B | +7 B |
Consequences by tier. A home miner's node at 10,000,000 UTXOs (approximate; Kaspa's set is of that order) holds
80 MB more on disk and 170 MB more in memory, against a 1 GiB dataset and a 2 to 4 GB node: no tier changes class, on
@ -119,12 +127,15 @@ retired at the widening; `pool/src/payout.rs` and `prover.rs` keep `as f64 / 1e1
|---|---|---|
| Overflow at a multiplication | `proving_pool_share` (u64) multiplies by 20 before dividing: overflows above 9.2 x 10^17 sompi, unreachable at 8 decimals (above the cap) and reached by the first full-rate block at 18 | `Amount::percent` splits quotient and remainder; the u128 family is exact to `u128::MAX` (tested) |
| The launch ramp | `launch_ramp` goes through u128 and back to u64; at 18 the result itself is above u64 | `launch_ramp_units` with a checked product and an overflow-free fallback (tested at `u128::MAX`) |
| The subsidy table | `SUBSIDY_PERIODS = 33` is a property of the 8-decimal base (31 bits); at 18 the base is 65 bits and the table is 66 long | `subsidy_periods_units(decimals)`, `per_second_subsidy_units` with a shift that is zero at 128 and above, never a wrap (tested) |
| The subsidy table | `SUBSIDY_PERIODS = 33` is a property of the 8-decimal base (31 bits); at 18 the base is 65 bits and the table is 66 long | `subsidy_periods_units(decimals)`, `per_second_subsidy_units` with a shift that is zero at 128 and above, never a wrap (tested); the economy lane's `EmissionSchedule` computes in u128 and is rescaled to the unit |
| The genesis block | block 1 merges the genesis and reads its coinbase payload for the rewards; a payload laid out at 8 bytes misparses at 18 | the mainnet and testnet genesis payloads carry a 16-byte subsidy (hashes recomputed; igneum-testnet-1's genesis must be re-cut on this layout by the testnet lane); the daemon refuses a genesis that does not parse at the unit, so an override file cannot move a devnet's unit over an 8-byte genesis |
| The inherited Kaspa wallet | `wallet/`, `cli/` and `rothschild` keep u64 types and meet `Amount` through `pending_u64()` (26 sites) | Igneum ships none of them (igneumd, igneum-miner and the app never link them; the Igneum wallet is the testnet-wallet lane); `tools/ci/base-unit-pending-check.sh` excludes them by rule and counts everything the node runs (0 sites) |
| Reds in the execution layer | the executor credits producer shares to blue blocks only, while the UTXO coinbase pays a red's 80 percent to the merging miner | pre-existing, not a unit matter; noted for the exec lane |
| The floor at the finer unit | 18 decimals mints 4,028,950,237 wei a second more than 10^10 x the 8-decimal rate (the 8-decimal floor drops them); the display agrees to 8 digits | stated; the total stays under the cap by under 100 IGN at both units (tested) |
| Storage mass constants assume sompi | C = 10,000 IGN = 10^12 sompi; at 18 it is 10^22, above u64, and `C x p^2` at the script-length plurality bound (100) is 10^26 | `storage_mass_parameter_units()`, `calc_storage_mass_units` in u128; the formula is scale-free, so a transaction weighs the same at both units (tested on the KIP-9 vectors and the three paths); the one floor at the unit (the mean input) can move the answer by at most the input plurality |
| Dust | KIP-9 refuses a one-unit output by mass (C x p^2 / 1 = 10^22 at 18, saturated to `u64::MAX`, over every limit), the same answer as 10^12 at 8; the relay floor `DEFAULT_MINIMUM_RELAY_TRANSACTION_FEE` = 100,000 sompi per kilogram is a unit-bearing constant | the mass rule holds at both units (tested); the relay floor becomes 10^-3 IGN per kilogram at the network's unit, a mempool constant, not consensus |
| `MAX_SOMPI` | 4 x 10^9 x 10^8 as a transaction cap; at 18 it is 4 x 10^27 and a tail removes its meaning | `supply_cap_units()` for the check, or the check goes at the widening; nothing in the type depends on it |
| Script numbers | KIP-10 introspection pushes amounts as i64 script numbers; a wei amount above 2^63 does not fit | at the widening: 16-byte script numbers for the two opcodes under tx version 2, or the opcodes fail above 2^63; the decision belongs to the covenant lane, flagged here |
| Script numbers | KIP-10 introspection pushes amounts as i64 script numbers; a wei amount above 2^63 (9.22 IGN at 18 decimals) does not fit | Decided and shipped in B8 (7 October 2026): a script number is 8 bytes at 8 decimals and 16 bytes on any other unit (`kaspa_txscript::script_num_len`, keyed on the unit like the hashes, not on the tx version); `OpTxInputAmount` and `OpTxOutputAmount` push at that width and every numeric opcode runs in i128 and refuses a result the width does not hold; the two opcodes alone would not do (a covenant subtracts and compares one opcode later); tested with the KIP-10 examples at both widths, the devnet's bytes and errors unchanged |
| JavaScript precision | a JSON number above 2^53 rounds | `Amount` is a string in JSON (tested) |
| A peer on the other unit | its coinbases differ by 10^10 | the unit is in the digest once it leaves 8; the handshake refuses it (tested) |
| A half-widened binary starts the testnet | the coinbase, the UTXO value and the wire still carry u64 | `IMPLEMENTED_DECIMALS` = [8]; `igneumd` refuses to start a network whose unit it cannot mint (section 8) |

View file

@ -4,12 +4,12 @@
use crate::pool::{JobRec, Member, NetInfo, Pool};
use crate::protocol::{hex_bytes, hex_u64, Msg};
use crate::state::{unix_ms, BlockRec, WEI_PER_SOMPI};
use crate::state::{unix_ms, BlockRec};
use crate::vardiff::{share_target, Vardiff};
use crate::verify::JobKey;
use kaspa_consensus_core::block::Block;
use kaspa_consensus_core::header::Header;
use kaspa_consensus_core::igneum::{block_subsidy, install_pow_genesis, install_pow_schedule, install_program_class_v3_activation, pow_genesis_dataset_log2, pow_genesis_day_index, pow_schedule, producer_share, program_class_v3_activation_daa, PowSchedule};
use kaspa_consensus_core::igneum::{install_pow_genesis, install_pow_schedule, install_program_class_v3_activation, pow_genesis_dataset_log2, pow_genesis_day_index, pow_schedule, program_class_v3_activation_daa, PowSchedule};
use kaspa_grpc_client::GrpcClient;
use kaspa_hashes::Hash;
use kaspa_notify::{listener::ListenerId, scope::NewBlockTemplateScope};
@ -247,8 +247,12 @@ pub async fn submit_block(pool: Arc<Pool>, member: Arc<Member>, mut raw: RpcRawB
Err(_) => detail = format!("node {}: timed out", i + 1),
}
}
let subsidy = block_subsidy(daa_score, 1);
let reward_wei = producer_share(subsidy) as u128 * WEI_PER_SOMPI;
// O-2.6: the subsidy in base units from the installed table (u128, never the u64 view) and the bridge to wei from
// the installed unit (10^10 at the devnet's 8 decimals, the identity at 18)
let subsidy = kaspa_consensus_core::unit::Amount(kaspa_consensus_core::igneum::emission_table().block_subsidy(daa_score, 1));
let reward_wei = kaspa_consensus_core::igneum::producer_share_units(subsidy)
.to_wei(kaspa_consensus_core::unit::base_unit_decimals())
.expect("a subsidy fits the EVM's wei");
if accepted {
let mut s = pool.state.lock().unwrap();
s.block_found(BlockRec {
@ -291,6 +295,16 @@ pub async fn confirm_loop(pool: Arc<Pool>) {
Ok(i) => i,
Err(_) => continue,
};
// O-2.6: the node's unit and emission schedule, installed once from its network, so the pool reads subsidies
// at the network's width (8 bytes and 10^10 wei per unit on the devnet; 16 bytes and the identity at 18)
{
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let params = kaspa_consensus_core::config::params::Params::from(info.network);
kaspa_consensus_core::unit::install_base_unit(params.base_unit_decimals);
params.install_emission_schedule();
});
}
if last_chain.is_none() {
last_chain = Some(info.pruning_point_hash);
}
@ -362,9 +376,10 @@ pub async fn net_loop(pool: Arc<Pool>) {
n.synced = i.is_synced;
n.node_version = i.server_version;
}
let subsidy = block_subsidy(n.daa_score, 1);
n.block_reward_ign = subsidy as f64 / 1e8;
n.miner_reward_ign = producer_share(subsidy) as f64 / 1e8;
let decimals = kaspa_consensus_core::unit::base_unit_decimals();
let subsidy = kaspa_consensus_core::unit::Amount(kaspa_consensus_core::igneum::emission_table().block_subsidy(n.daa_score, 1));
n.block_reward_ign = subsidy.0 as f64 / kaspa_consensus_core::unit::unit(decimals) as f64;
n.miner_reward_ign = kaspa_consensus_core::igneum::producer_share_units(subsidy).0 as f64 / kaspa_consensus_core::unit::unit(decimals) as f64;
n.updated_ms = unix_ms();
{
let members = pool.members_snapshot();

View file

@ -7,8 +7,8 @@ use std::collections::{HashMap, VecDeque};
use std::path::Path;
pub const WEI_PER_IGN: u128 = 1_000_000_000_000_000_000;
/// 1 IGN = 10^8 sompi on the UTXO side = 10^18 wei on the EVM side (igneum/exec/src/config.rs WEI_PER_SOMPI)
pub const WEI_PER_SOMPI: u128 = 10_000_000_000;
/// O-2.6 (7 October 2026): the bridge from a base unit to wei is `kaspa_consensus_core::unit::wei_per_unit` of the
/// network's unit (10^10 at the devnet's 8 decimals, the identity at the testnet's 18); nothing here is a constant.
pub fn unix_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)

View file

@ -10,11 +10,16 @@
# tools/ci/base-unit-pending-check.sh --self-test # fails on a known-failed tree, passes on a known-good one
set -euo pipefail
# Excluded: the inherited Kaspa wallet (wallet/), its cli (cli/) and the rothschild load tool, which Igneum does not ship
# (igneumd, igneum-miner and the app never link them; the Igneum wallet is its own lane on the testnet-wallet branch);
# their own u64 types meet the Amount boundary through pending_u64 and refuse above u64::MAX. Decided 7 October 2026
# (docs/design/base-unit.md section 8). Everything the node runs is counted.
count_sites() { # every call site, the definition in unit.rs excluded
grep -rn --include='*.rs' -F '.pending_u64()' "$1" 2>/dev/null | grep -v '/target' | grep -v 'consensus/core/src/unit.rs' || true
grep -rn --include='*.rs' -F '.pending_u64()' "$1" 2>/dev/null | grep -v '/target' | grep -v 'consensus/core/src/unit.rs' \
| grep -v -E '(^|/)(wallet|cli|rothschild)/' || true
}
claims_18() { # IMPLEMENTED_DECIMALS carries 18
grep -E 'pub const IMPLEMENTED_DECIMALS: &\[u8\] = &\[[^]]*\b18\b' "$1/consensus/core/src/unit.rs" >/dev/null 2>&1
grep -E 'pub const IMPLEMENTED_DECIMALS: &\[u8\] = &\[[^]]*(\b18\b|EVM_DECIMALS)' "$1/consensus/core/src/unit.rs" >/dev/null 2>&1
}
check() {
local tree=$1 sites n
@ -40,10 +45,11 @@ if [ "${1:-}" = --self-test ]; then
echo 'pub const IMPLEMENTED_DECIMALS: &[u8] = &[8, 18];' > "$t/good/consensus/core/src/unit.rs"
echo 'pub const IMPLEMENTED_DECIMALS: &[u8] = &[8];' > "$t/wip/consensus/core/src/unit.rs"
echo 'let v = amount.pending_u64();' > "$t/wip/rpc/src/x.rs"
mkdir -p "$t/good/wallet/core/src"; echo 'let v = amount.pending_u64();' > "$t/good/wallet/core/src/w.rs" # excluded by rule
if check "$t/bad" >/dev/null 2>&1; then echo "self-test: the known-failed tree passed"; exit 1; fi
check "$t/good" >/dev/null || { echo "self-test: the known-good tree failed"; exit 1; }
check "$t/wip" >/dev/null || { echo "self-test: the in-progress tree failed"; exit 1; }
rm -rf "$t"; echo "base-unit-pending self-test ok (fails on a site under 18, passes clean and in-progress trees)"; exit 0
rm -rf "$t"; echo "base-unit-pending self-test ok (fails on a node site under 18, passes clean, excluded-wallet and in-progress trees)"; exit 0
fi
[ -d "${1:-}" ] || { sed -n '2,12p' "$0"; exit 2; }
check "$1"

129
tools/fleet/base-unit-gate.sh Executable file
View file

@ -0,0 +1,129 @@
#!/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 chain block)) under the testnet schedule
# at 18 decimals (100 IGN a second, the 90-day ramp from 10%), computed here in exact integers
#
# 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.
#
# 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=/srv/builds/igneum-wt-decimals/vendor/igneum-node-decimals/target/release
ROOT=/srv/builds/_gate-decimals; A_RPC=28110; A_P2P=28111; A_EVM=28190; B_RPC=28120; B_P2P=28121; B_EVM=28191
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 --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 --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
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 $?"
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
LOW=$(grep -o "subsidy_in_payload=[0-9]*" "$ROOT/inspect.log" | cut -d= -f2 | sort -n | head -1)
say "smallest payload subsidy seen: $LOW (u64::MAX is 18446744073709551615)"
python3 - "$LOW" <<'PY' || die "a payload subsidy fits a u64: not an 18-decimal chain"
import sys; assert int(sys.argv[1]) > 18446744073709551615
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 "[$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: continue
chain_hash = r["hash"]
daa = next((int(m["daaScore"], 16) if isinstance(m["daaScore"], str) else int(m["daaScore"]) for m in r["mergeset"] if m["hash"] == chain_hash), None)
if daa is None: continue
expect = producer(subsidy(daa))
blues = [m for m in r["mergeset"] if m["blue"]]
for w in r["rewards"]:
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} (daa {daa})")
if w["miner"].lower().lstrip("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

View file

@ -107,8 +107,11 @@ async function setupSchema() {
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS tx_count int`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS evm_miner text`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS proof_records int`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS subsidy_sompi bigint`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS paid_sompi bigint`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS subsidy_sompi numeric`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS paid_sompi numeric`,
// O-2.6: an 18-decimal block's subsidy is above bigint (i64); numeric holds it, and the existing devnet columns move too
`ALTER TABLE ${TB} ALTER COLUMN subsidy_sompi TYPE numeric`,
`ALTER TABLE ${TB} ALTER COLUMN paid_sompi TYPE numeric`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS selected_parent text`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS number bigint`,
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS detail jsonb`,
@ -255,10 +258,11 @@ function minerFromCoinbase(block, prefix) {
const tx = block.transactions && block.transactions[0];
if (!tx) return { address: null, extra: null };
const b = payloadBytes(tx.payload);
if (b.length < 19) return { address: null, extra: null };
const len = b[18];
const script = b.slice(19, 19 + len);
const extra = b.slice(19 + len);
const head = 8 + subsidyBytes() + 2; // blue score, subsidy at the network's width, script version
if (b.length < head + 1) return { address: null, extra: null };
const len = b[head];
const script = b.slice(head + 1, head + 1 + len);
const extra = b.slice(head + 1 + len);
// The node prefixes the extra data with its own version tag ("2.1.0/"). Anything after that is the
// miner's tag (engine or label). The node exposes no engine name over RPC, so this is null on devnet v0.
// Finality v2: the miner's key reveal (IGNK ...) and the node's finality section (... IGNF) are binary; only the
@ -338,10 +342,11 @@ function coinbaseDetail(block, prefix) {
const tx = block.transactions && block.transactions[0];
if (!tx) return null;
const b = payloadBytes(tx.payload);
if (b.length < 19) return null;
const head = 8 + subsidyBytes() + 2; // blue score, subsidy at the network's width, script version
if (b.length < head + 1) return null;
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
const subsidy = dv.getBigUint64(8, true);
const extra = b.subarray(19 + b[18]);
const subsidy = readSubsidy(dv, b);
const extra = b.subarray(head + 1 + b[head]);
const text = Buffer.from(extra).toString('latin1');
const a = text.indexOf('IGNA');
const evmMiner = a >= 0 && /^[0-9a-f]{40}$/.test(text.slice(a + 4, a + 44)) ? '0x' + text.slice(a + 4, a + 44) : null;
@ -433,6 +438,15 @@ function localHashesPerSecond() {
}
let knownPeers = null; // Set of peer ids from the previous tick
let network = 'igneum-devnet';
// O-2.6 (7 October 2026): base units per IGN by network; the coinbase payload's subsidy field is 8 bytes at 8 decimals
// (the devnet, today's layout) and 16 bytes at 18 (the testnet and mainnet), so the payload offsets follow the unit.
const DECIMALS_BY_NETWORK = { 'igneum-devnet': 8, 'igneum-simnet': 8, 'igneum-testnet': 18, 'igneum-mainnet': 18 };
function decimalsOf(net) { const key = Object.keys(DECIMALS_BY_NETWORK).find((k) => String(net).startsWith(k)); return key ? DECIMALS_BY_NETWORK[key] : 8; }
function subsidyBytes() { return decimalsOf(network) === 8 ? 8 : 16; }
function readSubsidy(dv, b) { // u64 LE at 8 decimals, u128 LE (two u64 words) otherwise
if (subsidyBytes() === 8) return dv.getBigUint64(8, true);
return dv.getBigUint64(8, true) + (dv.getBigUint64(16, true) << 64n);
}
let addressPrefix = 'igneumdev';
let nodeVersion = null;
let sinkHash = null;
@ -503,7 +517,7 @@ async function flushBlocks() {
const rows = pendingBlocks.splice(0, 200);
const cols = ['hash', 'blue_score', 'daa_score', 'timestamp_ms', 'parents', 'parent_hashes', 'is_chain_block', 'vote_key_hash', 'miner_address', 'engine',
'tx_count', 'evm_miner', 'proof_records', 'subsidy_sompi', 'paid_sompi', 'selected_parent', 'detail'];
const cast = { parent_hashes: '::text[]', detail: '::jsonb', subsidy_sompi: '::bigint', paid_sompi: '::bigint' };
const cast = { parent_hashes: '::text[]', detail: '::jsonb', subsidy_sompi: '::numeric', paid_sompi: '::numeric' };
const params = []; const values = [];
for (const r of rows) {
const ph = [];