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>
18 KiB
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 |
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
| 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 |
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.
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 |
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 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 |
| 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 is in the tree tonight and what is left
In the tree (fork branch decimals from eec34ac3 on the box mirror: 3158571c the type, fa61e035 the parameter and the schedule, fcd6b6e9 the mass rule, 6d5f2488 the measurement; each commit tested on its own on igneum-build-1, the first two by a stash-and-run: 120 and 126 consensus-core tests; the final tree: kaspa-consensus-core 129 passed, igneum-exec 21, igneum-miner 18, kaspa-p2p-flows 33, kaspa-pow 15, kaspad 2 + 7; kaspa-consensus 102 passed and 1 failed, igneum_m20_tests::witnesses_are_checked_in_epoch_order_under_their_own_seeds under the igneum-pow feature, which fails identically on the untouched eec34ac3 and is not this lane's):
the parameter in Params, OverrideParams, the digest (once not 8), all four networks (devnet and simnet 8, testnet and
mainnet 18); unit::Amount with every byte form and the round-trip test above u64::MAX first; the schedule at any
unit (block_subsidy_units and family) asserted bit for bit against the u64 schedule at 8 and under the cap at 18;
calc_storage_mass_units with the same-answer test; Params::unit, wei_per_unit, storage_mass_parameter_units,
supply_cap_units; the daemon's base-unit line and its refusal to start on a unit the binary cannot mint; the
measurement. The devnet's digest is unchanged (tested: an explicit 8 in the override file gives the same digest).
Left (phase B, the widening proper; every row is a commit that compiles and passes on its own, in this order):
| Step | Files | Hours |
|---|---|---|
B1 TransactionOutput.value, UtxoEntry.amount, UtxoCell to Amount; tx version 2 in hashing/tx.rs and the sighash; MAX_SOMPI to the params |
consensus-core (tx, utxo, hashing, mass, errors, sign) | 4 |
B2 coinbase: CoinbaseManager on block_subsidy_units, payload 16 bytes under version 2, BlockRewardData, the validators |
consensus (coinbase, body validation, tx validation, utxo validation, test consensus) | 3 |
| B3 the UTXO store schema version with the legacy decode, utxo diffs, muhash, the UTXO index | consensus stores, database, indexes | 2 |
B4 p2p: value_hi and amount_hi, protocol version 17, converters |
protocol/p2p, flows | 2 |
| B5 RPC: gRPC pair, rpc-core model, wRPC serializer version, JSON strings, wasm BigInt | rpc, consensus/client, wasm | 3 |
| B6 mempool and mining: fee rate keys, relay floor at the unit, dust, templates | mining | 2 |
B7 exec bridge: execute_segment(Amount), wei_per_unit, the guest input type, the shard program id re-pinned for the testnet genesis only |
igneum/exec, proving/igneum-prove | 2 |
| B8 txscript: the two introspection opcodes at 16-byte numbers or refused above 2^63 | crypto/txscript | 2 |
B9 wallet, cli, pool, observer, public stats, the app's display on format_ign |
wallet, cli, pool, tools/observer, app | 3 |
B10 flip IMPLEMENTED_DECIMALS to [8, 18]; the six-crate suite and a Devnet 2 crossing at 8 (nothing moves); a two-node testnet-params chain at 18 mining for ten minutes with the coinbase, the explorer and viem reading the same number |
tools/fleet, the gate | 3 |
26 hours of lane work. The devnet needs none of it to keep running; the testnet genesis needs all of it, and B10 is the gate.