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:
parent
9ec98e0492
commit
39b8e599eb
6 changed files with 212 additions and 37 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
129
tools/fleet/base-unit-gate.sh
Executable 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
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue