iuna

iuna

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

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 }