Compare commits

...

10 commits

Author SHA1 Message Date
igneum-labs
33b307a005 Base unit (O-2.6): the B10 gate PASSED on the GPU form (RunPod 3090, 7 October 2026, 03:13 UK: 414 chain blocks at 18 decimals, 585 rewards against the schedule with 0 wrong, the balance equal on both nodes and to the sum of the rewards; the known-failed form fails 30 of 30); O-2.6 closed in the open items, the spec sentence, the ledger and the bench-log
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:15:11 +00:00
igneum-labs
47d0b8a15c base-unit gate: the UTXO-side check compares every inspected payload subsidy with the 18-decimal schedule (a day-0 testnet block is 10 IGN and fits a u64, so the old above-u64 check was wrong); the segment tag is a hex quantity; the segment and reward failures are named; export-pack is best effort on the testnet prefix (the miner prepares the pack itself); run 1 on the pod: 551 blocks found at 51.7 MH/s, 386 chain blocks, both nodes identical, 80/20 exact on 18 of 18, balance 4,409.40467592 IGN equal on both nodes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:03:56 +00:00
igneum-labs
548b987513 base-unit gate: the GPU form (WORKER=<igneum-worker-cuda|opencl>: the first pack exported from node A, the worker served, the miner preparing the next seeds); BIN and ROOT from the environment so the script runs on a rented wave box
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:50:16 +00:00
igneum-labs
994314dfe6 base-unit design: the B10 gate's runs so far (IsInIBD on a fresh chain fixed with unsynced mining; the CPU form's zero in 4.2 expected blocks; run 3 in flight; the GPU form is the gate that counts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 01:33:37 +00:00
igneum-labs
f640c7c4ed base-unit gate: both nodes run with --enable-unsynced-mining (a fresh chain's sink is the two-day-old genesis, so the mining rule never calls it synced and every found block was Reject(IsInIBD) on the first run, 00:2x UK); the CPU note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:32:19 +00:00
igneum-labs
c67df8dd4e Base unit (O-2.6): the design's section 8 as landed (the seventeen fork commits, the suites, the Igneum side, the B10 gate and what is left), spec 2.5 and O-2.6 as Decided and implemented, ledger E22 status, bench-log addendum, fork-divergence row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 00:00:15 +00:00
igneum-labs
39b8e599eb 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>
2026-10-06 23:58:55 +00:00
igneum-labs
9ec98e0492 tools/ci/base-unit-pending-check.sh (O-2.6 phase B): counts the fork's Amount::pending_u64() narrowing sites and refuses a tree whose IMPLEMENTED_DECIMALS carries 18 while any is left; self-test on a known-failed, a known-good and an in-progress tree; in the pre-push gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:48:42 +00:00
igneum-labs
673809fede Base unit (O-2.6): the composition with the economy lane's EmissionSchedule (one schedule written at 8 decimals and rescaled to the network's unit, one amount type), the 18-decimal rate defined as exactly 10^10 times the 8-decimal floor, and the ship version 0.3.17
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:22:24 +00:00
igneum-labs
50363a64f8 Base unit (O-2.6): the design, the proposed Decided sentence and the ledger row for 18 decimals
docs/design/base-unit.md: where every amount lives (UTXO value, entry, mass view, coinbase and its payload, fees, script introspection, p2p, gRPC, RPC model, JSON and wasm, wallet, UTXO index, the exec bridge, pool accounting, pool software, app display, observer and public stats, the manifest), the u128 Amount type, the serialisation and versioning table (tx version 2 for the 16-byte value, a store schema version with the u64 legacy decode, two uint64 varints on the wire with the low word on the existing field number), the EVM side (1 IGN = 10^18 wei, the bridge the identity), the devnet unchanged at 8 with its digest unmoved, the display rule (8 visible digits), the hostile review (the u64 pool share overflow, the ramp, the 66-period table, the floor at the finer unit, the storage mass constant at 10^22, dust and the relay floor, MAX_SOMPI, KIP-10 script numbers, JavaScript precision, the wrong-unit peer, the half-widened binary, Postgres bigint), the measurement (+8 B per UTXO on disk, +17 B in memory, +6.4 B per output on the wire) with the consequences by tier, and phase B in ten commits with hours (26). Spec 2.5 carries the proposed Decided sentence and the 18-decimal rate; 06-open-items O-2.6 proposed decided with its gate B10; fud-ledger E22; fork-divergence row for the fork commits (3158571c, fa61e035, fcd6b6e9, 6d5f2488 on the box mirror, branch decimals); bench-log "Base unit widening cost"; public-stats unit note; ledger-decisions row 5 lane state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 22:15:09 +00:00
14 changed files with 539 additions and 24 deletions

View file

@ -106,7 +106,7 @@ reflects a one-minute window):
| Field | Meaning |
|---|---|
| `unit` | IGN; the coinbase pays in 8-decimal units (open item O-2.6), the EVM shows 18 |
| `unit` | IGN; the devnet's coinbase pays in 8-decimal units and the EVM shows 18; the testnet and mainnet unit is 18 on both sides (O-2.6, proposed Decided 6 October 2026, `docs/design/base-unit.md`), so `decimals_consensus` reads the network's genesis parameter |
| `daa` | The newest DAA score the observer stored |
| `max_supply_ign` | 4,000,000,000, the hard cap (spec 2.5, no tail emission: spec 5.10) |
| `circulating_ign`, `circulating_sompi` | Minted so far by the rule: `E(t)` summed over every DAA second from 0 to `daa`, exact (floor sum, `mintedByRule`) |

View file

@ -2597,3 +2597,52 @@ USD 20 an hour on community pods, against a devnet of 1.16 GH/s.
Consequence: the devnet's hash is rentable for the price of a dinner, so nothing on it is a security result; the counter-ASIC and
finality work is tested there for correctness, not for cost. The cost argument only starts at the TH/s scale, where the rental
market's supply (not its price) is the limit, and that number belongs in the litepaper with this caveat.
## Base unit widening cost, 6 October 2026, 22:05Z (branch `decimals`, fork eec34ac3 + the O-2.6 commits; igneum-build-1)
What moving every amount from u64 (8 decimals) to u128 (18 decimals, `unit::Amount`) costs, on 1,000,000 synthetic UTXOs of the
devnet's shape (34-byte pay-to-pubkey script, DAA score, coinbase flag, no covenant). `cargo test -p kaspa-consensus-core --release
--lib unit_measure -- --ignored --nocapture` on igneum-build-1 (AX162, 128 GB), 0.84 s; `consensus/core/src/unit_measure.rs`.
| | 8 decimals (u64) | 18 decimals (u128) | Difference |
|---|---|---|---|
| UTXO set, bincode value plus the 35-byte RocksDB key | 97 B per entry, 97,000,000 B | 105 B per entry, 105,000,000 B | +8 B per entry, +8.25 percent |
| `size_of` the entry struct | 112 B | 128 B | +16 B (the u128 aligns the struct to 16) |
| Resident memory, 1,000,000 `Arc` entries | 151,945,216 B (151 B per entry) | 168,005,632 B (168 B per entry) | +17 B per entry, +10.6 percent |
| Wire, per output, protobuf overhead included, 1,000 schedule amounts | 6.0 B (`uint64` varint) | 12.4 B (two `uint64` varints, low and high) or 18 B (16-byte `bytes` field) | +6.4 B or +12 B |
| Wire, a 2-output transaction | | | +12.8 B or +24 B |
Consequences by tier: a node at 10,000,000 UTXOs (approximate, Kaspa's order) holds 80 MB more on disk and 170 MB more in memory,
against the 1 GiB dataset and a 2 to 4 GB node, so no home miner (8, 12, 16, 24 or 32 GB card), rig or pool node changes class on
Windows, Linux or macOS; hash rate, power and deadlines are untouched. At 1 BPS and 100 transactions a block the wire grows 1.3 KB
a second (110 MB a day) with two varints and twice that with the fixed field; at the 10 BPS step 1.1 GB against 2.1 GB a day, so the
design picks two varints and keeps the existing field number for the low word (a message under 2^64 is byte-identical to today's).
The fork's suites after the change: `kaspa-consensus-core` 129 passed (the 18 new tests among them), the rest in the O-2.6 report.
Addendum, 7 October 2026, 00:3x UK (branch `decimals`, B4 as built): on the real p2p messages the two-word form costs +12 B
on a fixed two-output transaction (233 to 245 B; +6.0 per output: +7 B above 2^64, +5 B below it) and +7 B on a UTXO entry
message (48 to 55 B); the 8-decimal bytes are asserted equal to a fixture encoded on the untouched tree. The suites of the
merged phase-B tree are in `docs/design/base-unit.md` section 8.
## Base unit gate, GPU form, 7 October 2026, 03:03 to 03:13 UK (branch `decimals`, fork df2fbd03; RunPod RTX 3090 24 GB, CUDA 12.8, glibc 2.39, USD 0.22/h, rented by the fleet lane)
`tools/fleet/base-unit-gate.sh` with `WORKER=igneum-worker-cuda` (compiled on the pod from proto-cuda/nvrtc): two igneumd on the
testnet params (18 decimals, `--enable-unsynced-mining`, digest 57c4c924...), both nodes and the miner on the pod on 127.0.0.1,
no peer outside it.
| | Value |
|---|---|
| Hash rate | 51.8 MH/s wall, 56.1 MH/s inside jobs, 1,854 jobs in 600.8 s |
| Blocks found / rejected | 585 / 0 (0.97 a second against 2^28 expected hashes a block) |
| Chain blocks (exec tip) | 414 on both nodes; sink identical |
| Coinbase payloads inspected (last 30) | 30 equal to the 18-decimal schedule (day-0 block 10 IGN = 10^19 base units, 16-byte field); 80/20 exact on 10 of 10 single-payee coinbases |
| Segment rewards checked against the schedule | 585, 0 wrong |
| `eth_getBalance` of the miner | 4,681,583,333,333,333,333,339 wei = 4,681.58333333 IGN, equal on both nodes and to the sum of the rewards (the identity bridge) |
| Known-failed form (the schedule at 8 against the recorded chain) | 30 of 30 payloads wrong |
| CPU form on igneum-build-1 | 0.31 MH/s; 0 blocks in an hour (4.2 expected, a 1.5 percent chance); the wrong tool for 2^28 a block |
Consequences: a 3090 alone mines the testnet at genesis bits at one block a second, so the testnet genesis bits (2^28, sized
for a few hundred MH/s) hold for the announced fleet and are far too easy for one card: the testnet lane's sizing stands as
written (re-sized to the launch fleet before the cut). For users nothing changes by tier: the 18-decimal node paid 414 blocks
of rewards into the EVM at the identity bridge with no extra cost beyond the measured +8 B per UTXO and +6 B per output.

214
docs/design/base-unit.md Normal file
View file

@ -0,0 +1,214 @@
# The base unit of IGN (O-2.6)
Decision proposed for the project lead's word (6 October 2026, 22:xx UK; his answer in `docs/plans/ledger-decisions.md` row 5 was
"18"): one IGN is 10^18 base units on the testnet and on mainnet, the EVM's wei, so a consensus amount and an EVM
balance are the same number and the bridge is the identity. The devnet keeps 10^8 (Kaspa's sompi, what it mints
today). The unit is a genesis parameter, `Params::base_unit_decimals`, per network, in the consensus digest once it
leaves 8. Every amount in the node becomes a `u128` behind one type, `kaspa_consensus_core::unit::Amount`, end to
end, so nothing depends on the cap fitting any type: the emission lane may add a tail or remove the cap and the
type does not care (headroom 3.4 x 10^38, which is 10^19 seconds of the 18-decimal full rate).
Why 18: every EVM wallet, bridge, explorer and rollup assumes it (`nativeCurrency.decimals: 18` is already what
`tools/evm-smoke/smoke.mjs` tells viem; MetaMask shows 18). Why not keep 8 and bridge at 10^10, as the devnet does: the
public text then carries two unit systems ("the coinbase pays 8-decimal units, the EVM shows 18", `docs/api/public-stats.md`),
every exchange integration has to know which API returns which, and a UTXO-side amount with 8 decimals cannot express a
wei-level EVM balance, so the two ledgers can never be reconciled to the unit. Why not U256: consensus-core has no alloy
dependency, u128 holds every schedule by a factor of 10^11, and u128 to U256 is lossless on the exec side.
## 1. Where an amount lives today (the fork at `ca3-v4-0316`, eec34ac3)
| Place | File | Type and byte form today | At 18 decimals |
|---|---|---|---|
| UTXO output value | `consensus/core/src/tx.rs:175` `TransactionOutput.value` | `u64`; borsh 8 LE; the tx hash and id write 8 LE bytes (`hashing/tx.rs:125`); the sighash covers it through `outputs_hash` | `Amount` (u128); 16 LE bytes under a new tx version (section 3) |
| UTXO set entry | `consensus/core/src/utxo/utxo_entry.rs:21` `UtxoEntry.amount` | `u64`; bincode in the store (`consensus/src/model/stores/utxo_set.rs`, `CachedDbAccess`, `database/src/access.rs:134`); muhash over the bincode | `Amount`; store schema version with a u64 legacy decode (the pre-Toccata `TLegacy` path, `access.rs:214`, is the pattern) |
| Mass view of a UTXO | `consensus/core/src/mass/mod.rs:141` `UtxoCell.amount`; `calc_storage_mass` | `u64`; C = `storage_mass_parameter` = 10^12 (`constants.rs` `STORAGE_MASS_PARAMETER = SOMPI_PER_KASPA x 10,000`), in Params and the digest | `UtxoCellUnits` and `calc_storage_mass_units` (in the tree now); C = `Params::storage_mass_parameter_units()` = 10^22, which no u64 holds |
| Coinbase subsidy | `consensus/core/src/igneum.rs` (`block_subsidy`, u64 table); `consensus/src/processes/coinbase.rs` (`CoinbaseData.subsidy` u64, payload `LENGTH_OF_SUBSIDY` = 8 LE bytes at `:14`, `:139`; `BlockRewardData.subsidy`) | `u64` everywhere | `block_subsidy_units` (in the tree now, u128); the payload carries 16 LE bytes under the new tx version |
| Coinbase outputs | `coinbase.rs:84 to 118` (producer, pool, red reward sums) | `u64` adds | `Amount` with checked adds |
| Tx validation caps | `consensus/src/processes/transaction_validator/tx_validation_in_isolation.rs:155, 165` `MAX_SOMPI` | `u64` = 4 x 10^9 x 10^8 | `Params::supply_cap_units()` or no cap check at all (a tail removes the meaning of MAX) |
| Fees (UTXO side) | `mining/src/mempool/model/frontier/feerate_key.rs:9` `fee: u64`; `rpc/core/src/model/mempool.rs:8`; `DEFAULT_MINIMUM_RELAY_TRANSACTION_FEE = 100_000` sompi per kilogram (`mining/src/mempool/config.rs:20`) | `u64` | `Amount`; the relay floor and the dust rule scale with the unit (section 7) |
| Script introspection | `crypto/txscript/src/opcodes/mod.rs:1100` `OpTxInputAmount`, `:1151` `OpTxOutputAmount` (KIP-10) | amounts pushed as script numbers, i64 | 16-byte script numbers for these two opcodes under the new tx version, or the opcodes refused above 2^63 (section 7) |
| P2P wire | `protocol/p2p/proto/p2p.proto:62` `uint64 value = 1` (output), `:190` `uint64 amount = 1` (UTXO entry) | varint, 6.0 B per schedule amount measured | low word stays field 1, `uint64 value_hi` added (section 3); 12.4 B measured |
| gRPC | `rpc/grpc/core/proto/rpc.proto:103, 122` `uint64 amount`, `:310` `uint64 fee` | varint | the same low and high pair |
| RPC model, wRPC borsh | `rpc/core/src/model/tx.rs:26, 245` | `u64`; borsh 8 LE | `Amount`; borsh 16 LE; the RPC serializer version steps (3 today for the checkpoints report) |
| JSON and wasm clients | `consensus/client/src/serializable/numeric.rs:34, 236`, `consensus/client/src/output.rs:56`, `utxo.rs:69` | JSON numbers (lossy past 2^53 in JavaScript) | `Amount` serialises as a decimal string in JSON (in the tree now); wasm hands out `BigInt` |
| Wallet | `wallet/core/src/utils.rs:34 to 70` (`sompi_to_kaspa` as f64), `wallet/pskt/src/output.rs:15`, `wallet/core/src/storage/transaction/*.rs`, `cli/src/utils.rs` | `u64` and `f64` formatting | `Amount::format_ign` and `parse_ign` (in the tree now, exact, never a float); the Igneum wallet lives on the `testnet-wallet` branch and is not in master yet |
| UTXO index | `indexes/core/src/indexed_utxos.rs:25` and the circulating supply counter | `u64` | `Amount`; the supply counter checked |
| Exec bridge | `igneum/exec/src/config.rs:43` `WEI_PER_SOMPI = 10^10`; `executor.rs:145` `execute_segment(.., subsidy_sompi: u64, ..)`, `:167` `producer as u128 x WEI_PER_SOMPI` | u64 in, U256 out | `Params::wei_per_unit()` (in the tree now, 1 at 18); `execute_segment` takes `Amount`; the SP1 guest reads the rewards in wei already (the fixture's `rewards` and `payouts` are U256), so its input type changes once and the shard program id is re-pinned at the testnet genesis, never on the devnet |
| Proving pool accounting | `igneum/exec/src/service.rs:1029` (`block_subsidy` u64 into the segment), `proving.rs` `split_pool_credit` (wei) | u64 subsidy, wei credits | `Amount` subsidy; credits unchanged (already wei) |
| Pool software | `pool/src/state.rs:9 to 11` `WEI_PER_IGN`, `WEI_PER_SOMPI`; `pool/src/node.rs:251, 366` | u64 sompi from RPC, wei out | reads the unit from the node (`igneum_getProvingStatus` or the params RPC) and drops `WEI_PER_SOMPI` |
| App display | `app/igneum-app/src/prover.rs:501, 548` (`wei as f64 / 1e18` in event lines) | f64 for log lines | `format_ign` for anything a user reads (section 6) |
| Observer and public stats | `tools/observer/observer.mjs:110` `subsidy_sompi bigint`, `paid_sompi bigint`; `docs/api/public-stats.md` `decimals_consensus: 8` | Postgres `bigint` is i64: 9.2 x 10^18, below one 18-decimal block | `numeric` columns; `decimals_consensus` reads the network's unit |
| Signed manifest, tuning kits | `app/igneum-app/src/config.rs` (update manifest), `infra/fleet` tuning files | no amount fields (checked 6 October 2026: the manifest carries URLs, versions and hashes; the tuning kits carry miner settings) | nothing |
## 1b. One schedule, one amount type (with the economy lane, 6 October 2026)
The economy lane's `EmissionSchedule` (fork branch `tail-emission-node`, 486895d6, merged into `decimals` at 6ffd5451)
holds its rates as bare `u128` base units and already computes in u128 with u64 views that refuse rather than wrap.
The composition: a schedule is a parameter struct and keeps `u128` (its JSON form is a decimal string, as `Amount`'s
is); every amount a transaction or a coinbase carries is `Amount`; the coinbase wraps `Amount(table.block_subsidy(..))`.
Schedules are written at 8 decimals and `EmissionSchedule::rescaled(8, 18)` carries one to a network's unit, so the
testnet pays `TESTNET_1` at 18 (100 x 10^18 base units a block) and mainnet `CURRENT` at 18; the devnet and simnet keep
`CURRENT` at 8 and their digest (pinned c562d70e..., asserted). The closed form at 18 (`block_subsidy_units`) is
defined as the 8-decimal floor times 10^10, so it equals the rescaled table block for block (asserted); a floor taken
at the finer unit would mint 4,028,950,237 wei a second more, read the same to 8 digits, and was not chosen. The
widening ships in the feature tree as 0.3.17 (0.3.16 went out as the corrective cut of 0.3.15 at 23:13 UK).
## 2. The type
`kaspa_consensus_core::unit::Amount(pub u128)`: `Copy`, `Ord`, `Hash`, `Default`, borsh (16 LE bytes), serde (a decimal
string in human-readable formats, the u128 in binary ones, reading a JSON number too), checked and saturating
arithmetic, `percent` without an intermediate product, `to_le_bytes` / `from_le_bytes`, `to_wire` / `from_wire`
(16 bytes, or the 8-byte u64 form every rusty-kaspa format carries today), `rescale(from, to)` (exact, refuses a
remainder), `to_wei(decimals)`, `format_ign` / `parse_ign` (section 6). Helpers: `unit(decimals)`, `wei_per_unit`,
`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 (as built, 7 October 2026)
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.
| 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
1 IGN = 10^18 wei, the base unit. `Params::wei_per_unit()` is 1 at 18 decimals and 10^10 at 8 (today's `WEI_PER_SOMPI`,
which the widening replaces). The executor credits `Amount::to_wei(decimals)` of the producer and pool shares; the pool
escrow, the payouts, the fee splits and the aggregator share are already in wei and do not change. The SP1 guest's
statement carries rewards and payouts in wei and changes only at the input type, so the shard program id moves once,
at the testnet genesis, where it is pinned fresh anyway; the devnet's pinned id is untouched.
## 5. What it costs (measured on igneum-build-1, 6 October 2026, `consensus/core/src/unit_measure.rs`, 1,000,000 synthetic UTXOs of the devnet's shape)
| | 8 decimals (u64) | 18 decimals (u128) | Difference |
|---|---|---|---|
| UTXO set, serialised + 35-byte key | 97 B per entry, 97.0 MB | 105 B per entry, 105.0 MB | +8 B, +8.25 percent |
| `size_of` the entry | 112 B | 128 B | +16 B (alignment to 16) |
| 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
8 GB cards or on 32 GB, on Windows, Linux or macOS. A pool or rig node is the same number. At 1 block a second and
100 transactions a block the wire grows 1.3 KB a second with two varints, 110 MB a day; at the 10 BPS step 1.1 GB a
day, which is why two varints are the recommendation and not the fixed 16 bytes. Nothing changes for a miner's hash
rate, power or deadline. For a wallet or exchange the number is the same as Ethereum's, which is the point.
## 6. Display
The app and the wallet show IGN with up to 8 digits after the point (`unit::VISIBLE_DECIMALS`), truncated toward
zero, trailing zeros trimmed, no point on a whole number; a user never sees 18 digits (`Amount::format_ign`:
31,688,087,814,028,950,237 wei reads "31.68808781", the same as the 8-decimal 3,168,808,781 sompi). An amount under
10^-8 IGN reads "0"; a tooltip or a detail view may use `format_ign_visible(decimals, 18)`. Input is `parse_ign`:
digits, one point, at most `decimals` fraction digits, no float anywhere (the wallet's `sompi_to_kaspa` f64 helpers are
retired at the widening; `pool/src/payout.rs` and `prover.rs` keep `as f64 / 1e18` for log lines only).
## 7. Hostile review
| Attack or slip | Finding | Answer |
|---|---|---|
| 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 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 (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) |
| Postgres `bigint` in the observer | i64 overflows at one 18-decimal block | `numeric` at the widening |
## 8. What landed (phase B, 6 to 7 October 2026) and what is left
Fork branch `decimals` on the box mirror (every commit built and its crates' suites run on igneum-build-1; the two
pre-existing reds `igneum_m20_tests::witnesses_are_checked_in_epoch_order_under_their_own_seeds` and the
`ban_is_decided_by_the_carrying_block` flake skipped, both red on the untouched eec34ac3):
| Step | Commit | What |
|---|---|---|
| the type | 3158571c | `unit::Amount` and every byte form |
| the parameter and the schedule | fa61e035 | `base_unit_decimals` in Params, overrides, the digest once not 8; the schedule at any unit |
| the mass rule | fcd6b6e9 | `calc_storage_mass_units` |
| the measurement | 6d5f2488 | section 5 |
| the economy lane | 6ffd5451 | merge of `tail-emission-node` 486895d6: one schedule, rescaled to the unit |
| the width rule | a91fb532 | every amount form follows the network's unit; the devnet byte-identical |
| B1 | 472664dc | output value, entry amount, mass cells, fees in Amount; hashes through `consensus_bytes`; u128 rules |
| B2 | eea7971a | the coinbase: subsidy, payload width, manager on the u128 table |
| B6 | 20a6fd64 | the mempool: fee keys, relay floor at the unit, template fees |
| B9a | c217eff0 | the inherited wallet crates compile at the boundary |
| B7 | 637dbfab | the exec bridge: `execute_segment(Amount)`, `to_wei` at the installed unit, the guest untouched |
| B3 | fa40457a (d98e4f8c, 431390e2) | stores and the UTXO index |
| B4 | 8d642f53 | p2p: the low and high words, no protocol version bump |
| B5 | b8e5b627 (ec3560da) | RPC: model, converters, gRPC pairs, wRPC, JSON, wasm |
| B8 | 23851be1 (65961e3c) | script numbers at the network's width |
| B10a | 0f649f6b | `IMPLEMENTED_DECIMALS` = [8, 18]; the genesis payloads at 16 bytes; the daemon's genesis check |
| the miner | df2fbd03 | `inspect` at the network's width; testnet and mainnet prefixes |
Suites on the merged tree (release, igneum-build-1): consensus-core 139, consensus 109, txscript 158 + 3, mining 52,
rpc-core 134, grpc-core 14, p2p 22 and 33, pow 15, miner 18, exec 22, utxoindex 9, index-core 9, database 22, pskt 5,
kaspad 2, db_compat 7; the workspace checks with every target but the two inherited `rpc_core_mock.rs` files (the
finality methods, red on the base tree). `tools/ci/base-unit-pending-check.sh`: no `pending_u64()` site in anything
the node runs; 26 remain in the inherited Kaspa wallet, cli and rothschild, excluded by rule (Igneum ships none of
them; the Igneum wallet is the testnet-wallet lane's).
The Igneum side (repository branch `decimals`): the observer reads the payload at the network's width and keeps
amounts in `numeric`; the pool reads the subsidy from the installed table in u128 and bridges by the installed unit
(it builds against `vendor/igneum-node`, so it compiles once the fork lands there: unverified tonight); the app's user
strings are hash rates and addresses, its reward lines log wei as f64 (no change); `site/api/public-stats` keeps
`decimals_consensus: 8` for the devnet it serves and reads the network's unit when the testnet observer exists.
B10, the gate (`tools/fleet/base-unit-gate.sh`): two nodes on the testnet params on igneum-build-1 start at 18
decimals (the daemon prints `Base unit: 10^18 base units per IGN (wei, the EVM's unit)`, digest 57c4c924...), peer,
and mine with the CPU engine; the checks are in the script's header. The CPU engine does 0.147 MH/s on 32 threads
against the testnet's 2^28 expected hashes a block, so a block takes 10 to 30 minutes on the box: the hour-long CPU
form (MIN_BLOCKS 3) is what runs tonight; the ten-minute, hundreds-of-blocks form needs a GPU wave box from the fleet
lane and the same script. PASSED on the GPU form, 7 October 2026, 03:13 UK (RunPod RTX 3090 rented by the fleet lane
for the hour, the CUDA worker compiled on it from proto-cuda/nvrtc, both nodes and the miner on the pod on their own
digest): 585 blocks found in 600 s at 51.8 MH/s (0.97 blocks a second against 2^28 hashes a block), 414 chain blocks,
the sink identical on both nodes, every one of the 30 inspected coinbase payloads equal to the 18-decimal testnet
schedule (a day-0 block is 10 IGN, 10^19 base units), 80/20 exact on every single-payee coinbase, 585 segment rewards
checked against the schedule with 0 wrong, `eth_getBalance` 4,681.58333333 IGN (4,681,583,333,333,333,333,339 wei)
equal on both nodes and equal to the sum of the rewards: the bridge is the identity. The known-failed form (the same
checks with the schedule at 8 decimals against the recorded chain) fails 30 of 30 payloads. Run 1 on the pod had
found the same chain shape (551 blocks, 4,409.40467592 IGN) and failed on two faults of the script itself, fixed in
67d87c39: the "above u64::MAX" payload check (a day-0 block fits a u64; the right check is the schedule) and the
segment tag (a hex quantity). The CPU runs before it, on igneum-build-1: run 1 (00:3x UK, 32 then 64 threads, 31 min) found two
blocks and the node refused both with `IsInIBD`, since a fresh chain's sink is the two-day-old genesis and the mining
rule never calls it synced; fixed by `--enable-unsynced-mining` on both nodes (the devnet's first node runs the same);
run 2 (01:31 to 02:32 UK, 90 threads, 0.314 MH/s, 1.13 x 10^9 hashes, 4.2 expected blocks) found none (a 1.5 percent
chance at that rate; the nodes peered, agreed on the sink, exec tips equal at 0); run 3 was stopped once the GPU form
passed. O-2.6 is closed; the testnet genesis can be cut on this tree once the testnet lane re-cuts igneum-testnet-1's
genesis on the 16-byte payload layout.
What is left after B10:
- igneum-testnet-1's genesis: the testnet lane re-cuts it on the 16-byte payload layout (the hash in this tree,
494fc9a3..., is the PROPOSED genesis re-laid; the lane's FINAL genesis 87617621... was cut on the 8-byte layout and
cannot start at 18).
- the pool's build against the fork (one `cargo check` when the fork lands in `vendor/igneum-node`).
- the Igneum wallet lane: amounts as `Amount`, `format_ign` for display, `parse_ign` for input (section 6).
- the inherited Kaspa wallet and cli: 26 `pending_u64()` sites, if they are ever shipped.
- the exec layer pays producer shares to blue blocks only while the UTXO coinbase pays a red's share to the merging
miner (pre-existing; the exec lane's).

View file

@ -129,6 +129,7 @@ Implements `docs/design/execution-layer.md` D1 to D10 and section 8.2 on a 3-nod
| `igneum/evm-types/` (new crate `igneum-evm-types`) | Decoding through alloy (`TxEnvelope::decode_2718`), sender recovery, Cancun intrinsic gas, the per-transaction and per-body state-free rules; depends on alloy only so consensus and the executor share one decoder | Design 1.5 state-free class in one place | None to consensus beyond its use above | New crate. Pins `alloy-consensus 2.5`, `alloy-primitives 1.7`, `alloy-eips 2.5`. |
| `igneum/exec/` (new crate `igneum-exec`) | The execution layer: `service.rs` chain follower (polls `get_virtual_chain_from_block`, builds segment(C) from `get_block_acceptance_data` which is written in `consensus_ordered_mergeset` order, unwinds reorgs from a 64-deep snapshot ring), `executor.rs` (revm 43 over the segment, rewards by rule, two-dimensional gas, fee flows, skip rule), `pgas.rs` (the prototype pgas table and the per-frame attribution inspector, CREATE rule for the registry), `state.rs` (in-memory revm `CacheDB`, MPT state root through alloy-trie as an output), `pool.rs` (EVM mempool), `rpc.rs` (`eth_*` and `igneum_*` JSON-RPC on axum), `registry.rs` and `contracts/DeveloperRegistry.sol` (system contract at `0x...0210`, runtime bytecode embedded), `bin/diff.rs` (`igneum-exec-diff`, the plain-revm differential harness) | Design sections 1 to 4 and 8.2 | None to consensus (the node runs without it under `--evm-disable`) | New crate. Pins `revm 43`, `alloy-trie 0.9`, `axum 0.8`. The in-memory state and the full-recompute state root are devnet scope (see "what is missing"). |
| `igneum/miner/src/main.rs` | `--vote-key-hash <hex32>` (so a miner's EVM address, the low 20 bytes, is a key it holds), `--hold-ms <n>` (paces the stub miner on a network without proof of work), `--network simnet` (simnet address prefix for label addresses) | Test-network tooling | None | Internal tool. |
| `consensus/core/src/unit.rs` (new), `consensus/core/src/igneum.rs`, `consensus/core/src/mass/mod.rs`, `consensus/core/src/config/params.rs`, `kaspad/src/daemon.rs`, `consensus/core/src/unit_measure.rs` (new, an ignored measurement) | O-2.6 (6 October 2026, branch `decimals`): `Params::base_unit_decimals` (8 devnet and simnet, 18 testnet and mainnet; `OverrideParams`; in the digest once not 8); `unit::Amount(u128)` with borsh 16 LE, serde (string in JSON, u128 in bincode), the 16-byte and legacy 8-byte wire forms, `rescale`, `to_wei`, `format_ign` (8 visible digits) and `parse_ign`; the schedule at any unit (`block_subsidy_units`, `base_subsidy_per_second_units`, `subsidy_periods_units`, `launch_ramp_units`, the 80/20 split on `Amount`); `calc_storage_mass_units` and `UtxoCellUnits`; `Params::unit`, `wei_per_unit`, `storage_mass_parameter_units`, `supply_cap_units`, `base_unit_valid`, `base_unit_implemented`; the daemon prints the unit and refuses a unit outside `unit::IMPLEMENTED_DECIMALS` ([8] until the widening) | One IGN is 10^18 base units on every public network so the EVM bridge is the identity; the devnet keeps 8 and its digest does not move; nothing in the type depends on the cap | Low tonight (additive; the u64 schedule and mass rule are untouched and asserted equal at 8). High at phase B (`docs/design/base-unit.md` section 8: the UTXO value, the coinbase payload, the stores, p2p, RPC, mempool, exec bridge, txscript, wallet) | Pure addition beside Kaspa's `SOMPI_PER_KASPA` on the night of 6 October; phase B (7 October 2026, the same branch, B1 to B10a in `docs/design/base-unit.md` section 8) replaced upstream's u64 amount with `Amount` in the output, the entry, the coinbase and its payload, the fees, the mass cells, the stores, the UTXO index, the p2p and gRPC wires (low and high words), the RPC model, the mempool, the exec bridge and the script numbers, every byte form keyed on the network's unit so the devnet is unchanged byte for byte; upstream merges touch every one of those sites. |
## Decisions recorded as open

View file

@ -574,6 +574,15 @@ Answer: Correct. The fee block carries the dev address in the producer output on
Evidence: `docs/analysis/horizon/economy-and-utility.md` section 4.4 (`devfee_out.md`) and proposal 7, 6 October 2026; the fee measured on a test network, 4 October 2026 (bench-log: 9 fee blocks in 785).
### E22. Eight decimals on a chain that calls itself EVM-native
"Your coinbase pays sompi, your EVM shows wei, and your own stats API has to carry two `decimals` fields to explain it. Every wallet and bridge assumes 18. Either the UTXO side is a second ledger nobody reconciles to the unit, or you widen every u64 in a Kaspa fork the week before a testnet."
Status: Decided and implemented (6 to 7 October 2026; the project lead's word "18" in `docs/plans/ledger-decisions.md` row 5, the coordinator's approval of the design at 23:2x UK; phase B landed in the fork on 7 October 2026, `docs/design/base-unit.md` section 8; the B10 gate PASSED at 03:13 UK on a rented RTX 3090: 414 chain blocks at 18 decimals, the coinbase, the gRPC and the execution layer reading one number, 4,681.58333333 IGN on both nodes): 10^18 base units per IGN on the testnet and mainnet, 10^8 on the devnet; a genesis parameter, per network, in the consensus digest once it leaves 8; every amount a `u128` behind one type. Spec 2.5 carries the sentence; `docs/design/base-unit.md` is the design. Was: Open (O-2.6), 8 decimals in the code.
Answer: Correct on both counts, and the second is the work. The unit is 18 on every public network, so a consensus amount and an EVM balance are the same number and the bridge is the identity (`Params::wei_per_unit()` = 1; the devnet's 10^10 is the same function at 8). The widening is real: at 18 decimals the first full-rate block is 31,688,087,814,028,950,237 base units, above `u64::MAX` (18,446,744,073,709,551,615), so no u64 in the fork can hold one coinbase, and the type is `u128` with 10^19 seconds of headroom at that rate, so the emission lane's cap or tail never meets the type again. What is in the tree tonight (fork branch `decimals`): the parameter in all four networks (devnet and simnet 8, testnet and mainnet 18), the type with every byte form and a round-trip test above `u64::MAX` as the first test, the schedule at any unit asserted bit for bit against the u64 schedule at 8 and under the cap at 18, the u128 storage mass rule with the same answer at both units, and a daemon that refuses to start a network at a unit the binary cannot mint (`unit::IMPLEMENTED_DECIMALS` = [8] until the widening lands). What the widening costs (measured on igneum-build-1, 1,000,000 synthetic UTXOs): +8 B per entry on disk (97 to 105 B, 8.25 percent), +17 B per entry in memory (151 to 168 B), +6.4 B per output on the wire with two varints (6.0 to 12.4 B). For a node at 10,000,000 UTXOs that is 80 MB of disk and 170 MB of memory, which changes no tier's class on any card or OS. Phase B landed the same night (the design's section 8, seventeen commits, the four lanes B3, B4, B5 and B8 fanned out and merged): every form of an amount follows the network's unit, 8 bytes at 8 decimals so the devnet is byte-identical, 16 at 18; no protocol, store or serializer version moved; the genesis payloads of the 18-decimal networks carry a 16-byte subsidy; the economy lane's schedule is written at 8 decimals and rescaled to the unit (one schedule, one amount type); it ships as 0.3.18.
Evidence: `docs/design/base-unit.md` (the inventory of every amount, the serialisation table, the hostile review); the fork's `consensus/core/src/unit.rs`, `igneum.rs` (`schedule_units_tests`), `mass/mod.rs` (`units_tests`), `config/params.rs` (`base_unit_is_per_network_and_in_the_digest_once_not_eight`); `docs/bench-log.md`, "Base unit widening cost".
## 5. Governance and the founders
### G1. No cryptography team

View file

@ -97,7 +97,7 @@ Still owed from the project lead after the close: the cryptanalysis spend (fundi
| 2 | Latency-shadow ladder | Explanation requested | Answer pending; the six-step ladder, every step by 90 percent signal, never unconditional, verifier-bounded at 10 ms, is being implemented behind its switch meanwhile. |
| 3 | Proof verification in consensus | On | `proving_consensus_verify_daa` = 0 in the testnet genesis. Devnet stays off. |
| 4 | Finality leave item | On | `finality_leave_activation_daa` = 0 in the testnet genesis. Devnet stays never until the 95 percent signal. |
| 5 | Base unit | 18 | 18 decimals on the testnet (O-2.6 Decided, pending the implementation lane's gate); the devnet keeps 8. |
| 5 | Base unit | 18 | 18 decimals on the testnet (O-2.6 Decided, pending the implementation lane's gate); the devnet keeps 8. Lane state 6 October 2026, 22:xx UK: design `docs/design/base-unit.md`; the genesis parameter `base_unit_decimals`, the `u128` `Amount` type, the schedule at any unit and the u128 storage mass rule are in the fork (branch `decimals`, consensus-core 129 tests green on the box); the widening of the UTXO value, coinbase payload, stores, wire, RPC, mempool, exec bridge, txscript and wallet is phase B (26 hours, ten commits), and `igneumd` refuses to start a network at 18 until it lands. |
| 6 | Cryptanalysis spend | Yes | An outside team attacks the hash class after class v4 has run a week of real hash; bounded (lane 2's USD 80,000 to 160,000); the procurement plan is owed. |
| 7 | Testnet date | Leave open | No month anywhere; the go checklist is the date. |

View file

@ -121,7 +121,7 @@ Designed (design document, "The token" and "Difficulty, block timing and proving
| Halving interval | 63,115,200 DAA s (2 years of 365.25 days), for ever | Decided (ledger E9), `HALVING_INTERVAL_SECONDS` |
| Launch ramp | linear from 10% at genesis to 100% at DAA second 2,592,000 (30 days) | Designed |
| Split | 80% block producer, 20% proving pool. A red block inside the DAA window pays its 80% to the miner of the block that merges it and its 20% to the pool | Designed; the red rule Decided 3 October 2026 (ledger E10, round 3), `coinbase.rs:102 to 109` |
| Base unit | Open (O-2.6): the code keeps Kaspa's 8 decimals (`SOMPI_PER_KASPA`, devnet v0), under which the cap is 4 x 10^17 units and fits a u64; 18 decimals (the EVM convention) puts the cap at 4 x 10^27 and needs a wider type. `docs/fork-map.md` b2 wrote `cap_sompi = 4e9 x 1e8`. Decided before the first testnet genesis |
| Base unit | 10^18 base units per IGN on the testnet and mainnet (the EVM's wei, so a consensus amount and an EVM balance are one number and the exec bridge is the identity); the devnet keeps 10^8 (Kaspa's sompi, what it mints). A genesis parameter, `Params::base_unit_decimals`, per network, in the consensus digest once it leaves 8. Every amount is a `u128` behind `unit::Amount`, so nothing depends on the cap fitting any type. Decided 6 October 2026 (O-2.6; the project lead's word "18", `docs/plans/ledger-decisions.md` row 5; the coordinator's approval of the design, 23:2x UK; design `docs/design/base-unit.md`). Implemented end to end in the fork on 7 October 2026 (branch `decimals`, phase B: the UTXO value and entry, the coinbase and its payload, the fees, the mass rule, the stores, the p2p and gRPC wires, the mempool, the exec bridge and the script numbers all carry `unit::Amount` at the network's width: 8 bytes at 8 decimals so the devnet is byte-identical, 16 bytes at 18; `unit::IMPLEMENTED_DECIMALS` = [8, 18]; the mainnet and testnet genesis coinbase payloads carry a 16-byte subsidy, so igneum-testnet-1's genesis is re-cut on that layout). The two-node gate at 18 decimals passed on 7 October 2026, 03:13 UK (`tools/fleet/base-unit-gate.sh`, design section 8). Ships as 0.3.18 (0.3.17 went without it). The emission schedule (`Params::emission`, the economy lane) is written at 8 decimals and rescaled to the network's unit, so one schedule reads the same at every unit (the 18-decimal rate is exactly 10^10 times the 8-decimal floor. Was: Open, 8 decimals in the code, `docs/fork-map.md` b2 `cap_sompi = 4e9 x 1e8` |
Emission per DAA second at DAA score `t`:
@ -130,7 +130,7 @@ E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200)
ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000) evaluated in integers as a rational with denominator 25,920,000
```
The pre-ramp rate is 31.68808781 IGN per DAA second at 8 decimals (`BASE_SUBSIDY_PER_SECOND_SOMPI` = 3,168,808,781, asserted by the code's own test) and about 31.688 IGN at any unit (Decided, ledger E9). The geometric series sums to 4 x 10^9 IGN; the ramp withholds 0.45 x 2,592,000 / 31,557,600 x 10^9 = about 37 million IGN that are never minted, and integer floors withhold a negligible further amount, so the cap is a strict bound.
The pre-ramp rate is 31.68808781 IGN per DAA second at 8 decimals (`BASE_SUBSIDY_PER_SECOND_SOMPI` = 3,168,808,781, asserted by the code's own test) and 31,688,087,814,028,950,237 base units at 18 (`igneum::base_subsidy_per_second_units(18)`, asserted; the floor is taken at the finer unit, so 18 decimals mints 4,028,950,237 wei a second more than 10^10 times the 8-decimal rate, and the display agrees to 8 digits); about 31.688 IGN at any unit (Decided, ledger E9). The geometric series sums to 4 x 10^9 IGN; the ramp withholds 0.45 x 2,592,000 / 31,557,600 x 10^9 = about 37 million IGN that are never minted, and integer floors withhold a negligible further amount, so the cap is a strict bound.
Emission is keyed to DAA score, not to timestamps: `E` is a function of DAA score and miner-chosen timestamps cannot mint (hostile review table, "Emission per wall-clock second invites timestamp games"). `E` is paid per block, so coins are blocks times `E`: when the controller lets the block rate run above target, short-run emission runs above schedule by the same factor, as on every proof-of-work chain, and the cap is unaffected because the halving schedule and the ramp are in DAA seconds (Decided 3 October 2026, ledger E10, round 3; the earlier sentence "more blocks never means more coins" is withdrawn, and the devnet of 3 October 2026 minted 4.7x the schedule for eight minutes during a retarget lag, `docs/review/round-3-2026-10-03.md`, "Tonight's devnet"). Payment is through the merging block's coinbase as in Kaspa (`coinbase.rs:97 to 142`): the coinbase of block B pays, for each blue block M in B's mergeset, `E(daa_score(B))` split 80% to M's miner and 20% to the proving pool; for each red block in B's mergeset that is inside the DAA window, the same `E` split 80% to B's own miner and 20% to the pool (`coinbase.rs:102 to 109`, Kaspa's rule, Decided, ledger E10); a red outside the DAA window earns only its coinbase-level fees, of which Igneum has none (fees are in section 5). How the DAA-score increment is apportioned among the blues of one mergeset at higher block rates is Open (O-2.7); at 1 BPS the mergeset is usually one block.

View file

@ -40,7 +40,7 @@ An item closes when its measurement is in `docs/bench-log.md` or its decision is
| O-2.3 | Pruning depth must stay above the longest checkpoint gap and never pass the latest lock (section 2.1, F3) | Define the bound once the first-month rule (O-3.1) and the stall behaviour (section 3.3.1) fix the longest gap; implement in `block_depth.rs` and the virtual processor | 2, with 3 |
| O-2.4 | Mapping of the 64-bit hash into the 256-bit target and the block level for pruning proofs (fork map a3) | Decision: `target64 = target256 >> 192`, `level = leading_zeros(hash64)` capped at 64, or widen the output; check `calc_level_from_pow` callers | 2 |
| O-2.5 | Header validation gains a chain-state dependency (the epoch seed) and must stay deterministic from headers plus certificates during IBD and pruning-proof validation (fork map a4, risk High) | Implement `seed_source` validation; sync a node from genesis and from a pruning point on the devnet | 2 |
| O-2.6 | Base unit: 10^18 (EVM) or 10^8 (fork map b2) | Decision, owner execution engineer with consensus engineer | 2 |
| O-2.6 (decided) | **Decided 6 October 2026 (the project lead's answer "18", `docs/plans/ledger-decisions.md` row 5): 10^18 base units per IGN on the testnet and mainnet, 10^8 on the devnet; a genesis parameter `base_unit_decimals`, per network, in the digest once not 8; every amount a `u128` behind `unit::Amount`, so no cap or tail has to fit any type.** Design `docs/design/base-unit.md`. In the fork (branch `decimals` on eec34ac3): the parameter in all four networks, the type with every byte form (an amount above u64::MAX round-trips borsh, bincode, JSON, the wire; the known-failed case first), the schedule at any unit (bit for bit the u64 schedule at 8; the first full-rate block at 18 is 3.17 x 10^19, above u64::MAX; the full schedule under the cap in checked u128), the u128 storage mass rule (same answer at both units), the daemon's refusal to start a unit the binary cannot mint. Measured: +8 B per UTXO on disk, +17 B in memory, +6.4 B per output on the wire with two varints | Phase B landed 7 October 2026 (fork branch `decimals`, B1 to B10a, design section 8): `IMPLEMENTED_DECIMALS` = [8, 18], no `pending_u64()` site in anything the node runs, the genesis payloads at 16 bytes, the B10 gate script in `tools/fleet/base-unit-gate.sh` (two testnet-params nodes at 18 on igneum-build-1; the hour-long CPU form tonight, the ten-minute GPU form on a wave box). Left: the testnet lane re-cuts igneum-testnet-1's genesis on the 16-byte payload; the pool's build against the fork; the Igneum wallet's display | 2, closed: the gate PASSED 7 October 2026, 03:13 UK (GPU form, RunPod 3090: 414 chain blocks at 18 decimals, 585 rewards against the schedule with 0 wrong, the balance equal on both nodes and to the sum of the rewards) |
| O-2.7 | Apportioning the DAA-score increment among several blue blocks in one mergeset at higher block rates (section 2.5) | Define before the first block-rate step; at 1 BPS the mergeset is usually one block | 2, before the 4 BPS step |
| O-2.9 | `docs/fork-divergence.md` does not exist | Write it as the fork is made | 2 |
| O-2.10 | Block-rate steps to 4 and 10 BPS each re-derive k, parents and mergeset limit and are hard forks (hostile review table) | Each step has its own test campaign, as Kaspa's Crescendo | later, consensus engineer |

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

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# The base-unit widening's phase B marker (O-2.6, docs/design/base-unit.md section 8). While a path of the node fork
# still carries a u64 amount it narrows through `Amount::pending_u64()`; every such site is removed by its own step
# (B3 stores, B4 p2p, B5 RPC, B6 mempool, B7 exec, B8 txscript, B9 wallet and tools). This check counts the sites in a
# fork tree and fails when any are left in a tree that claims 18 decimals implemented (`IMPLEMENTED_DECIMALS` carrying
# 18), so B10 cannot flip the list over a half-widened tree; in a tree that still claims only 8 it prints the inventory
# and passes.
#
# tools/ci/base-unit-pending-check.sh <fork tree> # a vendor/igneum-node-* worktree
# 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' \
| grep -v -E '(^|/)(wallet|cli|rothschild)/' || true
}
claims_18() { # IMPLEMENTED_DECIMALS carries 18
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
sites=$(count_sites "$tree"); n=$(printf '%s' "$sites" | grep -c . || true)
if claims_18 "$tree"; then
if [ "$n" -gt 0 ]; then
echo "base-unit-pending: $n pending_u64() site(s) left while IMPLEMENTED_DECIMALS carries 18:"; printf '%s\n' "$sites"
return 1
fi
echo "base-unit-pending: no pending_u64() site; 18 decimals implemented end to end"
else
echo "base-unit-pending: $n pending_u64() site(s) in a tree at 8 decimals (phase B in progress)"
printf '%s\n' "$sites" | awk -F: '{print $1}' | sort | uniq -c | sort -rn
fi
}
if [ "${1:-}" = --self-test ]; then
t=$(mktemp -d)
mkdir -p "$t/bad/consensus/core/src" "$t/bad/rpc/src" "$t/good/consensus/core/src" "$t/wip/consensus/core/src" "$t/wip/rpc/src"
echo 'pub const IMPLEMENTED_DECIMALS: &[u8] = &[DEVNET_DECIMALS, EVM_DECIMALS]; // 8, 18' > "$t/bad/consensus/core/src/unit.rs"
echo 'pub const IMPLEMENTED_DECIMALS: &[u8] = &[8, 18];' > "$t/bad/consensus/core/src/unit.rs"
echo 'let v = amount.pending_u64();' > "$t/bad/rpc/src/x.rs"
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 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"

View file

@ -70,6 +70,7 @@ tree_checks() {
run "pinned guest programs match their manifest" bash tools/ci/pinned-guests-check.sh
run "root prover playbooks kill the GPU server and unlink its socket" bash tools/ci/prover-socket-check.sh
run "commit-string gate self-test" bash tools/ci/commit-string-check.sh --self-test
run "base-unit pending-narrowing check self-test (O-2.6 phase B)" bash tools/ci/base-unit-pending-check.sh --self-test
run "build server remote checkout self-test" bash infra/build-server/remote-run.sh --self-test
run "no shell assignment hides behind a trailing comment (the swallowed-defaults class)" bash -c 'bash tools/ci/defaults-line-check.sh --self-test && bash tools/ci/defaults-line-check.sh'
run "the identity check's own self-test (excluded research path passes, exported leak fails)" bash tools/ci/identity-check.sh --self-test

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

@ -0,0 +1,157 @@
#!/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.
#
# A fresh chain is never "synced" by the mining rule (its sink is the two-day-old genesis), so both nodes run with
# --enable-unsynced-mining, as a devnet's first node does; without it every found block is Reject(IsInIBD) (seen 00:2x UK).
#
# Known-failed case: run with GATE_EXPECT_DECIMALS=8 against the same nodes and check 3 and 5 fail (the schedule at 8
# does not match an 18-decimal chain). The self-test target below does that on the recorded output.
set -euo pipefail
SECS="${1:-600}"; MIN_BLOCKS="${MIN_BLOCKS:-60}"; THREADS="${THREADS:-48}"
BIN="${BIN:-/srv/builds/igneum-wt-decimals/vendor/igneum-node-decimals/target/release}"
ROOT="${ROOT:-/srv/builds/_gate-decimals}"; A_RPC=28110; A_P2P=28111; A_EVM=28190; B_RPC=28120; B_P2P=28121; B_EVM=28191
# WORKER=<path to igneum-worker-cuda|igneum-worker-opencl>: the GPU form (a wave box; the fleet lane rents it): the first
# pack is exported from node A, the worker serves it and the miner prepares the next seeds; THREADS is then ignored
WORKER="${WORKER:-}"
EVM_ADDR="00000000000000000000000000000000000000aa"
fail=0
say() { echo "$(date -u +%H:%M:%SZ) gate: $*"; }
die() { say "FAIL: $*"; fail=1; }
stop_all() {
for p in "$ROOT"/*.pid; do [ -f "$p" ] && kill "$(cat "$p")" 2>/dev/null || true; done
sleep 2
}
trap stop_all EXIT
rm -rf "$ROOT"; mkdir -p "$ROOT/a" "$ROOT/b"
[ -x "$BIN/igneumd" ] && [ -x "$BIN/igneum-miner" ] || { echo "FAIL: binaries missing in $BIN"; exit 2; }
say "starting node A (testnet params, 18 decimals)"
"$BIN/igneumd" --testnet --netsuffix=1 --enable-unsynced-mining --appdir="$ROOT/a" --listen=127.0.0.1:$A_P2P --rpclisten=127.0.0.1:$A_RPC --evm-rpclisten=127.0.0.1:$A_EVM --outpeers=1 --loglevel=info > "$ROOT/a.log" 2>&1 &
echo $! > "$ROOT/a.pid"
sleep 3
grep -m1 "Base unit" "$ROOT/a.log" || true
if grep -q -E "refusing to start|panicked" "$ROOT/a.log"; then die "node A did not start: $(grep -m1 -E 'refusing|panicked' "$ROOT/a.log")"; exit 1; fi
say "starting node B, connected to A"
"$BIN/igneumd" --testnet --netsuffix=1 --enable-unsynced-mining --appdir="$ROOT/b" --listen=127.0.0.1:$B_P2P --rpclisten=127.0.0.1:$B_RPC --evm-rpclisten=127.0.0.1:$B_EVM --connect=127.0.0.1:$A_P2P --loglevel=info > "$ROOT/b.log" 2>&1 &
echo $! > "$ROOT/b.pid"
for i in $(seq 1 60); do
if curl -s -m 2 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:$A_EVM | grep -q result \
&& curl -s -m 2 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:$B_EVM | grep -q result; then break; fi
sleep 2
done
if [ -n "$WORKER" ]; then
say "nodes up; exporting the first pack from A, then mining $SECS s on the GPU worker $WORKER, payout to 0x$EVM_ADDR"
mkdir -p "$ROOT/packs/first" "$ROOT/packs/prepare"
"$BIN/igneum-miner" export-pack grpc://127.0.0.1:$A_RPC "$ROOT/packs/first" > "$ROOT/pack.log" 2>&1 || say "export-pack exit $? (the testnet address prefix; the miner prepares the pack itself)"
"$BIN/igneum-miner" mine grpc://127.0.0.1:$A_RPC 1 "$SECS" gate --worker "$WORKER" --worker-args "--pack $ROOT/packs/first" --prepare-packs "$ROOT/packs/prepare" --network testnet --payout-label gate --evm-address "$EVM_ADDR" --dev-fee 0 --status-secs 60 > "$ROOT/miner.log" 2>&1 || say "miner exit $?"
else
say "nodes up; mining $SECS s with $THREADS CPU threads on A, payout to 0x$EVM_ADDR"
nice -n 19 "$BIN/igneum-miner" mine grpc://127.0.0.1:$A_RPC "$THREADS" "$SECS" gate --engine igneum-pow --network testnet --payout-label gate --evm-address "$EVM_ADDR" --dev-fee 0 --status-secs 60 > "$ROOT/miner.log" 2>&1 || say "miner exit $?"
fi
sleep 5
say "miner done; last status: $(grep -E "blocks|found|accepted" "$ROOT/miner.log" | tail -1 | cut -c1-200)"
say "check 1: both nodes agree"
"$BIN/igneum-miner" watch 6 grpc://127.0.0.1:$A_RPC grpc://127.0.0.1:$B_RPC > "$ROOT/watch.log" 2>&1 || true
tail -2 "$ROOT/watch.log" | cut -c1-240
evm() { curl -s -m 5 -X POST -H 'content-type: application/json' --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$2\",\"params\":$3}" "http://127.0.0.1:$1" ; }
TIP_A=$(evm $A_EVM eth_blockNumber '[]' | jq -r .result); TIP_B=$(evm $B_EVM eth_blockNumber '[]' | jq -r .result)
say "exec tips: A $TIP_A B $TIP_B"
[ "$TIP_A" = "$TIP_B" ] || die "exec tips differ (A $TIP_A, B $TIP_B)"
BLOCKS=$((TIP_A))
[ "$BLOCKS" -ge "$MIN_BLOCKS" ] || die "only $BLOCKS chain blocks, wanted $MIN_BLOCKS"
say "check 3: the UTXO side over the last 30 blocks on both nodes"
"$BIN/igneum-miner" inspect 30 grpc://127.0.0.1:$A_RPC grpc://127.0.0.1:$B_RPC > "$ROOT/inspect.log" 2>&1 || true
grep -c "same_on_all_nodes=true" "$ROOT/inspect.log" | sed 's/^/ blocks identical on both nodes: /'
if grep -q "MISMATCH" "$ROOT/inspect.log"; then die "80/20 mismatch: $(grep -m1 MISMATCH "$ROOT/inspect.log")"; fi
if grep -q "same_on_all_nodes=false" "$ROOT/inspect.log"; then die "a block differs between the nodes"; fi
python3 - "$ROOT/inspect.log" "${GATE_EXPECT_DECIMALS:-18}" <<'PY' || die "a coinbase payload subsidy is not the 18-decimal schedule (see above)"
import re, sys
decimals = int(sys.argv[2]); UNIT = 10 ** decimals
launch_rate = 100 * UNIT; ramp_seconds = 90 * 86400; start = 10; bps = 1
def subsidy(daa):
s = daa // bps; full = launch_rate // bps
return full if s >= ramp_seconds else full * (start * ramp_seconds + (100 - start) * s) // (100 * ramp_seconds)
n = bad = 0
for line in open(sys.argv[1]):
m = re.search(r" daa=(\d+) .*subsidy_in_payload=(\d+)", line)
if not m: continue
n += 1; daa, got = int(m.group(1)), int(m.group(2))
if got != subsidy(daa):
bad += 1
if bad <= 3: print(f" daa {daa}: payload subsidy {got} != schedule {subsidy(daa)}")
print(f" payload subsidies checked {n}, wrong {bad}; one IGN = {UNIT}; day-0 block = {subsidy(0)} base units")
assert n > 0 and bad == 0
PY
tail -1 "$ROOT/inspect.log" | cut -c1-200
say "check 4 and 5: the execution layer, both nodes, against the schedule"
BAL_A=$(evm $A_EVM eth_getBalance "[\"0x$EVM_ADDR\",\"latest\"]" | jq -r .result); BAL_B=$(evm $B_EVM eth_getBalance "[\"0x$EVM_ADDR\",\"latest\"]" | jq -r .result)
say "balances: A $BAL_A B $BAL_B"
[ "$BAL_A" = "$BAL_B" ] || die "balances differ between the nodes"
: > "$ROOT/segments.jsonl"
for n in $(seq 1 "$BLOCKS"); do evm $A_EVM igneum_getSegment "[\"$(printf '0x%x' "$n")\"]" >> "$ROOT/segments.jsonl"; echo >> "$ROOT/segments.jsonl"; done
python3 - "$ROOT/segments.jsonl" "$BAL_A" "$EVM_ADDR" "${GATE_EXPECT_DECIMALS:-18}" <<'PY' || die "the execution layer does not match the schedule (see above)"
import json, sys
path, bal_hex, addr, decimals = sys.argv[1], sys.argv[2], sys.argv[3].lower(), int(sys.argv[4])
UNIT = 10 ** decimals
# the testnet schedule (EmissionSchedule::TESTNET_1 rescaled to the unit): 100 IGN a second, a 90-day ramp from 10 percent,
# the first monthly glide step is far beyond a ten-minute gate
launch_rate = 100 * UNIT; ramp_seconds = 90 * 86400; start = 10; bps = 1
def ramp(full, s):
if s >= ramp_seconds: return full
return full * (start * ramp_seconds + (100 - start) * s) // (100 * ramp_seconds)
def subsidy(daa): return ramp(launch_rate // bps, daa // bps)
def producer(a):
q, r = divmod(a, 100); pool = q * 20 + r * 20 // 100
return a - pool
total = 0; checked = 0; bad = 0
for line in open(path):
line = line.strip()
if not line: continue
r = json.loads(line).get("result")
if not r:
print(" segment query failed:", line[:120]); bad += 1; continue
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:
print(f" segment {r['number']}: the chain block is not in its own mergeset list"); bad += 1; 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().removeprefix("0x") == addr: total += wei
bal = int(bal_hex, 16)
print(f" rewards checked {checked}, wrong {bad}; sum of our rewards {total}; eth_getBalance {bal}; one IGN = {UNIT}")
print(f" balance in IGN (8 visible digits): {bal // UNIT}.{(bal % UNIT) // (UNIT // 10**8):08d}")
assert bad == 0, "a reward disagrees with the schedule"
assert total == bal, "the balance is not the sum of the rewards (the bridge is not the identity)"
assert bal > 18446744073709551615, "the balance fits a u64: not an 18-decimal chain"
PY
if [ "$fail" = 0 ]; then say "PASS: two nodes at 18 decimals, $BLOCKS chain blocks, the coinbase, the gRPC and the execution layer read one number"; else say "FAIL (see above)"; exit 1; fi

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 = [];