ticket.rs (8627B)
1 use std::collections::BTreeMap; 2 3 use anyhow::{Context, Result, bail}; 4 use sha2::{Digest, Sha256}; 5 6 use super::{ 7 Amount, Block, FinalizerMode, LaunchProfile, MAX_VDF_ROUNDS, Transaction, VDF_TARGET_BLOCK_MS, 8 hex_hash, 9 }; 10 11 #[derive(Clone, Debug, Eq, PartialEq)] 12 pub(super) struct BurnTicket { 13 pub(super) id: String, 14 pub(super) owner: String, 15 pub(super) amount: Amount, 16 pub(super) eligible_from_height: u64, 17 pub(super) eligible_until_height: u64, 18 } 19 20 pub(super) fn ranked_tickets_for_height( 21 parent: &Block, 22 target_height: u64, 23 tickets: &[BurnTicket], 24 ) -> Vec<BurnTicket> { 25 let mut remaining = tickets 26 .iter() 27 .filter(|ticket| ticket_is_eligible_for_height(ticket, target_height)) 28 .cloned() 29 .collect::<Vec<_>>(); 30 let mut ranked = Vec::with_capacity(remaining.len()); 31 32 for rank in 0.. { 33 let Some(selected_index) = 34 select_weighted_ticket_index(parent, target_height, rank, &remaining) 35 else { 36 break; 37 }; 38 ranked.push(remaining.remove(selected_index)); 39 } 40 41 ranked 42 } 43 44 fn select_weighted_ticket_index( 45 parent: &Block, 46 target_height: u64, 47 rank: u32, 48 tickets: &[BurnTicket], 49 ) -> Option<usize> { 50 let total_weight = tickets.iter().try_fold(0_u128, |total, ticket| { 51 total.checked_add(u128::from(ticket.amount)) 52 })?; 53 if total_weight == 0 { 54 return None; 55 } 56 57 let draw = weighted_ticket_draw(parent, target_height, rank, total_weight); 58 let mut cumulative = 0_u128; 59 for (index, ticket) in tickets.iter().enumerate() { 60 cumulative = cumulative.checked_add(u128::from(ticket.amount))?; 61 if draw < cumulative { 62 return Some(index); 63 } 64 } 65 None 66 } 67 68 fn weighted_ticket_draw(parent: &Block, target_height: u64, rank: u32, total_weight: u128) -> u128 { 69 let seed = if rank == 0 { 70 format!( 71 "iuna-ticket-draw:{}:{}:{}", 72 target_height, parent.hash, parent.vdf_output 73 ) 74 } else { 75 format!( 76 "iuna-ticket-draw-rank:{}:{}:{}:{}", 77 target_height, rank, parent.hash, parent.vdf_output 78 ) 79 }; 80 let digest = Sha256::digest(seed.as_bytes()); 81 let mut bytes = [0_u8; 16]; 82 bytes.copy_from_slice(&digest[..16]); 83 u128::from_be_bytes(bytes) % total_weight 84 } 85 86 pub(super) fn vdf_rounds_for_finalizer_rank(base_rounds: u64, rank: u32) -> Result<u64> { 87 let rounds = base_rounds 88 .checked_mul(u64::from( 89 rank.checked_add(1).context("finalizer rank overflows")?, 90 )) 91 .context("finalizer rank VDF rounds overflow")?; 92 if rounds > MAX_VDF_ROUNDS { 93 bail!("finalizer rank VDF rounds exceed maximum"); 94 } 95 Ok(rounds) 96 } 97 98 fn finalizer_rank_slot_delay_ms(rank: u32) -> Result<u64> { 99 VDF_TARGET_BLOCK_MS 100 .checked_mul(2) 101 .context("finalizer rank time slot overflow")? 102 .checked_mul(u64::from(rank)) 103 .context("finalizer rank time slot overflow") 104 } 105 106 pub(super) fn ticket_block_min_timestamp(parent: &Block, rank: u32) -> Result<u64> { 107 if rank == 0 { 108 return parent 109 .timestamp_ms 110 .checked_add(1) 111 .context("finalizer rank minimum timestamp overflow"); 112 } 113 114 parent 115 .timestamp_ms 116 .checked_add(finalizer_rank_slot_delay_ms(rank)?) 117 .context("finalizer rank minimum timestamp overflow") 118 } 119 120 pub(super) fn base_vdf_rounds_for_finalizer_rank(vdf_rounds: u64, rank: u32) -> u64 { 121 vdf_rounds / u64::from(rank.saturating_add(1).max(1)) 122 } 123 124 pub(super) fn tickets_created_by_block( 125 block: &Block, 126 profile: &LaunchProfile, 127 ) -> Result<Vec<BurnTicket>> { 128 tickets_created_by_transactions(block.height, &block.transactions, profile) 129 } 130 131 pub(super) fn tickets_created_by_transactions( 132 block_height: u64, 133 transactions: &[Transaction], 134 profile: &LaunchProfile, 135 ) -> Result<Vec<BurnTicket>> { 136 if profile.ticket_expiry_window_heights == 0 { 137 bail!("ticket expiry window must be at least one height"); 138 } 139 let mut tickets = Vec::new(); 140 for tx in transactions { 141 let Transaction::Burn { 142 inputs, 143 amount, 144 signature, 145 .. 146 } = tx 147 else { 148 continue; 149 }; 150 let Some(owner) = inputs.first().map(|input| input.owner.clone()) else { 151 continue; 152 }; 153 if *amount == 0 { 154 continue; 155 } 156 let target_height = block_height 157 .checked_add(profile.ticket_maturity_delay_heights) 158 .with_context(|| format!("ticket target height overflow at block {block_height}"))?; 159 let eligible_until_height = target_height 160 .checked_add(profile.ticket_expiry_window_heights - 1) 161 .with_context(|| format!("ticket expiry height overflow at block {block_height}"))?; 162 tickets.push(BurnTicket { 163 id: signature.clone(), 164 owner, 165 amount: *amount, 166 eligible_from_height: target_height, 167 eligible_until_height, 168 }); 169 } 170 Ok(tickets) 171 } 172 173 pub(super) fn genesis_tickets( 174 genesis_allocations: &BTreeMap<String, Amount>, 175 genesis: &Block, 176 profile: &LaunchProfile, 177 ) -> Result<Vec<BurnTicket>> { 178 if profile.ticket_maturity_delay_heights == 0 { 179 return tickets_created_by_block(genesis, profile); 180 } 181 182 let burn_tickets = genesis 183 .transactions 184 .iter() 185 .filter_map(|tx| { 186 let Transaction::Burn { 187 inputs, 188 amount, 189 signature, 190 .. 191 } = tx 192 else { 193 return None; 194 }; 195 let owner = inputs.first()?.owner.clone(); 196 (*amount > 0).then(|| (owner, *amount, signature.clone())) 197 }) 198 .collect::<Vec<_>>(); 199 200 if !burn_tickets.is_empty() { 201 return genesis_bootstrap_tickets(burn_tickets, profile, genesis); 202 } 203 204 let Some((owner, amount)) = genesis_allocations 205 .iter() 206 .rev() 207 .find(|(_, amount)| **amount > 0) 208 else { 209 return Ok(Vec::new()); 210 }; 211 genesis_bootstrap_tickets( 212 vec![( 213 owner.clone(), 214 1, 215 hex_hash(format!( 216 "iuna-genesis-ticket:{owner}:{amount}:{}", 217 genesis.hash 218 )), 219 )], 220 profile, 221 genesis, 222 ) 223 } 224 225 fn genesis_bootstrap_tickets( 226 source_tickets: Vec<(String, Amount, String)>, 227 profile: &LaunchProfile, 228 genesis: &Block, 229 ) -> Result<Vec<BurnTicket>> { 230 let mut tickets = Vec::new(); 231 for height in 1..=profile.ticket_maturity_delay_heights { 232 for (owner, amount, source_id) in &source_tickets { 233 tickets.push(BurnTicket { 234 id: hex_hash(format!( 235 "iuna-genesis-bootstrap-ticket:{}:{source_id}:{height}", 236 genesis.hash 237 )), 238 owner: owner.clone(), 239 amount: *amount, 240 eligible_from_height: height, 241 eligible_until_height: height, 242 }); 243 } 244 } 245 Ok(tickets) 246 } 247 248 pub(super) fn apply_finalizer_ticket_effects( 249 block: &Block, 250 tickets: &mut Vec<BurnTicket>, 251 ) -> Result<()> { 252 match block.finalizer_mode { 253 FinalizerMode::Ticket => consume_leader_ticket(block, tickets), 254 FinalizerMode::Recovery => { 255 tickets.retain(|ticket| { 256 !ticket_is_eligible_for_height(ticket, block.height) 257 && ticket.eligible_until_height > block.height 258 }); 259 Ok(()) 260 } 261 } 262 } 263 264 pub(super) fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { 265 let Some(proof) = &block.leader_proof else { 266 bail!("block is missing leader proof"); 267 }; 268 let Some(index) = tickets.iter().position(|ticket| { 269 ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) 270 }) else { 271 bail!("leader ticket is not pending for block {}", block.height); 272 }; 273 tickets.remove(index); 274 tickets.retain(|ticket| ticket.eligible_until_height > block.height); 275 Ok(()) 276 } 277 278 pub(super) fn ticket_is_eligible_for_height(ticket: &BurnTicket, height: u64) -> bool { 279 ticket.eligible_from_height <= height && height <= ticket.eligible_until_height 280 } 281 282 pub(super) fn mine_action_count(block: &Block) -> u64 { 283 block 284 .transactions 285 .iter() 286 .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) 287 .count() as u64 288 }