iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit ffeb7105be4062c7e54e331d2d41349d0d5e9833
parent 071fefae8b02690c40f3a438ac67cc46080e0698
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat, 22 Aug 2026 23:29:49 +0200

Expand ticket-backed burn committees

Diffstat:
MREADME.md | 33++++++++++++++++++++-------------
Mdocker-compose.yml | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mdocs/protocol.md | 38+++++++++++++++++++-------------------
Msrc/adapters/http/ui.rs | 5++---
Msrc/adapters/ui_data_store.rs | 2+-
Msrc/adapters/ui_index.rs | 4++--
Msrc/app.rs | 4++--
Msrc/cli.rs | 1+
Msrc/domain/adversarial_tests.rs | 175++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/domain/ledger_chain.rs | 4++++
Msrc/domain/ledger_ops.rs | 41+++++++++++++++++++++++++++++------------
Msrc/domain/ledger_queries.rs | 285++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain/ledger_reveal.rs | 20++++++++++++++++++--
Msrc/domain/protocol.rs | 4++--
Msrc/main.rs | 33+++++++++++++++++++++++++++++++++
Msrc/main_tests.rs | 29+++++++++++++++++++++++++----
16 files changed, 631 insertions(+), 135 deletions(-)

diff --git a/README.md b/README.md @@ -111,29 +111,32 @@ The binary prints a local management URL. Open it and follow setup. ## Optional: Local Docker Testnet -For local P2P and consensus testing, start a three-node testnet with Docker +For local P2P and consensus testing, start a six-node testnet with Docker Compose: ```sh docker compose up --build ``` -The compose file starts one bootstrap genesis node and two joining nodes on an -isolated Docker network. Bootstrap automatically finalizes with burns, node2 -starts automatic PoW mining, and node3 starts with automatic burn/finalization -enabled. Node3 can only create burns once its wallet has spendable IUNA. +The compose file starts one bootstrap genesis node and five joining nodes on an +isolated Docker network. Every node automatically mines with one PoW worker and +enables burn/finalization. Joining nodes can therefore earn their first +spendable IUNA without a bootstrap transfer and begin burning afterward. Management UIs are exposed on: - bootstrap: <http://127.0.0.1:18661/> - node2: <http://127.0.0.1:18662/> - node3: <http://127.0.0.1:18663/> +- node4: <http://127.0.0.1:18664/> +- node5: <http://127.0.0.1:18665/> +- node6: <http://127.0.0.1:18666/> -Node3 also exposes Stratum on `127.0.0.1:3333`. P2P ports are mapped to -`19444`, `19445`, and `19446` for local inspection, while nodes announce their +Node3 also exposes Stratum on `127.0.0.1:3333`. P2P ports `19444` through +`19449` are mapped for local inspection, while nodes announce their stable Docker-network addresses to each other. -The local compose file uses `test` as the management UI and wallet password for -all three nodes. On first start this configures the management UI password and +The local compose file uses `testtesttest` as the management UI and wallet +password for all six nodes. On first start this configures the management UI password and encrypts the wallet; on restart it unlocks the encrypted wallet so finalization and automatic mining can continue without UI login. Compose also sets `IUNA_SETUP_COMPLETE=true`, so after unlocking the management UI you land @@ -163,14 +166,18 @@ settings: - `IUNA_SETUP_COMPLETE=true|false` - `IUNA_AUTOMATIC_BURN_ENABLED=true|false` - `IUNA_POW_MINING_ENABLED=true|false` +- `IUNA_POW_MINING_WORKERS=1..32` The compose bootstrap also selects the isolated `iuna-local-testnet-v1` launch -profile. Its PoW burn-committee lineages are eligible immediately, so node2 can -provide a real second committee attestation as soon as its first mine action is -confirmed. The normal launch profile retains the 20-block lineage maturity. +profile. Its PoW burn-committee lineages are eligible immediately, so joining +nodes can provide independent committee attestations as soon as their first mine +actions are confirmed. Six distinct owners allow the testnet to exercise a full +five-member rank-1 committee even though the missed rank-0 owner is excluded. +The normal launch profile retains the 20-block lineage maturity. Existing compose volumes created with the normal profile must be reset once with `docker compose down -v`, because consensus launch profiles cannot be -changed in place. +changed in place. The five-slot committee is also a consensus reset: volumes +created by the earlier three-slot protocol must likewise be recreated. Stop the network while keeping chain data: diff --git a/docker-compose.yml b/docker-compose.yml @@ -19,9 +19,12 @@ services: <<: *iuna-node hostname: iuna-bootstrap environment: - IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-test} + IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest} IUNA_SETUP_COMPLETE: "true" IUNA_LOCAL_TESTNET: "true" + IUNA_AUTOMATIC_BURN_ENABLED: "true" + IUNA_POW_MINING_ENABLED: "true" + IUNA_POW_MINING_WORKERS: "1" entrypoint: ["/bin/sh", "-c"] command: - if [ -s /data/chain.sqlite3 ]; then exec iuna --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.10:9444 --debug; else exec iuna --genesis --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.10:9444 --debug; fi @@ -38,9 +41,11 @@ services: <<: *iuna-node hostname: iuna-node2 environment: - IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-test} + IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest} IUNA_SETUP_COMPLETE: "true" + IUNA_AUTOMATIC_BURN_ENABLED: "true" IUNA_POW_MINING_ENABLED: "true" + IUNA_POW_MINING_WORKERS: "1" entrypoint: ["/bin/sh", "-c"] command: - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.11:9444 --debug @@ -60,9 +65,11 @@ services: <<: *iuna-node hostname: iuna-node3 environment: - IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-test} + IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest} IUNA_SETUP_COMPLETE: "true" IUNA_AUTOMATIC_BURN_ENABLED: "true" + IUNA_POW_MINING_ENABLED: "true" + IUNA_POW_MINING_WORKERS: "1" entrypoint: ["/bin/sh", "-c"] command: - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.12:9444 --stratum 0.0.0.0:3333 --debug @@ -79,6 +86,78 @@ services: iuna-testnet: ipv4_address: 172.28.0.12 + node4: + <<: *iuna-node + hostname: iuna-node4 + environment: + IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest} + IUNA_SETUP_COMPLETE: "true" + IUNA_AUTOMATIC_BURN_ENABLED: "true" + IUNA_POW_MINING_ENABLED: "true" + IUNA_POW_MINING_WORKERS: "1" + entrypoint: ["/bin/sh", "-c"] + command: + - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.13:9444 --debug + depends_on: + bootstrap: + condition: service_healthy + ports: + - "8664:18661" + - "19447:9444" + volumes: + - node4-data:/data + networks: + iuna-testnet: + ipv4_address: 172.28.0.13 + + node5: + <<: *iuna-node + hostname: iuna-node5 + environment: + IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest} + IUNA_SETUP_COMPLETE: "true" + IUNA_AUTOMATIC_BURN_ENABLED: "true" + IUNA_POW_MINING_ENABLED: "true" + IUNA_POW_MINING_WORKERS: "1" + entrypoint: ["/bin/sh", "-c"] + command: + - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.14:9444 --debug + depends_on: + bootstrap: + condition: service_healthy + ports: + - "8665:18661" + - "19448:9444" + volumes: + - node5-data:/data + networks: + iuna-testnet: + ipv4_address: 172.28.0.14 + + node6: + <<: *iuna-node + hostname: iuna-node6 + environment: + IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest} + IUNA_SETUP_COMPLETE: "true" + IUNA_AUTOMATIC_BURN_ENABLED: "true" + IUNA_POW_MINING_ENABLED: "true" + IUNA_POW_MINING_WORKERS: "1" + entrypoint: ["/bin/sh", "-c"] + command: + - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.15:9444 --debug + depends_on: + bootstrap: + condition: service_healthy + ports: + - "8666:18661" + - "19449:9444" + volumes: + - node6-data:/data + networks: + iuna-testnet: + ipv4_address: 172.28.0.15 + networks: iuna-testnet: driver: bridge @@ -90,3 +169,6 @@ volumes: bootstrap-data: node2-data: node3-data: + node4-data: + node5-data: + node6-data: diff --git a/docs/protocol.md b/docs/protocol.md @@ -17,7 +17,7 @@ This is still experimental. The rules below describe the current devnet and main The current mainnet-candidate parameter set is intentionally close to Bitcoin where that is useful for operator expectations: - P2P network ID: `iuna-mainnet-candidate-v1`; -- protocol version: `1`; +- protocol version: `2`; - launch profile ID: `iuna-mainnet-candidate-v1`; - launch profile hash: `aef51531eaa3a5c5d3ea8a2524ffba029dcb106e4b0a432b57d5ac1f4f8963de`; - target block time: `10 minutes`; @@ -40,7 +40,7 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh - PoW maximum retarget step: `2` bits; - PoW minimum difficulty: `10` bits; - maximum mine actions per anchor: `2`; -- burn committee size: `3` slots; +- burn committee size: `5` slots; - maximum signed burn bundle size: `10,000` bytes; - burn committee lineage maturity: `20` blocks. @@ -173,9 +173,9 @@ The idea is: So a censoring finalizer cannot simply leave out third-party burns that the committee witnessed. To keep censoring, it must either keep those burns away from committee members, control enough committee influence, or disrupt the normal ticket path until weaker liveness rules take over. -Each block has one burn-selected finalizer and up to two additional independent burn committee members. The finalizer is committee slot `0`; the finalizer's block signature counts as its slot `0` burn-list attestation. The additional committee slots are selected from mature UTXO lineages, not from burn tickets, so committee capture requires a different resource from block production. +Each block has one burn-selected finalizer and up to four additional burn committee members. The finalizer is committee slot `0`; the finalizer's block signature counts as its slot `0` burn-list attestation. Additional committee selection is root-first: mature UTXO lineage weight selects a group, then a wallet with a valid ticket for the target height is selected from within that lineage. -Why not choose the whole committee from burn tickets? Because then a large burner could buy both block production and the inclusion watchdog. Instead, the extra committee members are selected from UTXO lineages that originate in PoW mine actions. Burn weight chooses who can finalize blocks; mature mined coin lineages help choose who witnesses burn inclusion. +Why not weight the whole committee directly by burn tickets? Because then a large burner could buy both block production and the inclusion watchdog with the same weight. Instead, mature mined-coin lineage value determines which root groups can win additional slots. A valid ticket only determines which wallet may represent a winning group; its burn amount does not determine that root's committee weight. A UTXO lineage is a lightweight ancestry tag: @@ -201,13 +201,13 @@ Splitting one large root across many addresses does not multiply committee influ The lineage weight is logarithmic. A larger root has more chance to be selected, but doubling value does not double influence forever. This keeps committee selection from becoming a simple rich-get-richer vote while still giving larger, older mined lineages some weight. -For a target height, validators derive a deterministic committee seed from the parent hash and height. Slot `0` is assigned to the actual block finalizer. Rank `0` ticket blocks can use slots `1` and `2`; rank `1` ticket blocks can use slot `1`; rank `2` and later ticket blocks use only the finalizer slot. Lower-ranked ticket owners that missed their slot are skipped for fallback committee selection, because their tickets are no longer valid for that height. Extra slots are assigned without replacement by weighted deterministic draws over eligible lineage roots using `root_weight`. +For a target height, validators derive a deterministic committee seed from the parent hash and height. Slot `0` is assigned to the actual block finalizer. Every ticket rank can derive up to four additional slots, while the rank-dependent quorum determines how many attestations are required. Lower-ranked ticket owners that missed their slot are skipped for fallback committee selection. Extra slots are assigned without replacement by weighted deterministic draws over eligible lineage roots using `root_weight`. -After a lineage root wins, validators deterministically choose one representative owner from the unspent outputs tagged with that root. The additional slots are meant to be independent from the finalizer, so the finalizer's address and any address already selected for an earlier additional burn committee slot are skipped when choosing representatives. If no eligible non-finalizer representative remains for a winning root, that root is skipped and the draw continues to the next eligible root. +After a lineage root wins, validators deterministically choose one representative from the owners of unspent outputs tagged with that root. The representative must own a valid ticket for the target height. Non-ticket owners cannot sign for the group, even when they hold the root's largest output. The finalizer, missed fallback owners, and addresses already selected for an earlier slot are skipped. If no eligible ticket-owning representative remains for a root, that root cannot provide a committee slot. The protocol can detect addresses and lineage roots, not hidden common control, so a finalizer using unrelated addresses is still a social and economic risk rather than something this rule can perfectly identify. -If fewer eligible non-finalizer lineages exist than the rank can use, the committee is smaller. If no eligible non-finalizer lineage exists, burn inclusion quorum falls back to `1-of-1` through the finalizer's implicit slot `0` attestation. +If fewer eligible roots contain an eligible non-finalizer ticket wallet, the committee is smaller. If none exists, burn inclusion quorum falls back to `1-of-1` through the finalizer's implicit slot `0` attestation. ### Burn Committee Reward Split @@ -215,13 +215,13 @@ The block reward is the total fee reward for the block. It remains a single dete For normal ticket blocks, lower-rank finalization pays more to the independent burn-inclusion committee: -- rank `0`: the finalizer receives `50%`; committee slot `1` and slot `2` split the other `50%` equally (`25%` each when both slots are available); -- rank `1`: the finalizer receives `50%`; committee slot `1` receives the other `50%`; +- rank `0`: the finalizer receives `50%`; the non-finalizer members whose attestations are included split the other `50%` equally; +- rank `1`: the finalizer receives `50%`; the non-finalizer members whose attestations are included split the other `50%` equally; - rank `2` and later: the finalizer receives `100%`. Recovery blocks pay `100%` to the recovery finalizer. -If fewer extra committee members are available than the rank rule can pay, the available extra members share the committee half. If no extra committee member is available, the finalizer receives the full reward. Integer amounts are rounded down into the committee half (`reward / 2`), so the finalizer receives the remainder when the reward is odd. Committee reward outputs do not create UTXO lineage; lineage selection remains based on mature mine-action descendants. +Only attestations actually included in the block earn a committee share. If no extra committee attestation is required, the finalizer receives the full reward. Integer amounts are rounded down into the committee half (`reward / 2`), so the finalizer receives the remainder when the reward is odd. Committee reward outputs do not create UTXO lineage; lineage selection remains based on mature mine-action descendants. A committee member can sign one burn bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid fee-paying pending burns ordered by absolute fee, with signature as the deterministic tie-breaker. Honest committee policy is to include every valid burn it selects by that canonical ordering, or to sign an empty bundle only when the signer knows no valid burn for that height. Empty bundles are an honest-policy signal, not something validators can prove from their own mempools. Consensus checks committee membership, signature validity, lineage assignment, ordering, and threshold. @@ -231,7 +231,7 @@ A block contains transfers, burns, mine actions, and one compact burn-bundle sec The compact burn-bundle section stores: -- up to two explicit burn committee bundle signatures for non-finalizer slots, in slot order; +- up to four explicit burn committee bundle signatures for non-finalizer slots, in slot order; - one deduplicated required burn list; - a small bitmask per burn saying which of the included committee bundles contained that burn. @@ -245,25 +245,25 @@ Block validity is not allowed to depend on a validator's local mempool. Validato A block may contain at most one bundle per slot. If a block includes one valid bundle for a slot, validators check that included bundle and do not need to know whether another bundle for the same slot existed elsewhere. If a block builder sees two different signed bundles for the same height and slot before block assembly, it ignores that slot's bundles for the round as local safety policy. The current protocol does not have a separate slashing rule for this. -Ticket blocks must carry every burn-list attestation that is available for their finalizer rank. Rank `0` has the strictest rule because it is the preferred path. Fallback ranks have fewer possible committee slots because missed lower-rank ticket owners are no longer valid for that height. If a committee member can participate under the rank rule, its attestation is mandatory; otherwise the block is invalid. +Ticket blocks need a rank-dependent threshold of burn-list attestations. Rank `0` has the strictest rule because it is the preferred path. Missed lower-rank ticket owners are excluded from fallback committees, but each rank can still select up to five committee members from the remaining eligible owners. -That is a deliberate liveness tradeoff. A lower-ranked finalizer can have a smaller committee, so its required burn list may omit burns that appeared only to members that are no longer eligible for that fallback rank. This is weaker for fairness than the rank `0` path, but stronger for liveness when the strict path is stuck. +That is a deliberate liveness tradeoff. A block only commits to the bundles it includes, so burns seen exclusively by non-selected or omitted committee members are not required. Lower ranks use a smaller threshold and are therefore weaker for fairness, but preserve liveness when the preferred path is stuck. The available committee size is the finalizer plus the selected non-finalizer committee members for that height: -- rank `0` needs all available burn committee attestations (`3-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation; -- rank `1` needs all available attestations for the reduced rank-1 committee (`2-of-2` or `1-of-1`); -- rank `2` and later ticket finalizers use only the finalizer's implicit slot `0` attestation (`1-of-1`). +- rank `0` needs `min(3, committee size)` attestations (`3-of-5`, `3-of-4`, `3-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation; +- rank `1` needs `min(2, committee size)` attestations (`2-of-5` through `2-of-2`, or `1-of-1`); +- rank `2` and later ticket finalizers need only the finalizer's implicit slot `0` attestation (`1-of-n`). Recovery blocks do not require burn-list signatures. They are the last liveness escape hatch after the ticket path has failed, so committee failure must not be able to stop the chain forever. Recovery is weaker for fairness and is not meant to be the normal block path. The ticket-block VDF seed is bound to the burn-list attestation hashes: -`seed = hash(parent hash || height || attestation_hash[0] || attestation_hash[1] || attestation_hash[2])` +`seed = hash(parent hash || height || attestation_hash[0] || ... || attestation_hash[4])` Recovery blocks additionally bind the block timestamp into the VDF seed: -`seed = hash(parent hash || height || timestamp_ms || attestation_hash[0] || attestation_hash[1] || attestation_hash[2])` +`seed = hash(parent hash || height || timestamp_ms || attestation_hash[0] || ... || attestation_hash[4])` The burn-list attestation hashes are part of the VDF seed. This forces the finalizer to choose the included burn-attestation set before doing the delay work. After the VDF is computed, changing that attestation set changes the seed and invalidates the work. @@ -305,7 +305,7 @@ When a node builds a block, the flow is: 3. For recovery blocks, ensure at least one anchor burn is from the recovery finalizer. 4. Include every burn required by the selected burn-bundle attestations. 5. Fill remaining block space with valid fee-paying transfers, additional burns, and mine actions ordered by fee rate. Mine actions are limited to `2` actions per anchor. -6. Bind the VDF seed to the three burn-attestation slot hashes, using default hashes for missing slots. Slot `0` uses the synthetic finalizer attestation hash instead of a separate burn-bundle signature. +6. Bind the VDF seed to the five burn-attestation slot hashes, using default hashes for missing slots. Slot `0` uses the synthetic finalizer attestation hash instead of a separate burn-bundle signature. Blocks are bounded by transaction count and serialized byte size. The mainnet-candidate maximum block size is `1,000,000` bytes. diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -248,9 +248,8 @@ fn burn_bundle_wallet_quorum(block: &Block) -> (usize, usize) { return (0, 0); } - // Consensus requires every available explicit committee signature for rank 0 - // and rank 1. Rank 2 and later have no additional committee slots. Therefore - // an accepted ticket block records its actual quorum without consulting the + // The block records the attestations actually included in its rank-dependent + // quorum. This UI summary does not reconstruct the historical committee from // unrelated burn-ticket rank owners. let committee_size = block.burn_bundle_section.signatures.len().saturating_add(1); (committee_size, committee_size) diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -221,7 +221,7 @@ impl SqliteUiDataStore { pub fn project_snapshot(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> { let updated_at_ms = unix_ms(); let ui_index = build_ui_chain_index(snapshot); - let utxos = Ledger::from_persisted_snapshot(snapshot.clone()) + let utxos = Ledger::from_preverified_snapshot(snapshot.clone()) .context("failed to rebuild ledger for UI UTXO projection")? .all_utxos(); let wallet_transactions = wallet_transactions_from_snapshot(snapshot); diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -24,7 +24,7 @@ pub(crate) fn burn_leader_ranks_for_blocks( snapshot: &ChainSnapshot, blocks: &[Block], ) -> BTreeMap<String, Vec<BurnLeaderRank>> { - let Some(ranks_by_height) = Ledger::from_persisted_snapshot(snapshot.clone()) + let Some(ranks_by_height) = Ledger::from_preverified_snapshot(snapshot.clone()) .ok() .and_then(|ledger| { ledger @@ -49,7 +49,7 @@ pub(crate) fn burn_leader_ranks_for_blocks( fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { let mut outputs = BTreeMap::new(); let mut running_ledger = snapshot.blocks.first().cloned().and_then(|genesis| { - Ledger::from_persisted_snapshot(ChainSnapshot { + Ledger::from_preverified_snapshot(ChainSnapshot { genesis_allocations: snapshot.genesis_allocations.clone(), vdf_rounds: snapshot.vdf_rounds, launch_profile: snapshot.launch_profile.clone(), diff --git a/src/app.rs b/src/app.rs @@ -38,7 +38,7 @@ pub type SharedPeerBook = Arc<Mutex<PeerBook>>; pub const DEFAULT_BURN_PER_BLOCK: Amount = 0; pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate-v1"; pub const MAINNET_NETWORK_ID: &str = "iuna-mainnet-v1"; pub const NETWORK_ID: &str = MAINNET_CANDIDATE_NETWORK_ID; @@ -66,7 +66,7 @@ mod tests { #[test] fn mainnet_candidate_network_parameters_are_frozen() { assert_eq!(DEFAULT_VDF_ROUNDS, 67_000_000); - assert_eq!(PROTOCOL_VERSION, 1); + assert_eq!(PROTOCOL_VERSION, 2); assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate-v1"); assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1"); assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID); diff --git a/src/cli.rs b/src/cli.rs @@ -290,6 +290,7 @@ pub(crate) fn help_text() -> &'static str { IUNA_SETUP_COMPLETE=true|false Persist initial setup completion at startup\n\ IUNA_AUTOMATIC_BURN_ENABLED=true|false Persist automatic burn/finalization at startup\n\ IUNA_POW_MINING_ENABLED=true|false Persist automatic PoW mining at startup\n\ + IUNA_POW_MINING_WORKERS=1..32 Set and persist the PoW worker count at startup\n\ IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" } diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -212,18 +212,19 @@ impl Harness { } fn next_rank(&self, rank: usize) -> BurnLeaderRank { - self.ledger - .burn_leader_ranks_for_block(self.ledger.height() + 1) - .unwrap() - .get(rank) - .cloned() - .unwrap_or_else(|| { - panic!( - "seed {} has no rank {rank} at height {}", - self.seed, - self.ledger.height() + 1 - ) - }) + let height = self.ledger.height() + 1; + let ticket = self + .ledger + .ticket_for_finalizer_rank(height, rank as u32) + .unwrap_or_else(|| panic!("seed {} has no rank {rank} at height {height}", self.seed)); + BurnLeaderRank { + rank: rank as u32, + ticket_id: ticket.id, + owner: ticket.owner, + amount: ticket.amount, + eligible_from_height: ticket.eligible_from_height, + eligible_until_height: ticket.eligible_until_height, + } } fn submit_anchor_burn(&mut self, wallet: &Wallet) -> Transaction { @@ -405,6 +406,32 @@ impl Harness { self.mature_lineages(attacker_roots, honest_roots); } + fn ensure_lineage_owners_have_next_height_tickets(&mut self) { + let target_height = self.ledger.height() + 1; + let owners = self + .ledger + .lineage_owners + .values() + .flat_map(|owners| owners.keys().cloned()) + .collect::<BTreeSet<_>>(); + for (index, owner) in owners.into_iter().enumerate() { + if self.ledger.tickets.iter().any(|ticket| { + ticket.owner == owner + && ticket.eligible_from_height <= target_height + && target_height <= ticket.eligible_until_height + }) { + continue; + } + self.ledger.tickets.push(BurnTicket { + id: format!("fixture-lineage-ticket-{target_height}-{index}"), + owner, + amount: 1, + eligible_from_height: target_height, + eligible_until_height: target_height, + }); + } + } + fn attacker_addresses(&self) -> BTreeSet<String> { std::iter::once(self.attacker.wallet.address().to_string()) .chain( @@ -1652,9 +1679,11 @@ fn mini_burn_committee_for_height( owner: finalizer.owner.clone(), weight: finalizer.amount, }]; - let max_committee_size = BURN_COMMITTEE_SIZE - .saturating_sub(finalizer_rank as usize) - .max(1); + let max_committee_size = BURN_COMMITTEE_SIZE; + let eligible_ticket_owners = ranked + .iter() + .map(|ticket| ticket.owner.clone()) + .collect::<BTreeSet<_>>(); let mut skipped_owners = ranked .iter() .take(finalizer_rank as usize) @@ -1668,6 +1697,7 @@ fn mini_burn_committee_for_height( state, &candidate.root, &skipped_owners, + &eligible_ticket_owners, )?; Some(MiniLineageCandidate { owner, ..candidate }) }) @@ -1693,13 +1723,18 @@ fn mini_burn_committee_for_height( state, &candidate.root, &skipped_owners, + &eligible_ticket_owners, ) .is_some() }); for candidate in &mut remaining { - candidate.owner = - mini_representative_owner_for_lineage_root(state, &candidate.root, &skipped_owners) - .expect("retained mini lineage candidate has representative owner"); + candidate.owner = mini_representative_owner_for_lineage_root( + state, + &candidate.root, + &skipped_owners, + &eligible_ticket_owners, + ) + .expect("retained mini lineage candidate has representative owner"); } } @@ -1749,11 +1784,16 @@ fn mini_representative_owner_for_lineage_root( state: &MiniLineageState, root: &UtxoLineageRoot, skipped_owners: &BTreeSet<String>, + eligible_ticket_owners: &BTreeSet<String>, ) -> Option<String> { state.lineage_owners.get(root).and_then(|owners| { owners .iter() - .filter(|(owner, outputs)| !skipped_owners.contains(*owner) && !outputs.is_empty()) + .filter(|(owner, outputs)| { + eligible_ticket_owners.contains(*owner) + && !skipped_owners.contains(*owner) + && !outputs.is_empty() + }) .filter_map(|(owner, outputs)| { let (outpoint, amount) = outputs .iter() @@ -1921,8 +1961,8 @@ fn mini_required_explicit_burn_signatures( return 0; } match finalizer_mode { - FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.saturating_sub(1), - FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.saturating_sub(1), + FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1), + FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1), FinalizerMode::Ticket | FinalizerMode::Recovery => 0, } } @@ -2097,36 +2137,36 @@ fn committee_roots_are_unique(committee: &[BurnCommitteeMember]) -> bool { fn rank_zero_reward_is_credited_to_finalizer_and_extra_committee_members() { let mut harness = Harness::new(91, 50, 50, AdversaryStrategy::Honest); harness.mature_lineages(2, 2); + harness.ensure_lineage_owners_have_next_height_tickets(); let committee = harness.ledger.burn_committee_for_next_block(); - assert_eq!(committee.len(), 3, "test setup needs a full committee"); + assert!( + committee.len() >= 3, + "test setup needs multiple eligible ticket-owning committee members" + ); let finalizer = harness.next_rank(0).owner; - let committee_two = committee - .iter() - .find(|member| member.slot == 1) - .expect("slot 1 should be assigned") - .owner - .clone(); - let committee_three = committee + let committee_owners = committee .iter() - .find(|member| member.slot == 2) - .expect("slot 2 should be assigned") - .owner - .clone(); + .filter(|member| member.slot > 0) + .map(|member| member.owner.clone()) + .collect::<Vec<_>>(); let third_party = harness .wallets .values() .find(|wallet| { wallet.address() != finalizer - && wallet.address() != committee_two - && wallet.address() != committee_three + && !committee_owners + .iter() + .any(|owner| owner == wallet.address()) }) .expect("test setup has a third-party burner") .clone(); let finalizer_before = harness.ledger.balance_of(&finalizer); - let committee_two_before = harness.ledger.balance_of(&committee_two); - let committee_three_before = harness.ledger.balance_of(&committee_three); + let committee_before = committee_owners + .iter() + .map(|owner| (owner.clone(), harness.ledger.balance_of(owner))) + .collect::<BTreeMap<_, _>>(); let finalizer_wallet = harness.wallet(&finalizer).clone(); harness.submit_anchor_burn(&finalizer_wallet); @@ -2144,14 +2184,15 @@ fn rank_zero_reward_is_credited_to_finalizer_and_extra_committee_members() { harness.ledger.balance_of(&finalizer), finalizer_before + 50 - 2 ); - assert_eq!( - harness.ledger.balance_of(&committee_two), - committee_two_before + 25 - ); - assert_eq!( - harness.ledger.balance_of(&committee_three), - committee_three_before + 25 - ); + let committee_reward = committee_owners + .iter() + .map(|owner| { + let credited = harness.ledger.balance_of(owner) - committee_before[owner]; + assert!(credited > 0, "included committee member must be rewarded"); + credited + }) + .sum::<Amount>(); + assert_eq!(committee_reward, 50); } proptest! { @@ -2649,6 +2690,7 @@ fn mini_burn_bundle_quorum_oracle_matches_consensus_mutations() { fn finalizer_anchor_alone_requires_available_committee_signatures() { let mut harness = harness_for_percent(20, 25); harness.mature_lineages(1, 4); + harness.ensure_lineage_owners_have_next_height_tickets(); let leader = harness.next_rank(0); let finalizer = harness.wallet(&leader.owner).clone(); harness.submit_anchor_burn(&finalizer); @@ -2673,20 +2715,8 @@ fn finalizer_anchor_alone_requires_available_committee_signatures() { fn rank_one_committee_excludes_missed_rank_zero_owner_and_requires_remaining_slot() { let mut harness = harness_for_percent(22, 25); harness.mature_lineages(2, 4); + harness.ensure_lineage_owners_have_next_height_tickets(); let target_height = harness.ledger.height() + 1; - harness.ledger.tickets = harness - .honest - .iter() - .take(2) - .enumerate() - .map(|(index, wallet)| BurnTicket { - id: format!("fallback-ticket-{index}"), - owner: wallet.address().to_string(), - amount: MICRO_IUNA, - eligible_from_height: target_height, - eligible_until_height: target_height, - }) - .collect(); let rank_zero = harness .ledger .ticket_for_finalizer_rank(target_height, 0) @@ -2699,10 +2729,9 @@ fn rank_one_committee_excludes_missed_rank_zero_owner_and_requires_remaining_slo .owner; let committee = harness.ledger.burn_committee_for_next_ticket_block(1); - assert_eq!( - committee.len(), - 2, - "test setup should have one remaining rank-1 committee member" + assert!( + committee.len() > 1, + "test setup should retain at least one rank-1 committee member" ); assert_eq!( committee.first().map(|member| member.owner.as_str()), @@ -2895,6 +2924,7 @@ fn post_genesis_transactions_cannot_spend_with_genesis_input_signatures() { fn invalid_committee_signature_is_rejected() { let mut harness = harness_for_percent(13, 25); harness.mature_lineages(1, 4); + harness.ensure_lineage_owners_have_next_height_tickets(); let member = harness .ledger .burn_committee_for_next_block() @@ -2938,10 +2968,16 @@ fn non_mature_lineage_cannot_join_committee() { } #[test] -fn local_testnet_lineage_is_immediately_required_by_rank_zero_quorum() { +fn local_testnet_lineage_with_an_eligible_ticket_is_immediately_required_by_rank_zero_quorum() { let mut harness = harness_for_percent(141, 25); harness.ledger.launch_profile.burn_lineage_maturity_heights = 0; - let committee_wallet = harness.attacker.lineage_wallets[0].clone(); + let current_leader = harness.next_rank(0).owner; + let committee_wallet = harness + .honest + .iter() + .find(|wallet| wallet.address() != current_leader) + .expect("test setup needs a non-finalizer ticket wallet") + .clone(); let mine = harness .ledger .build_mine(committee_wallet.address()) @@ -2956,6 +2992,17 @@ fn local_testnet_lineage_is_immediately_required_by_rank_zero_quorum() { .any(|member| member.slot > 0 && member.owner == committee_wallet.address()), "local-testnet lineage did not enter the next committee: {committee:?}" ); + assert!( + harness + .ledger + .explicit_burn_bundle_signatures_required_for_next_block( + FinalizerMode::Ticket, + 0, + harness.next_rank(0).owner.as_str(), + ) + > 0, + "rank-0 committee should require an explicit signature: {committee:?}" + ); let leader = harness.next_rank(0); let finalizer = harness.wallet(&leader.owner).clone(); diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -96,6 +96,10 @@ impl Ledger { Self::from_snapshot_at(snapshot, u64::MAX) } + pub(crate) fn from_preverified_snapshot(snapshot: ChainSnapshot) -> Result<Self> { + Self::from_snapshot_with_vdf_policy(snapshot, false, u64::MAX) + } + pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> { Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms) } diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -479,8 +479,12 @@ pub fn reward_outputs_for_block( fn reward_committee_slots(block: &Block) -> Vec<u8> { match block.finalizer_mode { - FinalizerMode::Ticket if block.finalizer_rank == 0 => vec![1, 2], - FinalizerMode::Ticket if block.finalizer_rank == 1 => vec![1], + FinalizerMode::Ticket if block.finalizer_rank <= 1 => block + .burn_bundle_section + .signatures + .iter() + .map(|signature| signature.slot) + .collect(), FinalizerMode::Ticket | FinalizerMode::Recovery => Vec::new(), } } @@ -602,7 +606,7 @@ fn committee_reward_outpoint(block_hash: &str, slot: u8) -> OutPoint { #[cfg(test)] mod tests { use super::*; - use crate::domain::BurnBundleSection; + use crate::domain::{BurnBundleSection, BurnBundleSignature}; fn reward_block(finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount) -> Block { let mut block = Block { @@ -633,6 +637,17 @@ mod tests { } } + fn attest(block: &mut Block, slots: &[u8]) { + block.burn_bundle_section.signatures = slots + .iter() + .map(|slot| BurnBundleSignature { + slot: *slot, + member: format!("committee-{slot}"), + signature: format!("signature-{slot}"), + }) + .collect(); + } + fn output_amount(outputs: &[(OutPoint, TxOutput)], owner: &str) -> Amount { outputs .iter() @@ -643,7 +658,8 @@ mod tests { #[test] fn rank_zero_splits_half_to_two_extra_committee_members() { - let block = reward_block(FinalizerMode::Ticket, 0, 100); + let mut block = reward_block(FinalizerMode::Ticket, 0, 100); + attest(&mut block, &[1, 2]); let committee = vec![ committee_member(0, "finalizer"), committee_member(1, "committee-2"), @@ -674,7 +690,8 @@ mod tests { #[test] fn rank_zero_gives_committee_half_to_the_only_available_extra_member() { - let block = reward_block(FinalizerMode::Ticket, 0, 101); + let mut block = reward_block(FinalizerMode::Ticket, 0, 101); + attest(&mut block, &[1]); let committee = vec![ committee_member(0, "finalizer"), committee_member(1, "committee-2"), @@ -687,20 +704,20 @@ mod tests { } #[test] - fn rank_one_splits_only_with_committee_slot_one() { - let block = reward_block(FinalizerMode::Ticket, 1, 100); + fn rank_one_splits_with_the_member_that_attested() { + let mut block = reward_block(FinalizerMode::Ticket, 1, 100); + attest(&mut block, &[2]); let committee = vec![ - committee_member(0, "missed-primary"), - committee_member(1, "committee-2"), - committee_member(2, "committee-3"), + committee_member(0, "finalizer"), + committee_member(1, "committee-1"), + committee_member(2, "committee-2"), ]; let outputs = reward_outputs_for_block(&block, &committee); assert_eq!(output_amount(&outputs, "finalizer"), 50); assert_eq!(output_amount(&outputs, "committee-2"), 50); - assert_eq!(output_amount(&outputs, "committee-3"), 0); - assert_eq!(output_amount(&outputs, "missed-primary"), 0); + assert_eq!(output_amount(&outputs, "committee-1"), 0); } #[test] diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -255,9 +255,11 @@ impl Ledger { owner: finalizer.owner.clone(), weight: finalizer.amount, }]; - let max_committee_size = super::BURN_COMMITTEE_SIZE - .saturating_sub(finalizer_rank as usize) - .max(1); + let max_committee_size = super::BURN_COMMITTEE_SIZE; + let eligible_ticket_owners = ranked + .iter() + .map(|ticket| ticket.owner.clone()) + .collect::<BTreeSet<_>>(); let mut skipped_owners = ranked .iter() .take(finalizer_rank as usize) @@ -268,8 +270,11 @@ impl Ledger { .eligible_lineage_candidates(&skipped_owners) .into_iter() .filter_map(|candidate| { - let owner = - self.representative_owner_for_lineage_root(&candidate.root, &skipped_owners)?; + let owner = self.representative_owner_for_lineage_root( + &candidate.root, + &skipped_owners, + &eligible_ticket_owners, + )?; Some(LineageCommitteeCandidate { root: candidate.root, value: candidate.value, @@ -296,12 +301,20 @@ impl Ledger { remaining.retain(|candidate| { candidate.root != selected.root && self - .representative_owner_for_lineage_root(&candidate.root, &skipped_owners) + .representative_owner_for_lineage_root( + &candidate.root, + &skipped_owners, + &eligible_ticket_owners, + ) .is_some() }); for candidate in &mut remaining { candidate.owner = self - .representative_owner_for_lineage_root(&candidate.root, &skipped_owners) + .representative_owner_for_lineage_root( + &candidate.root, + &skipped_owners, + &eligible_ticket_owners, + ) .expect("retained lineage candidate has representative owner"); } } @@ -349,11 +362,16 @@ impl Ledger { &self, root: &UtxoLineageRoot, skipped_owners: &BTreeSet<String>, + eligible_ticket_owners: &BTreeSet<String>, ) -> Option<String> { self.lineage_owners.get(root).and_then(|owners| { owners .iter() - .filter(|(owner, outputs)| !skipped_owners.contains(*owner) && !outputs.is_empty()) + .filter(|(owner, outputs)| { + eligible_ticket_owners.contains(*owner) + && !skipped_owners.contains(*owner) + && !outputs.is_empty() + }) .filter_map(|(owner, outputs)| { let (outpoint, amount) = outputs.iter().max_by(|left, right| { left.1.cmp(right.1).then_with(|| right.0.cmp(left.0)) @@ -516,3 +534,254 @@ impl Ledger { + 1 } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{BURN_COMMITTEE_SIZE, GenesisBurn, MICRO_IUNA, Wallet}; + use proptest::prelude::*; + use proptest::test_runner::Config; + + fn synthetic_committee_ledger(seed: u64) -> Ledger { + let mut ledger = Ledger::new(BTreeMap::new(), 1); + ledger.launch_profile.burn_lineage_maturity_heights = 0; + let owners = (0..10) + .map(|index| format!("owner-{seed}-{index}")) + .collect::<Vec<_>>(); + + ledger.tickets = owners + .iter() + .enumerate() + .map(|(index, owner)| { + let valid = index == 0 || seed.rotate_right(index as u32) & 1 == 1; + let (eligible_from_height, eligible_until_height) = if valid { + (1, 1) + } else if index % 2 == 0 { + (0, 0) + } else { + (2, 3) + }; + BurnTicket { + id: format!("ticket-{seed}-{index}"), + owner: owner.clone(), + amount: 1 + seed.rotate_left(index as u32) % 100, + eligible_from_height, + eligible_until_height, + } + }) + .collect(); + + for root_index in 0_u32..8 { + let root = UtxoLineageRoot { + outpoint: OutPoint { + txid: format!("{:064x}", u128::from(seed) << 8 | u128::from(root_index)), + index: root_index, + }, + height: u64::from((seed.rotate_right(root_index) & 0b11) == 0), + }; + let first_owner = (root_index as usize + seed as usize) % owners.len(); + let second_owner = (first_owner + 1 + root_index as usize) % owners.len(); + let first_value = 1 + seed.rotate_left(root_index) % 1_000; + let second_value = 1 + seed.rotate_right(root_index) % 1_000; + ledger + .lineage_values + .insert(root.clone(), first_value + second_value); + ledger.lineage_owners.insert( + root, + BTreeMap::from([ + ( + owners[first_owner].clone(), + BTreeMap::from([( + OutPoint { + txid: format!("{:064x}", 0x100_u128 + root_index as u128), + index: 0, + }, + first_value, + )]), + ), + ( + owners[second_owner].clone(), + BTreeMap::from([( + OutPoint { + txid: format!("{:064x}", 0x200_u128 + root_index as u128), + index: 0, + }, + second_value, + )]), + ), + ]), + ); + } + ledger + } + + #[test] + fn lineage_committee_selects_a_ticket_owner_within_the_selected_root() { + let finalizer = Wallet::from_seed("lineage-ticket-finalizer"); + let ticket_owner = Wallet::from_seed("lineage-ticket-owner"); + let non_ticket_owner = Wallet::from_seed("lineage-non-ticket-owner"); + let mut ledger = Ledger::new_with_genesis_burns( + BTreeMap::from([(finalizer.address().to_string(), 10 * MICRO_IUNA)]), + vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + ledger.launch_profile.burn_lineage_maturity_heights = 0; + + let ticket_root = UtxoLineageRoot { + outpoint: OutPoint { + txid: "1".repeat(64), + index: 0, + }, + height: 0, + }; + ledger.lineage_values.insert(ticket_root.clone(), 110); + ledger.lineage_owners.insert( + ticket_root.clone(), + BTreeMap::from([ + ( + non_ticket_owner.address().to_string(), + BTreeMap::from([( + OutPoint { + txid: "2".repeat(64), + index: 0, + }, + 100, + )]), + ), + ( + ticket_owner.address().to_string(), + BTreeMap::from([( + OutPoint { + txid: "3".repeat(64), + index: 0, + }, + 10, + )]), + ), + ]), + ); + + let non_ticket_root = UtxoLineageRoot { + outpoint: OutPoint { + txid: "4".repeat(64), + index: 0, + }, + height: 0, + }; + ledger.lineage_values.insert(non_ticket_root.clone(), 1_000); + ledger.lineage_owners.insert( + non_ticket_root, + BTreeMap::from([( + non_ticket_owner.address().to_string(), + BTreeMap::from([( + OutPoint { + txid: "5".repeat(64), + index: 0, + }, + 1_000, + )]), + )]), + ); + + let ranked = vec![ + BurnTicket { + id: "finalizer-ticket".to_string(), + owner: finalizer.address().to_string(), + amount: 1, + eligible_from_height: 1, + eligible_until_height: 1, + }, + BurnTicket { + id: "committee-ticket".to_string(), + owner: ticket_owner.address().to_string(), + amount: 1, + eligible_from_height: 1, + eligible_until_height: 1, + }, + ]; + + let committee = ledger.lineage_burn_committee_for_height(1, ranked, 0); + + assert_eq!(committee.len(), 2); + assert_eq!(committee[1].root, ticket_root.outpoint.id()); + assert_eq!(committee[1].owner, ticket_owner.address()); + assert_ne!(committee[1].owner, non_ticket_owner.address()); + } + + proptest! { + #![proptest_config(Config { cases: 128, .. Config::default() })] + + #[test] + fn committee_members_are_eligible_ticket_owners_in_distinct_mature_roots( + seed in any::<u64>(), + requested_rank in 0usize..10, + ) { + let ledger = synthetic_committee_ledger(seed); + let target_height = ledger.height() + 1; + let ranked = ranked_tickets_for_height(ledger.tip(), target_height, &ledger.tickets); + let rank = requested_rank % ranked.len(); + let committee = ledger.burn_committee_for_next_ticket_block(rank as u32); + let repeated = ledger.burn_committee_for_next_ticket_block(rank as u32); + let excluded_owners = ranked + .iter() + .take(rank + 1) + .map(|ticket| ticket.owner.as_str()) + .collect::<BTreeSet<_>>(); + + prop_assert_eq!(&committee, &repeated, "committee selection must be deterministic"); + prop_assert!(!committee.is_empty()); + prop_assert!(committee.len() <= BURN_COMMITTEE_SIZE); + + let mut owners = BTreeSet::new(); + let mut roots = BTreeSet::new(); + for (index, member) in committee.iter().enumerate() { + prop_assert_eq!(usize::from(member.slot), index, "committee slots must be contiguous"); + prop_assert!(owners.insert(member.owner.as_str()), "committee owners must be unique"); + prop_assert!( + ledger.tickets.iter().any(|ticket| { + ticket.owner == member.owner + && ticket.eligible_from_height <= target_height + && target_height <= ticket.eligible_until_height + }), + "committee member {} has no ticket for height {target_height}", + member.owner, + ); + + if member.slot == 0 { + prop_assert_eq!(&member.owner, &ranked[rank].owner); + prop_assert_eq!(&member.root, &ranked[rank].id); + prop_assert_eq!(member.weight, ranked[rank].amount); + continue; + } + + prop_assert!(!excluded_owners.contains(member.owner.as_str())); + let (root, root_owners) = ledger + .lineage_owners + .iter() + .find(|(root, _)| root.outpoint.id() == member.root) + .expect("selected committee root must exist"); + prop_assert!(roots.insert(root.clone()), "lineage roots must be unique"); + prop_assert_eq!(member.weight, ledger.lineage_values[root]); + prop_assert!( + root.height + .saturating_add(ledger.launch_profile.burn_lineage_maturity_heights) + <= ledger.tip().height, + "committee root must be mature", + ); + prop_assert!( + root_owners + .get(&member.owner) + .is_some_and(|outputs| !outputs.is_empty()), + "committee member must currently belong to the selected lineage root", + ); + prop_assert!( + root_owners + .keys() + .all(|owner| !excluded_owners.contains(owner.as_str())), + "a root containing a missed owner or finalizer must be excluded", + ); + } + } + } +} diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -308,8 +308,8 @@ impl Ledger { return 0; } match finalizer_mode { - FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.saturating_sub(1), - FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.saturating_sub(1), + FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1), + FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1), FinalizerMode::Ticket => 0, FinalizerMode::Recovery => 0, } @@ -494,10 +494,26 @@ mod tests { let ledger = ledger(); assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 5), + 2 + ); + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 4), + 2 + ); + assert_eq!( ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3), 2 ); assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 2), + 1 + ); + assert_eq!( + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 5), + 1 + ); + assert_eq!( ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 2), 1 ); diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -13,7 +13,7 @@ pub const RECOVERY_BLOCK_DELAY_MS: u64 = VDF_TARGET_BLOCK_MS * 6; pub const MAX_VDF_ROUNDS: u64 = i64::MAX as u64; pub const MINE_DIFFICULTY_BITS: u32 = 12; pub const MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2; -pub const BURN_COMMITTEE_SIZE: usize = 3; +pub const BURN_COMMITTEE_SIZE: usize = 5; pub const MAX_BURN_BUNDLE_BYTES: usize = 10_000; pub const BURN_LINEAGE_MATURITY_HEIGHTS: u64 = 20; @@ -58,7 +58,7 @@ mod tests { assert_eq!(RECOVERY_BLOCK_DELAY_MS, 6 * VDF_TARGET_BLOCK_MS); assert_eq!(MINE_DIFFICULTY_BITS, 12); assert_eq!(MINE_ACTIONS_PER_ANCHOR_LIMIT, 2); - assert_eq!(BURN_COMMITTEE_SIZE, 3); + assert_eq!(BURN_COMMITTEE_SIZE, 5); assert_eq!(MAX_BURN_BUNDLE_BYTES, 10_000); assert_eq!(BURN_LINEAGE_MATURITY_HEIGHTS, 20); assert_eq!(MAX_PENDING_TRANSACTIONS, 10_000); diff --git a/src/main.rs b/src/main.rs @@ -42,6 +42,7 @@ const VDF_MEASUREMENT_MIN_ELAPSED: Duration = Duration::from_millis(150); const VDF_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(10); const AUTOMATIC_BURN_ENABLED_ENV: &str = "IUNA_AUTOMATIC_BURN_ENABLED"; const POW_MINING_ENABLED_ENV: &str = "IUNA_POW_MINING_ENABLED"; +const POW_MINING_WORKERS_ENV: &str = "IUNA_POW_MINING_WORKERS"; const LOCAL_TESTNET_ENV: &str = "IUNA_LOCAL_TESTNET"; const SETUP_COMPLETE_ENV: &str = "IUNA_SETUP_COMPLETE"; const WALLET_PASSWORD_ENV: &str = "IUNA_WALLET_PASSWORD"; @@ -72,6 +73,7 @@ async fn main() -> Result<()> { let startup_wallet_password = startup_wallet_password_from_env()?; let startup_automatic_burn_enabled = startup_bool_from_env(AUTOMATIC_BURN_ENABLED_ENV)?; let startup_pow_mining_enabled = startup_bool_from_env(POW_MINING_ENABLED_ENV)?; + let startup_pow_mining_workers = startup_pow_mining_workers_from_env()?; let startup_local_testnet = startup_bool_from_env(LOCAL_TESTNET_ENV)?.unwrap_or(false); let startup_setup_complete = startup_bool_from_env(SETUP_COMPLETE_ENV)?; let p2p_config_dirty = apply_cli_p2p_config_overrides(&opts, &mut ui_config); @@ -92,6 +94,7 @@ async fn main() -> Result<()> { &mut ui_config, startup_automatic_burn_enabled, startup_pow_mining_enabled, + startup_pow_mining_workers, ); let setup_config_dirty = apply_startup_setup_config_override( &opts, @@ -345,10 +348,34 @@ fn parse_startup_bool_env_value(name: &str, normalized: &str) -> Result<bool> { } } +fn startup_pow_mining_workers_from_env() -> Result<Option<u8>> { + let Some(value) = std::env::var_os(POW_MINING_WORKERS_ENV) else { + return Ok(None); + }; + let value = value + .into_string() + .map_err(|_| anyhow::anyhow!("{POW_MINING_WORKERS_ENV} must be valid UTF-8"))?; + parse_startup_pow_mining_workers_env_value(value.trim()).map(Some) +} + +fn parse_startup_pow_mining_workers_env_value(value: &str) -> Result<u8> { + let workers = value + .parse::<u8>() + .with_context(|| format!("{POW_MINING_WORKERS_ENV} must be an integer"))?; + if !(1..=config_store::MAX_POW_MINING_WORKERS).contains(&workers) { + bail!( + "{POW_MINING_WORKERS_ENV} must be between 1 and {}", + config_store::MAX_POW_MINING_WORKERS + ); + } + Ok(workers) +} + fn apply_startup_mining_config_overrides( ui_config: &mut config_store::UiConfig, automatic_burn_enabled: Option<bool>, pow_mining_enabled: Option<bool>, + pow_mining_workers: Option<u8>, ) -> bool { let mut dirty = false; if let Some(enabled) = automatic_burn_enabled { @@ -363,6 +390,12 @@ fn apply_startup_mining_config_overrides( dirty = true; } } + if let Some(workers) = pow_mining_workers { + if ui_config.pow_mining_workers != workers { + ui_config.pow_mining_workers = workers; + dirty = true; + } + } dirty } diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -22,8 +22,9 @@ use super::{ apply_startup_wallet_password_config, configured_p2p_announce_addr, configured_p2p_bind_addr, configured_stratum_addr, extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger, load_startup_wallet, measure_vdf_rounds, - parse_startup_bool_env_value, persist_chain_snapshot, project_ui_data_store, - run_chain_persistence_with_interval, validate_wallet_for_mode, + parse_startup_bool_env_value, parse_startup_pow_mining_workers_env_value, + persist_chain_snapshot, project_ui_data_store, run_chain_persistence_with_interval, + validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -33,6 +34,7 @@ fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { #[test] fn help_mentions_dev_seed_verify_bypass_env() { assert!(help_text().contains("IUNA_WALLET_PASSWORD=<password>")); + assert!(help_text().contains("IUNA_POW_MINING_WORKERS=1..32")); assert!(help_text().contains("IUNA_SETUP_COMPLETE=true|false")); assert!(help_text().contains("IUNA_AUTOMATIC_BURN_ENABLED=true|false")); assert!(help_text().contains("IUNA_POW_MINING_ENABLED=true|false")); @@ -47,13 +49,16 @@ fn local_testnet_compose_uses_one_obvious_test_password() { let compose = include_str!("../docker-compose.yml"); assert_eq!( compose - .matches("IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-test}") + .matches("IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest}") .count(), - 3 + 6 ); assert!(!compose.contains("IUNA_BOOTSTRAP_WALLET_PASSWORD")); assert!(!compose.contains("IUNA_NODE2_WALLET_PASSWORD")); assert!(!compose.contains("IUNA_NODE3_WALLET_PASSWORD")); + assert!(!compose.contains("IUNA_NODE4_WALLET_PASSWORD")); + assert!(!compose.contains("IUNA_NODE5_WALLET_PASSWORD")); + assert!(!compose.contains("IUNA_NODE6_WALLET_PASSWORD")); } #[test] @@ -252,14 +257,17 @@ fn startup_mining_env_overrides_persisted_config() { &mut config, Some(true), Some(false), + Some(1), )); assert!(config.mining_enabled); assert!(!config.pow_mining_enabled); + assert_eq!(config.pow_mining_workers, 1); assert!(!apply_startup_mining_config_overrides( &mut config, Some(true), Some(false), + Some(1), )); } @@ -277,6 +285,7 @@ fn startup_mining_env_can_override_genesis_defaults() { &mut config, Some(false), Some(true), + Some(1), )); assert!(!config.mining_enabled); assert!(config.pow_mining_enabled); @@ -296,6 +305,18 @@ fn startup_bool_env_parser_accepts_common_boolean_values() { } #[test] +fn startup_pow_worker_parser_enforces_supported_range() { + assert_eq!(parse_startup_pow_mining_workers_env_value("1").unwrap(), 1); + assert_eq!( + parse_startup_pow_mining_workers_env_value("32").unwrap(), + 32 + ); + assert!(parse_startup_pow_mining_workers_env_value("0").is_err()); + assert!(parse_startup_pow_mining_workers_env_value("33").is_err()); + assert!(parse_startup_pow_mining_workers_env_value("many").is_err()); +} + +#[test] fn startup_setup_complete_env_marks_join_and_genesis_nodes_ready() { let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); let mut join_config = UiConfig::default();