iuna

iuna

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

commit fb71f0f7fbfcfec715eda75e5a40eda0e9071b0d
parent 44d2006d204c828729a77c4859eb94f7ea96d9c3
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 20 Aug 2026 15:01:51 +0200

Enforce rank-specific burn committee rewards

Diffstat:
Mdocs/protocol.md | 26++++++++++++++++++++------
Msrc/adapters/ui_data_store.rs | 36+++++++++++++++++-------------------
Msrc/adapters/ui_index.rs | 36+++++++++++++++++++++++++-----------
Msrc/app.rs | 4++--
Msrc/app/automatic_mining.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/app/node_lifecycle.rs | 2+-
Msrc/app/receive.rs | 7+++++--
Msrc/app/wallet.rs | 56++++++++++++++++++++++++++++++++++++++++----------------
Msrc/domain.rs | 1+
Msrc/domain/adversarial_tests.rs | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/domain/ledger_apply.rs | 5+++--
Msrc/domain/ledger_ops.rs | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/domain/ledger_prepare.rs | 3++-
Msrc/domain/ledger_queries.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/domain/ledger_reveal.rs | 197++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
15 files changed, 885 insertions(+), 221 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -199,13 +199,27 @@ 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 block finalizer. Slots `1` and `2` 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. 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`. 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. 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 than two eligible non-finalizer lineages exist, the committee has the finalizer plus one or zero additional members. 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 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. + +### Burn Committee Reward Split + +The block reward is the total fee reward for the block. It remains a single deterministic amount in the block, but validators credit it as one or more implicit reward outputs. + +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 `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. 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. @@ -229,15 +243,15 @@ 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 enough burn-list attestations for their finalizer rank. Rank `0` has the strictest rule because it is the preferred path. Fallback ranks relax the threshold so the chain can keep moving if the primary finalizer or some committee members are unavailable. If the primary cannot gather the required attestations in time, a lower-ranked finalizer eventually gets a looser threshold. +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. -That is a deliberate liveness tradeoff. A lower-ranked finalizer can use fewer attestations, so its required burn list may omit burns that appeared only in committee bundles it did not use. 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 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. 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 one fewer than all available attestations, with at least one attestation required (`2-of-3`, `1-of-2`, or `1-of-1`); -- rank `2` and later ticket finalizers need one valid burn-list attestation from the available committee (`1-of-3`, `1-of-2`, or `1-of-1`), where the finalizer's block signature still counts if no non-finalizer bundle is included. +- 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`). 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. diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -14,7 +14,7 @@ use crate::{ adapters::ui_index::{UiChainIndex, build_ui_chain_index}, domain::{ Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_REWARD, OutPoint, Transaction, - TxInput, TxOutput, hex_hash, + TxInput, TxOutput, hex_hash, reward_outputs_for_block, }, }; @@ -1121,6 +1121,11 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> let mut metric_utxos = metric_genesis_utxos(snapshot); for block in &snapshot.blocks { + let reward_committee = if block.height == 0 { + Vec::new() + } else { + running_ledger.burn_committee_for_block(block) + }; let mut transfer_count = 0_u64; let mut burn_count = 0_u64; let mut mine_count = 0_u64; @@ -1132,6 +1137,9 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> for signature in &block.burn_bundle_section.signatures { known_wallet_addresses.insert(signature.member.clone()); } + for (_, output) in reward_outputs_for_block(block, &reward_committee) { + known_wallet_addresses.insert(output.address); + } for transaction in &block.transactions { collect_transaction_addresses(transaction, &mut known_wallet_addresses); metric_apply_public_transaction(transaction, &mut metric_utxos)?; @@ -1151,6 +1159,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> } } } + metric_index_block_reward(&mut metric_utxos, block, &reward_committee); total_burned_amount = total_burned_amount .checked_add(burned_amount) .and_then(|amount| amount.checked_add(burned_fee_amount)) @@ -1161,7 +1170,6 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .apply_preverified_block_at(block.clone(), u64::MAX) .with_context(|| format!("failed to replay block {} for metrics", block.height))?; } - metric_index_block_reward(&mut metric_utxos, block); let circulating_supply = ledger_circulating_supply(&running_ledger)?; let block_time_ms = previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous)); @@ -1278,17 +1286,14 @@ fn metric_index_transaction_outputs( } } -fn metric_index_block_reward(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) { - if block.reward == 0 { - return; +fn metric_index_block_reward( + utxos: &mut BTreeMap<OutPoint, TxOutput>, + block: &Block, + committee: &[crate::domain::BurnCommitteeMember], +) { + for (outpoint, output) in reward_outputs_for_block(block, committee) { + utxos.insert(outpoint, output); } - utxos.insert( - metric_reward_outpoint(&block.hash), - TxOutput { - address: block.miner.clone(), - amount: block.reward, - }, - ); } fn metric_genesis_allocation_outpoint(address: &str) -> OutPoint { @@ -1298,13 +1303,6 @@ fn metric_genesis_allocation_outpoint(address: &str) -> OutPoint { } } -fn metric_reward_outpoint(block_hash: &str) -> OutPoint { - OutPoint { - txid: block_hash.to_string(), - index: u32::MAX, - } -} - fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) { match transaction { Transaction::Transfer { diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use crate::domain::{ Block, BurnLeaderRank, ChainSnapshot, Ledger, OutPoint, Transaction, TxOutput, hex_hash, + reward_outputs_for_block, }; #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -47,6 +48,15 @@ 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 { + genesis_allocations: snapshot.genesis_allocations.clone(), + vdf_rounds: snapshot.vdf_rounds, + launch_profile: snapshot.launch_profile.clone(), + blocks: vec![genesis], + }) + .ok() + }); for (address, amount) in &snapshot.genesis_allocations { if *amount == 0 { continue; @@ -63,17 +73,21 @@ fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOu for transaction in &block.transactions { index_transaction_outputs(&mut outputs, transaction); } - if block.reward > 0 { - outputs.insert( - OutPoint { - txid: block.hash.clone(), - index: u32::MAX, - }, - TxOutput { - address: block.miner.clone(), - amount: block.reward, - }, - ); + let reward_committee = if block.height == 0 { + Vec::new() + } else { + running_ledger + .as_ref() + .map(|ledger| ledger.burn_committee_for_block(block)) + .unwrap_or_default() + }; + for (outpoint, output) in reward_outputs_for_block(block, &reward_committee) { + outputs.insert(outpoint, output); + } + if block.height > 0 { + if let Some(ledger) = running_ledger.as_mut() { + let _ = ledger.apply_preverified_block_at(block.clone(), u64::MAX); + } } } outputs diff --git a/src/app.rs b/src/app.rs @@ -134,8 +134,8 @@ pub struct NodeCore { last_auto_pow_mine_anchor: Option<String>, last_auto_pow_mine_status: Option<String>, auto_pow_mine_cursor: Option<AutoPowMineCursor>, - burn_bundles: BTreeMap<(u64, u8), BurnBundle>, - equivocated_burn_bundle_slots: BTreeSet<(u64, u8)>, + burn_bundles: BTreeMap<(u64, u8, String), BurnBundle>, + equivocated_burn_bundle_slots: BTreeSet<(u64, u8, String)>, burn_bundle_collection_started: Option<(u64, u64)>, local_block_anchor_burn: Option<(u64, Transaction)>, outbox: Vec<GossipEnvelope>, diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -456,11 +456,10 @@ impl NodeCore { } else { 0 }; - let wallet_is_committee_member = self + let wallet_is_committee_member = !self .ledger - .burn_committee_for_next_block() - .iter() - .any(|member| member.owner == self.wallet.address()); + .burn_committee_memberships_for_next_block(self.wallet.address()) + .is_empty(); if explicit_signatures_required == 0 || (!wallet_is_committee_member && !will_run_vdf) { if explicit_signatures_required == 0 { self.burn_bundle_collection_started = None; @@ -485,10 +484,13 @@ impl NodeCore { timestamp_ms: u64, ) -> Result<PreparedBlock> { let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); + let finalizer_rank = ledger + .finalizer_rank_for_next_block(self.wallet.address()) + .context("cannot prepare ticket block without a mature burn ticket")?; ledger.prepare_next_block_with_required_burn_and_burn_bundles( self.wallet.address(), timestamp_ms, - self.usable_burn_bundles(), + self.usable_burn_bundles_for_finalizer_rank(finalizer_rank), required_burn_signature.as_deref(), ) } @@ -498,7 +500,7 @@ impl NodeCore { ledger.prepare_recovery_block_with_required_burn_and_burn_bundles( self.wallet.address(), timestamp_ms, - self.usable_burn_bundles(), + Vec::new(), required_burn_signature.as_deref(), ) } @@ -545,7 +547,7 @@ mod tests { use crate::{ adapters::chain_store::SqliteChainStore, app::{GossipEnvelope, InMemoryNetwork}, - domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf}, + domain::{BurnBundle, GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf}, }; use tempfile::tempdir; @@ -582,6 +584,49 @@ mod tests { } #[test] + fn same_slot_bundles_for_different_members_do_not_conflict_locally() { + let wallet = Wallet::from_seed("rank-bundle-cache-node"); + let ledger = funded_ledger(std::slice::from_ref(&wallet)); + let mut node = NodeCore::from_ledger(wallet, ledger, 0); + let next_height = node.ledger.height() + 1; + let alpha = BurnBundle { + height: next_height, + prev_hash: node.ledger.tip_hash().to_string(), + slot: 1, + member: "alpha".to_string(), + burns: Vec::new(), + signature: "sig-alpha".to_string(), + }; + let beta = BurnBundle { + height: next_height, + prev_hash: node.ledger.tip_hash().to_string(), + slot: 1, + member: "beta".to_string(), + burns: Vec::new(), + signature: "sig-beta".to_string(), + }; + + node.burn_bundles + .insert((alpha.height, alpha.slot, alpha.member.clone()), alpha); + node.burn_bundles + .insert((beta.height, beta.slot, beta.member.clone()), beta); + node.equivocated_burn_bundle_slots + .insert((next_height, 1, "alpha".to_string())); + + let usable = node.usable_burn_bundles(); + assert!( + usable + .iter() + .all(|bundle| bundle.slot != 1 || bundle.member != "alpha") + ); + assert!( + usable + .iter() + .any(|bundle| bundle.slot == 1 && bundle.member == "beta") + ); + } + + #[test] fn in_memory_network_survives_adversarial_gossip_restart_and_converges() { let alice = Wallet::from_seed("network-adversarial-alice"); let bob = Wallet::from_seed("network-adversarial-bob"); @@ -688,7 +733,7 @@ mod tests { .unwrap() .usable_burn_bundles() .into_iter() - .all(|candidate| candidate.slot != bundle.slot) + .all(|candidate| candidate.slot != bundle.slot || candidate.member != bundle.member) ); let block = { diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -102,7 +102,7 @@ impl NodeCore { last_auto_pow_mine_anchor: None, last_auto_pow_mine_status: None, auto_pow_mine_cursor: None, - burn_bundles: BTreeMap::<(u64, u8), BurnBundle>::new(), + burn_bundles: BTreeMap::<(u64, u8, String), BurnBundle>::new(), equivocated_burn_bundle_slots: BTreeSet::new(), burn_bundle_collection_started: None, local_block_anchor_burn: None, diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -31,7 +31,7 @@ impl NodeCore { if bundle.height > next_height { return Ok(()); } - let key = (bundle.height, bundle.slot); + let key = (bundle.height, bundle.slot, bundle.member.clone()); self.ledger.precheck_next_block_burn_bundle(&bundle)?; for burn in &bundle.burns { self.receive_gossiped_transaction(burn.clone())?; @@ -276,7 +276,10 @@ mod tests { let error = receiver.receive_burn_bundle(oversized_bundle).unwrap_err(); - assert!(error.to_string().contains("burn bundle exceeds max size")); + assert!( + error.to_string().contains("burn bundle exceeds max size"), + "{error:#}" + ); assert!( receiver .ledger() diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use anyhow::{Context, Result, bail}; use crate::domain::{ @@ -180,11 +182,13 @@ impl NodeCore { let mut bundles = self .burn_bundles .iter() - .filter(|((height, slot), _)| { + .filter(|((height, slot, member), _)| { *height == next_height - && !self - .equivocated_burn_bundle_slots - .contains(&(*height, *slot)) + && !self.equivocated_burn_bundle_slots.contains(&( + *height, + *slot, + member.clone(), + )) }) .map(|(_, bundle)| bundle.clone()) .collect::<Vec<_>>(); @@ -192,12 +196,32 @@ impl NodeCore { bundles } + pub(super) fn usable_burn_bundles_for_finalizer_rank( + &self, + finalizer_rank: u32, + ) -> Vec<BurnBundle> { + let committee = self + .ledger + .burn_committee_for_next_ticket_block(finalizer_rank) + .into_iter() + .map(|member| (member.slot, member.owner)) + .collect::<BTreeMap<_, _>>(); + self.usable_burn_bundles() + .into_iter() + .filter(|bundle| { + committee + .get(&bundle.slot) + .is_some_and(|owner| *owner == bundle.member) + }) + .collect() + } + pub(super) fn prune_burn_bundles(&mut self) { let height = self.ledger.height(); self.burn_bundles - .retain(|(bundle_height, _), _| *bundle_height > height); + .retain(|(bundle_height, _, _), _| *bundle_height > height); self.equivocated_burn_bundle_slots - .retain(|(bundle_height, _)| *bundle_height > height); + .retain(|(bundle_height, _, _)| *bundle_height > height); } pub(super) fn publish_burn_bundle_for_next_block(&mut self) -> Result<()> { @@ -206,17 +230,17 @@ impl NodeCore { NodeWallet::Locked { .. } => return Ok(()), }; let (ledger, _) = self.ledger_with_local_block_anchor(); - let Some(bundle) = ledger.build_burn_bundle(wallet)? else { - return Ok(()); - }; - let key = (bundle.height, bundle.slot); - if self.equivocated_burn_bundle_slots.contains(&key) || self.burn_bundles.contains_key(&key) - { - return Ok(()); + for bundle in ledger.build_burn_bundles(wallet)? { + let key = (bundle.height, bundle.slot, bundle.member.clone()); + if self.equivocated_burn_bundle_slots.contains(&key) + || self.burn_bundles.contains_key(&key) + { + continue; + } + ledger.validate_next_block_burn_bundles(vec![bundle.clone()])?; + self.burn_bundles.insert(key, bundle.clone()); + self.outbox.push(GossipEnvelope::BurnBundle(bundle)); } - ledger.validate_next_block_burn_bundles(vec![bundle.clone()])?; - self.burn_bundles.insert(key, bundle.clone()); - self.outbox.push(GossipEnvelope::BurnBundle(bundle)); Ok(()) } diff --git a/src/domain.rs b/src/domain.rs @@ -42,6 +42,7 @@ use ledger_lineage::{ LineageOwnerValues, UtxoLineageRoot, insert_output_with_lineage, output_lineage_root_for_transaction, spend_inputs_with_lineage, }; +pub use ledger_ops::reward_outputs_for_block; use ledger_ops::{ apply_transaction, credit_reward_output, ensure_block_has_burn, ensure_block_has_burn_from, recovery_vdf_seed_for_child, validate_genesis_burn_transaction, vdf_seed_for_child, diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -14,7 +14,7 @@ use super::{ BurnBundleSignature, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, FinalizerMode, GenesisBurn, LeaderProofPayload, Ledger, MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MICRO_IUNA, MaskedBurn, OutPoint, Transaction, TransactionSubmitOutcome, TxOutput, UtxoLineageRoot, - VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf, + VDF_TARGET_BLOCK_MS, Wallet, hex_hash, reward_outputs_for_block, run_vdf, }; const NOW_MS: u64 = 10_000_000_000; @@ -257,22 +257,35 @@ impl Harness { } fn committee_bundles(&self) -> Vec<BurnBundle> { - self.ledger - .burn_committee_for_next_block() - .into_iter() - .filter_map(|member| { - self.wallets - .get(&member.owner) - .and_then(|wallet| self.ledger.build_burn_bundle(wallet).unwrap()) - }) - .collect() + self.committee_bundles_for_rank(0) + } + + fn committee_bundles_for_rank(&self, rank: usize) -> Vec<BurnBundle> { + let burns = self + .ledger + .pending() + .iter() + .filter(|transaction| transaction.is_burn()) + .cloned() + .collect::<Vec<_>>(); + self.committee_bundles_for_rank_and_burns(rank, burns) } - fn committee_bundles_for_burns(&self, burns: Vec<Transaction>) -> Vec<BurnBundle> { + fn committee_bundles_for_rank_and_burns( + &self, + rank: usize, + mut burns: Vec<Transaction>, + ) -> Vec<BurnBundle> { let height = self.ledger.height() + 1; let prev_hash = self.ledger.tip_hash().to_string(); + burns.sort_by(|left, right| { + right + .fee() + .cmp(&left.fee()) + .then_with(|| left.signature().cmp(right.signature())) + }); self.ledger - .burn_committee_for_next_block() + .burn_committee_for_next_ticket_block(rank as u32) .into_iter() .filter_map(|member| { let wallet = self.wallets.get(&member.owner)?; @@ -320,7 +333,7 @@ impl Harness { let leader = self.next_rank(rank); let wallet = self.wallet(&leader.owner).clone(); self.submit_anchor_burn(&wallet); - let bundles = self.committee_bundles(); + let bundles = self.committee_bundles_for_rank(rank); let block = self.finish_ticket_block_from_pending(rank, bundles); self.ledger .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms)) @@ -492,10 +505,14 @@ impl Harness { }; finalizations += 1; + metrics.attacker_net_reward += i128::from(attacker_reward_from_block( + &block, + &committee, + &attacker_addresses, + )); if attacker_addresses.contains(&block.miner) { attacker_finalizations += 1; metrics.attacker_burn_cost = metrics.attacker_burn_cost.saturating_add(1); - metrics.attacker_net_reward += i128::from(block.reward); } if let Some(burn) = third_party_burn { censored_third_party_burns += usize::from( @@ -629,7 +646,8 @@ impl Harness { if rank > 0 { fallback_blocks += 1; } - let bundles = self.committee_bundles_for_burns( + let bundles = self.committee_bundles_for_rank_and_burns( + rank, pressure_burns.into_iter().take(1).collect::<Vec<_>>(), ); let leader = self.next_rank(rank); @@ -663,10 +681,14 @@ impl Harness { }; finalizations += 1; + metrics.attacker_net_reward += i128::from(attacker_reward_from_block( + &block, + &committee, + &attacker_addresses, + )); if attacker_addresses.contains(&block.miner) { attacker_finalizations += 1; metrics.attacker_burn_cost = metrics.attacker_burn_cost.saturating_add(1); - metrics.attacker_net_reward += i128::from(block.reward); } for burn in visible_burns { let included = block @@ -1394,13 +1416,18 @@ fn mini_lineage_state(snapshot: &ChainSnapshot) -> Option<MiniLineageState> { for transaction in &genesis.transactions { mini_apply_transaction(&mut state, transaction, genesis.height, false)?; } - mini_credit_reward(&mut state, genesis)?; + mini_credit_reward(&mut state, genesis, &[])?; + let mut tickets = mini_genesis_tickets(&snapshot.genesis_allocations, genesis, snapshot)?; + let mut parent = genesis; for block in snapshot.blocks.iter().skip(1) { + let committee = mini_burn_committee_for_block(parent, block, &tickets, &state); for transaction in &block.transactions { mini_apply_transaction(&mut state, transaction, block.height, true)?; } - mini_credit_reward(&mut state, block)?; + mini_credit_reward(&mut state, block, &committee)?; + mini_apply_ticket_block(parent, block, snapshot, &mut tickets)?; + parent = block; } Some(state) @@ -1514,22 +1541,15 @@ fn mini_insert_output( Some(()) } -fn mini_credit_reward(state: &mut MiniLineageState, block: &Block) -> Option<()> { - if block.reward == 0 { - return Some(()); +fn mini_credit_reward( + state: &mut MiniLineageState, + block: &Block, + committee: &[BurnCommitteeMember], +) -> Option<()> { + for (outpoint, output) in reward_outputs_for_block(block, committee) { + mini_insert_output(state, outpoint, output, None)?; } - mini_insert_output( - state, - OutPoint { - txid: block.hash.clone(), - index: u32::MAX, - }, - TxOutput { - address: block.miner.clone(), - amount: block.reward, - }, - None, - ) + Some(()) } fn mini_subtract_lineage( @@ -1588,17 +1608,42 @@ fn mini_burn_committee_for_next_block(ledger: &Ledger) -> Option<Vec<BurnCommitt parent.height.checked_add(1)?, &tickets, &state, + 0, )) } +fn mini_burn_committee_for_block( + parent: &Block, + block: &Block, + tickets: &[MiniTicket], + state: &MiniLineageState, +) -> Vec<BurnCommitteeMember> { + match block.finalizer_mode { + FinalizerMode::Ticket => mini_burn_committee_for_height( + parent, + block.height, + tickets, + state, + block.finalizer_rank, + ), + FinalizerMode::Recovery => vec![BurnCommitteeMember { + slot: 0, + root: block.hash.clone(), + owner: block.miner.clone(), + weight: 0, + }], + } +} + fn mini_burn_committee_for_height( parent: &Block, height: u64, tickets: &[MiniTicket], state: &MiniLineageState, + finalizer_rank: u32, ) -> Vec<BurnCommitteeMember> { let ranked = mini_ranked_tickets_for_height(parent, height, tickets); - let Some(finalizer) = ranked.first() else { + let Some(finalizer) = ranked.get(finalizer_rank as usize) else { return Vec::new(); }; let mut committee = vec![BurnCommitteeMember { @@ -1607,8 +1652,16 @@ fn mini_burn_committee_for_height( owner: finalizer.owner.clone(), weight: finalizer.amount, }]; - let mut skipped_owners = BTreeSet::from([finalizer.owner.clone()]); - let mut remaining = mini_eligible_lineage_candidates(parent, state, &finalizer.owner) + let max_committee_size = BURN_COMMITTEE_SIZE + .saturating_sub(finalizer_rank as usize) + .max(1); + let mut skipped_owners = ranked + .iter() + .take(finalizer_rank as usize) + .map(|ticket| ticket.owner.clone()) + .collect::<BTreeSet<_>>(); + skipped_owners.insert(finalizer.owner.clone()); + let mut remaining = mini_eligible_lineage_candidates(parent, state, &skipped_owners) .into_iter() .filter_map(|candidate| { let owner = mini_representative_owner_for_lineage_root( @@ -1620,7 +1673,7 @@ fn mini_burn_committee_for_height( }) .collect::<Vec<_>>(); - for slot in 1..BURN_COMMITTEE_SIZE { + for slot in 1..max_committee_size { let Some(index) = mini_select_weighted_lineage_index(parent, height, slot as u8, &remaining) else { @@ -1656,7 +1709,7 @@ fn mini_burn_committee_for_height( fn mini_eligible_lineage_candidates( parent: &Block, state: &MiniLineageState, - finalizer: &str, + skipped_owners: &BTreeSet<String>, ) -> Vec<MiniLineageCandidate> { state .lineage_values @@ -1664,7 +1717,9 @@ fn mini_eligible_lineage_candidates( .filter(|(root, value)| { **value > 0 && root.height.saturating_add(BURN_LINEAGE_MATURITY_HEIGHTS) <= parent.height - && !mini_lineage_root_has_owner(state, root, finalizer) + && !skipped_owners + .iter() + .any(|owner| mini_lineage_root_has_owner(state, root, owner)) }) .filter_map(|(root, value)| { let weight = mini_lineage_committee_weight(*value); @@ -1787,7 +1842,6 @@ fn mini_validate_burn_bundle_section(ledger: &Ledger, block: &Block) -> Option<( block.finalizer_mode, block.finalizer_rank, committee.len(), - mini_transactions_have_attestable_burns(&block.transactions, &block.miner), ); if section.signatures.len() < required_signatures { return None; @@ -1862,33 +1916,17 @@ fn mini_required_explicit_burn_signatures( finalizer_mode: FinalizerMode, finalizer_rank: u32, committee_size: usize, - has_attested_burns: bool, ) -> usize { - if !has_attested_burns || committee_size == 0 { + if committee_size == 0 { 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(2), + FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.saturating_sub(1), FinalizerMode::Ticket | FinalizerMode::Recovery => 0, } } -fn mini_transactions_have_attestable_burns(transactions: &[Transaction], finalizer: &str) -> bool { - let mut finalizer_anchor_seen = false; - for transaction in transactions { - if !transaction.is_burn() { - continue; - } - if transaction.sender() == finalizer && !finalizer_anchor_seen { - finalizer_anchor_seen = true; - continue; - } - return true; - } - false -} - fn mini_matching_burn_by_signature(attested: &Transaction, block: &Block) -> bool { block.transactions.iter().any(|transaction| { transaction.is_burn() @@ -2007,6 +2045,18 @@ fn live_supply(ledger: &Ledger) -> Amount { .expect("test supply should not overflow") } +fn attacker_reward_from_block( + block: &Block, + committee: &[BurnCommitteeMember], + attacker_addresses: &BTreeSet<String>, +) -> Amount { + reward_outputs_for_block(block, committee) + .into_iter() + .filter(|(_, output)| attacker_addresses.contains(&output.address)) + .try_fold(0_u64, |total, (_, output)| total.checked_add(output.amount)) + .expect("test reward should not overflow") +} + fn expected_supply(snapshot: &ChainSnapshot) -> Amount { mini_expected_supply(snapshot).expect("test snapshot supply accounting should not overflow") } @@ -2042,6 +2092,67 @@ fn committee_roots_are_unique(committee: &[BurnCommitteeMember]) -> bool { .all(|member| roots.insert(member.root.clone())) } +#[test] +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); + let committee = harness.ledger.burn_committee_for_next_block(); + assert_eq!(committee.len(), 3, "test setup needs a full committee"); + + 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 + .iter() + .find(|member| member.slot == 2) + .expect("slot 2 should be assigned") + .owner + .clone(); + let third_party = harness + .wallets + .values() + .find(|wallet| { + wallet.address() != finalizer + && wallet.address() != committee_two + && wallet.address() != committee_three + }) + .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 finalizer_wallet = harness.wallet(&finalizer).clone(); + harness.submit_anchor_burn(&finalizer_wallet); + harness.submit_fee_burn(&third_party, 1, 99); + let bundles = harness.committee_bundles(); + let block = harness.finish_ticket_block_from_pending(0, bundles); + assert_eq!(block.reward, 100); + + harness + .ledger + .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS)) + .unwrap(); + + assert_eq!( + 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 + ); +} + proptest! { #![proptest_config(Config { cases: 32, .. Config::default() })] @@ -2528,14 +2639,132 @@ fn mini_burn_bundle_quorum_oracle_matches_consensus_mutations() { } #[test] -fn finalizer_anchor_alone_does_not_require_committee_signatures() { +fn finalizer_anchor_alone_requires_available_committee_signatures() { let mut harness = harness_for_percent(20, 25); harness.mature_lineages(1, 4); - let block = harness.prepared_ticket_block(0, Vec::new()); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let without_committee = harness.finish_ticket_block_from_pending(0, Vec::new()); + assert_rejects( + harness.ledger.clone(), + without_committee, + "anchor-only block without available committee signatures", + ); + + let bundles = harness.committee_bundles(); + let with_committee = harness.finish_ticket_block_from_pending(0, bundles); harness .ledger - .apply_block_at(block, NOW_MS.saturating_add(1)) + .apply_block_at(with_committee, NOW_MS.saturating_add(1)) + .unwrap(); +} + +#[test] +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); + 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) + .expect("synthetic rank 0 ticket should be eligible") + .owner; + let rank_one = harness + .ledger + .ticket_for_finalizer_rank(target_height, 1) + .expect("synthetic rank 1 ticket should be eligible") + .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_eq!( + committee.first().map(|member| member.owner.as_str()), + Some(rank_one.as_str()) + ); + assert!( + committee.iter().all(|member| member.owner != rank_zero), + "missed rank-0 owner must not remain in rank-1 committee: {committee:?}" + ); + + let finalizer = harness.wallet(&rank_one).clone(); + harness.submit_anchor_burn(&finalizer); + let without_committee = finish_prepared_block( + &finalizer, + harness + .ledger + .prepare_next_block_with_burn_bundles( + finalizer.address(), + harness + .ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS * 2) + .saturating_add(1), + Vec::new(), + ) + .unwrap(), + ); + assert_rejects( + harness.ledger.clone(), + without_committee, + "rank-1 block without remaining committee signature", + ); + + let member = committee + .iter() + .find(|member| member.slot == 1) + .expect("rank-1 committee should have slot 1") + .clone(); + let wallet = harness.wallet(&member.owner); + let bundle = wallet.burn_bundle(BurnBundlePayload { + height: harness.ledger.height() + 1, + prev_hash: harness.ledger.tip_hash().to_string(), + slot: member.slot, + member: member.owner, + burns: Vec::new(), + }); + let with_committee = finish_prepared_block( + &finalizer, + harness + .ledger + .prepare_next_block_with_burn_bundles( + finalizer.address(), + harness + .ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS * 2) + .saturating_add(1), + vec![bundle], + ) + .unwrap(), + ); + + harness + .ledger + .apply_block_at( + with_committee, + NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS * 2 + 1), + ) .unwrap(); } @@ -2553,7 +2782,8 @@ fn pending_third_party_burn_does_not_affect_block_validity() { .unwrap() .clone(); let third_party = harness.submit_fee_burn(&victim, 1, 1); - let mut block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let bundles = harness.committee_bundles_for_rank_and_burns(0, Vec::new()); + let mut block = harness.finish_ticket_block_from_pending(0, bundles); block .transactions .retain(|tx| tx.signature() == anchor.signature()); diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use anyhow::{Context, Result, bail}; use super::ledger_ops::{ - block_reward, credit_reward_output, ensure_block_has_burn, ensure_outputs_do_not_overflow, + block_reward, credit_reward_outputs, ensure_block_has_burn, ensure_outputs_do_not_overflow, ensure_single_input_owner, ensure_valid_recovery_block, validate_block_fee_policy, verify_leader_proof, }; @@ -69,6 +69,7 @@ impl Ledger { bail!("block VDF output is invalid"); } + let reward_committee = self.burn_committee_for_block(&block); let mut utxos = self.utxos.clone(); let mut utxo_lineage = self.utxo_lineage.clone(); let mut lineage_values = self.lineage_values.clone(); @@ -100,7 +101,7 @@ impl Ledger { let mut tickets = self.tickets.clone(); apply_finalizer_ticket_effects(self.tip(), &block, &mut tickets)?; tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); - credit_reward_output(&mut utxos, &block)?; + credit_reward_outputs(&mut utxos, &block, &reward_committee)?; self.utxos = utxos; self.utxo_lineage = utxo_lineage; self.lineage_values = lineage_values; diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -9,10 +9,10 @@ use super::selection::{TransactionKind, fee_rate_key}; use super::ticket::ticket_is_eligible_for_height; use super::transaction::Transaction; use super::{ - Amount, Block, BlockSelection, BurnTicket, FinalizerMode, LeaderProof, LeaderProofPayload, - MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, SIGNATURE_BYTES, TxInput, - TxOutput, decode_hex_array, validate_address, validate_hash, validate_protocol_id, - validate_signature, + Amount, BURN_COMMITTEE_SIZE, Block, BlockSelection, BurnCommitteeMember, BurnTicket, + FinalizerMode, LeaderProof, LeaderProofPayload, MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, + RECOVERY_BLOCK_DELAY_MS, SIGNATURE_BYTES, TxInput, TxOutput, decode_hex_array, + validate_address, validate_hash, validate_protocol_id, validate_signature, }; pub(super) fn validate_genesis_allocations( @@ -393,20 +393,95 @@ pub(super) fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result Ok(()) } +pub(super) fn credit_reward_outputs( + utxos: &mut BTreeMap<OutPoint, TxOutput>, + block: &Block, + committee: &[BurnCommitteeMember], +) -> Result<()> { + let outputs = reward_outputs_for_block(block, committee); + let tx_outputs = outputs + .iter() + .map(|(_, output)| output.clone()) + .collect::<Vec<_>>(); + ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; + for (outpoint, output) in outputs { + utxos.insert(outpoint, output); + } + Ok(()) +} + pub(super) fn credit_reward_output( utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block, ) -> Result<()> { + credit_reward_outputs(utxos, block, &[]) +} + +pub fn reward_outputs_for_block( + block: &Block, + committee: &[BurnCommitteeMember], +) -> Vec<(OutPoint, TxOutput)> { if block.reward == 0 { - return Ok(()); + return Vec::new(); } - let output = TxOutput { - address: block.miner.clone(), - amount: block.reward, + + let mut outputs = Vec::new(); + let committee_slots = reward_committee_slots(block); + let committee_members = committee_slots + .into_iter() + .filter_map(|slot| { + committee + .iter() + .find(|member| member.slot == slot && member.owner != block.miner) + }) + .collect::<Vec<_>>(); + let committee_pool = if committee_members.is_empty() { + 0 + } else { + block.reward / 2 }; - ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; - utxos.insert(reward_outpoint(&block.hash), output); - Ok(()) + let finalizer_amount = block.reward.saturating_sub(committee_pool); + + if finalizer_amount > 0 { + outputs.push(( + reward_outpoint(&block.hash), + TxOutput { + address: block.miner.clone(), + amount: finalizer_amount, + }, + )); + } + + let mut remaining = committee_pool; + for (index, member) in committee_members.iter().enumerate() { + let members_left = committee_members.len() - index; + let amount = if members_left == 1 { + remaining + } else { + remaining / members_left as u64 + }; + remaining = remaining.saturating_sub(amount); + if amount == 0 { + continue; + } + outputs.push(( + committee_reward_outpoint(&block.hash, member.slot), + TxOutput { + address: member.owner.clone(), + amount, + }, + )); + } + + outputs +} + +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 | FinalizerMode::Recovery => Vec::new(), + } } pub(super) fn ensure_outputs_do_not_overflow( @@ -514,3 +589,144 @@ pub(super) fn reward_outpoint(block_hash: &str) -> OutPoint { index: u32::MAX, } } + +fn committee_reward_outpoint(block_hash: &str, slot: u8) -> OutPoint { + let slot = usize::from(slot).min(BURN_COMMITTEE_SIZE.saturating_sub(1)); + OutPoint { + txid: block_hash.to_string(), + index: u32::MAX.saturating_sub(slot as u32), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::BurnBundleSection; + + fn reward_block(finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount) -> Block { + let mut block = Block { + height: 1, + prev_hash: "p".repeat(64), + timestamp_ms: 1, + miner: "finalizer".to_string(), + finalizer_mode, + finalizer_rank, + reward, + vdf_rounds: 1, + vdf_output: "out".to_string(), + leader_proof: None, + burn_bundle_section: BurnBundleSection::default(), + transactions: Vec::new(), + hash: "h".repeat(64), + }; + block.hash = block.compute_hash(); + block + } + + fn committee_member(slot: u8, owner: &str) -> BurnCommitteeMember { + BurnCommitteeMember { + slot, + root: format!("root-{slot}"), + owner: owner.to_string(), + weight: 1, + } + } + + fn output_amount(outputs: &[(OutPoint, TxOutput)], owner: &str) -> Amount { + outputs + .iter() + .filter(|(_, output)| output.address == owner) + .map(|(_, output)| output.amount) + .sum() + } + + #[test] + fn rank_zero_splits_half_to_two_extra_committee_members() { + let block = reward_block(FinalizerMode::Ticket, 0, 100); + let committee = vec![ + committee_member(0, "finalizer"), + committee_member(1, "committee-2"), + committee_member(2, "committee-3"), + ]; + + let outputs = reward_outputs_for_block(&block, &committee); + + assert_eq!(output_amount(&outputs, "finalizer"), 50); + assert_eq!(output_amount(&outputs, "committee-2"), 25); + assert_eq!(output_amount(&outputs, "committee-3"), 25); + assert!( + outputs + .iter() + .any(|(outpoint, _)| outpoint.index == u32::MAX) + ); + assert!( + outputs + .iter() + .any(|(outpoint, _)| outpoint.index == u32::MAX - 1) + ); + assert!( + outputs + .iter() + .any(|(outpoint, _)| outpoint.index == u32::MAX - 2) + ); + } + + #[test] + fn rank_zero_gives_committee_half_to_the_only_available_extra_member() { + let block = reward_block(FinalizerMode::Ticket, 0, 101); + let committee = vec![ + committee_member(0, "finalizer"), + committee_member(1, "committee-2"), + ]; + + let outputs = reward_outputs_for_block(&block, &committee); + + assert_eq!(output_amount(&outputs, "finalizer"), 51); + assert_eq!(output_amount(&outputs, "committee-2"), 50); + } + + #[test] + fn rank_one_splits_only_with_committee_slot_one() { + let block = reward_block(FinalizerMode::Ticket, 1, 100); + let committee = vec![ + committee_member(0, "missed-primary"), + committee_member(1, "committee-2"), + committee_member(2, "committee-3"), + ]; + + 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); + } + + #[test] + fn rank_two_and_recovery_pay_the_finalizer_only() { + let committee = vec![ + committee_member(1, "committee-2"), + committee_member(2, "committee-3"), + ]; + + let rank_two = reward_block(FinalizerMode::Ticket, 2, 100); + let recovery = reward_block(FinalizerMode::Recovery, 0, 100); + + assert_eq!( + output_amount( + &reward_outputs_for_block(&rank_two, &committee), + "finalizer" + ), + 100 + ); + assert_eq!(reward_outputs_for_block(&rank_two, &committee).len(), 1); + assert_eq!( + output_amount( + &reward_outputs_for_block(&recovery, &committee), + "finalizer" + ), + 100 + ); + assert_eq!(reward_outputs_for_block(&recovery, &committee).len(), 1); + } +} diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs @@ -53,7 +53,8 @@ impl Ledger { bail!("no selected leader for block {height}"); } - let burn_bundles = self.validate_next_block_burn_bundles(burn_bundles)?; + let burn_bundles = + self.validate_next_block_burn_bundles_for_finalizer_rank(finalizer_rank, burn_bundles)?; let burn_bundle_section = self.burn_bundle_section_from_bundles(burn_bundles); let selection = self.select_block_transactions_with_burn_section( miner, diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -182,20 +182,71 @@ impl Ledger { } pub fn burn_committee_for_next_block(&self) -> Vec<BurnCommitteeMember> { - self.burn_committee_for_height(self.tip().height + 1) + self.burn_committee_for_next_ticket_block(0) + } + + pub fn burn_committee_for_next_ticket_block( + &self, + finalizer_rank: u32, + ) -> Vec<BurnCommitteeMember> { + self.burn_committee_for_ticket_block(self.tip().height + 1, finalizer_rank) } pub fn burn_committee_for_height(&self, height: u64) -> Vec<BurnCommitteeMember> { + self.burn_committee_for_ticket_block(height, 0) + } + + pub fn burn_committee_for_block(&self, block: &Block) -> Vec<BurnCommitteeMember> { + match block.finalizer_mode { + super::FinalizerMode::Ticket => { + self.burn_committee_for_ticket_block(block.height, block.finalizer_rank) + } + super::FinalizerMode::Recovery => vec![BurnCommitteeMember { + slot: 0, + root: block.hash.clone(), + owner: block.miner.clone(), + weight: 0, + }], + } + } + + fn burn_committee_for_ticket_block( + &self, + height: u64, + finalizer_rank: u32, + ) -> Vec<BurnCommitteeMember> { let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets); - self.lineage_burn_committee_for_height(height, ranked) + self.lineage_burn_committee_for_height(height, ranked, finalizer_rank) + } + + pub fn burn_committee_memberships_for_next_block( + &self, + owner: &str, + ) -> Vec<BurnCommitteeMember> { + let max_rank = self + .finalizer_rank_count_for_next_block() + .min(super::BURN_COMMITTEE_SIZE); + let mut memberships = Vec::new(); + let mut seen_slots = BTreeSet::new(); + for rank in 0..max_rank { + for member in self.burn_committee_for_next_ticket_block(rank as u32) { + if member.owner != owner || !seen_slots.insert(member.slot) { + continue; + } + memberships.push(member); + } + } + memberships.sort_by_key(|member| member.slot); + memberships } fn lineage_burn_committee_for_height( &self, height: u64, ranked: Vec<BurnTicket>, + finalizer_rank: u32, ) -> Vec<BurnCommitteeMember> { - let Some(finalizer) = ranked.first() else { + let Some(finalizer) = ranked.get(finalizer_rank as usize) else { return Vec::new(); }; let mut committee = vec![BurnCommitteeMember { @@ -204,9 +255,17 @@ impl Ledger { owner: finalizer.owner.clone(), weight: finalizer.amount, }]; - let mut skipped_owners = BTreeSet::from([finalizer.owner.clone()]); + let max_committee_size = super::BURN_COMMITTEE_SIZE + .saturating_sub(finalizer_rank as usize) + .max(1); + let mut skipped_owners = ranked + .iter() + .take(finalizer_rank as usize) + .map(|ticket| ticket.owner.clone()) + .collect::<BTreeSet<_>>(); + skipped_owners.insert(finalizer.owner.clone()); let mut remaining = self - .eligible_lineage_candidates(finalizer.owner.as_str()) + .eligible_lineage_candidates(&skipped_owners) .into_iter() .filter_map(|candidate| { let owner = @@ -220,7 +279,7 @@ impl Ledger { }) .collect::<Vec<_>>(); - for slot in 1..super::BURN_COMMITTEE_SIZE { + for slot in 1..max_committee_size { let Some(index) = select_weighted_lineage_index(self.tip(), height, slot as u8, &remaining) else { @@ -250,7 +309,10 @@ impl Ledger { committee } - fn eligible_lineage_candidates(&self, finalizer: &str) -> Vec<LineageCommitteeCandidate> { + fn eligible_lineage_candidates( + &self, + skipped_owners: &BTreeSet<String>, + ) -> Vec<LineageCommitteeCandidate> { let parent_height = self.tip().height; self.lineage_values .iter() @@ -260,7 +322,9 @@ impl Ledger { .height .saturating_add(super::BURN_LINEAGE_MATURITY_HEIGHTS) <= parent_height - && !self.lineage_root_has_owner(root, finalizer) + && !skipped_owners + .iter() + .any(|owner| self.lineage_root_has_owner(root, owner)) }) .filter_map(|(root, value)| { let weight = lineage_committee_weight(*value); diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -19,26 +19,28 @@ impl Ledger { &self, finalizer_mode: FinalizerMode, finalizer_rank: u32, - finalizer: &str, + _finalizer: &str, ) -> usize { - self.required_explicit_burn_signatures( - finalizer_mode, - finalizer_rank, - self.burn_committee_for_next_block().len(), - transactions_have_attestable_burns(&self.pending, finalizer), - ) + let committee_size = match finalizer_mode { + FinalizerMode::Ticket => self + .burn_committee_for_next_ticket_block(finalizer_rank) + .len(), + FinalizerMode::Recovery => 1, + }; + self.required_explicit_burn_signatures(finalizer_mode, finalizer_rank, committee_size) } pub fn build_burn_bundle(&self, wallet: &Wallet) -> Result<Option<BurnBundle>> { + Ok(self.build_burn_bundles(wallet)?.into_iter().next()) + } + + pub fn build_burn_bundles(&self, wallet: &Wallet) -> Result<Vec<BurnBundle>> { let height = self.tip().height + 1; let prev_hash = self.tip().hash.clone(); - let Some(member) = self - .burn_committee_for_next_block() - .into_iter() - .find(|member| member.owner == wallet.address()) - else { - return Ok(None); - }; + let memberships = self.burn_committee_memberships_for_next_block(wallet.address()); + if memberships.is_empty() { + return Ok(Vec::new()); + } let mut burns = self .valid_pending_transactions() .into_iter() @@ -51,31 +53,32 @@ impl Ledger { .then_with(|| left.signature().cmp(right.signature())) }); - let mut selected = Vec::new(); - for burn in burns { - let mut candidate = selected.clone(); - candidate.push(burn); - let bundle = wallet.burn_bundle(BurnBundlePayload { + let mut bundles = Vec::new(); + for member in memberships { + let mut selected = Vec::new(); + for burn in &burns { + let mut candidate = selected.clone(); + candidate.push(burn.clone()); + let bundle = wallet.burn_bundle(BurnBundlePayload { + height, + prev_hash: prev_hash.clone(), + slot: member.slot, + member: wallet.address().to_string(), + burns: candidate.clone(), + }); + if bundle.serialized_size_bytes()? <= MAX_BURN_BUNDLE_BYTES { + selected = candidate; + } + } + bundles.push(wallet.burn_bundle(BurnBundlePayload { height, prev_hash: prev_hash.clone(), slot: member.slot, member: wallet.address().to_string(), - burns: candidate.clone(), - }); - if bundle.serialized_size_bytes()? <= MAX_BURN_BUNDLE_BYTES { - selected = candidate; - } + burns: selected, + })); } - if selected.is_empty() && !self.burn_bundle_attestations_required_for_next_block() { - return Ok(None); - } - Ok(Some(wallet.burn_bundle(BurnBundlePayload { - height, - prev_hash, - slot: member.slot, - member: wallet.address().to_string(), - burns: selected, - }))) + Ok(bundles) } pub fn validate_next_block_burn_bundles( @@ -84,25 +87,63 @@ impl Ledger { ) -> Result<Vec<BurnBundle>> { let expected_height = self.tip().height + 1; let expected_prev_hash = self.tip().hash.clone(); - self.validate_burn_bundles_for_block(expected_height, &expected_prev_hash, bundles) + self.validate_burn_bundles_for_any_next_ticket_block( + expected_height, + &expected_prev_hash, + bundles, + ) } - pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> { + pub(super) fn validate_next_block_burn_bundles_for_finalizer_rank( + &self, + finalizer_rank: u32, + bundles: Vec<BurnBundle>, + ) -> Result<Vec<BurnBundle>> { let expected_height = self.tip().height + 1; let expected_prev_hash = self.tip().hash.clone(); let committee = self - .burn_committee_for_height(expected_height) + .burn_committee_for_next_ticket_block(finalizer_rank) .into_iter() .map(|member| (member.slot, member)) .collect::<BTreeMap<_, _>>(); - self.precheck_burn_bundle_for_block( + self.validate_burn_bundles_for_committee( expected_height, &expected_prev_hash, &committee, - bundle, + bundles, ) } + pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> { + let expected_height = self.tip().height + 1; + let expected_prev_hash = self.tip().hash.clone(); + let max_rank = self + .finalizer_rank_count_for_next_block() + .min(BURN_COMMITTEE_SIZE); + let mut first_error = None; + for rank in 0..max_rank { + let committee = self + .burn_committee_for_next_ticket_block(rank as u32) + .into_iter() + .map(|member| (member.slot, member)) + .collect::<BTreeMap<_, _>>(); + match self.precheck_burn_bundle_for_block( + expected_height, + &expected_prev_hash, + &committee, + bundle, + ) { + Ok(()) => return Ok(()), + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + Err(first_error.unwrap_or_else(|| anyhow::anyhow!("burn bundle slot is not assigned"))) + } + #[cfg(test)] pub fn test_burn_bundle(&self, wallet: &Wallet, burns: Vec<Transaction>) -> BurnBundle { let height = self.tip().height + 1; @@ -175,7 +216,7 @@ impl Ledger { bail!("burn bundle signatures are not in slot order"); } let committee = self - .burn_committee_for_height(block.height) + .burn_committee_for_block(block) .into_iter() .map(|member| (member.slot, member)) .collect::<BTreeMap<_, _>>(); @@ -207,7 +248,6 @@ impl Ledger { block.finalizer_mode, block.finalizer_rank, committee.len(), - transactions_have_attestable_burns(&block.transactions, &block.miner), ); if section.signatures.len() < required_signatures { bail!( @@ -263,23 +303,56 @@ impl Ledger { finalizer_mode: FinalizerMode, finalizer_rank: u32, committee_size: usize, - has_attested_burns: bool, ) -> usize { - if !has_attested_burns || committee_size == 0 { + if committee_size == 0 { 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(2), + FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.saturating_sub(1), FinalizerMode::Ticket => 0, FinalizerMode::Recovery => 0, } } - fn validate_burn_bundles_for_block( + fn validate_burn_bundles_for_any_next_ticket_block( + &self, + expected_height: u64, + expected_prev_hash: &str, + bundles: Vec<BurnBundle>, + ) -> Result<Vec<BurnBundle>> { + let max_rank = self + .finalizer_rank_count_for_next_block() + .min(BURN_COMMITTEE_SIZE); + let mut first_error = None; + for rank in 0..max_rank { + let committee = self + .burn_committee_for_next_ticket_block(rank as u32) + .into_iter() + .map(|member| (member.slot, member)) + .collect::<BTreeMap<_, _>>(); + match self.validate_burn_bundles_for_committee( + expected_height, + expected_prev_hash, + &committee, + bundles.clone(), + ) { + Ok(validated) => return Ok(validated), + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + Err(first_error.unwrap_or_else(|| anyhow::anyhow!("no ticket committee is available"))) + } + + fn validate_burn_bundles_for_committee( &self, expected_height: u64, expected_prev_hash: &str, + committee: &BTreeMap<u8, BurnCommitteeMember>, mut bundles: Vec<BurnBundle>, ) -> Result<Vec<BurnBundle>> { if bundles.len() > BURN_COMMITTEE_SIZE { @@ -289,11 +362,6 @@ impl Ledger { if bundles.windows(2).any(|pair| pair[0].slot == pair[1].slot) { bail!("duplicate burn bundle slot"); } - let committee = self - .burn_committee_for_height(expected_height) - .into_iter() - .map(|member| (member.slot, member)) - .collect::<BTreeMap<_, _>>(); let mut seen_members = BTreeSet::new(); for bundle in &bundles { if !seen_members.insert(bundle.member.clone()) { @@ -302,7 +370,7 @@ impl Ledger { self.precheck_burn_bundle_for_block( expected_height, expected_prev_hash, - &committee, + committee, bundle, )?; for burn in &bundle.burns { @@ -378,21 +446,6 @@ fn matching_burn_by_signature<'a>( }) } -fn transactions_have_attestable_burns(transactions: &[Transaction], finalizer: &str) -> bool { - let mut finalizer_anchor_seen = false; - for transaction in transactions { - if !transaction.is_burn() { - continue; - } - if transaction.sender() == finalizer && !finalizer_anchor_seen { - finalizer_anchor_seen = true; - continue; - } - return true; - } - false -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -437,27 +490,27 @@ mod tests { } #[test] - fn burn_quorum_depends_on_rank_and_included_burns() { + fn burn_quorum_depends_on_rank_and_available_committee() { let ledger = ledger(); assert_eq!( - ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3, true), + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3), 2 ); assert_eq!( - ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 3, true), + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 2), 1 ); assert_eq!( - ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 2, 3, true), + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 2, 3), 0 ); assert_eq!( - ledger.required_explicit_burn_signatures(FinalizerMode::Recovery, 0, 3, true), + ledger.required_explicit_burn_signatures(FinalizerMode::Recovery, 0, 3), 0 ); assert_eq!( - ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3, false), + ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 1), 0 ); }