iuna

iuna

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

commit 21b851f39ff76bafbcd894fb53a061615ba6dd66
parent 5edcc6c742c37f8c5b792d778886b0d385a84b0c
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 25 Aug 2026 10:45:06 +0200

Test and harden protocol invariants

Diffstat:
Mdocs/protocol.md | 13+++++++------
Msrc/adapters/p2p/peer_addr.rs | 16++++++++++++++++
Msrc/app/automatic_mining.rs | 22++++++++++++++++++----
Msrc/app/gossip.rs | 24++++++++++++++++++++++++
Msrc/domain/adversarial_tests.rs | 34++++++++++++++++++++++++++++------
Msrc/domain/ledger_apply.rs | 32+++++++++++++++++++++++++++++---
Msrc/domain/ledger_chain.rs | 22++++++++++++++++++++--
Msrc/domain/ledger_consensus.rs | 24++++--------------------
Msrc/domain/ledger_lineage.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_mempool.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_ops.rs | 38++++++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_pending.rs | 235++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/domain/ledger_queries.rs | 42+++++++++++++++++++++++++++++++++++++++++-
Msrc/domain/ledger_reveal.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/mine_policy.rs | 33+++++++++++++++++++++++++++++++++
Msrc/domain/protocol.rs | 4++++
Msrc/domain/reveal.rs | 25+++++++++++++++++++++++++
Msrc/domain/ticket.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/vdf/mod.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
19 files changed, 1146 insertions(+), 88 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -42,7 +42,8 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh - maximum mine actions per anchor: `2`; - burn committee size: `5` slots; - maximum signed burn bundle size: `10,000` bytes; -- burn committee lineage maturity: `20` blocks. +- burn committee lineage maturity: `20` blocks; +- fallback ticket invalidation activation height: `300`. Changing any value in this section requires a conscious mainnet-candidate reset or later hard-fork process. @@ -82,7 +83,7 @@ For each block height, eligible tickets are ranked: The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules. -Fallback finalization invalidates missed ticket opportunities. If a ticket block is finalized by rank `1` or higher, nodes invalidate all tickets ranked from `0` through the finalizing rank for that height. They also invalidate any other currently eligible tickets owned by those same addresses. Future tickets from those addresses that are not yet eligible remain pending. Rank `0` ticket blocks continue to consume only the winning ticket. +From height `300`, fallback finalization invalidates missed ticket opportunities. If a ticket block is finalized by rank `1` or higher, nodes invalidate all tickets ranked from `0` through the finalizing rank for that height. They also invalidate any other currently eligible tickets owned by those same addresses. Future tickets from those addresses that are not yet eligible remain pending. Rank `0` ticket blocks continue to consume only the winning ticket. Earlier candidate history keeps the legacy behavior of consuming only the finalizing ticket. Every normal block must include at least one burn. This keeps the future ticket pool alive even during quiet periods. A node that may finalize prepares a local anchor burn for the next block from the finalizer wallet, and that anchor burn appears directly in the block. @@ -201,7 +202,7 @@ 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. 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`. +For a target height and committee slot, validators derive a deterministic committee seed from the parent hash, parent VDF output, target height, and slot number. 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 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. @@ -243,7 +244,7 @@ Validators reconstruct each signed committee bundle from this compact section be Block validity is not allowed to depend on a validator's local mempool. Validators decide the required burn-list threshold from deterministic chain and block data only. Pending burns can affect local relay, bundle-signing, and block-building policy, but they cannot make the same block valid on one node and invalid on another. -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. +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 the same assigned member sign two different bundles for the same height, parent, and slot before block assembly, it ignores that member's bundles for the round as local safety policy. Different ticket ranks can assign different members to the same slot, so those bundles do not conflict. The current protocol does not have a separate slashing rule for this. 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. @@ -267,7 +268,7 @@ Recovery blocks additionally bind the block timestamp into the VDF seed: 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. -Slot `0` uses a synthetic finalizer attestation hash derived from the parent, height, finalizer address, and the block's canonical deduplicated required burn list. Slots `1` and `2` use the signed burn-bundle hash or the fixed default hash when absent. +Slot `0` uses a synthetic finalizer attestation hash derived from the parent, height, finalizer address, and the block's canonical deduplicated required burn list. Slots `1` through `4` use the signed burn-bundle hash or the fixed default hash when absent. For recovery blocks, missing burn-list attestations use the fixed default hashes. A recovery block may include available burn bundles, but it does not need burn bundles in order to be valid. @@ -294,7 +295,7 @@ When a ticket finalizer is collecting burn-bundle attestations and has fewer sig rank requires, it may request the missing slots. Peers answer from their local cache with matching signed burn bundles for that height, parent hash, and slot set. -Nodes only keep transactions in their local mempool when they are valid, fee-paying, and unexpired. Pending transaction, burn-bundle, and orphan pools are bounded by both item count and serialized byte size. +Nodes only keep transactions in their local mempool when they are valid, fee-paying, and unexpired. Pending and orphan transaction pools are bounded by both item count and serialized byte size. A signed burn bundle is limited to `10,000` bytes, and nodes only cache valid bundles for the next height and current parent. ## Block Selection diff --git a/src/adapters/p2p/peer_addr.rs b/src/adapters/p2p/peer_addr.rs @@ -154,3 +154,19 @@ pub(super) fn inbound_error_counts_as_misbehavior(message: &str) -> bool { !message.contains("block timestamp is too far in the future") && !message.contains("block timestamp is before finalizer rank") } + +#[cfg(test)] +mod tests { + use super::inbound_error_counts_as_misbehavior; + + #[test] + fn future_and_unopened_rank_slot_errors_are_temporal_not_misbehavior() { + assert!(!inbound_error_counts_as_misbehavior( + "block timestamp is too far in the future" + )); + assert!(!inbound_error_counts_as_misbehavior( + "block timestamp is before finalizer rank 1 time slot" + )); + assert!(inbound_error_counts_as_misbehavior("block hash is invalid")); + } +} diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -491,9 +491,7 @@ impl NodeCore { timestamp_ms } }; - let elapsed = timestamp_ms.saturating_sub(started_at); - (elapsed < BURN_BUNDLE_COLLECTION_MS) - .then(|| BURN_BUNDLE_COLLECTION_MS.saturating_sub(elapsed)) + burn_bundle_collection_remaining_ms(started_at, timestamp_ms) } fn request_missing_burn_bundles_for_next_block(&mut self, _timestamp_ms: u64) { @@ -621,6 +619,11 @@ impl NodeCore { } } +fn burn_bundle_collection_remaining_ms(started_at_ms: u64, now_ms: u64) -> Option<u64> { + let elapsed = now_ms.saturating_sub(started_at_ms); + (elapsed < BURN_BUNDLE_COLLECTION_MS).then(|| BURN_BUNDLE_COLLECTION_MS.saturating_sub(elapsed)) +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -635,7 +638,18 @@ mod tests { }; use tempfile::tempdir; - use super::NodeCore; + use super::{NodeCore, burn_bundle_collection_remaining_ms}; + + #[test] + fn burn_bundle_collection_waits_exactly_thirty_seconds() { + assert_eq!( + burn_bundle_collection_remaining_ms(1_000, 1_000), + Some(30_000) + ); + assert_eq!(burn_bundle_collection_remaining_ms(1_000, 30_999), Some(1)); + assert_eq!(burn_bundle_collection_remaining_ms(1_000, 31_000), None); + assert_eq!(burn_bundle_collection_remaining_ms(1_000, 40_000), None); + } fn funded_ledger(wallets: &[Wallet]) -> Ledger { let allocations = wallets diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -150,6 +150,30 @@ mod tests { } #[test] + fn mempool_gossip_rebroadcasts_public_transfers() { + let alice = Wallet::from_seed("mempool-gossip-transfer-alice"); + let bob = Wallet::from_seed("mempool-gossip-transfer-bob"); + let ledger = Ledger::new( + BTreeMap::from([(alice.address().to_string(), 10 * MICRO_IUNA)]), + 1, + ); + let mut node = NodeCore::from_ledger(alice, ledger, 0); + let transfer = node + .transfer_with_fee(bob.address(), MICRO_IUNA, 1) + .unwrap(); + node.drain_outbox(); + + assert!(node.mempool_gossip().iter().any(|envelope| { + match envelope { + GossipEnvelope::Transactions { transactions } => transactions + .iter() + .any(|transaction| transaction.signature() == transfer.signature()), + _ => false, + } + })); + } + + #[test] fn mempool_gossip_includes_public_mine_actions() { let alice = Wallet::from_seed("mine-gossip-alice"); let ledger = Ledger::new(BTreeMap::new(), 1); diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -1622,7 +1622,8 @@ fn mini_newest_lineage_root( } fn mini_lineage_committee_weight(value: Amount) -> u64 { - u64::BITS as u64 - value.saturating_add(1).leading_zeros() as u64 - 1 + let one_plus_value = u128::from(value) + 1; + u128::BITS as u64 - one_plus_value.leading_zeros() as u64 - 1 } fn mini_burn_committee_for_next_block(ledger: &Ledger) -> Option<Vec<BurnCommitteeMember>> { @@ -2621,6 +2622,19 @@ fn misused_or_extra_committee_bundle_is_rejected() { fn mini_burn_bundle_quorum_oracle_matches_consensus_mutations() { let mut harness = harness_for_percent(38, 25); harness.mature_lineages(2, 4); + let lineage_owners = harness + .ledger + .lineage_owners + .values() + .flat_map(|owners| owners.keys().cloned()) + .collect::<BTreeSet<_>>(); + for owner in lineage_owners { + let wallet = harness.wallet(&owner).clone(); + harness.submit_fee_burn(&wallet, 1, 1); + } + for _ in 0..harness.ledger.launch_profile.ticket_maturity_delay_heights { + harness.mine_ticket_block(0); + } assert_mini_burn_committee_matches(&harness.ledger); let leader = harness.next_rank(0); @@ -2840,10 +2854,11 @@ fn pending_third_party_burn_does_not_affect_block_validity() { } #[test] -#[ignore = "long-running third-party burn committee coverage; run via deployment.sh"] -fn included_third_party_burn_requires_committee_signatures() { +#[ignore = "long-running required third-party burn committee coverage; run via deployment.sh"] +fn required_third_party_burn_requires_committee_signatures() { let mut harness = harness_for_percent(21, 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); @@ -2853,13 +2868,20 @@ fn included_third_party_burn_requires_committee_signatures() { .find(|wallet| wallet.address() != finalizer.address()) .unwrap() .clone(); - harness.submit_fee_burn(&victim, 1, 1); - let block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let third_party = harness.submit_fee_burn(&victim, 1, 1); + let bundles = harness.committee_bundles_for_rank_and_burns(0, vec![third_party]); + assert!( + !bundles.is_empty(), + "test setup should provide an explicit committee signature" + ); + let mut block = harness.finish_ticket_block_from_pending(0, bundles); + block.burn_bundle_section.signatures.clear(); + rehash(&mut block); assert_rejects( harness.ledger, block, - "included third-party burn without committee signatures", + "required third-party burn without committee signatures", ); } diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -190,9 +190,6 @@ impl Ledger { if block.transactions.len() > self.launch_profile.max_block_transactions { bail!("block has too many transactions"); } - if block.transactions.len() > self.launch_profile.max_block_transactions { - bail!("block has too many transaction items"); - } if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes { bail!("block exceeds max block size"); } @@ -323,3 +320,32 @@ fn apply_transaction_with_lineage( } Ok(()) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + #[test] + fn median_time_past_uses_the_median_of_the_latest_eleven_blocks() { + let mut ledger = Ledger::new(BTreeMap::new(), 1); + let template = ledger.tip().clone(); + let timestamps = [5, 500, 20, 400, 30, 300, 40, 200, 50, 100, 60, 1_000]; + ledger.chain = timestamps + .into_iter() + .enumerate() + .map(|(index, timestamp_ms)| { + let mut block = template.clone(); + block.height = index as u64; + block.timestamp_ms = timestamp_ms; + block.hash = format!("{:064x}", index + 1); + block + }) + .collect(); + + // The oldest value (5) falls outside the 11-block window. The sorted + // window is 20,30,40,50,60,100,200,300,400,500,1000. + assert_eq!(ledger.median_time_past(), 100); + } +} diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -247,8 +247,7 @@ impl Ledger { return ForkChoice::KeepLocal; } - let finalized_floor = local_height.saturating_sub(super::FORK_FINALITY_DEPTH); - if fork_point.common_ancestor_height < finalized_floor { + if fork_rewrites_finalized_history(local_height, fork_point.common_ancestor_height) { return ForkChoice::KeepLocal; } @@ -309,3 +308,22 @@ impl Ledger { *self = candidate; } } + +fn fork_rewrites_finalized_history(local_height: u64, common_ancestor_height: u64) -> bool { + let finalized_floor = local_height.saturating_sub(super::FORK_FINALITY_DEPTH); + common_ancestor_height < finalized_floor +} + +#[cfg(test)] +mod tests { + use super::fork_rewrites_finalized_history; + use crate::domain::FORK_FINALITY_DEPTH; + + #[test] + fn forks_may_rewrite_six_blocks_but_not_seven() { + assert_eq!(FORK_FINALITY_DEPTH, 6); + assert!(!fork_rewrites_finalized_history(100, 94)); + assert!(fork_rewrites_finalized_history(100, 93)); + assert!(!fork_rewrites_finalized_history(5, 0)); + } +} diff --git a/src/domain/ledger_consensus.rs b/src/domain/ledger_consensus.rs @@ -5,7 +5,7 @@ use super::ticket::{ BurnTicket, base_vdf_rounds_for_finalizer_rank, mine_action_count, ranked_tickets_for_height, vdf_rounds_for_finalizer_rank, }; -use super::vdf::{VDF_RETARGET_WINDOW_BLOCKS, retarget_vdf_rounds, vdf_retarget_observed_block_ms}; +use super::vdf::{recent_vdf_retarget_average_observed_block_ms, retarget_vdf_rounds}; use super::{Block, FinalizerMode, Ledger}; impl Ledger { @@ -17,26 +17,10 @@ impl Ledger { return self.vdf_rounds; } - let mut total_observed_ms = 0_u128; - let mut observed_blocks = 0_u128; - for pair in self - .chain - .windows(2) - .rev() - .filter(|pair| pair[0].height > 0) - .take(VDF_RETARGET_WINDOW_BLOCKS) - { - let Some(observed_ms) = vdf_retarget_observed_block_ms(&pair[0], &pair[1]) else { - continue; - }; - total_observed_ms += u128::from(observed_ms); - observed_blocks += 1; - } - if observed_blocks == 0 { + let Some(average_observed_ms) = recent_vdf_retarget_average_observed_block_ms(&self.chain) + else { return self.vdf_rounds; - } - - let average_observed_ms = (total_observed_ms / observed_blocks) as u64; + }; let base_rounds = base_vdf_rounds_for_finalizer_rank(tip.vdf_rounds, tip.finalizer_rank); retarget_vdf_rounds(base_rounds, average_observed_ms) } diff --git a/src/domain/ledger_lineage.rs b/src/domain/ledger_lineage.rs @@ -147,3 +147,173 @@ fn subtract_lineage_owner_value( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{TxInput, Wallet}; + + fn root(txid: char, height: u64) -> UtxoLineageRoot { + UtxoLineageRoot { + outpoint: OutPoint { + txid: txid.to_string().repeat(64), + index: 0, + }, + height, + } + } + + #[test] + fn newest_lineage_uses_height_then_deterministic_outpoint_tiebreak() { + let old = root('f', 4); + let same_age_low = root('1', 5); + let same_age_high = root('2', 5); + + assert_eq!( + newest_lineage_root(Some(old), Some(same_age_low.clone())), + Some(same_age_low.clone()) + ); + assert_eq!( + newest_lineage_root(Some(same_age_low), Some(same_age_high.clone())), + Some(same_age_high) + ); + assert_eq!(newest_lineage_root(None, None), None); + } + + #[test] + fn transfer_descendants_inherit_the_newest_spent_mine_root() { + let wallet = Wallet::from_seed("lineage-merge-wallet"); + let older = root('1', 4); + let newer = root('2', 5); + let older_outpoint = OutPoint { + txid: "a".repeat(64), + index: 0, + }; + let newer_outpoint = OutPoint { + txid: "b".repeat(64), + index: 0, + }; + let transaction = Transaction::Transfer { + inputs: vec![ + TxInput { + outpoint: older_outpoint.clone(), + owner: wallet.address().to_string(), + signature: "s".repeat(128), + }, + TxInput { + outpoint: newer_outpoint.clone(), + owner: wallet.address().to_string(), + signature: "s".repeat(128), + }, + ], + outputs: vec![TxOutput { + address: wallet.address().to_string(), + amount: 12, + }], + fee: 1, + signature: "s".repeat(128), + }; + let mut utxos = BTreeMap::from([ + ( + older_outpoint.clone(), + TxOutput { + address: wallet.address().to_string(), + amount: 5, + }, + ), + ( + newer_outpoint.clone(), + TxOutput { + address: wallet.address().to_string(), + amount: 8, + }, + ), + ]); + let mut utxo_lineage = BTreeMap::from([ + (older_outpoint.clone(), older.clone()), + (newer_outpoint.clone(), newer.clone()), + ]); + let mut lineage_values = BTreeMap::from([(older.clone(), 5), (newer.clone(), 8)]); + let mut lineage_owners = BTreeMap::from([ + ( + older.clone(), + BTreeMap::from([( + wallet.address().to_string(), + BTreeMap::from([(older_outpoint, 5)]), + )]), + ), + ( + newer.clone(), + BTreeMap::from([( + wallet.address().to_string(), + BTreeMap::from([(newer_outpoint, 8)]), + )]), + ), + ]); + + let (_, inherited) = spend_inputs_with_lineage( + &transaction, + &mut utxos, + &mut utxo_lineage, + &mut lineage_values, + &mut lineage_owners, + ) + .unwrap(); + assert_eq!(inherited, Some(newer.clone())); + + let descendant = OutPoint { + txid: "c".repeat(64), + index: 0, + }; + insert_output_with_lineage( + descendant.clone(), + TxOutput { + address: wallet.address().to_string(), + amount: 12, + }, + inherited, + &mut utxos, + &mut utxo_lineage, + &mut lineage_values, + &mut lineage_owners, + ) + .unwrap(); + + assert_eq!(utxo_lineage.get(&descendant), Some(&newer)); + assert_eq!(lineage_values, BTreeMap::from([(newer, 12)])); + } + + #[test] + fn mine_outputs_start_roots_and_unrooted_outputs_stay_unweighted() { + let mine = Transaction::Mine { + recipient: "recipient".to_string(), + anchor: "a".repeat(64), + salt: 1, + nonce: 1, + difficulty_bits: 10, + proof_header: None, + signature: "b".repeat(64), + }; + let transfer = Transaction::Transfer { + inputs: Vec::new(), + outputs: Vec::new(), + fee: 1, + signature: "c".repeat(128), + }; + + assert_eq!( + output_lineage_root_for_transaction(&mine, 20, None), + Some(UtxoLineageRoot { + outpoint: OutPoint { + txid: "b".repeat(64), + index: 0, + }, + height: 20, + }) + ); + assert_eq!( + output_lineage_root_for_transaction(&transfer, 20, None), + None + ); + } +} diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs @@ -127,3 +127,107 @@ fn serialized_len<T: Serialize>(item: &T) -> Result<usize> { .context("failed to serialize pending item for size check") .map(|bytes| bytes.len()) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::domain::transaction::{UnsignedTxInput, UnsignedUtxoTransaction}; + use crate::domain::{OutPoint, TxOutput, Wallet}; + + fn dummy_mine(signature_digit: char) -> Transaction { + Transaction::Mine { + recipient: "recipient".to_string(), + anchor: "a".repeat(64), + salt: 1, + nonce: 1, + difficulty_bits: 10, + proof_header: None, + signature: signature_digit.to_string().repeat(64), + } + } + + #[test] + fn pending_pool_byte_limit_accepts_the_boundary_and_rejects_one_byte_over() { + let item = "bounded-item"; + let item_bytes = serialized_len(&item).unwrap(); + + assert_eq!( + ensure_pending_pool_bytes( + "test pool", + MAX_PENDING_POOL_BYTES - item_bytes, + &item, + MAX_PENDING_POOL_BYTES, + ) + .unwrap(), + item_bytes + ); + assert!( + ensure_pending_pool_bytes( + "test pool", + MAX_PENDING_POOL_BYTES - item_bytes + 1, + &item, + MAX_PENDING_POOL_BYTES, + ) + .unwrap_err() + .to_string() + .contains("byte limit exceeded") + ); + } + + #[test] + fn mempool_rejects_transaction_after_ten_thousand_items() { + let alice = Wallet::from_seed("pending-limit-alice"); + let bob = Wallet::from_seed("pending-limit-bob"); + let mut ledger = Ledger::new( + BTreeMap::from([ + (alice.address().to_string(), 10), + (bob.address().to_string(), 10), + ]), + 1, + ); + let candidate = ledger.build_transfer(&alice, bob.address(), 1, 1).unwrap(); + ledger.pending = vec![dummy_mine('f'); MAX_PENDING_TRANSACTIONS]; + + assert_eq!(ledger.pending.len(), 10_000); + assert!( + ledger + .submit_transaction(candidate) + .unwrap_err() + .to_string() + .contains("mempool is full") + ); + } + + #[test] + fn orphan_pool_rejects_transaction_after_1024_items() { + let wallet = Wallet::from_seed("orphan-limit-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); + let orphan = UnsignedUtxoTransaction::Transfer { + inputs: vec![UnsignedTxInput { + outpoint: OutPoint { + txid: "b".repeat(64), + index: 0, + }, + owner: wallet.address().to_string(), + }], + outputs: vec![TxOutput { + address: wallet.address().to_string(), + amount: 1, + }], + fee: 1, + } + .sign(&wallet); + ledger.orphans = vec![dummy_mine('e'); MAX_ORPHAN_TRANSACTIONS]; + + assert_eq!(ledger.orphans.len(), 1_024); + assert!( + ledger + .submit_transaction(orphan) + .unwrap_err() + .to_string() + .contains("orphan transaction pool is full") + ); + } +} diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -747,4 +747,42 @@ mod tests { ); assert_eq!(reward_outputs_for_block(&recovery, &committee).len(), 1); } + + #[test] + fn finalizer_and_committee_reward_outputs_do_not_create_lineage() { + let mut block = reward_block(FinalizerMode::Ticket, 0, 100); + attest(&mut block, &[1]); + let committee = vec![ + committee_member(0, "finalizer"), + committee_member(1, "committee-1"), + ]; + let mut utxos = BTreeMap::new(); + let utxo_lineage = BTreeMap::<OutPoint, super::super::UtxoLineageRoot>::new(); + let lineage_values = BTreeMap::<super::super::UtxoLineageRoot, Amount>::new(); + + credit_reward_outputs(&mut utxos, &block, &committee).unwrap(); + + assert_eq!(utxos.len(), 2); + assert!( + utxos + .keys() + .all(|outpoint| !utxo_lineage.contains_key(outpoint)) + ); + assert!(lineage_values.is_empty()); + } + + #[test] + fn recovery_vdf_seed_binds_timestamp_while_ticket_seed_does_not() { + let hashes = std::array::from_fn(super::super::default_burn_bundle_hash); + let ticket_seed = vdf_seed_for_child(&"a".repeat(64), 42, &hashes); + let recovery_at_one = recovery_vdf_seed_for_child(&"a".repeat(64), 42, 1, &hashes); + let recovery_at_two = recovery_vdf_seed_for_child(&"a".repeat(64), 42, 2, &hashes); + + assert_ne!(ticket_seed, recovery_at_one); + assert_ne!(recovery_at_one, recovery_at_two); + assert_eq!( + ticket_seed, + vdf_seed_for_child(&"a".repeat(64), 42, &hashes) + ); + } } diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -119,6 +119,34 @@ impl Ledger { .map(|burn| burn.signature().to_string()) .collect::<BTreeSet<_>>(); let mut selected_required_burn_signatures = BTreeSet::new(); + + let anchor_index = if let Some(signature) = required_burn_signature { + Some( + remaining + .iter() + .position(|transaction| transaction.signature() == signature) + .with_context(|| format!("required burn {signature} is not pending"))?, + ) + } else if let Some(owner) = required_burn_owner { + best_selectable_burn_from_index(&remaining, &utxos, owner) + } else { + best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) + }; + if let Some(index) = anchor_index { + let tx = remaining.remove(index); + let signature = tx.signature().to_string(); + self.select_required_anchor_burn( + tx, + required_burn_owner, + burn_bundle_section, + &mut utxos, + &mut selected, + )?; + if required_burn_signatures.contains(&signature) { + selected_required_burn_signatures.insert(signature); + } + } + let mut index = 0; while index < remaining.len() && selected_required_burn_signatures.len() < required_burn_signatures.len() @@ -131,6 +159,9 @@ impl Ledger { if !tx.is_burn() { bail!("attested transaction must be a burn"); } + if selected.len() >= self.launch_profile.max_block_transactions { + bail!("attested burns do not fit within the block transaction count limit"); + } let signature = tx.signature().to_string(); let mut candidate = BlockSelection { transactions: selected.clone(), @@ -156,50 +187,6 @@ impl Ledger { bail!("attested burn {missing} is not pending"); } - if let Some(signature) = required_burn_signature { - if !selected - .iter() - .any(|transaction| transaction.signature() == signature) - { - let index = remaining - .iter() - .position(|transaction| transaction.signature() == signature) - .with_context(|| format!("required burn {signature} is not pending"))?; - let tx = remaining.remove(index); - self.select_required_anchor_burn( - tx, - required_burn_owner, - burn_bundle_section, - &mut utxos, - &mut selected, - )?; - } - } - - let needs_first_burn = !selected.iter().any(Transaction::is_burn); - let needs_owner_burn = required_burn_owner.is_some_and(|owner| { - !selected - .iter() - .any(|transaction| transaction.is_burn() && transaction.sender() == owner) - }); - if needs_first_burn || needs_owner_burn { - let first_burn_index = if let Some(owner) = required_burn_owner { - best_selectable_burn_from_index(&remaining, &utxos, owner) - } else { - best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) - }; - if let Some(index) = first_burn_index { - let tx = remaining.remove(index); - self.select_required_anchor_burn( - tx, - required_burn_owner, - burn_bundle_section, - &mut utxos, - &mut selected, - )?; - } - } - while selected.len() < self.launch_profile.max_block_transactions { let Some(index) = best_selectable_transaction_index(&remaining, &utxos, None) else { break; @@ -240,6 +227,9 @@ impl Ledger { bail!("required block anchor burn must be from the recovery finalizer"); } } + if selected.len() >= self.launch_profile.max_block_transactions { + bail!("required block anchor burn does not fit within the transaction count limit"); + } let mut candidate = BlockSelection { transactions: selected.clone(), }; @@ -477,3 +467,160 @@ impl Ledger { Ok(utxos) } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::domain::{BurnBundleSection, FinalizerMode, MaskedBurn, Wallet}; + + fn extend_synthetic_chain_to(ledger: &mut Ledger, target_height: u64) { + while ledger.height() < target_height { + let parent = ledger.tip().clone(); + let height = parent.height + 1; + let mut block = parent; + block.height = height; + block.prev_hash = block.hash.clone(); + block.hash = format!("{height:064x}"); + block.timestamp_ms = height; + block.finalizer_mode = FinalizerMode::Ticket; + block.finalizer_rank = 0; + block.burn_bundle_section = BurnBundleSection::default(); + block.transactions.clear(); + ledger.chain.push(block); + } + } + + #[test] + fn mine_actions_expire_after_ten_blocks() { + let wallet = Wallet::from_seed("mine-anchor-expiry-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); + let anchor = ledger.genesis_hash().to_string(); + let mine = Transaction::Mine { + recipient: wallet.address().to_string(), + anchor, + salt: 1, + nonce: 1, + difficulty_bits: ledger.mine_difficulty_bits_for_anchor_height(0), + proof_header: None, + signature: "a".repeat(64), + }; + + extend_synthetic_chain_to(&mut ledger, MINE_MAX_ANCHOR_AGE_BLOCKS); + assert!(ledger.validate_transaction_terms(&mine).is_ok()); + + extend_synthetic_chain_to(&mut ledger, MINE_MAX_ANCHOR_AGE_BLOCKS + 1); + assert!( + ledger + .validate_transaction_terms(&mine) + .unwrap_err() + .to_string() + .contains("anchor is too old") + ); + } + + #[test] + fn block_selection_reserves_anchor_first_then_orders_remaining_by_fee_rate() { + let finalizer = Wallet::from_seed("selection-finalizer"); + let high_fee_sender = Wallet::from_seed("selection-high-fee"); + let medium_fee_sender = Wallet::from_seed("selection-medium-fee"); + let recipient = Wallet::from_seed("selection-recipient"); + let mut ledger = Ledger::new( + BTreeMap::from([ + (finalizer.address().to_string(), 1_000), + (high_fee_sender.address().to_string(), 1_000), + (medium_fee_sender.address().to_string(), 1_000), + ]), + 1, + ); + let anchor = ledger.build_burn(&finalizer, 1, 1).unwrap(); + let high_fee = ledger + .build_transfer(&high_fee_sender, recipient.address(), 1, 20) + .unwrap(); + let medium_fee = ledger.build_burn(&medium_fee_sender, 1, 5).unwrap(); + for transaction in [&medium_fee, &anchor, &high_fee] { + ledger.submit_transaction(transaction.clone()).unwrap(); + } + + let selection = ledger + .select_block_transactions_with_required_burn_owner( + Some(finalizer.address()), + Some(anchor.signature()), + &BurnBundleSection::default(), + ) + .unwrap(); + + assert_eq!(selection.transactions[0].signature(), anchor.signature()); + assert!(selection.transactions[0].is_burn()); + assert!(selection.transactions[1..].windows(2).all(|pair| { + super::super::selection::fee_rate_key(&pair[0]) + >= super::super::selection::fee_rate_key(&pair[1]) + })); + assert!( + selection + .transactions + .iter() + .any(|transaction| transaction.signature() == high_fee.signature()) + ); + } + + #[test] + fn public_transfers_and_burns_require_nonzero_fees() { + let alice = Wallet::from_seed("zero-fee-alice"); + let bob = Wallet::from_seed("zero-fee-bob"); + let ledger = Ledger::new(BTreeMap::from([(alice.address().to_string(), 10)]), 1); + + assert!( + ledger + .build_transfer(&alice, bob.address(), 1, 0) + .unwrap_err() + .to_string() + .contains("fee must be greater than zero") + ); + assert!( + ledger + .build_burn(&alice, 1, 0) + .unwrap_err() + .to_string() + .contains("fee must be greater than zero") + ); + } + + #[test] + fn required_anchor_and_attested_burns_must_fit_transaction_count_limit() { + let finalizer = Wallet::from_seed("count-limit-finalizer"); + let committee_sender = Wallet::from_seed("count-limit-committee"); + let mut ledger = Ledger::new( + BTreeMap::from([ + (finalizer.address().to_string(), 10), + (committee_sender.address().to_string(), 10), + ]), + 1, + ); + let anchor = ledger.build_burn(&finalizer, 1, 1).unwrap(); + let attested = ledger.build_burn(&committee_sender, 1, 1).unwrap(); + ledger.submit_transaction(anchor.clone()).unwrap(); + ledger.submit_transaction(attested.clone()).unwrap(); + ledger.launch_profile.max_block_transactions = 1; + let section = BurnBundleSection { + signatures: Vec::new(), + burns: vec![MaskedBurn { + burn: attested, + bundle_mask: 0, + }], + }; + + assert!( + ledger + .select_block_transactions_with_required_burn_owner( + Some(finalizer.address()), + Some(anchor.signature()), + &section, + ) + .unwrap_err() + .to_string() + .contains("transaction count limit") + ); + } +} diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -34,7 +34,8 @@ pub(super) struct LineageCommitteeCandidate { } pub(super) fn lineage_committee_weight(value: Amount) -> u64 { - u64::BITS as u64 - value.saturating_add(1).leading_zeros() as u64 - 1 + let one_plus_value = u128::from(value) + 1; + u128::BITS as u64 - one_plus_value.leading_zeros() as u64 - 1 } pub(super) fn select_weighted_lineage_index( @@ -747,6 +748,45 @@ mod tests { assert_ne!(committee[1].owner, non_ticket_owner.address()); } + #[test] + fn lineage_weight_is_floor_log2_of_one_plus_root_value() { + assert_eq!(lineage_committee_weight(0), 0); + assert_eq!(lineage_committee_weight(1), 1); + assert_eq!(lineage_committee_weight(2), 1); + assert_eq!(lineage_committee_weight(3), 2); + assert_eq!(lineage_committee_weight(7), 3); + assert_eq!(lineage_committee_weight(8), 3); + assert_eq!(lineage_committee_weight(u64::MAX), 64); + } + + #[test] + fn committee_draw_seed_has_a_fixed_parent_vdf_height_and_slot_vector() { + let mut parent = Ledger::new(BTreeMap::new(), 1).tip().clone(); + parent.hash = "a".repeat(64); + parent.vdf_output = "parent-vdf".to_string(); + let candidates = [1_u64, 2, 4] + .into_iter() + .enumerate() + .map(|(index, weight)| LineageCommitteeCandidate { + root: UtxoLineageRoot { + outpoint: OutPoint { + txid: format!("{:064x}", index + 1), + index: 0, + }, + height: 1, + }, + value: weight, + weight, + owner: format!("owner-{index}"), + }) + .collect::<Vec<_>>(); + + assert_eq!( + select_weighted_lineage_index(&parent, 42, 1, &candidates), + Some(2) + ); + } + proptest! { #![proptest_config(Config { cases: 128, .. Config::default() })] diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -609,4 +609,89 @@ mod tests { .contains("burn bundle references a burn that is not in the mempool") ); } + + #[test] + fn invalid_committee_bundle_signature_is_rejected() { + let alice = Wallet::from_seed("bundle-signature-alice"); + let bob = Wallet::from_seed("bundle-signature-bob"); + let ledger = funded_ledger(&[alice.clone(), bob.clone()]); + let finalizer_address = ledger.expected_leader_for_next_block().unwrap(); + let finalizer = [&alice, &bob] + .into_iter() + .find(|wallet| wallet.address() == finalizer_address) + .unwrap(); + let mut bundle = ledger.test_burn_bundle(finalizer, Vec::new()); + let replacement = if bundle.signature.starts_with('0') { + "1" + } else { + "0" + }; + bundle.signature.replace_range(0..1, replacement); + + assert!( + ledger + .validate_next_block_burn_bundles(vec![bundle]) + .unwrap_err() + .to_string() + .contains("signature") + ); + } + + #[test] + fn block_bundle_validation_rejects_duplicate_slots() { + let alice = Wallet::from_seed("duplicate-slot-alice"); + let bob = Wallet::from_seed("duplicate-slot-bob"); + let ledger = funded_ledger(&[alice.clone(), bob.clone()]); + let finalizer_address = ledger.expected_leader_for_next_block().unwrap(); + let finalizer = [&alice, &bob] + .into_iter() + .find(|wallet| wallet.address() == finalizer_address) + .unwrap(); + let bundle = ledger.test_burn_bundle(finalizer, Vec::new()); + + assert!( + ledger + .validate_next_block_burn_bundles(vec![bundle.clone(), bundle]) + .unwrap_err() + .to_string() + .contains("duplicate burn bundle slot") + ); + } + + #[test] + fn attested_burn_omitted_from_block_is_rejected_without_consulting_mempool_policy() { + let alice = Wallet::from_seed("required-burn-alice"); + let bob = Wallet::from_seed("required-burn-bob"); + let mut ledger = funded_ledger(&[alice.clone(), bob.clone()]); + let finalizer_address = ledger.expected_leader_for_next_block().unwrap(); + let finalizer = [&alice, &bob] + .into_iter() + .find(|wallet| wallet.address() == finalizer_address) + .unwrap(); + let other = [&alice, &bob] + .into_iter() + .find(|wallet| wallet.address() != finalizer_address) + .unwrap(); + let pending_burn = ledger.build_burn(other, 1, 1).unwrap(); + ledger.submit_transaction(pending_burn.clone()).unwrap(); + let anchor = ledger.build_burn(finalizer, 1, 1).unwrap(); + ledger.submit_transaction(anchor).unwrap(); + let mut block = ledger + .prepare_next_block(finalizer.address(), 1) + .unwrap() + .finish(finalizer, "unused-vdf".to_string()); + let bundle = ledger.test_burn_bundle(finalizer, vec![pending_burn.clone()]); + block.burn_bundle_section = ledger.burn_bundle_section_from_bundles(vec![bundle]); + block + .transactions + .retain(|transaction| transaction.signature() != pending_burn.signature()); + + assert!( + ledger + .validate_burn_bundle_section_for_block(&block) + .unwrap_err() + .to_string() + .contains("attested burn is not included") + ); + } } diff --git a/src/domain/mine_policy.rs b/src/domain/mine_policy.rs @@ -106,4 +106,37 @@ mod tests { assert_eq!(retarget_mine_difficulty_bits(33, 20), 34); assert_eq!(retarget_mine_difficulty_bits(40, 10), 40); } + + #[test] + fn pow_retarget_parameters_and_action_targets_match_the_protocol() { + assert_eq!(MINE_RETARGET_WINDOW_BLOCKS, 10); + assert_eq!(MINE_TARGET_ACTIONS_PER_BLOCK, 1); + assert_eq!(MINE_MAX_RETARGET_STEP_BITS, 2); + assert_eq!(MINE_MIN_DIFFICULTY_BITS, 10); + assert_eq!(MINE_MAX_ANCHOR_AGE_BLOCKS, 10); + + assert_eq!(retarget_mine_difficulty_bits(12, 0), 10); + assert_eq!(retarget_mine_difficulty_bits(12, 5), 11); + assert_eq!(retarget_mine_difficulty_bits(12, 9), 12); + assert_eq!(retarget_mine_difficulty_bits(12, 10), 12); + assert_eq!(retarget_mine_difficulty_bits(12, 20), 13); + assert_eq!(retarget_mine_difficulty_bits(12, 40), 14); + assert_eq!(retarget_mine_difficulty_bits(12, 1_000), 14); + } + + #[test] + fn block_rejects_a_third_mine_action_for_the_same_anchor() { + let mine = |signature: &str| Transaction::Mine { + recipient: "recipient".to_string(), + anchor: "a".repeat(64), + salt: 1, + nonce: 1, + difficulty_bits: 10, + proof_header: None, + signature: signature.repeat(64), + }; + + assert!(ensure_mine_anchor_limit(1, &[mine("1"), mine("2")]).is_ok()); + assert!(ensure_mine_anchor_limit(1, &[mine("1"), mine("2"), mine("3")]).is_err()); + } } diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -73,5 +73,9 @@ mod tests { assert_eq!(PUBLIC_KEY_BYTES, 32); assert_eq!(HASH_BYTES, 32); assert_eq!(SIGNATURE_BYTES, 64); + assert_eq!( + crate::domain::ticket::MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT, + 300 + ); } } diff --git a/src/domain/reveal.rs b/src/domain/reveal.rs @@ -214,3 +214,28 @@ pub(super) fn canonical_burn_bundle_hashes( ) -> String { bundle_hashes.join("|") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn burn_bundle_hash_array_covers_slots_zero_through_four() { + let slot_four = BurnBundle { + height: 1, + prev_hash: "parent".to_string(), + slot: 4, + member: "member".to_string(), + burns: Vec::new(), + signature: "signature".to_string(), + }; + + let hashes = burn_bundle_hashes(std::slice::from_ref(&slot_four)); + + assert_eq!(hashes.len(), 5); + for (slot, hash) in hashes.iter().enumerate().take(4) { + assert_eq!(hash, &default_burn_bundle_hash(slot)); + } + assert_eq!(hashes[4], slot_four.bundle_hash()); + } +} diff --git a/src/domain/ticket.rs b/src/domain/ticket.rs @@ -328,3 +328,199 @@ pub(super) fn mine_action_count(block: &Block) -> u64 { .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) .count() as u64 } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{BurnBundleSection, LeaderProof, TxInput}; + + fn parent(height: u64) -> Block { + Block { + height, + prev_hash: "0".repeat(64), + timestamp_ms: 1_000, + miner: "parent".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 0, + vdf_rounds: 100, + vdf_output: "parent-vdf-output".to_string(), + leader_proof: None, + burn_bundle_section: BurnBundleSection::default(), + transactions: Vec::new(), + hash: "1".repeat(64), + } + } + + fn ticket(id: char, owner: &str, from: u64, until: u64) -> BurnTicket { + BurnTicket { + id: id.to_string().repeat(64), + owner: owner.to_string(), + amount: 1, + eligible_from_height: from, + eligible_until_height: until, + } + } + + fn ticket_block(parent: &Block, height: u64, rank: u32, selected: &BurnTicket) -> Block { + Block { + height, + prev_hash: parent.hash.clone(), + timestamp_ms: ticket_block_min_timestamp(parent, rank).unwrap(), + miner: selected.owner.clone(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: rank, + reward: 0, + vdf_rounds: vdf_rounds_for_finalizer_rank(100, rank).unwrap(), + vdf_output: "child-vdf-output".to_string(), + leader_proof: Some(LeaderProof { + ticket_id: selected.id.clone(), + public_key: selected.owner.clone(), + signature: "2".repeat(128), + }), + burn_bundle_section: BurnBundleSection::default(), + transactions: Vec::new(), + hash: "3".repeat(64), + } + } + + #[test] + fn burns_create_tickets_after_three_blocks_for_three_heights() { + let burn = Transaction::Burn { + inputs: vec![TxInput { + outpoint: super::super::OutPoint { + txid: "a".repeat(64), + index: 0, + }, + owner: "burner".to_string(), + signature: "b".repeat(128), + }], + change: Vec::new(), + amount: 42, + fee: 1, + signature: "b".repeat(128), + }; + + let tickets = + tickets_created_by_transactions(10, &[burn], &LaunchProfile::default()).unwrap(); + + assert_eq!(tickets.len(), 1); + assert_eq!(tickets[0].amount, 42); + assert_eq!(tickets[0].eligible_from_height, 13); + assert_eq!(tickets[0].eligible_until_height, 15); + } + + #[test] + fn ticket_draw_has_a_fixed_parent_vdf_height_rank_and_weight_vector() { + let parent = parent(41); + let tickets = vec![ + BurnTicket { + amount: 2, + ..ticket('a', "alice", 42, 42) + }, + BurnTicket { + amount: 3, + ..ticket('b', "bob", 42, 42) + }, + BurnTicket { + amount: 5, + ..ticket('c', "carol", 42, 42) + }, + ]; + + let ranked = ranked_tickets_for_height(&parent, 42, &tickets) + .into_iter() + .map(|ticket| ticket.id) + .collect::<Vec<_>>(); + + assert_eq!(ranked, vec!["c".repeat(64), "a".repeat(64), "b".repeat(64)]); + } + + #[test] + fn fallback_consumes_missed_owners_current_tickets_but_keeps_future_tickets() { + assert_eq!(MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT, 300); + let parent = parent(299); + let mut tickets = vec![ + ticket('a', "alice", 300, 302), + ticket('b', "bob", 300, 302), + ticket('c', "carol", 300, 302), + ticket('d', "alice", 300, 302), + ticket('e', "bob", 301, 303), + ]; + let ranked = ranked_tickets_for_height(&parent, 300, &tickets); + let selected = ranked[1].clone(); + let missed_owner = ranked[0].owner.clone(); + let selected_owner = selected.owner.clone(); + let block = ticket_block(&parent, 300, 1, &selected); + + consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); + + assert!(tickets.iter().all(|ticket| { + ticket.eligible_from_height > 300 + || (ticket.owner != missed_owner && ticket.owner != selected_owner) + })); + assert!( + tickets.iter().any(|ticket| ticket.id == "e".repeat(64)), + "future tickets must remain pending" + ); + } + + #[test] + fn fallback_before_activation_consumes_only_the_finalizing_ticket() { + let parent = parent(MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT - 2); + let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT - 1; + let mut tickets = vec![ + ticket('a', "alice", height, height + 2), + ticket('b', "bob", height, height + 2), + ticket('c', "alice", height, height + 2), + ]; + let ranked = ranked_tickets_for_height(&parent, height, &tickets); + let missed_ticket_id = ranked[0].id.clone(); + let selected = ranked[1].clone(); + let block = ticket_block(&parent, height, 1, &selected); + + consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); + + assert_eq!(tickets.len(), 2); + assert!(tickets.iter().all(|ticket| ticket.id != selected.id)); + assert!( + tickets.iter().any(|ticket| ticket.id == missed_ticket_id), + "the missed rank-0 ticket must survive before activation" + ); + } + + #[test] + fn rank_zero_consumes_only_its_winning_ticket() { + let parent = parent(300); + let mut tickets = vec![ + ticket('a', "alice", 301, 303), + ticket('b', "alice", 301, 303), + ticket('c', "bob", 301, 303), + ]; + let selected = ranked_tickets_for_height(&parent, 301, &tickets)[0].clone(); + let block = ticket_block(&parent, 301, 0, &selected); + + consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); + + assert_eq!(tickets.len(), 2); + assert!(tickets.iter().all(|ticket| ticket.id != selected.id)); + } + + #[test] + fn fallback_vdf_rounds_and_time_slots_scale_with_rank() { + let parent = parent(10); + + assert_eq!(vdf_rounds_for_finalizer_rank(100, 0).unwrap(), 100); + assert_eq!(vdf_rounds_for_finalizer_rank(100, 1).unwrap(), 200); + assert_eq!(vdf_rounds_for_finalizer_rank(100, 2).unwrap(), 300); + assert_eq!(ticket_block_min_timestamp(&parent, 0).unwrap(), 1_001); + assert_eq!( + ticket_block_min_timestamp(&parent, 1).unwrap(), + 1_000 + 2 * VDF_TARGET_BLOCK_MS + ); + assert_eq!( + ticket_block_min_timestamp(&parent, 2).unwrap(), + 1_000 + 4 * VDF_TARGET_BLOCK_MS + ); + } +} diff --git a/src/domain/vdf/mod.rs b/src/domain/vdf/mod.rs @@ -145,6 +145,24 @@ pub(super) fn vdf_retarget_observed_block_ms(parent: &Block, child: &Block) -> O )) } +pub(super) fn recent_vdf_retarget_average_observed_block_ms(chain: &[Block]) -> Option<u64> { + let observations = chain + .windows(2) + .rev() + .filter(|pair| pair[0].height > 0) + .filter_map(|pair| vdf_retarget_observed_block_ms(&pair[0], &pair[1])) + .take(VDF_RETARGET_WINDOW_BLOCKS) + .collect::<Vec<_>>(); + if observations.is_empty() { + return None; + } + let total = observations + .iter() + .map(|observed| u128::from(*observed)) + .sum::<u128>(); + Some((total / observations.len() as u128) as u64) +} + fn maybe_report_vdf_progress( last_progress: &mut Instant, progress_interval: Duration, @@ -177,9 +195,102 @@ mod tests { }; use super::{ - VDF_SOLUTION_PREFIX, VdfProgressPhase, run_vdf, run_vdf_cancellable_with_progress, - run_vdf_with_progress, vdf_solution_placeholder, verify_vdf, wesolowski, + MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, MAX_VDF_RETARGET_STEP_PERCENT, + MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, VDF_RETARGET_DEADBAND_PERCENT, + VDF_RETARGET_WINDOW_BLOCKS, VDF_SOLUTION_PREFIX, VdfProgressPhase, + clamped_vdf_retarget_observed_block_ms, recent_vdf_retarget_average_observed_block_ms, + retarget_vdf_rounds, run_vdf, run_vdf_cancellable_with_progress, run_vdf_with_progress, + vdf_retarget_observed_block_ms, vdf_solution_placeholder, verify_vdf, wesolowski, }; + use crate::domain::{Block, BurnBundleSection, FinalizerMode, VDF_TARGET_BLOCK_MS}; + + fn block(height: u64, timestamp_ms: u64, mode: FinalizerMode, rank: u32) -> Block { + Block { + height, + prev_hash: format!("{height:064x}"), + timestamp_ms, + miner: "finalizer".to_string(), + finalizer_mode: mode, + finalizer_rank: rank, + reward: 0, + vdf_rounds: 100, + vdf_output: format!("output-{height}"), + leader_proof: None, + burn_bundle_section: BurnBundleSection::default(), + transactions: Vec::new(), + hash: format!("{:064x}", height + 1), + } + } + + #[test] + fn vdf_retarget_parameters_and_boundaries_match_the_protocol() { + assert_eq!(VDF_RETARGET_WINDOW_BLOCKS, 20); + assert_eq!(VDF_RETARGET_DEADBAND_PERCENT, 10); + assert_eq!(MAX_VDF_RETARGET_STEP_PERCENT, 2); + assert_eq!(MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, VDF_TARGET_BLOCK_MS / 4); + assert_eq!(MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, VDF_TARGET_BLOCK_MS * 4); + + assert_eq!( + retarget_vdf_rounds(1_000, VDF_TARGET_BLOCK_MS * 9 / 10), + 1_000 + ); + assert_eq!( + retarget_vdf_rounds(1_000, VDF_TARGET_BLOCK_MS * 11 / 10), + 1_000 + ); + assert_eq!(retarget_vdf_rounds(1_000, VDF_TARGET_BLOCK_MS / 2), 1_020); + assert_eq!(retarget_vdf_rounds(1_000, VDF_TARGET_BLOCK_MS * 2), 980); + assert_eq!( + clamped_vdf_retarget_observed_block_ms(1), + VDF_TARGET_BLOCK_MS / 4 + ); + assert_eq!( + clamped_vdf_retarget_observed_block_ms(u64::MAX), + VDF_TARGET_BLOCK_MS * 4 + ); + } + + #[test] + fn vdf_retarget_observes_only_rank_zero_ticket_blocks() { + let parent = block(1, 1_000, FinalizerMode::Ticket, 0); + let primary = block(2, 1_000 + VDF_TARGET_BLOCK_MS, FinalizerMode::Ticket, 0); + let fallback = block(2, 1_000 + VDF_TARGET_BLOCK_MS, FinalizerMode::Ticket, 1); + let recovery = block(2, 1_000 + VDF_TARGET_BLOCK_MS, FinalizerMode::Recovery, 0); + + assert_eq!( + vdf_retarget_observed_block_ms(&parent, &primary), + Some(VDF_TARGET_BLOCK_MS) + ); + assert_eq!(vdf_retarget_observed_block_ms(&parent, &fallback), None); + assert_eq!(vdf_retarget_observed_block_ms(&parent, &recovery), None); + } + + #[test] + fn fallback_blocks_do_not_consume_the_twenty_primary_observation_window() { + let mut chain = vec![block(0, 0, FinalizerMode::Ticket, 0)]; + chain.push(block(1, VDF_TARGET_BLOCK_MS / 2, FinalizerMode::Ticket, 0)); + for height in 2..=21 { + chain.push(block( + height, + height * (VDF_TARGET_BLOCK_MS / 2), + FinalizerMode::Ticket, + 0, + )); + } + for height in 22..=46 { + chain.push(block( + height, + height * (VDF_TARGET_BLOCK_MS / 2), + FinalizerMode::Ticket, + 1, + )); + } + + assert_eq!( + recent_vdf_retarget_average_observed_block_ms(&chain), + Some(VDF_TARGET_BLOCK_MS / 2) + ); + } #[test] fn vdf_solution_verifies_and_is_bound_to_seed_and_rounds() {