iuna

iuna

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

tests.rs (107410B)


      1 use super::*;
      2 
      3 #[test]
      4 fn target_block_time_is_five_minutes() {
      5     assert_eq!(VDF_TARGET_BLOCK_MS, 5 * 60 * 1_000);
      6 }
      7 
      8 fn test_utxo_outpoint(index: usize) -> OutPoint {
      9     OutPoint {
     10         txid: format!("{index:064x}"),
     11         index: 0,
     12     }
     13 }
     14 
     15 fn named_test_outpoint(name: &str) -> OutPoint {
     16     OutPoint {
     17         txid: hex_hash(format!("test-utxo:{name}")),
     18         index: 0,
     19     }
     20 }
     21 
     22 fn ledger_with_wallet_utxos(wallet: &Wallet, amounts: &[Amount]) -> Ledger {
     23     let mut ledger = Ledger::new(BTreeMap::new(), 1);
     24     ledger.utxos = amounts
     25         .iter()
     26         .enumerate()
     27         .map(|(index, amount)| {
     28             (
     29                 test_utxo_outpoint(index),
     30                 TxOutput {
     31                     address: wallet.address().to_string(),
     32                     amount: *amount,
     33                 },
     34             )
     35         })
     36         .collect();
     37     ledger
     38 }
     39 
     40 fn pending_balances(ledger: &Ledger) -> BTreeMap<String, Amount> {
     41     balances_from_utxos(&ledger.utxos_after_valid_pending().unwrap())
     42 }
     43 
     44 fn ledger_with_allocation(wallet: &Wallet, amount: Amount) -> Ledger {
     45     let mut genesis = BTreeMap::new();
     46     genesis.insert(wallet.address().to_string(), amount);
     47     Ledger::new(genesis, 1)
     48 }
     49 
     50 fn mine_burn_block_with_mines(ledger: &mut Ledger, wallet: &Wallet, mine_actions: usize) {
     51     let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap();
     52     ledger.submit_transaction(burn).unwrap();
     53     for _ in 0..mine_actions {
     54         let mine = ledger.build_mine(wallet.address()).unwrap();
     55         ledger.submit_transaction(mine).unwrap();
     56     }
     57     let block = ledger.mine_next_block(wallet, ledger.height() + 1).unwrap();
     58     ledger.apply_block(block).unwrap();
     59 }
     60 
     61 fn test_mine_with_salt(ledger: &Ledger, recipient: &str, salt: u64) -> Transaction {
     62     let anchor = ledger.tip().hash.clone();
     63     let difficulty_bits = ledger.current_mine_difficulty_bits();
     64     for nonce in 0..u64::MAX {
     65         let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits);
     66         if hash_meets_difficulty(&signature, difficulty_bits) {
     67             return Transaction::Mine {
     68                 recipient: recipient.to_string(),
     69                 anchor,
     70                 salt,
     71                 nonce,
     72                 difficulty_bits,
     73                 proof_header: None,
     74                 signature,
     75             };
     76         }
     77     }
     78     panic!("test should find a valid mine action");
     79 }
     80 
     81 fn apply_preverified_burn_block_at(
     82     ledger: &mut Ledger,
     83     wallet: &Wallet,
     84     timestamp_ms: u64,
     85 ) -> Block {
     86     let burn = ledger.build_burn(wallet, 1, 0).unwrap();
     87     ledger.submit_transaction(burn).unwrap();
     88     let work = ledger
     89         .prepare_next_block(wallet.address(), timestamp_ms)
     90         .unwrap();
     91     let block = work.finish(wallet, "preverified-vdf".to_string());
     92     ledger
     93         .apply_preverified_block_at(block.clone(), u64::MAX)
     94         .unwrap();
     95     block
     96 }
     97 
     98 fn apply_preverified_burn_block_with_mines(
     99     ledger: &mut Ledger,
    100     wallet: &Wallet,
    101     mine_actions: usize,
    102 ) {
    103     let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap();
    104     ledger.submit_transaction(burn).unwrap();
    105     let timestamp_ms = ledger
    106         .tip()
    107         .timestamp_ms
    108         .saturating_add(VDF_TARGET_BLOCK_MS);
    109     let mut block = ledger
    110         .prepare_next_block(wallet.address(), timestamp_ms)
    111         .unwrap()
    112         .finish(wallet, "preverified-vdf".to_string());
    113     for salt in 0..mine_actions {
    114         block.transactions.push(test_mine_with_salt(
    115             ledger,
    116             wallet.address(),
    117             salt as u64 + 1,
    118         ));
    119     }
    120     block.reward = fee_reward(&block.transactions).unwrap();
    121     block.hash = block.compute_hash();
    122     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
    123 }
    124 
    125 fn vdf_retarget_sample_block(
    126     timestamp_ms: u64,
    127     finalizer_mode: FinalizerMode,
    128     finalizer_rank: u32,
    129 ) -> Block {
    130     Block {
    131         height: 1,
    132         prev_hash: String::new(),
    133         timestamp_ms,
    134         miner: String::new(),
    135         finalizer_mode,
    136         finalizer_rank,
    137         reward: 0,
    138         vdf_rounds: 1,
    139         vdf_output: String::new(),
    140         leader_proof: None,
    141         blinded_transactions: Vec::new(),
    142         reveal_bundle_section: RevealBundleSection::default(),
    143         transactions: Vec::new(),
    144         hash: String::new(),
    145     }
    146 }
    147 
    148 const TEST_BURN_AMOUNT: Amount = MICRO_IUNA / 10;
    149 
    150 fn unsigned_mine(ledger: &Ledger, recipient: &str) -> Transaction {
    151     let anchor = ledger.tip().hash.clone();
    152     let difficulty_bits = ledger.current_mine_difficulty_bits();
    153     for nonce in 0..u64::MAX {
    154         let salt = 1;
    155         let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits);
    156         if hash_meets_difficulty(&signature, difficulty_bits) {
    157             return Transaction::Mine {
    158                 recipient: recipient.to_string(),
    159                 anchor,
    160                 salt,
    161                 nonce,
    162                 difficulty_bits,
    163                 proof_header: None,
    164                 signature,
    165             };
    166         }
    167     }
    168     panic!("expected to find mine proof");
    169 }
    170 
    171 fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet {
    172     wallets
    173         .iter()
    174         .find(|wallet| wallet.address() == address)
    175         .unwrap_or_else(|| panic!("missing wallet for address {address}"))
    176 }
    177 
    178 fn ledger_with_finalizers(
    179     finalizers: &[Wallet],
    180     extra_allocations: &[(&Wallet, Amount)],
    181 ) -> Ledger {
    182     let mut allocations = BTreeMap::new();
    183     for wallet in finalizers {
    184         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
    185     }
    186     for (wallet, amount) in extra_allocations {
    187         allocations.insert(wallet.address().to_string(), *amount);
    188     }
    189     Ledger::new_with_genesis_burns(
    190         allocations,
    191         finalizers
    192             .iter()
    193             .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
    194             .collect(),
    195         1,
    196     )
    197     .unwrap()
    198 }
    199 
    200 fn mine_preverified_as_next_leader(
    201     ledger: &mut Ledger,
    202     wallets: &[Wallet],
    203     timestamp_ms: u64,
    204 ) -> Block {
    205     let leader = ledger.expected_leader_for_next_block().unwrap();
    206     let wallet = wallet_for_address(wallets, &leader);
    207     let prepared = ledger
    208         .prepare_next_block(wallet.address(), timestamp_ms)
    209         .unwrap();
    210     let block = prepared.finish(wallet, "preverified-vdf".to_string());
    211     ledger
    212         .apply_preverified_block_at(block.clone(), u64::MAX)
    213         .unwrap();
    214     block
    215 }
    216 
    217 fn mine_preverified_as_next_leader_with_reveal_bundles(
    218     ledger: &mut Ledger,
    219     wallets: &[Wallet],
    220     timestamp_ms: u64,
    221 ) -> Block {
    222     let block =
    223         prepare_preverified_as_next_leader_with_reveal_bundles(ledger, wallets, timestamp_ms);
    224     ledger
    225         .apply_preverified_block_at(block.clone(), u64::MAX)
    226         .unwrap();
    227     block
    228 }
    229 
    230 fn prepare_preverified_as_next_leader_with_reveal_bundles(
    231     ledger: &Ledger,
    232     wallets: &[Wallet],
    233     timestamp_ms: u64,
    234 ) -> Block {
    235     let bundles = ledger
    236         .reveal_committee_for_next_block()
    237         .into_iter()
    238         .filter_map(|member| {
    239             let wallet = wallet_for_address(wallets, &member.owner);
    240             ledger.build_reveal_bundle(wallet).unwrap()
    241         })
    242         .collect::<Vec<_>>();
    243     let leader = ledger.expected_leader_for_next_block().unwrap();
    244     let wallet = wallet_for_address(wallets, &leader);
    245     let prepared = ledger
    246         .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles)
    247         .unwrap();
    248     prepared.finish(wallet, "preverified-vdf".to_string())
    249 }
    250 
    251 fn queue_next_leader_burn(ledger: &mut Ledger, wallets: &[Wallet]) {
    252     let leader = ledger.expected_leader_for_next_block().unwrap();
    253     let wallet = wallet_for_address(wallets, &leader);
    254     let burn = ledger.build_burn(wallet, 1, 0).unwrap();
    255     ledger.submit_transaction(burn).unwrap();
    256 }
    257 
    258 fn transfer_with_extra_zero_outputs(
    259     ledger: &Ledger,
    260     wallet: &Wallet,
    261     to: &str,
    262     amount: Amount,
    263     fee: Amount,
    264     extra_outputs: usize,
    265 ) -> Transaction {
    266     let required = amount.checked_add(fee).unwrap();
    267     let (inputs, input_total) = ledger.select_inputs(wallet.address(), required).unwrap();
    268     let mut outputs = vec![TxOutput {
    269         address: to.to_string(),
    270         amount,
    271     }];
    272     outputs.extend((0..extra_outputs).map(|_| TxOutput {
    273         address: to.to_string(),
    274         amount: 0,
    275     }));
    276     let change = input_total - required;
    277     if change > 0 {
    278         outputs.push(TxOutput {
    279             address: wallet.address().to_string(),
    280             amount: change,
    281         });
    282     }
    283     UnsignedUtxoTransaction::Transfer {
    284         inputs,
    285         outputs,
    286         fee,
    287     }
    288     .sign(wallet)
    289 }
    290 
    291 #[test]
    292 fn wallet_utxos_only_include_outputs_owned_by_address() {
    293     let alice = Wallet::from_seed("wallet-utxos-alice");
    294     let bob = Wallet::from_seed("wallet-utxos-bob");
    295     let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3]);
    296     ledger.utxos.insert(
    297         named_test_outpoint("bob"),
    298         TxOutput {
    299             address: bob.address().to_string(),
    300             amount: 5,
    301         },
    302     );
    303 
    304     let alice_utxos = ledger.utxos_for_address(alice.address());
    305     let total = alice_utxos
    306         .iter()
    307         .map(|(_, output)| output.amount)
    308         .sum::<Amount>();
    309 
    310     assert_eq!(alice_utxos.len(), 2);
    311     assert_eq!(total, ledger.balance_of(alice.address()));
    312     assert!(
    313         alice_utxos
    314             .iter()
    315             .all(|(_, output)| output.address == alice.address())
    316     );
    317 }
    318 
    319 #[test]
    320 fn transfer_combines_multiple_small_utxos_to_cover_amount_and_fee() {
    321     let alice = Wallet::from_seed("combine-small-utxos-alice");
    322     let bob = Wallet::from_seed("combine-small-utxos-bob");
    323     let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]);
    324 
    325     let tx = ledger.build_transfer(&alice, bob.address(), 2, 1).unwrap();
    326 
    327     let Transaction::Transfer {
    328         inputs,
    329         outputs,
    330         fee,
    331         ..
    332     } = &tx
    333     else {
    334         panic!("expected transfer");
    335     };
    336     assert_eq!(inputs.len(), 3);
    337     assert_eq!(*fee, 1);
    338     assert_eq!(
    339         outputs,
    340         &[TxOutput {
    341             address: bob.address().to_string(),
    342             amount: 2
    343         }]
    344     );
    345 
    346     ledger.submit_transaction(tx).unwrap();
    347     let balances = pending_balances(&ledger);
    348     assert_eq!(
    349         balances.get(alice.address()).copied().unwrap_or_default(),
    350         0
    351     );
    352     assert_eq!(balances.get(bob.address()).copied().unwrap_or_default(), 2);
    353 }
    354 
    355 #[test]
    356 fn transfer_returns_change_when_combined_utxos_exceed_payment() {
    357     let alice = Wallet::from_seed("combine-change-alice");
    358     let bob = Wallet::from_seed("combine-change-bob");
    359     let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]);
    360 
    361     let tx = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap();
    362 
    363     let Transaction::Transfer {
    364         inputs, outputs, ..
    365     } = &tx
    366     else {
    367         panic!("expected transfer");
    368     };
    369     assert_eq!(inputs.len(), 3);
    370     assert_eq!(
    371         outputs,
    372         &[
    373             TxOutput {
    374                 address: bob.address().to_string(),
    375                 amount: 3
    376             },
    377             TxOutput {
    378                 address: alice.address().to_string(),
    379                 amount: 1
    380             }
    381         ]
    382     );
    383 
    384     ledger.submit_transaction(tx).unwrap();
    385     let balances = pending_balances(&ledger);
    386     assert_eq!(balances.get(alice.address()).copied(), Some(1));
    387     assert_eq!(balances.get(bob.address()).copied(), Some(3));
    388 }
    389 
    390 #[test]
    391 fn transaction_economic_size_uses_compact_canonical_fields() {
    392     let alice = Wallet::from_seed("economic-size-alice");
    393     let bob = Wallet::from_seed("economic-size-bob");
    394     let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]);
    395     let selected = vec![
    396         test_utxo_outpoint(0),
    397         test_utxo_outpoint(1),
    398         test_utxo_outpoint(2),
    399     ];
    400 
    401     let tx = ledger
    402         .build_transfer_with_inputs(&alice, bob.address(), 3, 0, &selected)
    403         .unwrap();
    404 
    405     assert!(tx.economic_size_bytes() < tx.serialized_size_bytes().unwrap());
    406     assert_eq!(
    407         tx.economic_size_bytes(),
    408         1 + 1 + (3 * (32 + 1 + 32)) + 1 + (2 * (32 + 1)) + 1 + 64
    409     );
    410 }
    411 
    412 #[test]
    413 fn blinded_fee_rate_size_uses_visible_envelope_bytes() {
    414     let alice = Wallet::from_seed("blinded-fee-size-alice");
    415     let ledger = ledger_with_wallet_utxos(&alice, &[10]);
    416     let built = ledger
    417         .build_blinded_burn(&alice, 1, 2, ledger.height() + 4)
    418         .unwrap();
    419 
    420     assert_eq!(
    421         built.transaction.fee_rate_size_bytes(),
    422         built.transaction.serialized_size_bytes().unwrap()
    423     );
    424     assert!(built.transaction.fee_rate_size_bytes() > built.transaction.encrypted_size as usize);
    425 }
    426 
    427 #[test]
    428 fn blinded_fee_split_burns_rounding_dust() {
    429     let committer = Wallet::from_seed("blinded-split-committer");
    430     let executor = Wallet::from_seed("blinded-split-executor");
    431     let commitment = "01".repeat(32);
    432     let active = ActiveBlindedTransaction {
    433         transaction: BlindedTransaction {
    434             commitment: commitment.clone(),
    435             inputs: Vec::new(),
    436             fee: 1,
    437             encrypted_size: 1,
    438             expires_at_height: 2,
    439             nonce: "02".repeat(BLINDED_NONCE_BYTES),
    440             ciphertext: "03".to_string(),
    441             payload_hash: "04".repeat(32),
    442         },
    443         locked_outputs: Vec::new(),
    444         included_height: 1,
    445         included_by: committer.address().to_string(),
    446     };
    447     let transaction = Transaction::Transfer {
    448         inputs: Vec::new(),
    449         outputs: Vec::new(),
    450         fee: 1,
    451         signature: String::new(),
    452     };
    453     let mut utxos = BTreeMap::new();
    454 
    455     credit_blinded_fee_outputs(
    456         &mut utxos,
    457         &active,
    458         executor.address(),
    459         &transaction,
    460         &[],
    461         3,
    462         false,
    463     )
    464     .unwrap();
    465 
    466     assert!(!utxos.contains_key(&blinded_committer_fee_outpoint(&commitment)));
    467     assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment)));
    468 }
    469 
    470 #[test]
    471 fn blinded_fee_split_pays_no_reveal_finalizer_without_signed_reveal_lists() {
    472     let committer = Wallet::from_seed("blinded-no-list-committer");
    473     let executor = Wallet::from_seed("blinded-no-list-executor");
    474     let commitment = "06".repeat(32);
    475     let active = ActiveBlindedTransaction {
    476         transaction: BlindedTransaction {
    477             commitment: commitment.clone(),
    478             inputs: Vec::new(),
    479             fee: 100,
    480             encrypted_size: 1,
    481             expires_at_height: 2,
    482             nonce: "02".repeat(BLINDED_NONCE_BYTES),
    483             ciphertext: "03".to_string(),
    484             payload_hash: "04".repeat(32),
    485         },
    486         locked_outputs: Vec::new(),
    487         included_height: 1,
    488         included_by: committer.address().to_string(),
    489     };
    490     let transaction = Transaction::Transfer {
    491         inputs: Vec::new(),
    492         outputs: Vec::new(),
    493         fee: 100,
    494         signature: String::new(),
    495     };
    496     let mut utxos = BTreeMap::new();
    497 
    498     credit_blinded_fee_outputs(
    499         &mut utxos,
    500         &active,
    501         executor.address(),
    502         &transaction,
    503         &[],
    504         3,
    505         false,
    506     )
    507     .unwrap();
    508 
    509     assert_eq!(
    510         utxos.get(&blinded_committer_fee_outpoint(&commitment)),
    511         Some(&TxOutput {
    512             address: committer.address().to_string(),
    513             amount: 35,
    514         })
    515     );
    516     assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment)));
    517 }
    518 
    519 #[test]
    520 fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() {
    521     let committer = Wallet::from_seed("blinded-scale-committer");
    522     let executor = Wallet::from_seed("blinded-scale-executor");
    523     let signer_a = Wallet::from_seed("blinded-scale-signer-a");
    524     let signer_b = Wallet::from_seed("blinded-scale-signer-b");
    525     let commitment = "05".repeat(32);
    526     let active = ActiveBlindedTransaction {
    527         transaction: BlindedTransaction {
    528             commitment: commitment.clone(),
    529             inputs: Vec::new(),
    530             fee: 7,
    531             encrypted_size: 1,
    532             expires_at_height: 2,
    533             nonce: "02".repeat(BLINDED_NONCE_BYTES),
    534             ciphertext: "03".to_string(),
    535             payload_hash: "04".repeat(32),
    536         },
    537         locked_outputs: Vec::new(),
    538         included_height: 1,
    539         included_by: committer.address().to_string(),
    540     };
    541     let transaction = Transaction::Transfer {
    542         inputs: Vec::new(),
    543         outputs: Vec::new(),
    544         fee: 100,
    545         signature: String::new(),
    546     };
    547     let mut utxos = BTreeMap::new();
    548     let signatures = vec![
    549         RevealBundleSignature {
    550             slot: 0,
    551             member: signer_a.address().to_string(),
    552             signature: "11".repeat(SIGNATURE_BYTES),
    553         },
    554         RevealBundleSignature {
    555             slot: 2,
    556             member: signer_b.address().to_string(),
    557             signature: "22".repeat(SIGNATURE_BYTES),
    558         },
    559     ];
    560 
    561     credit_blinded_fee_outputs(
    562         &mut utxos,
    563         &active,
    564         executor.address(),
    565         &transaction,
    566         &signatures,
    567         3,
    568         false,
    569     )
    570     .unwrap();
    571 
    572     assert_eq!(
    573         utxos.get(&blinded_committer_fee_outpoint(&commitment)),
    574         Some(&TxOutput {
    575             address: committer.address().to_string(),
    576             amount: 35,
    577         })
    578     );
    579     assert_eq!(
    580         utxos.get(&blinded_executor_fee_outpoint(&commitment)),
    581         Some(&TxOutput {
    582             address: executor.address().to_string(),
    583             amount: 23,
    584         })
    585     );
    586     assert_eq!(
    587         utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 0)),
    588         Some(&TxOutput {
    589             address: signer_a.address().to_string(),
    590             amount: 10,
    591         })
    592     );
    593     assert_eq!(
    594         utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 2)),
    595         Some(&TxOutput {
    596             address: signer_b.address().to_string(),
    597             amount: 10,
    598         })
    599     );
    600 }
    601 
    602 #[test]
    603 fn blinded_reveal_finalizer_fee_scales_by_available_reveal_bundle_slots() {
    604     let fee = 300_000;
    605     let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS);
    606 
    607     assert_eq!(blinded_reveal_finalizer_fee(fee, 0, 3), 0);
    608     assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 3), full_share / 3);
    609     assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 2), full_share / 2);
    610     assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 1), full_share);
    611     assert_eq!(blinded_reveal_finalizer_fee(fee, 2, 3), full_share * 2 / 3);
    612     assert_eq!(blinded_reveal_finalizer_fee(fee, 3, 3), full_share);
    613     assert_eq!(blinded_reveal_finalizer_fee(fee, 4, 3), full_share);
    614 }
    615 
    616 #[test]
    617 fn reveal_fee_attribution_uses_reveal_mask_from_height_750() {
    618     let alice = Wallet::from_seed("mask-fee-alice");
    619     let bob = Wallet::from_seed("mask-fee-bob");
    620     let carol = Wallet::from_seed("mask-fee-carol");
    621     let reveal = MaskedBlindedReveal {
    622         reveal: BlindedReveal {
    623             commitment: hex_hash("mask-fee-reveal"),
    624             key: "00".repeat(BLINDED_KEY_BYTES),
    625         },
    626         bundle_mask: 0b0000_0101,
    627     };
    628     let section = RevealBundleSection {
    629         signatures: vec![
    630             RevealBundleSignature {
    631                 slot: 0,
    632                 member: alice.address().to_string(),
    633                 signature: "11".repeat(SIGNATURE_BYTES),
    634             },
    635             RevealBundleSignature {
    636                 slot: 1,
    637                 member: bob.address().to_string(),
    638                 signature: "22".repeat(SIGNATURE_BYTES),
    639             },
    640             RevealBundleSignature {
    641                 slot: 2,
    642                 member: carol.address().to_string(),
    643                 signature: "33".repeat(SIGNATURE_BYTES),
    644             },
    645         ],
    646         reveals: vec![reveal.clone()],
    647     };
    648 
    649     let legacy_signers =
    650         reveal_fee_signatures_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT - 1, &section, &reveal);
    651     assert_eq!(legacy_signers.len(), 3);
    652     assert_eq!(
    653         reveal_fee_bundle_count_for_height(
    654             REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT - 1,
    655             &section,
    656             &reveal
    657         ),
    658         3
    659     );
    660 
    661     let masked_signers =
    662         reveal_fee_signatures_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, &section, &reveal);
    663     assert_eq!(
    664         masked_signers
    665             .iter()
    666             .map(|signature| signature.member.as_str())
    667             .collect::<Vec<_>>(),
    668         vec![alice.address(), carol.address()]
    669     );
    670     assert_eq!(
    671         reveal_fee_bundle_count_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, &section, &reveal),
    672         2
    673     );
    674 }
    675 
    676 #[test]
    677 fn transfer_rejects_invalid_recipient_address() {
    678     let alice = Wallet::from_seed("invalid-transfer-recipient-alice");
    679     let ledger = ledger_with_wallet_utxos(&alice, &[10]);
    680 
    681     let error = ledger.build_transfer(&alice, "aa", 1, 0).unwrap_err();
    682 
    683     assert!(format!("{error:#}").contains("invalid transfer recipient address"));
    684 }
    685 
    686 #[test]
    687 fn mine_rejects_invalid_recipient_address_before_pow() {
    688     let ledger = Ledger::new(BTreeMap::new(), 1);
    689 
    690     let error = ledger.build_mine("aa").unwrap_err();
    691 
    692     assert!(format!("{error:#}").contains("invalid mine recipient address"));
    693 }
    694 
    695 #[test]
    696 fn mine_search_respects_nonce_attempt_limit() {
    697     let alice = Wallet::from_seed("bounded-mine-search-alice");
    698     let ledger = Ledger::new(BTreeMap::new(), 1);
    699 
    700     let outcome = ledger.search_mine(alice.address(), 1, 0, 0).unwrap();
    701 
    702     assert!(outcome.transaction.is_none());
    703     assert_eq!(outcome.next_nonce, 0);
    704     assert_eq!(outcome.attempts, 0);
    705 }
    706 
    707 #[test]
    708 fn mempool_rejects_invalid_input_outpoint_id() {
    709     let alice = Wallet::from_seed("invalid-outpoint-alice");
    710     let bob = Wallet::from_seed("invalid-outpoint-bob");
    711     let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
    712     let unsigned = UnsignedUtxoTransaction::Transfer {
    713         inputs: vec![UnsignedTxInput {
    714             outpoint: OutPoint {
    715                 txid: "aa".to_string(),
    716                 index: 0,
    717             },
    718             owner: alice.address().to_string(),
    719         }],
    720         outputs: vec![TxOutput {
    721             address: bob.address().to_string(),
    722             amount: 1,
    723         }],
    724         fee: 0,
    725     };
    726     let transaction = unsigned.sign(&alice);
    727 
    728     let error = ledger.submit_transaction(transaction).unwrap_err();
    729 
    730     assert!(format!("{error:#}").contains("invalid input outpoint txid"));
    731     assert!(ledger.pending().is_empty());
    732 }
    733 
    734 #[test]
    735 fn missing_input_transaction_goes_to_orphan_pool_not_pending_mempool() {
    736     let alice = Wallet::from_seed("missing-input-orphan-alice");
    737     let bob = Wallet::from_seed("missing-input-orphan-bob");
    738     let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
    739     let transaction = UnsignedUtxoTransaction::Transfer {
    740         inputs: vec![UnsignedTxInput {
    741             outpoint: OutPoint {
    742                 txid: hex_hash("missing-input-orphan"),
    743                 index: 0,
    744             },
    745             owner: alice.address().to_string(),
    746         }],
    747         outputs: vec![TxOutput {
    748             address: bob.address().to_string(),
    749             amount: 1,
    750         }],
    751         fee: 0,
    752     }
    753     .sign(&alice);
    754 
    755     let outcome = ledger.submit_transaction_with_outcome(transaction).unwrap();
    756 
    757     assert_eq!(outcome, TransactionSubmitOutcome::Added);
    758     assert!(ledger.pending().is_empty());
    759     assert_eq!(ledger.orphan_transactions().len(), 1);
    760 }
    761 
    762 #[test]
    763 fn vdf_retarget_observed_block_time_is_clamped() {
    764     assert_eq!(
    765         clamped_vdf_retarget_observed_block_ms(1),
    766         MIN_VDF_RETARGET_OBSERVED_BLOCK_MS
    767     );
    768     assert_eq!(
    769         clamped_vdf_retarget_observed_block_ms(VDF_TARGET_BLOCK_MS),
    770         VDF_TARGET_BLOCK_MS
    771     );
    772     assert_eq!(
    773         clamped_vdf_retarget_observed_block_ms(u64::MAX),
    774         MAX_VDF_RETARGET_OBSERVED_BLOCK_MS
    775     );
    776 }
    777 
    778 #[test]
    779 fn vdf_retarget_observed_block_time_ignores_ticket_fallback_ranks() {
    780     let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0);
    781     let fallback_child =
    782         vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1);
    783 
    784     assert_eq!(
    785         vdf_retarget_observed_block_ms(&parent, &fallback_child),
    786         None
    787     );
    788 }
    789 
    790 #[test]
    791 fn vdf_retarget_observed_block_time_ignores_recovery_blocks() {
    792     let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0);
    793     let recovery_child =
    794         vdf_retarget_sample_block(RECOVERY_BLOCK_DELAY_MS, FinalizerMode::Recovery, 0);
    795 
    796     assert_eq!(
    797         vdf_retarget_observed_block_ms(&parent, &recovery_child),
    798         None
    799     );
    800 }
    801 
    802 #[test]
    803 fn vdf_retarget_keeps_rounds_inside_deadband() {
    804     let current = 1_000;
    805     let low_deadband_edge =
    806         VDF_TARGET_BLOCK_MS - VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100;
    807     let high_deadband_edge =
    808         VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100;
    809 
    810     assert_eq!(retarget_vdf_rounds(current, low_deadband_edge), current);
    811     assert_eq!(retarget_vdf_rounds(current, VDF_TARGET_BLOCK_MS), current);
    812     assert_eq!(retarget_vdf_rounds(current, high_deadband_edge), current);
    813 }
    814 
    815 #[test]
    816 fn vdf_retarget_limits_each_step_to_two_percent() {
    817     let current = 1_000;
    818 
    819     assert_eq!(
    820         retarget_vdf_rounds(current, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS),
    821         1_020
    822     );
    823     assert_eq!(
    824         retarget_vdf_rounds(current, MAX_VDF_RETARGET_OBSERVED_BLOCK_MS),
    825         980
    826     );
    827 }
    828 
    829 #[test]
    830 fn vdf_rounds_retarget_below_legacy_u32_limit_after_slow_blocks() {
    831     let wallet = Wallet::from_seed("vdf-rounds-slow-above-u32");
    832     let initial_rounds = u64::from(u32::MAX);
    833     let mut allocations = BTreeMap::new();
    834     allocations.insert(wallet.address().to_string(), 1_000);
    835     let mut ledger = Ledger::new_with_genesis_burns(
    836         allocations,
    837         vec![GenesisBurn::new(wallet.address(), 1)],
    838         initial_rounds,
    839     )
    840     .unwrap();
    841 
    842     let block1 = apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
    843     assert_eq!(block1.vdf_rounds, initial_rounds);
    844     assert_eq!(ledger.vdf_rounds(), initial_rounds);
    845 
    846     let block2 = apply_preverified_burn_block_at(
    847         &mut ledger,
    848         &wallet,
    849         VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2,
    850     );
    851     assert_eq!(block2.vdf_rounds, initial_rounds);
    852 
    853     assert!(
    854         ledger.vdf_rounds() < initial_rounds,
    855         "slow blocks should retarget below the legacy u32 VDF rounds ceiling"
    856     );
    857 }
    858 
    859 #[test]
    860 fn fallback_block_is_excluded_from_vdf_retarget_observations() {
    861     let alice = Wallet::from_seed("fallback-retarget-alice");
    862     let bob = Wallet::from_seed("fallback-retarget-bob");
    863     let wallets = [&alice, &bob];
    864     let mut genesis = BTreeMap::new();
    865     genesis.insert(alice.address().to_string(), 1_000);
    866     genesis.insert(bob.address().to_string(), 1_000);
    867     let mut ledger = Ledger::new_with_genesis_burns(
    868         genesis,
    869         vec![
    870             GenesisBurn::new(alice.address(), 1),
    871             GenesisBurn::new(bob.address(), 1),
    872         ],
    873         100,
    874     )
    875     .unwrap();
    876 
    877     let primary = ledger.expected_leader_for_next_block().unwrap();
    878     let primary_wallet = wallets
    879         .into_iter()
    880         .find(|wallet| wallet.address() == primary)
    881         .unwrap();
    882     apply_preverified_burn_block_at(&mut ledger, primary_wallet, VDF_TARGET_BLOCK_MS);
    883     assert_eq!(ledger.vdf_rounds(), 100);
    884 
    885     let primary = ledger.expected_leader_for_next_block().unwrap();
    886     let fallback = wallets
    887         .into_iter()
    888         .find(|wallet| wallet.address() != primary)
    889         .unwrap();
    890     let timestamp_ms = ledger.tip().timestamp_ms + 1;
    891     let block = apply_preverified_burn_block_at(&mut ledger, fallback, timestamp_ms);
    892 
    893     assert_eq!(block.finalizer_rank, 1);
    894     assert_eq!(block.vdf_rounds, 200);
    895     assert_eq!(ledger.vdf_rounds(), 100);
    896 }
    897 
    898 #[test]
    899 fn recovery_block_is_excluded_from_vdf_retarget_observations() {
    900     let alice = Wallet::from_seed("recovery-retarget-alice");
    901     let bob = Wallet::from_seed("recovery-retarget-bob");
    902     let mut genesis = BTreeMap::new();
    903     genesis.insert(alice.address().to_string(), 1_000);
    904     genesis.insert(bob.address().to_string(), 1_000);
    905     let mut ledger =
    906         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 100)
    907             .unwrap();
    908 
    909     apply_preverified_burn_block_at(&mut ledger, &alice, VDF_TARGET_BLOCK_MS);
    910     assert_eq!(ledger.vdf_rounds(), 100);
    911 
    912     let burn = ledger.build_burn(&bob, 1, 0).unwrap();
    913     ledger.submit_transaction(burn).unwrap();
    914     let block = ledger
    915         .mine_recovery_block(&bob, VDF_TARGET_BLOCK_MS + RECOVERY_BLOCK_DELAY_MS)
    916         .unwrap();
    917     assert_eq!(block.finalizer_mode, FinalizerMode::Recovery);
    918     ledger.apply_block(block).unwrap();
    919 
    920     assert_eq!(ledger.vdf_rounds(), 100);
    921 }
    922 
    923 #[test]
    924 fn generated_vdf_retarget_decreases_after_slow_blocks_above_legacy_limit() {
    925     let legacy_limit = u64::from(u32::MAX);
    926     let slow_observed_ms = [
    927         VDF_TARGET_BLOCK_MS * 6 / 5,
    928         VDF_TARGET_BLOCK_MS * 2,
    929         VDF_TARGET_BLOCK_MS * 3,
    930         MAX_VDF_RETARGET_OBSERVED_BLOCK_MS,
    931     ];
    932 
    933     for seed in 0..16_u64 {
    934         let wallet = Wallet::from_seed(&format!("generated-vdf-retarget-{seed}"));
    935         let initial_rounds = legacy_limit + 1 + seed * 1_000_003;
    936         let mut allocations = BTreeMap::new();
    937         allocations.insert(wallet.address().to_string(), 1_000);
    938         let mut ledger = Ledger::new_with_genesis_burns(
    939             allocations,
    940             vec![GenesisBurn::new(wallet.address(), 1)],
    941             initial_rounds,
    942         )
    943         .unwrap();
    944         let observed_ms = slow_observed_ms[seed as usize % slow_observed_ms.len()];
    945 
    946         apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
    947         let second = apply_preverified_burn_block_at(
    948             &mut ledger,
    949             &wallet,
    950             VDF_TARGET_BLOCK_MS + observed_ms,
    951         );
    952 
    953         assert_eq!(second.vdf_rounds, initial_rounds);
    954         assert!(
    955             ledger.vdf_rounds() < initial_rounds,
    956             "seed {seed} with observed {observed_ms}ms should lower VDF rounds from {initial_rounds}, got {}",
    957             ledger.vdf_rounds()
    958         );
    959 
    960         let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
    961         ledger.submit_transaction(burn).unwrap();
    962         let next_work = ledger
    963             .prepare_next_block(wallet.address(), second.timestamp_ms + VDF_TARGET_BLOCK_MS)
    964             .unwrap();
    965         assert_eq!(next_work.vdf_rounds(), ledger.vdf_rounds());
    966     }
    967 }
    968 
    969 #[test]
    970 fn block_timestamp_future_check_uses_supplied_network_time() {
    971     let wallet = Wallet::from_seed("adjusted-time-domain");
    972     let mut allocations = BTreeMap::new();
    973     allocations.insert(wallet.address().to_string(), 1_000);
    974     let mut ledger = Ledger::new(allocations, 1);
    975 
    976     let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
    977     assert!(ledger.submit_transaction(burn).unwrap());
    978     let block = ledger.mine_next_block(&wallet, 10 * 60 * 1_000).unwrap();
    979 
    980     let error = ledger.apply_block_at(block, 1_000).unwrap_err();
    981 
    982     assert!(format!("{error:#}").contains("too far in the future"));
    983 }
    984 
    985 #[test]
    986 fn ticket_block_timestamp_uses_finalizer_rank_time_slot() {
    987     let alice = Wallet::from_seed("rank-slot-alice");
    988     let bob = Wallet::from_seed("rank-slot-bob");
    989     let wallets = [alice.clone(), bob.clone()];
    990     let mut allocations = BTreeMap::new();
    991     allocations.insert(alice.address().to_string(), 1_000);
    992     allocations.insert(bob.address().to_string(), 1_000);
    993     let mut ledger = Ledger::new_with_genesis_burns(
    994         allocations,
    995         vec![
    996             GenesisBurn::new(alice.address(), 1),
    997             GenesisBurn::new(bob.address(), 1),
    998         ],
    999         100,
   1000     )
   1001     .unwrap();
   1002 
   1003     let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap());
   1004     let burn = ledger.build_burn(primary, 1, 0).unwrap();
   1005     ledger.submit_transaction(burn).unwrap();
   1006     let work = ledger.prepare_next_block(primary.address(), 1).unwrap();
   1007     assert_eq!(work.timestamp_ms(), 1);
   1008     let block = work.finish(primary, "preverified-vdf".to_string());
   1009     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
   1010 
   1011     let fallback = wallets
   1012         .iter()
   1013         .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
   1014         .expect("expected rank 1 fallback");
   1015     let burn = ledger.build_burn(fallback, 1, 0).unwrap();
   1016     ledger.submit_transaction(burn).unwrap();
   1017     let parent_timestamp = ledger.tip().timestamp_ms;
   1018     let work = ledger
   1019         .prepare_next_block(fallback.address(), parent_timestamp + 1)
   1020         .unwrap();
   1021 
   1022     assert_eq!(
   1023         work.timestamp_ms(),
   1024         parent_timestamp + VDF_TARGET_BLOCK_MS * 2
   1025     );
   1026     assert_eq!(work.vdf_rounds(), ledger.vdf_rounds() * 2);
   1027 }
   1028 
   1029 #[test]
   1030 fn required_anchor_burn_is_selected_before_higher_fee_burns_when_block_is_full() {
   1031     let wallet = Wallet::from_seed("required-anchor-priority-wallet");
   1032     let mut allocations = BTreeMap::new();
   1033     allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
   1034     let mut ledger = Ledger::new_with_genesis_burns(
   1035         allocations,
   1036         vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
   1037         1,
   1038     )
   1039     .unwrap();
   1040     let high_fee_outpoint = named_test_outpoint("required-anchor-priority-high-fee");
   1041     let anchor_outpoint = named_test_outpoint("required-anchor-priority-anchor");
   1042     ledger.utxos.insert(
   1043         high_fee_outpoint.clone(),
   1044         TxOutput {
   1045             address: wallet.address().to_string(),
   1046             amount: 3,
   1047         },
   1048     );
   1049     ledger.utxos.insert(
   1050         anchor_outpoint.clone(),
   1051         TxOutput {
   1052             address: wallet.address().to_string(),
   1053             amount: 2,
   1054         },
   1055     );
   1056     let high_fee_burn = ledger
   1057         .build_burn_with_inputs(&wallet, 1, 1, &[high_fee_outpoint])
   1058         .unwrap();
   1059     let anchor_burn = ledger
   1060         .build_burn_with_inputs(&wallet, 1, 0, &[anchor_outpoint])
   1061         .unwrap();
   1062     ledger.submit_transaction(high_fee_burn).unwrap();
   1063     ledger.submit_transaction(anchor_burn.clone()).unwrap();
   1064     ledger.launch_profile.max_block_transactions = 1;
   1065 
   1066     let work = ledger
   1067         .prepare_next_block_with_required_burn_and_reveal_bundles(
   1068             wallet.address(),
   1069             1,
   1070             Vec::new(),
   1071             Some(anchor_burn.signature()),
   1072         )
   1073         .unwrap();
   1074     let block = work.finish(&wallet, "preverified-vdf".to_string());
   1075 
   1076     assert_eq!(block.transactions.len(), 1);
   1077     assert_eq!(block.transactions[0].signature(), anchor_burn.signature());
   1078 }
   1079 
   1080 #[test]
   1081 fn late_ticket_vdf_completion_is_visible_to_retarget() {
   1082     let wallet = Wallet::from_seed("late-ticket-vdf-wallet");
   1083     let mut allocations = BTreeMap::new();
   1084     allocations.insert(wallet.address().to_string(), 1_000);
   1085     let mut ledger = Ledger::new_with_genesis_burns(
   1086         allocations,
   1087         vec![GenesisBurn::new(wallet.address(), 1)],
   1088         100,
   1089     )
   1090     .unwrap();
   1091 
   1092     apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
   1093     assert_eq!(ledger.vdf_rounds(), 100);
   1094 
   1095     let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
   1096     ledger.submit_transaction(burn).unwrap();
   1097     let work = ledger
   1098         .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
   1099         .unwrap();
   1100     let scheduled_timestamp = work.timestamp_ms();
   1101     let late_timestamp = ledger.tip().timestamp_ms + VDF_TARGET_BLOCK_MS * 3;
   1102     assert!(late_timestamp > scheduled_timestamp);
   1103 
   1104     let block = work.finish_at(&wallet, "preverified-vdf".to_string(), late_timestamp);
   1105     assert_eq!(block.timestamp_ms, late_timestamp);
   1106     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
   1107 
   1108     assert!(
   1109         ledger.vdf_rounds() < 100,
   1110         "late VDF completion should lower future VDF rounds"
   1111     );
   1112 }
   1113 
   1114 #[test]
   1115 fn block_before_finalizer_rank_time_slot_is_rejected() {
   1116     let alice = Wallet::from_seed("rank-slot-reject-alice");
   1117     let bob = Wallet::from_seed("rank-slot-reject-bob");
   1118     let wallets = [alice.clone(), bob.clone()];
   1119     let mut allocations = BTreeMap::new();
   1120     allocations.insert(alice.address().to_string(), 1_000);
   1121     allocations.insert(bob.address().to_string(), 1_000);
   1122     let mut ledger = Ledger::new_with_genesis_burns(
   1123         allocations,
   1124         vec![
   1125             GenesisBurn::new(alice.address(), 1),
   1126             GenesisBurn::new(bob.address(), 1),
   1127         ],
   1128         100,
   1129     )
   1130     .unwrap();
   1131 
   1132     let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap());
   1133     let burn = ledger.build_burn(primary, 1, 0).unwrap();
   1134     ledger.submit_transaction(burn).unwrap();
   1135     let work = ledger.prepare_next_block(primary.address(), 1).unwrap();
   1136     let block = work.finish(primary, "preverified-vdf".to_string());
   1137     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
   1138 
   1139     let fallback = wallets
   1140         .iter()
   1141         .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
   1142         .expect("expected rank 1 fallback");
   1143     let burn = ledger.build_burn(fallback, 1, 0).unwrap();
   1144     ledger.submit_transaction(burn).unwrap();
   1145     let parent_timestamp = ledger.tip().timestamp_ms;
   1146     let work = ledger
   1147         .prepare_next_block(fallback.address(), parent_timestamp + 1)
   1148         .unwrap();
   1149     let mut block = work.finish(fallback, "preverified-vdf".to_string());
   1150     block.timestamp_ms = parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - 1;
   1151     block.hash = block.compute_hash();
   1152 
   1153     let error = ledger
   1154         .apply_preverified_block_at(block, u64::MAX)
   1155         .unwrap_err();
   1156 
   1157     assert!(format!("{error:#}").contains("before finalizer rank 1 time slot"));
   1158 }
   1159 
   1160 #[test]
   1161 fn mempool_rejects_transaction_that_cannot_fit_in_a_block() {
   1162     let alice = Wallet::from_seed("oversized-select-alice");
   1163     let bob = Wallet::from_seed("oversized-select-bob");
   1164     let mut allocations = BTreeMap::new();
   1165     allocations.insert(alice.address().to_string(), 1);
   1166     allocations.insert(bob.address().to_string(), 300_000);
   1167     let mut ledger =
   1168         Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10)
   1169             .unwrap();
   1170     let burn = ledger.build_burn(&alice, 1, 0).unwrap();
   1171     ledger.submit_transaction(burn).unwrap();
   1172     let oversized =
   1173         transfer_with_extra_zero_outputs(&ledger, &bob, alice.address(), 1, 100_000, 4_000);
   1174     assert!(oversized.serialized_size_bytes().unwrap() > MAX_BLOCK_BYTES);
   1175 
   1176     let error = ledger.submit_transaction(oversized).unwrap_err();
   1177 
   1178     assert!(format!("{error:#}").contains("transaction exceeds max block size"));
   1179 }
   1180 
   1181 #[test]
   1182 fn transfer_builder_rejects_selected_utxos_that_make_transaction_too_large() {
   1183     let alice = Wallet::from_seed("oversized-selected-utxos-alice");
   1184     let bob = Wallet::from_seed("oversized-selected-utxos-bob");
   1185     let amounts = vec![1; 2_000];
   1186     let ledger = ledger_with_wallet_utxos(&alice, &amounts);
   1187     let selected = (0..amounts.len())
   1188         .map(test_utxo_outpoint)
   1189         .collect::<Vec<_>>();
   1190 
   1191     let error = ledger
   1192         .build_transfer_with_inputs(&alice, bob.address(), 1, 0, &selected)
   1193         .unwrap_err();
   1194 
   1195     assert!(format!("{error:#}").contains("transaction exceeds max block size"));
   1196 }
   1197 
   1198 #[test]
   1199 fn transfer_can_spend_selected_utxos_when_they_cover_amount_and_fee() {
   1200     let alice = Wallet::from_seed("selected-utxos-alice");
   1201     let bob = Wallet::from_seed("selected-utxos-bob");
   1202     let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
   1203     let selected = vec![test_utxo_outpoint(2)];
   1204 
   1205     let tx = ledger
   1206         .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected)
   1207         .unwrap();
   1208 
   1209     let Transaction::Transfer {
   1210         inputs, outputs, ..
   1211     } = &tx
   1212     else {
   1213         panic!("expected transfer");
   1214     };
   1215     assert_eq!(inputs.len(), 1);
   1216     assert_eq!(inputs[0].outpoint, selected[0]);
   1217     assert_eq!(
   1218         outputs,
   1219         &[
   1220             TxOutput {
   1221                 address: bob.address().to_string(),
   1222                 amount: 2
   1223             },
   1224             TxOutput {
   1225                 address: alice.address().to_string(),
   1226                 amount: 2
   1227             }
   1228         ]
   1229     );
   1230 
   1231     ledger.submit_transaction(tx).unwrap();
   1232     let balances = pending_balances(&ledger);
   1233     assert_eq!(balances.get(bob.address()).copied(), Some(2));
   1234     assert_eq!(balances.get(alice.address()).copied(), Some(7));
   1235 }
   1236 
   1237 #[test]
   1238 fn burn_can_spend_selected_utxos_when_they_cover_amount_and_fee() {
   1239     let alice = Wallet::from_seed("selected-burn-utxos-alice");
   1240     let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
   1241     let selected = vec![test_utxo_outpoint(1)];
   1242 
   1243     let tx = ledger
   1244         .build_burn_with_inputs(&alice, 1, 1, &selected)
   1245         .unwrap();
   1246 
   1247     let Transaction::Burn {
   1248         inputs,
   1249         change,
   1250         amount,
   1251         fee,
   1252         ..
   1253     } = &tx
   1254     else {
   1255         panic!("expected burn");
   1256     };
   1257     assert_eq!(*amount, 1);
   1258     assert_eq!(*fee, 1);
   1259     assert_eq!(inputs.len(), 1);
   1260     assert_eq!(inputs[0].outpoint, selected[0]);
   1261     assert_eq!(
   1262         change,
   1263         &[TxOutput {
   1264             address: alice.address().to_string(),
   1265             amount: 1
   1266         }]
   1267     );
   1268 
   1269     ledger.submit_transaction(tx).unwrap();
   1270     let balances = pending_balances(&ledger);
   1271     assert_eq!(balances.get(alice.address()).copied(), Some(8));
   1272 }
   1273 
   1274 #[test]
   1275 fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() {
   1276     let alice = Wallet::from_seed("selected-utxos-insufficient-alice");
   1277     let bob = Wallet::from_seed("selected-utxos-insufficient-bob");
   1278     let ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
   1279     let selected = vec![test_utxo_outpoint(0)];
   1280 
   1281     let error = ledger
   1282         .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected)
   1283         .unwrap_err();
   1284 
   1285     assert!(format!("{error:#}").contains("selected UTXOs do not cover"));
   1286 }
   1287 
   1288 #[test]
   1289 fn transfer_rejects_selected_utxos_owned_by_someone_else() {
   1290     let alice = Wallet::from_seed("selected-utxos-owner-alice");
   1291     let bob = Wallet::from_seed("selected-utxos-owner-bob");
   1292     let carol = Wallet::from_seed("selected-utxos-owner-carol");
   1293     let mut ledger = ledger_with_wallet_utxos(&alice, &[5]);
   1294     ledger.utxos.insert(
   1295         named_test_outpoint("carol"),
   1296         TxOutput {
   1297             address: carol.address().to_string(),
   1298             amount: 5,
   1299         },
   1300     );
   1301     let selected = vec![named_test_outpoint("carol")];
   1302 
   1303     let error = ledger
   1304         .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected)
   1305         .unwrap_err();
   1306 
   1307     assert!(format!("{error:#}").contains("is not owned"));
   1308 }
   1309 
   1310 #[test]
   1311 fn transfer_rejects_when_combined_utxos_do_not_cover_amount_plus_fee() {
   1312     let alice = Wallet::from_seed("combine-insufficient-alice");
   1313     let bob = Wallet::from_seed("combine-insufficient-bob");
   1314     let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]);
   1315 
   1316     let error = ledger
   1317         .build_transfer(&alice, bob.address(), 3, 1)
   1318         .unwrap_err();
   1319 
   1320     assert!(format!("{error:#}").contains("insufficient funds"));
   1321 }
   1322 
   1323 #[test]
   1324 fn pending_change_from_combined_utxos_can_fund_next_transaction() {
   1325     let alice = Wallet::from_seed("combine-pending-change-alice");
   1326     let bob = Wallet::from_seed("combine-pending-change-bob");
   1327     let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]);
   1328 
   1329     let first = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap();
   1330     let first_signature = first.signature().to_string();
   1331     ledger.submit_transaction(first).unwrap();
   1332 
   1333     let second = ledger.build_transfer(&alice, bob.address(), 1, 0).unwrap();
   1334     let Transaction::Transfer { inputs, .. } = &second else {
   1335         panic!("expected transfer");
   1336     };
   1337     assert_eq!(inputs.len(), 1);
   1338     assert_eq!(inputs[0].outpoint.txid, first_signature);
   1339     assert_eq!(inputs[0].outpoint.index, 1);
   1340 
   1341     ledger.submit_transaction(second).unwrap();
   1342     let balances = pending_balances(&ledger);
   1343     assert_eq!(
   1344         balances.get(alice.address()).copied().unwrap_or_default(),
   1345         0
   1346     );
   1347     assert_eq!(balances.get(bob.address()).copied(), Some(4));
   1348 }
   1349 
   1350 #[test]
   1351 fn winning_burn_ticket_is_consumed_even_when_window_remains() {
   1352     let parent = ticket_test_block(3, FinalizerMode::Ticket, 0, "parent");
   1353     let mut tickets = vec![
   1354         BurnTicket {
   1355             id: "high-burn".to_string(),
   1356             owner: "alice".to_string(),
   1357             amount: 10_000,
   1358             eligible_from_height: 4,
   1359             eligible_until_height: 6,
   1360         },
   1361         BurnTicket {
   1362             id: "small-burn".to_string(),
   1363             owner: "bob".to_string(),
   1364             amount: 1,
   1365             eligible_from_height: 5,
   1366             eligible_until_height: 7,
   1367         },
   1368     ];
   1369     let block = ticket_test_child(&parent, 4, "alice", 0, "high-burn");
   1370 
   1371     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1372 
   1373     assert!(
   1374         tickets.iter().all(|ticket| ticket.id != "high-burn"),
   1375         "a winning burn must not remain eligible for the rest of its window"
   1376     );
   1377     assert!(
   1378         tickets.iter().any(|ticket| ticket.id == "small-burn"),
   1379         "unselected future tickets should remain pending"
   1380     );
   1381 }
   1382 
   1383 fn ticket_test_block(
   1384     height: u64,
   1385     finalizer_mode: FinalizerMode,
   1386     finalizer_rank: u32,
   1387     seed: &str,
   1388 ) -> Block {
   1389     let mut block = Block {
   1390         height,
   1391         prev_hash: hex_hash(format!("ticket-test-prev:{seed}:{height}")),
   1392         timestamp_ms: height,
   1393         miner: seed.to_string(),
   1394         finalizer_mode,
   1395         finalizer_rank,
   1396         reward: BLOCK_REWARD,
   1397         vdf_rounds: 1,
   1398         vdf_output: hex_hash(format!("ticket-test-vdf:{seed}:{height}")),
   1399         leader_proof: None,
   1400         blinded_transactions: Vec::new(),
   1401         reveal_bundle_section: RevealBundleSection::default(),
   1402         transactions: Vec::new(),
   1403         hash: String::new(),
   1404     };
   1405     block.hash = block.compute_hash();
   1406     block
   1407 }
   1408 
   1409 fn ticket_test_child(
   1410     parent: &Block,
   1411     height: u64,
   1412     miner: &str,
   1413     finalizer_rank: u32,
   1414     ticket_id: &str,
   1415 ) -> Block {
   1416     let mut block = ticket_test_block(height, FinalizerMode::Ticket, finalizer_rank, miner);
   1417     block.prev_hash = parent.hash.clone();
   1418     block.leader_proof = Some(LeaderProof {
   1419         ticket_id: ticket_id.to_string(),
   1420         public_key: miner.to_string(),
   1421         signature: "signature".to_string(),
   1422     });
   1423     block.hash = block.compute_hash();
   1424     block
   1425 }
   1426 
   1427 fn fallback_invalidation_tickets(height: u64) -> Vec<BurnTicket> {
   1428     vec![
   1429         BurnTicket {
   1430             id: "alice-main".to_string(),
   1431             owner: "alice".to_string(),
   1432             amount: 10,
   1433             eligible_from_height: height,
   1434             eligible_until_height: height + 2,
   1435         },
   1436         BurnTicket {
   1437             id: "alice-other-eligible".to_string(),
   1438             owner: "alice".to_string(),
   1439             amount: 7,
   1440             eligible_from_height: height,
   1441             eligible_until_height: height + 2,
   1442         },
   1443         BurnTicket {
   1444             id: "bob-main".to_string(),
   1445             owner: "bob".to_string(),
   1446             amount: 9,
   1447             eligible_from_height: height,
   1448             eligible_until_height: height + 2,
   1449         },
   1450         BurnTicket {
   1451             id: "bob-other-eligible".to_string(),
   1452             owner: "bob".to_string(),
   1453             amount: 6,
   1454             eligible_from_height: height,
   1455             eligible_until_height: height + 2,
   1456         },
   1457         BurnTicket {
   1458             id: "carol-main".to_string(),
   1459             owner: "carol".to_string(),
   1460             amount: 8,
   1461             eligible_from_height: height,
   1462             eligible_until_height: height + 2,
   1463         },
   1464         BurnTicket {
   1465             id: "future-same-owner".to_string(),
   1466             owner: "alice".to_string(),
   1467             amount: 5,
   1468             eligible_from_height: height + 1,
   1469             eligible_until_height: height + 3,
   1470         },
   1471     ]
   1472 }
   1473 
   1474 #[test]
   1475 fn primary_ticket_at_activation_height_only_consumes_winning_ticket() {
   1476     let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT;
   1477     let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "primary-parent");
   1478     let mut tickets = fallback_invalidation_tickets(height);
   1479     let ranked = ranked_tickets_for_height(&parent, height, &tickets);
   1480     let leader = &ranked[0];
   1481     let leader_id = leader.id.clone();
   1482     let leader_owner = leader.owner.clone();
   1483     let eligible_before = tickets
   1484         .iter()
   1485         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1486         .count();
   1487     let block = ticket_test_child(&parent, height, &leader_owner, 0, &leader_id);
   1488 
   1489     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1490 
   1491     assert!(tickets.iter().all(|ticket| ticket.id != leader_id));
   1492     let eligible_after = tickets
   1493         .iter()
   1494         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1495         .count();
   1496     assert_eq!(eligible_after, eligible_before - 1);
   1497 }
   1498 
   1499 #[test]
   1500 fn fallback_ticket_before_activation_height_only_consumes_winning_ticket() {
   1501     let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT - 1;
   1502     let parent = ticket_test_block(
   1503         height - 1,
   1504         FinalizerMode::Ticket,
   1505         0,
   1506         "legacy-fallback-parent",
   1507     );
   1508     let mut tickets = fallback_invalidation_tickets(height);
   1509     let ranked = ranked_tickets_for_height(&parent, height, &tickets);
   1510     let fallback = &ranked[1];
   1511     let fallback_id = fallback.id.clone();
   1512     let fallback_owner = fallback.owner.clone();
   1513     let eligible_before = tickets
   1514         .iter()
   1515         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1516         .count();
   1517     let block = ticket_test_child(&parent, height, &fallback_owner, 1, &fallback_id);
   1518 
   1519     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1520 
   1521     assert!(tickets.iter().all(|ticket| ticket.id != fallback_id));
   1522     let eligible_after = tickets
   1523         .iter()
   1524         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1525         .count();
   1526     assert_eq!(eligible_after, eligible_before - 1);
   1527 }
   1528 
   1529 #[test]
   1530 fn fallback_ticket_invalidates_missed_ranks_and_current_sibling_tickets_from_height_300() {
   1531     let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT;
   1532     let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "fallback-parent");
   1533     let mut tickets = fallback_invalidation_tickets(height);
   1534     let ranked = ranked_tickets_for_height(&parent, height, &tickets);
   1535     let prefix_owners = ranked
   1536         .iter()
   1537         .take(2)
   1538         .map(|ticket| ticket.owner.clone())
   1539         .collect::<BTreeSet<_>>();
   1540     let fallback = &ranked[1];
   1541     let block = ticket_test_child(&parent, height, &fallback.owner, 1, &fallback.id);
   1542 
   1543     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1544 
   1545     assert!(
   1546         tickets.iter().all(|ticket| {
   1547             !ticket_is_eligible_for_height(ticket, height) || !prefix_owners.contains(&ticket.owner)
   1548         }),
   1549         "eligible tickets from missed ranks and the fallback finalizer should be invalidated"
   1550     );
   1551     assert!(
   1552         tickets
   1553             .iter()
   1554             .any(|ticket| ticket.id == "future-same-owner"),
   1555         "future tickets from invalidated owners should stay pending"
   1556     );
   1557 }
   1558 
   1559 #[test]
   1560 fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() {
   1561     let alice = Wallet::from_seed("burn-rank-alice");
   1562     let bob = Wallet::from_seed("burn-rank-bob");
   1563     let mut allocations = BTreeMap::new();
   1564     allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
   1565     allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA);
   1566     let ledger = Ledger::new_with_genesis_burns(
   1567         allocations,
   1568         vec![
   1569             GenesisBurn::new(alice.address(), MICRO_IUNA),
   1570             GenesisBurn::new(bob.address(), MICRO_IUNA),
   1571         ],
   1572         1,
   1573     )
   1574     .unwrap();
   1575 
   1576     let ranks = ledger.burn_leader_ranks_for_block(1).unwrap();
   1577     let leader = ledger.expected_leader_for_next_block().unwrap();
   1578 
   1579     assert_eq!(ranks.len(), 2);
   1580     assert_eq!(ranks[0].rank, 0);
   1581     assert_eq!(ranks[0].owner, leader);
   1582     assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA));
   1583     assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new());
   1584 
   1585     let batch = ledger.burn_leader_ranks_for_blocks([0, 1]).unwrap();
   1586     assert!(batch.get(&0).unwrap().is_empty());
   1587     assert_eq!(batch.get(&1), Some(&ranks));
   1588 }
   1589 
   1590 #[test]
   1591 fn mine_recipient_is_bound_to_proof_hash() {
   1592     let alice = Wallet::from_seed("mine-proof-alice");
   1593     let bob = Wallet::from_seed("mine-proof-bob");
   1594     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1595     let mut forged = unsigned_mine(&ledger, alice.address());
   1596     if let Transaction::Mine { recipient, .. } = &mut forged {
   1597         *recipient = bob.address().to_string();
   1598     }
   1599 
   1600     let error = ledger.submit_transaction(forged).unwrap_err();
   1601 
   1602     assert!(format!("{error:#}").contains("proof hash is invalid"));
   1603 }
   1604 
   1605 #[test]
   1606 fn mine_action_uses_fixed_reward_and_fixed_finalizer_fee() {
   1607     let alice = Wallet::from_seed("mine-fixed-reward-alice");
   1608     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1609 
   1610     let mine = ledger.build_mine(alice.address()).unwrap();
   1611 
   1612     assert_eq!(mine.amount(), MINE_REWARD);
   1613     assert_eq!(mine.fee(), MINE_FINALIZER_FEE);
   1614     assert!(ledger.submit_transaction(mine).unwrap());
   1615 }
   1616 
   1617 #[test]
   1618 fn burn_fee_goes_to_block_finalizer() {
   1619     let alice = Wallet::from_seed("burn-fee-finalizer-alice");
   1620     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1621 
   1622     let burn_fee = 12;
   1623     let burn = ledger
   1624         .build_burn(&alice, TEST_BURN_AMOUNT, burn_fee)
   1625         .unwrap();
   1626     ledger.submit_transaction(burn).unwrap();
   1627     let prepared = ledger.prepare_next_block(alice.address(), 1).unwrap();
   1628 
   1629     assert_eq!(prepared.reward, burn_fee);
   1630 }
   1631 
   1632 #[test]
   1633 fn blinded_burn_commits_ciphertext_and_reveal_executes_later() {
   1634     let alice = Wallet::from_seed("blinded-burn-finalizer-alice");
   1635     let bob = Wallet::from_seed("blinded-burn-finalizer-bob");
   1636     let carol = Wallet::from_seed("blinded-burn-carol");
   1637     let finalizers = [alice.clone(), bob.clone()];
   1638     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   1639     let fee = 100;
   1640     let burn_amount = 3;
   1641     let before_carol = ledger.balance_of(carol.address());
   1642 
   1643     let blinded = ledger
   1644         .build_blinded_burn(&carol, burn_amount, fee, ledger.height() + 4)
   1645         .unwrap();
   1646     assert!(!blinded.transaction.ciphertext.contains("burn"));
   1647     assert!(!blinded.transaction.ciphertext.contains(carol.address()));
   1648     ledger
   1649         .submit_blinded_transaction(blinded.transaction.clone())
   1650         .unwrap();
   1651     queue_next_leader_burn(&mut ledger, &finalizers);
   1652 
   1653     let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   1654     let inclusion_finalizer = commit_block.miner.clone();
   1655     assert_eq!(
   1656         commit_block
   1657             .transactions
   1658             .iter()
   1659             .filter(|transaction| transaction.is_burn())
   1660             .count(),
   1661         1
   1662     );
   1663     assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]);
   1664     assert_eq!(commit_block.reward, 0);
   1665     let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer);
   1666 
   1667     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   1668     queue_next_leader_burn(&mut ledger, &finalizers);
   1669     let reveal_block =
   1670         mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2);
   1671     let reveal_executor = reveal_block.miner.clone();
   1672 
   1673     assert_eq!(reveal_block.all_blinded_reveals().len(), 1);
   1674     assert_eq!(
   1675         ledger.balance_of(carol.address()),
   1676         before_carol - burn_amount - fee
   1677     );
   1678     assert!(ledger.tickets.iter().any(|ticket| {
   1679         ticket.owner == carol.address()
   1680             && ticket.amount == burn_amount
   1681             && ticket.eligible_from_height
   1682                 == reveal_block.height + ledger.launch_profile.ticket_maturity_delay_heights
   1683     }));
   1684     let reveal_plaintext_burn_spent_by_inclusion_finalizer = reveal_block
   1685         .transactions
   1686         .iter()
   1687         .filter(|transaction| {
   1688             transaction.is_burn() && transaction.sender() == inclusion_finalizer.as_str()
   1689         })
   1690         .fold(0_u64, |total, transaction| {
   1691             total + transaction.amount() + transaction.fee()
   1692         });
   1693     let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
   1694     let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1695         fee,
   1696         reveal_block.included_reveal_bundle_count(),
   1697         ledger
   1698             .burn_leader_ranks_for_block(reveal_block.height)
   1699             .unwrap()
   1700             .len(),
   1701     );
   1702     let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS);
   1703     let commitment = &commit_block.blinded_transactions[0].commitment;
   1704     assert_eq!(
   1705         ledger
   1706             .utxos
   1707             .get(&blinded_committer_fee_outpoint(commitment))
   1708             .unwrap(),
   1709         &TxOutput {
   1710             address: inclusion_finalizer.clone(),
   1711             amount: committer_fee,
   1712         }
   1713     );
   1714     assert!(
   1715         !ledger
   1716             .utxos
   1717             .contains_key(&blinded_executor_fee_outpoint(commitment))
   1718     );
   1719     assert_eq!(reveal_block.reward, reveal_finalizer_fee);
   1720     assert_eq!(
   1721         ledger
   1722             .utxos
   1723             .get(&reward_outpoint(&reveal_block.hash))
   1724             .unwrap(),
   1725         &TxOutput {
   1726             address: reveal_executor.clone(),
   1727             amount: reveal_finalizer_fee,
   1728         }
   1729     );
   1730     for signature in &reveal_block.reveal_bundle_section.signatures {
   1731         assert_eq!(
   1732             ledger
   1733                 .utxos
   1734                 .get(&blinded_reveal_bundle_signer_fee_outpoint(
   1735                     commitment,
   1736                     signature.slot
   1737                 ))
   1738                 .unwrap(),
   1739             &TxOutput {
   1740                 address: signature.member.clone(),
   1741                 amount: reveal_bundle_signer_fee,
   1742             }
   1743         );
   1744     }
   1745     let mut inclusion_finalizer_fee = committer_fee;
   1746     if inclusion_finalizer == reveal_executor {
   1747         inclusion_finalizer_fee += reveal_finalizer_fee;
   1748     }
   1749     inclusion_finalizer_fee += reveal_block
   1750         .reveal_bundle_section
   1751         .signatures
   1752         .iter()
   1753         .filter(|signature| signature.member == inclusion_finalizer)
   1754         .count() as u64
   1755         * reveal_bundle_signer_fee;
   1756     assert_eq!(
   1757         ledger.balance_of(&inclusion_finalizer),
   1758         before_inclusion_finalizer + inclusion_finalizer_fee
   1759             - reveal_plaintext_burn_spent_by_inclusion_finalizer
   1760     );
   1761 }
   1762 
   1763 #[test]
   1764 fn blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() {
   1765     let alice = Wallet::from_seed("aggregated-finalizer-fee-alice");
   1766     let bob = Wallet::from_seed("aggregated-finalizer-fee-bob");
   1767     let carol = Wallet::from_seed("aggregated-finalizer-fee-carol");
   1768     let dave = Wallet::from_seed("aggregated-finalizer-fee-dave");
   1769     let finalizers = [alice.clone(), bob.clone()];
   1770     let mut ledger = ledger_with_finalizers(
   1771         &finalizers,
   1772         &[(&carol, 10 * MICRO_IUNA), (&dave, 10 * MICRO_IUNA)],
   1773     );
   1774     let first_fee = 100;
   1775     let second_fee = 200;
   1776     let first_blinded = ledger
   1777         .build_blinded_burn(&carol, 3, first_fee, ledger.height() + 4)
   1778         .unwrap();
   1779     let second_blinded = ledger
   1780         .build_blinded_burn(&dave, 4, second_fee, ledger.height() + 4)
   1781         .unwrap();
   1782     let first_commitment = first_blinded.transaction.commitment.clone();
   1783     let second_commitment = second_blinded.transaction.commitment.clone();
   1784     ledger
   1785         .submit_blinded_transaction(first_blinded.transaction)
   1786         .unwrap();
   1787     ledger
   1788         .submit_blinded_transaction(second_blinded.transaction)
   1789         .unwrap();
   1790     queue_next_leader_burn(&mut ledger, &finalizers);
   1791     let commit_timestamp_ms = ledger
   1792         .tip()
   1793         .timestamp_ms
   1794         .saturating_add(VDF_TARGET_BLOCK_MS);
   1795     let commit_block =
   1796         mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms);
   1797     assert_eq!(commit_block.height, 1);
   1798 
   1799     ledger.submit_blinded_reveal(first_blinded.reveal).unwrap();
   1800     ledger.submit_blinded_reveal(second_blinded.reveal).unwrap();
   1801     queue_next_leader_burn(&mut ledger, &finalizers);
   1802     let reveal_timestamp_ms = ledger
   1803         .tip()
   1804         .timestamp_ms
   1805         .saturating_add(VDF_TARGET_BLOCK_MS);
   1806     let reveal_block = prepare_preverified_as_next_leader_with_reveal_bundles(
   1807         &ledger,
   1808         &finalizers,
   1809         reveal_timestamp_ms,
   1810     );
   1811     let first_reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1812         first_fee,
   1813         reveal_block.included_reveal_bundle_count(),
   1814         ledger
   1815             .burn_leader_ranks_for_block(reveal_block.height)
   1816             .unwrap()
   1817             .len(),
   1818     );
   1819     let second_reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1820         second_fee,
   1821         reveal_block.included_reveal_bundle_count(),
   1822         ledger
   1823             .burn_leader_ranks_for_block(reveal_block.height)
   1824             .unwrap()
   1825             .len(),
   1826     );
   1827     let aggregate_reveal_finalizer_fee = first_reveal_finalizer_fee
   1828         .checked_add(second_reveal_finalizer_fee)
   1829         .unwrap();
   1830 
   1831     assert_eq!(reveal_block.height, 2);
   1832     assert_eq!(reveal_block.reward, aggregate_reveal_finalizer_fee);
   1833     let mut plain_fee_reward_block = reveal_block.clone();
   1834     plain_fee_reward_block.reward = fee_reward(&plain_fee_reward_block.transactions).unwrap();
   1835     plain_fee_reward_block.hash = plain_fee_reward_block.compute_hash();
   1836     let error = ledger
   1837         .clone()
   1838         .apply_preverified_block_at(plain_fee_reward_block, u64::MAX)
   1839         .unwrap_err();
   1840     assert!(format!("{error:#}").contains("block reward is invalid"));
   1841 
   1842     ledger
   1843         .apply_preverified_block_at(reveal_block.clone(), u64::MAX)
   1844         .unwrap();
   1845     assert!(
   1846         !ledger
   1847             .utxos
   1848             .contains_key(&blinded_executor_fee_outpoint(&first_commitment))
   1849     );
   1850     assert!(
   1851         !ledger
   1852             .utxos
   1853             .contains_key(&blinded_executor_fee_outpoint(&second_commitment))
   1854     );
   1855     assert_eq!(
   1856         ledger.utxos.get(&reward_outpoint(&reveal_block.hash)),
   1857         Some(&TxOutput {
   1858             address: reveal_block.miner.clone(),
   1859             amount: aggregate_reveal_finalizer_fee,
   1860         })
   1861     );
   1862 }
   1863 
   1864 #[test]
   1865 fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() {
   1866     let alice = Wallet::from_seed("blinded-lock-alice");
   1867     let bob = Wallet::from_seed("blinded-lock-bob");
   1868     let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
   1869     let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap();
   1870     let visible_inputs = transfer.inputs().to_vec();
   1871 
   1872     let blinded = ledger
   1873         .build_blinded_transaction(&alice, transfer, ledger.height() + 4)
   1874         .unwrap();
   1875 
   1876     assert_eq!(blinded.transaction.inputs.len(), visible_inputs.len());
   1877     assert_eq!(
   1878         unsigned_inputs(&blinded.transaction.inputs),
   1879         unsigned_inputs(&visible_inputs)
   1880     );
   1881     ledger
   1882         .submit_blinded_transaction(blinded.transaction)
   1883         .unwrap();
   1884     let error = ledger
   1885         .build_transfer(&alice, bob.address(), 1, 0)
   1886         .unwrap_err();
   1887     assert!(format!("{error:#}").contains("insufficient funds"));
   1888 }
   1889 
   1890 #[test]
   1891 fn blinded_payload_omits_visible_inputs_and_reconstructs_transaction_on_reveal() {
   1892     let alice = Wallet::from_seed("blinded-compact-payload-alice");
   1893     let bob = Wallet::from_seed("blinded-compact-payload-bob");
   1894     let ledger = ledger_with_wallet_utxos(&alice, &[10]);
   1895     let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap();
   1896     let full_transaction_bytes = serde_json::to_vec(&transfer).unwrap().len();
   1897     let blinded = ledger
   1898         .build_blinded_transaction(&alice, transfer.clone(), ledger.height() + 4)
   1899         .unwrap();
   1900     let key = decode_hex_array::<BLINDED_KEY_BYTES>(&blinded.reveal.key).unwrap();
   1901     let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&blinded.transaction.nonce).unwrap();
   1902     let ciphertext = decode_hex(&blinded.transaction.ciphertext).unwrap();
   1903 
   1904     let plaintext = decrypt_blinded_payload(
   1905         &key,
   1906         &nonce,
   1907         &signed_blinded_inputs(&unsigned_inputs(&blinded.transaction.inputs), ""),
   1908         blinded.transaction.fee,
   1909         blinded.transaction.expires_at_height,
   1910         &ciphertext,
   1911     )
   1912     .unwrap();
   1913     let payload: serde_json::Value = serde_json::from_slice(&plaintext).unwrap();
   1914     let revealed = decrypt_blinded_transaction(&blinded.transaction, &blinded.reveal).unwrap();
   1915 
   1916     assert_eq!(
   1917         payload.get("kind").and_then(|kind| kind.as_str()),
   1918         Some("transfer")
   1919     );
   1920     assert!(payload.get("inputs").is_none());
   1921     assert!(plaintext.len() < full_transaction_bytes);
   1922     assert_eq!(revealed, transfer);
   1923 }
   1924 
   1925 #[test]
   1926 fn unrevealed_blinded_utxo_commit_burns_fee_and_returns_change() {
   1927     let alice = Wallet::from_seed("blinded-expiry-alice");
   1928     let bob = Wallet::from_seed("blinded-expiry-bob");
   1929     let carol = Wallet::from_seed("blinded-expiry-carol");
   1930     let finalizers = [alice.clone(), bob.clone()];
   1931     let carol_balance = 10 * MICRO_IUNA;
   1932     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, carol_balance)]);
   1933     let fee = 100;
   1934     let blinded = ledger
   1935         .build_blinded_burn(&carol, 3, fee, ledger.height() + 2)
   1936         .unwrap();
   1937     let commitment = blinded.transaction.commitment.clone();
   1938     ledger
   1939         .submit_blinded_transaction(blinded.transaction)
   1940         .unwrap();
   1941     queue_next_leader_burn(&mut ledger, &finalizers);
   1942     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   1943 
   1944     assert_eq!(ledger.balance_of(carol.address()), 0);
   1945     queue_next_leader_burn(&mut ledger, &finalizers);
   1946     mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
   1947 
   1948     assert_eq!(
   1949         ledger
   1950             .utxos
   1951             .get(&blinded_committer_fee_outpoint(&commitment)),
   1952         None
   1953     );
   1954     assert_eq!(
   1955         ledger
   1956             .utxos
   1957             .get(&blinded_expiry_change_outpoint(&commitment)),
   1958         Some(&TxOutput {
   1959             address: carol.address().to_string(),
   1960             amount: carol_balance - fee,
   1961         })
   1962     );
   1963     assert_eq!(ledger.balance_of(carol.address()), carol_balance - fee);
   1964 }
   1965 
   1966 #[test]
   1967 fn fee_bearing_blinded_commit_without_inputs_is_rejected() {
   1968     let alice = Wallet::from_seed("blinded-no-input-fee-alice");
   1969     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1970     let mut blinded = ledger
   1971         .build_blinded_burn(&alice, 1, 1, ledger.height() + 4)
   1972         .unwrap()
   1973         .transaction;
   1974     blinded.inputs.clear();
   1975     blinded.commitment = blinded_transaction_commitment(&blinded).unwrap();
   1976 
   1977     let error = ledger.submit_blinded_transaction(blinded).unwrap_err();
   1978 
   1979     assert!(format!("{error:#}").contains("must lock visible inputs"));
   1980 }
   1981 
   1982 #[test]
   1983 fn mine_actions_cannot_be_blinded() {
   1984     let alice = Wallet::from_seed("blinded-mine-collateral-alice");
   1985     let ledger = ledger_with_finalizers(&[alice.clone()], &[]);
   1986     let mine = ledger.build_mine(alice.address()).unwrap();
   1987     let error = ledger
   1988         .build_blinded_transaction(&alice, mine, ledger.height() + 4)
   1989         .unwrap_err();
   1990 
   1991     assert!(format!("{error:#}").contains("mine actions are public"));
   1992 }
   1993 
   1994 #[test]
   1995 fn reveal_bundle_hashes_are_bound_to_next_block_vdf_seed() {
   1996     let alice = Wallet::from_seed("bundle-seed-alice");
   1997     let bob = Wallet::from_seed("bundle-seed-bob");
   1998     let carol = Wallet::from_seed("bundle-seed-carol");
   1999     let finalizers = [alice.clone(), bob.clone()];
   2000     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2001     let blinded = ledger
   2002         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2003         .unwrap();
   2004     ledger
   2005         .submit_blinded_transaction(blinded.transaction.clone())
   2006         .unwrap();
   2007     queue_next_leader_burn(&mut ledger, &finalizers);
   2008     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2009     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2010     queue_next_leader_burn(&mut ledger, &finalizers);
   2011     let leader = ledger.expected_leader_for_next_block().unwrap();
   2012     let leader_wallet = wallet_for_address(&finalizers, &leader);
   2013     let bundles = ledger
   2014         .reveal_committee_for_next_block()
   2015         .into_iter()
   2016         .filter_map(|member| {
   2017             let wallet = wallet_for_address(&finalizers, &member.owner);
   2018             ledger.build_reveal_bundle(wallet).unwrap()
   2019         })
   2020         .collect::<Vec<_>>();
   2021     if bundles.len() > 1 {
   2022         let mut reversed = bundles.clone();
   2023         reversed.reverse();
   2024         let error = ledger
   2025             .validate_next_block_reveal_bundles(reversed)
   2026             .unwrap_err();
   2027         assert!(format!("{error:#}").contains("reveal bundles are not in slot order"));
   2028     }
   2029 
   2030     let without_bundles = ledger
   2031         .prepare_next_block(leader_wallet.address(), ledger.tip().timestamp_ms + 1)
   2032         .unwrap();
   2033     let with_bundles = ledger
   2034         .prepare_next_block_with_reveal_bundles(
   2035             leader_wallet.address(),
   2036             ledger.tip().timestamp_ms + 1,
   2037             bundles,
   2038         )
   2039         .unwrap();
   2040 
   2041     assert_ne!(without_bundles.vdf_seed(), with_bundles.vdf_seed());
   2042 }
   2043 
   2044 #[test]
   2045 fn reveal_committee_includes_next_block_finalizer_as_slot_zero() {
   2046     let alice = Wallet::from_seed("bundle-finalizer-slot-alice");
   2047     let bob = Wallet::from_seed("bundle-finalizer-slot-bob");
   2048     let carol = Wallet::from_seed("bundle-finalizer-slot-carol");
   2049     let dave = Wallet::from_seed("bundle-finalizer-slot-dave");
   2050     let erin = Wallet::from_seed("bundle-finalizer-slot-erin");
   2051     let finalizers = [alice.clone(), bob.clone(), carol.clone(), dave.clone()];
   2052     let mut ledger = ledger_with_finalizers(&finalizers, &[(&erin, 10 * MICRO_IUNA)]);
   2053     let blinded = ledger
   2054         .build_blinded_burn(&erin, 3, 7, ledger.height() + 4)
   2055         .unwrap();
   2056     let commitment = blinded.transaction.commitment.clone();
   2057     ledger
   2058         .submit_blinded_transaction(blinded.transaction)
   2059         .unwrap();
   2060     queue_next_leader_burn(&mut ledger, &finalizers);
   2061     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2062     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2063     queue_next_leader_burn(&mut ledger, &finalizers);
   2064 
   2065     let leader = ledger.expected_leader_for_next_block().unwrap();
   2066     let committee = ledger.reveal_committee_for_next_block();
   2067     let leader_wallet = wallet_for_address(&finalizers, &leader);
   2068     let bundle = ledger.build_reveal_bundle(leader_wallet).unwrap().unwrap();
   2069 
   2070     assert_eq!(committee.first().map(|member| member.slot), Some(0));
   2071     assert_eq!(committee.first().map(|member| member.rank), Some(0));
   2072     assert_eq!(
   2073         committee.first().map(|member| member.owner.as_str()),
   2074         Some(leader.as_str())
   2075     );
   2076     assert_eq!(bundle.slot, 0);
   2077     assert_eq!(bundle.member, leader);
   2078     assert!(
   2079         bundle
   2080             .reveals
   2081             .iter()
   2082             .any(|reveal| reveal.commitment == commitment)
   2083     );
   2084 }
   2085 
   2086 #[test]
   2087 fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() {
   2088     let alice = Wallet::from_seed("bundle-compact-alice");
   2089     let bob = Wallet::from_seed("bundle-compact-bob");
   2090     let carol = Wallet::from_seed("bundle-compact-carol");
   2091     let dave = Wallet::from_seed("bundle-compact-dave");
   2092     let finalizers = [alice.clone(), bob.clone(), carol.clone()];
   2093     let mut ledger = ledger_with_finalizers(&finalizers, &[(&dave, 10 * MICRO_IUNA)]);
   2094     let blinded = ledger
   2095         .build_blinded_burn(&dave, 3, 7, ledger.height() + 4)
   2096         .unwrap();
   2097     ledger
   2098         .submit_blinded_transaction(blinded.transaction.clone())
   2099         .unwrap();
   2100     queue_next_leader_burn(&mut ledger, &finalizers);
   2101     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2102     ledger
   2103         .submit_blinded_reveal(blinded.reveal.clone())
   2104         .unwrap();
   2105     queue_next_leader_burn(&mut ledger, &finalizers);
   2106 
   2107     let mut bundles = ledger
   2108         .reveal_committee_for_next_block()
   2109         .into_iter()
   2110         .filter_map(|member| {
   2111             let wallet = wallet_for_address(&finalizers, &member.owner);
   2112             ledger.build_reveal_bundle(wallet).unwrap()
   2113         })
   2114         .collect::<Vec<_>>();
   2115     assert!(bundles.len() >= 2);
   2116     bundles.truncate(2);
   2117     let expected_hashes = reveal_bundle_hashes(&bundles);
   2118     let expected_mask = bundles
   2119         .iter()
   2120         .fold(0_u8, |mask, bundle| mask | (1_u8 << bundle.slot));
   2121 
   2122     let leader = ledger.expected_leader_for_next_block().unwrap();
   2123     let leader_wallet = wallet_for_address(&finalizers, &leader);
   2124     let prepared = ledger
   2125         .prepare_next_block_with_reveal_bundles(
   2126             leader_wallet.address(),
   2127             ledger.tip().timestamp_ms + 1,
   2128             bundles.clone(),
   2129         )
   2130         .unwrap();
   2131     let block = prepared.finish(leader_wallet, "preverified-vdf".to_string());
   2132 
   2133     assert_eq!(block.reveal_bundle_section.signatures.len(), 2);
   2134     assert_eq!(block.reveal_bundle_section.reveals.len(), 1);
   2135     assert_eq!(
   2136         block.reveal_bundle_section.reveals[0].bundle_mask,
   2137         expected_mask
   2138     );
   2139     assert_eq!(block.all_blinded_reveals(), vec![&blinded.reveal]);
   2140     assert_eq!(block.reveal_bundle_hashes(), expected_hashes);
   2141     assert_eq!(
   2142         block
   2143             .reveal_bundle_section
   2144             .expand(block.height, &block.prev_hash),
   2145         bundles
   2146     );
   2147 }
   2148 
   2149 fn ledger_with_duplicate_owner_reveal_tickets() -> Ledger {
   2150     let alice = Wallet::from_seed("bundle-unique-owner-alice");
   2151     let bob = Wallet::from_seed("bundle-unique-owner-bob");
   2152     let mut ledger = Ledger::new(BTreeMap::new(), 1);
   2153     ledger.tickets = vec![
   2154         BurnTicket {
   2155             id: "alice-0".to_string(),
   2156             owner: alice.address().to_string(),
   2157             amount: MICRO_IUNA,
   2158             eligible_from_height: 499,
   2159             eligible_until_height: 500,
   2160         },
   2161         BurnTicket {
   2162             id: "alice-1".to_string(),
   2163             owner: alice.address().to_string(),
   2164             amount: MICRO_IUNA,
   2165             eligible_from_height: 499,
   2166             eligible_until_height: 500,
   2167         },
   2168         BurnTicket {
   2169             id: "bob-0".to_string(),
   2170             owner: bob.address().to_string(),
   2171             amount: MICRO_IUNA,
   2172             eligible_from_height: 499,
   2173             eligible_until_height: 500,
   2174         },
   2175     ];
   2176     ledger
   2177 }
   2178 
   2179 #[test]
   2180 fn reveal_committee_allows_duplicate_owners_before_height_500() {
   2181     let ledger = ledger_with_duplicate_owner_reveal_tickets();
   2182 
   2183     let committee = ledger.reveal_committee_for_height(499);
   2184     let owners = committee
   2185         .iter()
   2186         .map(|member| member.owner.as_str())
   2187         .collect::<BTreeSet<_>>();
   2188 
   2189     assert_eq!(committee.len(), 3);
   2190     assert_eq!(owners.len(), 2);
   2191 }
   2192 
   2193 #[test]
   2194 fn reveal_committee_uses_unique_ticket_owners_from_height_500() {
   2195     let ledger = ledger_with_duplicate_owner_reveal_tickets();
   2196 
   2197     let committee = ledger.reveal_committee_for_height(500);
   2198     let owners = committee
   2199         .iter()
   2200         .map(|member| member.owner.as_str())
   2201         .collect::<BTreeSet<_>>();
   2202 
   2203     assert_eq!(committee.len(), 2);
   2204     assert_eq!(owners.len(), 2);
   2205     assert_eq!(committee.first().map(|member| member.rank), Some(0));
   2206 }
   2207 
   2208 #[test]
   2209 fn reveal_bundle_validation_rejects_wrong_signature_and_slot() {
   2210     let alice = Wallet::from_seed("bundle-invalid-alice");
   2211     let bob = Wallet::from_seed("bundle-invalid-bob");
   2212     let carol = Wallet::from_seed("bundle-invalid-carol");
   2213     let finalizers = [alice.clone(), bob.clone()];
   2214     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2215     let blinded = ledger
   2216         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2217         .unwrap();
   2218     ledger
   2219         .submit_blinded_transaction(blinded.transaction.clone())
   2220         .unwrap();
   2221     queue_next_leader_burn(&mut ledger, &finalizers);
   2222     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2223     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2224     let member = ledger.reveal_committee_for_next_block()[0].clone();
   2225     let wallet = wallet_for_address(&finalizers, &member.owner);
   2226     let bundle = ledger.build_reveal_bundle(wallet).unwrap().unwrap();
   2227 
   2228     let mut wrong_signature = bundle.clone();
   2229     wrong_signature.signature = "00".repeat(SIGNATURE_BYTES);
   2230     let error = ledger
   2231         .validate_next_block_reveal_bundles(vec![wrong_signature])
   2232         .unwrap_err();
   2233     assert!(format!("{error:#}").contains("reveal bundle signature is invalid"));
   2234 
   2235     let mut wrong_slot = bundle;
   2236     wrong_slot.slot = REVEAL_COMMITTEE_SIZE as u8 - 1;
   2237     let error = ledger
   2238         .validate_next_block_reveal_bundles(vec![wrong_slot])
   2239         .unwrap_err();
   2240     assert!(
   2241         format!("{error:#}").contains("reveal bundle slot is not assigned")
   2242             || format!("{error:#}").contains("reveal bundle member is not assigned to slot")
   2243     );
   2244 }
   2245 
   2246 #[test]
   2247 fn blinded_reveal_with_wrong_key_is_rejected_in_block() {
   2248     let alice = Wallet::from_seed("blinded-wrong-key-finalizer-alice");
   2249     let bob = Wallet::from_seed("blinded-wrong-key-finalizer-bob");
   2250     let carol = Wallet::from_seed("blinded-wrong-key-carol");
   2251     let finalizers = [alice.clone(), bob.clone()];
   2252     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2253     let blinded = ledger
   2254         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2255         .unwrap();
   2256     ledger
   2257         .submit_blinded_transaction(blinded.transaction.clone())
   2258         .unwrap();
   2259     queue_next_leader_burn(&mut ledger, &finalizers);
   2260     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2261 
   2262     let leader = ledger.expected_leader_for_next_block().unwrap();
   2263     let wallet = wallet_for_address(&finalizers, &leader);
   2264     let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
   2265     ledger.submit_transaction(filler_burn).unwrap();
   2266     let mut prepared = ledger
   2267         .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
   2268         .unwrap();
   2269     let committee_member = ledger.reveal_committee_for_next_block()[0].clone();
   2270     let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner);
   2271     let wrong_reveal = BlindedReveal {
   2272         commitment: blinded.transaction.commitment,
   2273         key: "00".repeat(BLINDED_KEY_BYTES),
   2274     };
   2275     let wrong_bundle = committee_wallet.reveal_bundle(RevealBundlePayload {
   2276         height: prepared.height,
   2277         prev_hash: prepared.prev_hash.clone(),
   2278         slot: committee_member.slot,
   2279         member: committee_wallet.address().to_string(),
   2280         reveals: vec![wrong_reveal],
   2281     });
   2282     prepared.reveal_bundle_section = ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle]);
   2283     prepared.reward = blinded_reveal_finalizer_fee(
   2284         blinded.transaction.fee,
   2285         prepared.reveal_bundle_section.signatures.len(),
   2286         ledger.reveal_committee_for_next_block().len(),
   2287     );
   2288     let block = prepared.finish(wallet, "preverified-vdf".to_string());
   2289 
   2290     let error = ledger
   2291         .apply_preverified_block_at(block, u64::MAX)
   2292         .unwrap_err();
   2293 
   2294     assert!(
   2295         format!("{error:#}").contains("decrypt blinded transaction"),
   2296         "{error:#}"
   2297     );
   2298 }
   2299 
   2300 #[test]
   2301 fn expired_blinded_reveal_is_not_selected() {
   2302     let alice = Wallet::from_seed("blinded-expire-finalizer-alice");
   2303     let bob = Wallet::from_seed("blinded-expire-finalizer-bob");
   2304     let carol = Wallet::from_seed("blinded-expire-carol");
   2305     let finalizers = [alice.clone(), bob.clone()];
   2306     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2307     let blinded = ledger
   2308         .build_blinded_burn(&carol, 3, 7, ledger.height() + 2)
   2309         .unwrap();
   2310     ledger
   2311         .submit_blinded_transaction(blinded.transaction.clone())
   2312         .unwrap();
   2313     queue_next_leader_burn(&mut ledger, &finalizers);
   2314     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2315 
   2316     let leader = ledger.expected_leader_for_next_block().unwrap();
   2317     let wallet = wallet_for_address(&finalizers, &leader);
   2318     let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
   2319     ledger.submit_transaction(filler_burn).unwrap();
   2320     mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
   2321 
   2322     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2323     assert!(ledger.valid_pending_blinded_reveals().is_empty());
   2324 }
   2325 
   2326 #[test]
   2327 fn active_blinded_reveal_displaces_invalid_reveal_spam_when_pool_is_full() {
   2328     let alice = Wallet::from_seed("blinded-spam-finalizer-alice");
   2329     let bob = Wallet::from_seed("blinded-spam-finalizer-bob");
   2330     let carol = Wallet::from_seed("blinded-spam-carol");
   2331     let finalizers = [alice.clone(), bob.clone()];
   2332     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2333     let blinded = ledger
   2334         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2335         .unwrap();
   2336     ledger
   2337         .submit_blinded_transaction(blinded.transaction.clone())
   2338         .unwrap();
   2339     queue_next_leader_burn(&mut ledger, &finalizers);
   2340     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2341 
   2342     for index in 0..MAX_PENDING_TRANSACTIONS {
   2343         ledger
   2344             .submit_blinded_reveal(BlindedReveal {
   2345                 commitment: hex_hash(format!("unknown-blinded-reveal-spam:{index}")),
   2346                 key: "00".repeat(BLINDED_KEY_BYTES),
   2347             })
   2348             .unwrap();
   2349     }
   2350     assert_eq!(
   2351         ledger.pending_blinded_reveals().len(),
   2352         MAX_PENDING_TRANSACTIONS
   2353     );
   2354     assert!(ledger.valid_pending_blinded_reveals().is_empty());
   2355 
   2356     assert!(
   2357         ledger
   2358             .submit_blinded_reveal(blinded.reveal.clone())
   2359             .unwrap()
   2360     );
   2361 
   2362     assert_eq!(
   2363         ledger.pending_blinded_reveals().len(),
   2364         MAX_PENDING_TRANSACTIONS
   2365     );
   2366     assert_eq!(ledger.valid_pending_blinded_reveals(), vec![blinded.reveal]);
   2367 }
   2368 
   2369 #[test]
   2370 fn recovery_block_includes_pending_blinded_transactions_when_space_allows() {
   2371     let alice = Wallet::from_seed("recovery-blinded-commit-alice");
   2372     let bob = Wallet::from_seed("recovery-blinded-commit-bob");
   2373     let carol = Wallet::from_seed("recovery-blinded-commit-carol");
   2374     let mut ledger = ledger_with_finalizers(
   2375         &[alice],
   2376         &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)],
   2377     );
   2378     let blinded = ledger
   2379         .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4)
   2380         .unwrap();
   2381     ledger
   2382         .submit_blinded_transaction(blinded.transaction.clone())
   2383         .unwrap();
   2384     let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
   2385     ledger.submit_transaction(recovery_burn).unwrap();
   2386 
   2387     let block = ledger
   2388         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   2389         .unwrap();
   2390 
   2391     assert!(
   2392         block
   2393             .blinded_transactions
   2394             .iter()
   2395             .any(|transaction| transaction.commitment == blinded.transaction.commitment)
   2396     );
   2397 }
   2398 
   2399 #[test]
   2400 fn recovery_block_size_selection_uses_recovery_skeleton() {
   2401     let alice = Wallet::from_seed("recovery-size-commit-alice");
   2402     let bob = Wallet::from_seed("recovery-size-commit-bob");
   2403     let carol = Wallet::from_seed("recovery-size-commit-carol");
   2404     let mut ledger = ledger_with_finalizers(
   2405         &[alice],
   2406         &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)],
   2407     );
   2408     let blinded = ledger
   2409         .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4)
   2410         .unwrap();
   2411     ledger
   2412         .submit_blinded_transaction(blinded.transaction.clone())
   2413         .unwrap();
   2414     let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
   2415     ledger.submit_transaction(recovery_burn.clone()).unwrap();
   2416     let recovery_selection = BlockSelection {
   2417         transactions: vec![recovery_burn],
   2418         blinded_transactions: vec![blinded.transaction.clone()],
   2419     };
   2420     let recovery_estimate =
   2421         estimated_block_selection_size_bytes(&recovery_selection, true).unwrap();
   2422     let ticket_estimate = estimated_block_selection_size_bytes(&recovery_selection, false).unwrap();
   2423     assert!(recovery_estimate < ticket_estimate);
   2424 
   2425     ledger.launch_profile.max_block_bytes = recovery_estimate;
   2426     let tight_block = ledger
   2427         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   2428         .unwrap();
   2429 
   2430     assert!(
   2431         tight_block
   2432             .blinded_transactions
   2433             .iter()
   2434             .any(|transaction| transaction.commitment == blinded.transaction.commitment)
   2435     );
   2436 }
   2437 
   2438 #[test]
   2439 fn recovery_block_includes_pending_blinded_reveals_when_space_allows() {
   2440     let alice = Wallet::from_seed("recovery-blinded-reveal-alice");
   2441     let bob = Wallet::from_seed("recovery-blinded-reveal-bob");
   2442     let carol = Wallet::from_seed("recovery-blinded-reveal-carol");
   2443     let finalizers = [alice.clone(), bob.clone()];
   2444     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2445     let blinded = ledger
   2446         .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4)
   2447         .unwrap();
   2448     ledger
   2449         .submit_blinded_transaction(blinded.transaction.clone())
   2450         .unwrap();
   2451     queue_next_leader_burn(&mut ledger, &finalizers);
   2452     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2453     ledger
   2454         .submit_blinded_reveal(blinded.reveal.clone())
   2455         .unwrap();
   2456     let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
   2457     ledger.submit_transaction(recovery_burn).unwrap();
   2458 
   2459     let bundles = ledger
   2460         .reveal_committee_for_next_block()
   2461         .into_iter()
   2462         .filter_map(|member| {
   2463             let wallet = wallet_for_address(&finalizers, &member.owner);
   2464             ledger.build_reveal_bundle(wallet).unwrap()
   2465         })
   2466         .collect::<Vec<_>>();
   2467     let prepared = ledger
   2468         .prepare_recovery_block_with_reveal_bundles(
   2469             bob.address(),
   2470             ledger.recovery_block_min_timestamp(),
   2471             bundles,
   2472         )
   2473         .unwrap();
   2474     let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
   2475     let block = prepared.finish(&bob, vdf_output);
   2476 
   2477     assert!(
   2478         block
   2479             .all_blinded_reveals()
   2480             .iter()
   2481             .any(|reveal| reveal.commitment == blinded.transaction.commitment)
   2482     );
   2483 }
   2484 
   2485 #[test]
   2486 fn blinded_transaction_expiring_at_next_height_is_not_selected() {
   2487     let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice");
   2488     let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob");
   2489     let carol = Wallet::from_seed("blinded-next-expire-carol");
   2490     let finalizers = [alice.clone(), bob.clone()];
   2491     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2492     let blinded = ledger
   2493         .build_blinded_burn(&carol, 3, 7, ledger.height() + 1)
   2494         .unwrap();
   2495     ledger
   2496         .submit_blinded_transaction(blinded.transaction)
   2497         .unwrap();
   2498 
   2499     let leader = ledger.expected_leader_for_next_block().unwrap();
   2500     let wallet = wallet_for_address(&finalizers, &leader);
   2501     let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err();
   2502 
   2503     assert!(format!("{error:#}").contains("block must include at least one burn transaction"));
   2504 }
   2505 
   2506 #[test]
   2507 fn blinded_transaction_expiry_cannot_exceed_protocol_window() {
   2508     let alice = Wallet::from_seed("blinded-window-finalizer-alice");
   2509     let bob = Wallet::from_seed("blinded-window-finalizer-bob");
   2510     let carol = Wallet::from_seed("blinded-window-carol");
   2511     let finalizers = [alice, bob];
   2512     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2513     let max_expiry = ledger
   2514         .height()
   2515         .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS);
   2516 
   2517     ledger.build_blinded_burn(&carol, 3, 7, max_expiry).unwrap();
   2518 
   2519     let error = ledger
   2520         .build_blinded_burn(&carol, 3, 7, max_expiry + 1)
   2521         .unwrap_err();
   2522     assert!(format!("{error:#}").contains("expiry is too far in the future"));
   2523 
   2524     let mut forged = ledger
   2525         .build_blinded_burn(&carol, 3, 7, max_expiry)
   2526         .unwrap()
   2527         .transaction;
   2528     forged.expires_at_height = max_expiry + 1;
   2529     forged.commitment = blinded_transaction_commitment(&forged).unwrap();
   2530     let error = ledger.submit_blinded_transaction(forged).unwrap_err();
   2531     assert!(format!("{error:#}").contains("expiry is too far in the future"));
   2532 }
   2533 
   2534 #[test]
   2535 fn blinded_transaction_does_not_satisfy_plaintext_burn_requirement() {
   2536     let alice = Wallet::from_seed("blinded-no-burn-finalizer-alice");
   2537     let bob = Wallet::from_seed("blinded-no-burn-finalizer-bob");
   2538     let carol = Wallet::from_seed("blinded-no-burn-carol");
   2539     let finalizers = [alice.clone(), bob.clone()];
   2540     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2541     let blinded = ledger
   2542         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2543         .unwrap();
   2544     ledger
   2545         .submit_blinded_transaction(blinded.transaction)
   2546         .unwrap();
   2547 
   2548     let leader = ledger.expected_leader_for_next_block().unwrap();
   2549     let wallet = wallet_for_address(&finalizers, &leader);
   2550     let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err();
   2551 
   2552     assert!(format!("{error:#}").contains("block must include at least one burn transaction"));
   2553 }
   2554 
   2555 #[test]
   2556 fn revealed_blinded_transaction_cannot_be_included_again() {
   2557     let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice");
   2558     let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob");
   2559     let carol = Wallet::from_seed("blinded-duplicate-carol");
   2560     let finalizers = [alice.clone(), bob.clone()];
   2561     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2562     let blinded = ledger
   2563         .build_blinded_burn(&carol, 3, 7, ledger.height() + 6)
   2564         .unwrap();
   2565     ledger
   2566         .submit_blinded_transaction(blinded.transaction.clone())
   2567         .unwrap();
   2568     queue_next_leader_burn(&mut ledger, &finalizers);
   2569     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2570     ledger
   2571         .submit_blinded_reveal(blinded.reveal.clone())
   2572         .unwrap();
   2573     queue_next_leader_burn(&mut ledger, &finalizers);
   2574     mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2);
   2575 
   2576     let leader = ledger.expected_leader_for_next_block().unwrap();
   2577     let wallet = wallet_for_address(&finalizers, &leader);
   2578     let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
   2579     ledger.submit_transaction(filler_burn).unwrap();
   2580     let mut prepared = ledger
   2581         .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
   2582         .unwrap();
   2583     prepared
   2584         .blinded_transactions
   2585         .push(blinded.transaction.clone());
   2586     let block = prepared.finish(wallet, "preverified-vdf".to_string());
   2587 
   2588     let error = ledger
   2589         .apply_preverified_block_at(block, u64::MAX)
   2590         .unwrap_err();
   2591 
   2592     assert!(format!("{error:#}").contains("blinded transaction is already on chain"));
   2593 }
   2594 
   2595 #[test]
   2596 fn pending_blinded_reveals_expose_revealed_transaction_data() {
   2597     let alice = Wallet::from_seed("pending-reveal-data-finalizer-alice");
   2598     let bob = Wallet::from_seed("pending-reveal-data-finalizer-bob");
   2599     let carol = Wallet::from_seed("pending-reveal-data-carol");
   2600     let finalizers = [alice.clone(), bob.clone()];
   2601     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2602     let blinded = ledger
   2603         .build_blinded_burn(&carol, 3, 7, ledger.height() + 6)
   2604         .unwrap();
   2605     let commitment = blinded.transaction.commitment.clone();
   2606     ledger
   2607         .submit_blinded_transaction(blinded.transaction)
   2608         .unwrap();
   2609     queue_next_leader_burn(&mut ledger, &finalizers);
   2610     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2611 
   2612     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2613 
   2614     let revealed = ledger.pending_revealed_blinded_transactions();
   2615     assert_eq!(revealed.len(), 1);
   2616     assert_eq!(revealed[0].commitment, commitment);
   2617     assert_eq!(revealed[0].height, ledger.height() + 1);
   2618     assert_eq!(revealed[0].transaction.amount(), 3);
   2619     assert_eq!(revealed[0].transaction.fee(), 7);
   2620     assert!(revealed[0].transaction.is_burn());
   2621 }
   2622 
   2623 #[test]
   2624 fn abandoned_fork_blinded_transactions_return_to_mempool() {
   2625     let alice = Wallet::from_seed("blinded-reorg-finalizer-alice");
   2626     let bob = Wallet::from_seed("blinded-reorg-finalizer-bob");
   2627     let carol = Wallet::from_seed("blinded-reorg-carol");
   2628     let finalizers = [alice.clone(), bob.clone()];
   2629     let mut local = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2630     let mut remote = local.clone();
   2631     let blinded = local
   2632         .build_blinded_burn(&carol, 3, 7, local.height() + 8)
   2633         .unwrap();
   2634     local
   2635         .submit_blinded_transaction(blinded.transaction.clone())
   2636         .unwrap();
   2637     queue_next_leader_burn(&mut local, &finalizers);
   2638     mine_preverified_as_next_leader(&mut local, &finalizers, 1);
   2639 
   2640     for timestamp_ms in [1, 2] {
   2641         let leader = remote.expected_leader_for_next_block().unwrap();
   2642         let wallet = wallet_for_address(&finalizers, &leader);
   2643         let burn = remote.build_burn(wallet, 1, 0).unwrap();
   2644         remote.submit_transaction(burn).unwrap();
   2645         mine_preverified_as_next_leader(&mut remote, &finalizers, timestamp_ms);
   2646     }
   2647 
   2648     assert!(
   2649         local
   2650             .extend_from_preverified_snapshot_at(remote.snapshot(), u64::MAX)
   2651             .unwrap()
   2652     );
   2653     assert!(local.has_blinded_transaction(&blinded.transaction.commitment));
   2654     assert_eq!(
   2655         local.pending_blinded_transactions(),
   2656         std::slice::from_ref(&blinded.transaction)
   2657     );
   2658 }
   2659 
   2660 #[test]
   2661 fn block_selection_includes_mine_action_after_required_block_burn() {
   2662     let alice = Wallet::from_seed("mine-fixed-reward-select-alice");
   2663     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2664 
   2665     let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap();
   2666     ledger.submit_transaction(burn).unwrap();
   2667     let mine = ledger.build_mine(alice.address()).unwrap();
   2668     ledger.submit_transaction(mine.clone()).unwrap();
   2669 
   2670     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2671 
   2672     assert_eq!(
   2673         block.transactions.iter().filter(|tx| tx.is_burn()).count(),
   2674         1
   2675     );
   2676     assert!(
   2677         block
   2678             .transactions
   2679             .iter()
   2680             .any(|tx| tx.signature() == mine.signature())
   2681     );
   2682     assert_eq!(block.reward, MINE_FINALIZER_FEE);
   2683 }
   2684 
   2685 #[test]
   2686 fn block_selection_can_skip_mine_action_when_space_is_limited() {
   2687     let alice = Wallet::from_seed("mine-space-limit-alice");
   2688     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2689     ledger.launch_profile.max_block_transactions = 2;
   2690 
   2691     let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
   2692     ledger.submit_transaction(burn).unwrap();
   2693     let first_mine = ledger.build_mine(alice.address()).unwrap();
   2694     ledger.submit_transaction(first_mine).unwrap();
   2695     let second_mine = ledger.build_mine(alice.address()).unwrap();
   2696     ledger.submit_transaction(second_mine).unwrap();
   2697 
   2698     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2699 
   2700     assert_eq!(block.transactions.len(), 2);
   2701     assert!(block.transactions.iter().any(Transaction::is_burn));
   2702     assert_eq!(block.reward, MINE_FINALIZER_FEE);
   2703 }
   2704 
   2705 #[test]
   2706 fn pending_mine_outputs_are_not_spendable_until_confirmed() {
   2707     let alice = Wallet::from_seed("pending-mine-spend-alice");
   2708     let bob = Wallet::from_seed("pending-mine-spend-bob");
   2709     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2710 
   2711     let mine = ledger.build_mine(alice.address()).unwrap();
   2712     let mine_outpoint = OutPoint {
   2713         txid: mine.signature().to_string(),
   2714         index: 0,
   2715     };
   2716     ledger.submit_transaction(mine.clone()).unwrap();
   2717 
   2718     assert!(
   2719         !ledger
   2720             .available_utxos_for_address(alice.address())
   2721             .unwrap()
   2722             .iter()
   2723             .any(|(outpoint, _)| outpoint == &mine_outpoint)
   2724     );
   2725     let pending_error = ledger
   2726         .build_transfer_with_inputs(
   2727             &alice,
   2728             bob.address(),
   2729             TEST_BURN_AMOUNT,
   2730             0,
   2731             std::slice::from_ref(&mine_outpoint),
   2732         )
   2733         .unwrap_err();
   2734     assert!(format!("{pending_error:#}").contains("not spendable"));
   2735 
   2736     let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap();
   2737     ledger.submit_transaction(burn).unwrap();
   2738     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2739     assert!(
   2740         block
   2741             .transactions
   2742             .iter()
   2743             .any(|tx| tx.signature() == mine.signature())
   2744     );
   2745     ledger.apply_locally_mined_block(block).unwrap();
   2746 
   2747     assert!(
   2748         ledger
   2749             .available_utxos_for_address(alice.address())
   2750             .unwrap()
   2751             .iter()
   2752             .any(|(outpoint, _)| outpoint == &mine_outpoint)
   2753     );
   2754     ledger
   2755         .build_transfer_with_inputs(
   2756             &alice,
   2757             bob.address(),
   2758             TEST_BURN_AMOUNT,
   2759             0,
   2760             std::slice::from_ref(&mine_outpoint),
   2761         )
   2762         .unwrap();
   2763 }
   2764 
   2765 #[test]
   2766 fn burns_built_after_pending_mine_do_not_spend_pending_mine_output() {
   2767     let alice = Wallet::from_seed("pending-mine-burn-alice");
   2768     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2769 
   2770     let mine = ledger.build_mine(alice.address()).unwrap();
   2771     let mine_outpoint = OutPoint {
   2772         txid: mine.signature().to_string(),
   2773         index: 0,
   2774     };
   2775     ledger.submit_transaction(mine).unwrap();
   2776 
   2777     let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap();
   2778 
   2779     let Transaction::Burn { inputs, .. } = &burn else {
   2780         panic!("expected burn transaction");
   2781     };
   2782     assert!(!inputs.iter().any(|input| input.outpoint == mine_outpoint));
   2783 }
   2784 
   2785 #[test]
   2786 fn pending_blinded_transactions_with_spent_inputs_are_pruned_after_block_apply() {
   2787     let alice = Wallet::from_seed("pending-blind-spent-prune-alice");
   2788     let mut mempool_ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2789     let mut block_ledger = mempool_ledger.clone();
   2790     let amount = mempool_ledger.balance_of(alice.address());
   2791     let blinded = mempool_ledger
   2792         .build_blinded_burn(&alice, amount, 0, mempool_ledger.height() + 4)
   2793         .unwrap();
   2794     mempool_ledger
   2795         .submit_blinded_transaction(blinded.transaction.clone())
   2796         .unwrap();
   2797 
   2798     let burn = block_ledger.build_burn(&alice, amount, 0).unwrap();
   2799     let Transaction::Burn { inputs, .. } = &burn else {
   2800         panic!("expected burn transaction");
   2801     };
   2802     assert!(blinded.transaction.inputs.iter().any(|input| {
   2803         inputs
   2804             .iter()
   2805             .any(|burn_input| burn_input.outpoint == input.outpoint)
   2806     }));
   2807     block_ledger.submit_transaction(burn).unwrap();
   2808     let block = block_ledger.mine_next_block(&alice, 1).unwrap();
   2809 
   2810     mempool_ledger.apply_block(block).unwrap();
   2811 
   2812     assert!(mempool_ledger.pending_blinded_transactions().is_empty());
   2813 }
   2814 
   2815 #[test]
   2816 fn block_selection_limits_mine_actions_per_anchor() {
   2817     let alice = Wallet::from_seed("mine-anchor-limit-selection-alice");
   2818     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2819 
   2820     let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
   2821     ledger.submit_transaction(burn).unwrap();
   2822     let first_mine = ledger.build_mine(alice.address()).unwrap();
   2823     ledger.submit_transaction(first_mine.clone()).unwrap();
   2824     let second_mine = ledger.build_mine(alice.address()).unwrap();
   2825     ledger.submit_transaction(second_mine.clone()).unwrap();
   2826     assert_eq!(ledger.pending().len(), 3);
   2827     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2828 
   2829     assert_eq!(block.transactions.len(), 3);
   2830     assert!(block.transactions.iter().any(Transaction::is_burn));
   2831     let included_mines = block
   2832         .transactions
   2833         .iter()
   2834         .filter(|transaction| matches!(transaction, Transaction::Mine { .. }))
   2835         .count();
   2836     assert_eq!(included_mines, MINE_ACTIONS_PER_ANCHOR_LIMIT);
   2837     assert!(
   2838         block
   2839             .transactions
   2840             .iter()
   2841             .any(|tx| tx.signature() == first_mine.signature())
   2842     );
   2843     assert!(
   2844         block
   2845             .transactions
   2846             .iter()
   2847             .any(|tx| tx.signature() == second_mine.signature())
   2848     );
   2849     assert_ne!(first_mine.signature(), second_mine.signature());
   2850     assert_eq!(block.reward, first_mine.fee() + second_mine.fee());
   2851 }
   2852 
   2853 #[test]
   2854 fn blocks_reject_too_many_mine_actions_for_one_anchor() {
   2855     let alice = Wallet::from_seed("mine-anchor-limit-block-alice");
   2856     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2857 
   2858     let first_mine = test_mine_with_salt(&ledger, alice.address(), 1);
   2859     let second_mine = test_mine_with_salt(&ledger, alice.address(), 2);
   2860     let third_mine = test_mine_with_salt(&ledger, alice.address(), 3);
   2861     let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
   2862     ledger.submit_transaction(burn).unwrap();
   2863     let mut block = ledger
   2864         .prepare_next_block(
   2865             alice.address(),
   2866             ledger
   2867                 .tip()
   2868                 .timestamp_ms
   2869                 .saturating_add(VDF_TARGET_BLOCK_MS),
   2870         )
   2871         .unwrap()
   2872         .finish(&alice, "preverified-vdf".to_string());
   2873     block.transactions.push(first_mine);
   2874     block.transactions.push(second_mine);
   2875     block.transactions.push(third_mine);
   2876     block.reward = fee_reward(&block.transactions).unwrap();
   2877     block.hash = block.compute_hash();
   2878 
   2879     let error = ledger
   2880         .apply_preverified_block_at(block, u64::MAX)
   2881         .unwrap_err();
   2882 
   2883     assert!(format!("{error:#}").contains("mine actions per anchor limit"));
   2884 }
   2885 
   2886 #[test]
   2887 fn mempool_rejects_mine_actions_above_anchor_limit() {
   2888     let alice = Wallet::from_seed("mine-anchor-limit-mempool-alice");
   2889     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2890 
   2891     let first_mine = test_mine_with_salt(&ledger, alice.address(), 1);
   2892     let second_mine = test_mine_with_salt(&ledger, alice.address(), 2);
   2893     let third_mine = test_mine_with_salt(&ledger, alice.address(), 3);
   2894     ledger.submit_transaction(first_mine).unwrap();
   2895     ledger.submit_transaction(second_mine).unwrap();
   2896     let error = ledger.submit_transaction(third_mine).unwrap_err();
   2897 
   2898     assert!(format!("{error:#}").contains("mine transaction anchor limit reached"));
   2899 }
   2900 
   2901 #[test]
   2902 fn mine_difficulty_increases_when_issuance_exceeds_target_window() {
   2903     let alice = Wallet::from_seed("mine-difficulty-up-alice");
   2904     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2905 
   2906     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2907         apply_preverified_burn_block_with_mines(&mut ledger, &alice, 2);
   2908     }
   2909 
   2910     assert_eq!(
   2911         ledger.current_mine_difficulty_bits(),
   2912         MINE_DIFFICULTY_BITS + 1
   2913     );
   2914     let mine = ledger.build_mine(alice.address()).unwrap();
   2915     let Transaction::Mine {
   2916         difficulty_bits, ..
   2917     } = mine
   2918     else {
   2919         panic!("expected mine action");
   2920     };
   2921     assert_eq!(difficulty_bits, MINE_DIFFICULTY_BITS + 1);
   2922 }
   2923 
   2924 #[test]
   2925 fn mine_difficulty_decreases_when_issuance_is_below_target_window() {
   2926     let alice = Wallet::from_seed("mine-difficulty-down-alice");
   2927     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2928 
   2929     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2930         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2931     }
   2932 
   2933     assert_eq!(
   2934         ledger.current_mine_difficulty_bits(),
   2935         MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS
   2936     );
   2937 
   2938     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2939         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2940     }
   2941 
   2942     assert_eq!(
   2943         ledger.current_mine_difficulty_bits(),
   2944         MINE_MIN_DIFFICULTY_BITS
   2945     );
   2946 
   2947     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2948         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2949     }
   2950 
   2951     assert_eq!(
   2952         ledger.current_mine_difficulty_bits(),
   2953         MINE_MIN_DIFFICULTY_BITS
   2954     );
   2955 }
   2956 
   2957 #[test]
   2958 fn mine_actions_expire_when_anchor_is_too_old() {
   2959     let alice = Wallet::from_seed("mine-anchor-expiry-alice");
   2960     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2961     let stale_mine = ledger.build_mine(alice.address()).unwrap();
   2962 
   2963     for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS {
   2964         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2965     }
   2966 
   2967     let error = ledger.submit_transaction(stale_mine).unwrap_err();
   2968     assert!(format!("{error:#}").contains("mine transaction anchor is too old"));
   2969 }
   2970 
   2971 #[test]
   2972 fn pending_mine_actions_are_removed_when_anchor_expires() {
   2973     let alice = Wallet::from_seed("pending-mine-anchor-expiry-alice");
   2974     let bob = Wallet::from_seed("pending-mine-anchor-expiry-bob");
   2975     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2976     ledger.launch_profile.max_block_transactions = 1;
   2977     let stale_mine = ledger.build_mine(bob.address()).unwrap();
   2978     ledger.submit_transaction(stale_mine.clone()).unwrap();
   2979 
   2980     for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS {
   2981         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2982     }
   2983 
   2984     assert!(
   2985         ledger
   2986             .pending()
   2987             .iter()
   2988             .all(|tx| tx.signature() != stale_mine.signature())
   2989     );
   2990 }
   2991 
   2992 #[test]
   2993 fn stratum_mine_header_proof_is_validated() {
   2994     let alice = Wallet::from_seed("stratum-proof-alice");
   2995     let ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2996     let anchor = ledger.tip().hash.clone();
   2997     let difficulty_bits = ledger.current_mine_difficulty_bits();
   2998     let template = ledger
   2999         .stratum_mine_template(alice.address(), anchor, 1, difficulty_bits)
   3000         .unwrap();
   3001 
   3002     let mut accepted = None;
   3003     for nonce in 0_u32..50_000 {
   3004         let result = ledger.build_stratum_mine(
   3005             template.clone(),
   3006             StratumMineShare {
   3007                 extranonce2: [0, 0, 0, 0],
   3008                 header_nonce: nonce.to_le_bytes(),
   3009             },
   3010         );
   3011         if let Ok(tx) = result {
   3012             accepted = Some(tx);
   3013             break;
   3014         }
   3015     }
   3016 
   3017     let tx = accepted.expect("expected Stratum proof within search range");
   3018     let Transaction::Mine {
   3019         proof_header,
   3020         signature,
   3021         ..
   3022     } = tx
   3023     else {
   3024         panic!("expected mine action");
   3025     };
   3026     assert_eq!(proof_header.as_deref().unwrap_or_default().len(), 160);
   3027     assert!(hash_meets_difficulty(&signature, difficulty_bits));
   3028 }
   3029 
   3030 #[test]
   3031 fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() {
   3032     let alice = Wallet::from_seed("stratum-salt-alice");
   3033     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   3034     let anchor = ledger.tip().hash.clone();
   3035     let difficulty_bits = ledger.current_mine_difficulty_bits();
   3036 
   3037     for salt in [1, 2] {
   3038         let template = ledger
   3039             .stratum_mine_template(alice.address(), anchor.clone(), salt, difficulty_bits)
   3040             .unwrap();
   3041         let mut accepted = None;
   3042         for nonce in 0_u32..50_000 {
   3043             let result = ledger.build_stratum_mine(
   3044                 template.clone(),
   3045                 StratumMineShare {
   3046                     extranonce2: [0, 0, 0, 0],
   3047                     header_nonce: nonce.to_le_bytes(),
   3048                 },
   3049             );
   3050             if let Ok(tx) = result {
   3051                 accepted = Some(tx);
   3052                 break;
   3053             }
   3054         }
   3055         let tx = accepted.expect("expected Stratum proof within search range");
   3056         assert!(ledger.submit_transaction(tx).unwrap());
   3057     }
   3058 
   3059     assert_eq!(ledger.pending().len(), 2);
   3060     let salts = ledger
   3061         .pending()
   3062         .iter()
   3063         .map(|tx| match tx {
   3064             Transaction::Mine { salt, .. } => *salt,
   3065             _ => panic!("expected mine action"),
   3066         })
   3067         .collect::<BTreeSet<_>>();
   3068     assert_eq!(salts, BTreeSet::from([1, 2]));
   3069 }