iuna

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

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 }