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