From 39b8e599ebb439099e2d86fe0ac7a08891031c7e Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:58:55 +0000 Subject: [PATCH] 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 --- docs/design/base-unit.md | 41 +++++---- pool/src/node.rs | 29 +++++-- pool/src/state.rs | 4 +- tools/ci/base-unit-pending-check.sh | 12 ++- tools/fleet/base-unit-gate.sh | 129 ++++++++++++++++++++++++++++ tools/observer/observer.mjs | 34 +++++--- 6 files changed, 212 insertions(+), 37 deletions(-) create mode 100755 tools/fleet/base-unit-gate.sh diff --git a/docs/design/base-unit.md b/docs/design/base-unit.md index 1b198d581..1786509c7 100644 --- a/docs/design/base-unit.md +++ b/docs/design/base-unit.md @@ -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) | diff --git a/pool/src/node.rs b/pool/src/node.rs index 513813aa3..fe9a9cdb1 100644 --- a/pool/src/node.rs +++ b/pool/src/node.rs @@ -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, member: Arc, 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) { 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) { 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(); diff --git a/pool/src/state.rs b/pool/src/state.rs index 6d2ceae34..7e33cfbde 100644 --- a/pool/src/state.rs +++ b/pool/src/state.rs @@ -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) diff --git a/tools/ci/base-unit-pending-check.sh b/tools/ci/base-unit-pending-check.sh index 488aa3836..a14358b95 100755 --- a/tools/ci/base-unit-pending-check.sh +++ b/tools/ci/base-unit-pending-check.sh @@ -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" diff --git a/tools/fleet/base-unit-gate.sh b/tools/fleet/base-unit-gate.sh new file mode 100755 index 000000000..33d3445d5 --- /dev/null +++ b/tools/fleet/base-unit-gate.sh @@ -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 diff --git a/tools/observer/observer.mjs b/tools/observer/observer.mjs index 98527020a..5220fbcb6 100644 --- a/tools/observer/observer.mjs +++ b/tools/observer/observer.mjs @@ -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 = [];