iuna

iuna

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

tests.rs (102132B)


      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 transfer_rejects_invalid_recipient_address() {
    618     let alice = Wallet::from_seed("invalid-transfer-recipient-alice");
    619     let ledger = ledger_with_wallet_utxos(&alice, &[10]);
    620 
    621     let error = ledger.build_transfer(&alice, "aa", 1, 0).unwrap_err();
    622 
    623     assert!(format!("{error:#}").contains("invalid transfer recipient address"));
    624 }
    625 
    626 #[test]
    627 fn mine_rejects_invalid_recipient_address_before_pow() {
    628     let ledger = Ledger::new(BTreeMap::new(), 1);
    629 
    630     let error = ledger.build_mine("aa").unwrap_err();
    631 
    632     assert!(format!("{error:#}").contains("invalid mine recipient address"));
    633 }
    634 
    635 #[test]
    636 fn mine_search_respects_nonce_attempt_limit() {
    637     let alice = Wallet::from_seed("bounded-mine-search-alice");
    638     let ledger = Ledger::new(BTreeMap::new(), 1);
    639 
    640     let outcome = ledger.search_mine(alice.address(), 1, 0, 0).unwrap();
    641 
    642     assert!(outcome.transaction.is_none());
    643     assert_eq!(outcome.next_nonce, 0);
    644     assert_eq!(outcome.attempts, 0);
    645 }
    646 
    647 #[test]
    648 fn mempool_rejects_invalid_input_outpoint_id() {
    649     let alice = Wallet::from_seed("invalid-outpoint-alice");
    650     let bob = Wallet::from_seed("invalid-outpoint-bob");
    651     let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
    652     let unsigned = UnsignedUtxoTransaction::Transfer {
    653         inputs: vec![UnsignedTxInput {
    654             outpoint: OutPoint {
    655                 txid: "aa".to_string(),
    656                 index: 0,
    657             },
    658             owner: alice.address().to_string(),
    659         }],
    660         outputs: vec![TxOutput {
    661             address: bob.address().to_string(),
    662             amount: 1,
    663         }],
    664         fee: 0,
    665     };
    666     let transaction = unsigned.sign(&alice);
    667 
    668     let error = ledger.submit_transaction(transaction).unwrap_err();
    669 
    670     assert!(format!("{error:#}").contains("invalid input outpoint txid"));
    671     assert!(ledger.pending().is_empty());
    672 }
    673 
    674 #[test]
    675 fn missing_input_transaction_goes_to_orphan_pool_not_pending_mempool() {
    676     let alice = Wallet::from_seed("missing-input-orphan-alice");
    677     let bob = Wallet::from_seed("missing-input-orphan-bob");
    678     let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
    679     let transaction = UnsignedUtxoTransaction::Transfer {
    680         inputs: vec![UnsignedTxInput {
    681             outpoint: OutPoint {
    682                 txid: hex_hash("missing-input-orphan"),
    683                 index: 0,
    684             },
    685             owner: alice.address().to_string(),
    686         }],
    687         outputs: vec![TxOutput {
    688             address: bob.address().to_string(),
    689             amount: 1,
    690         }],
    691         fee: 0,
    692     }
    693     .sign(&alice);
    694 
    695     let outcome = ledger.submit_transaction_with_outcome(transaction).unwrap();
    696 
    697     assert_eq!(outcome, TransactionSubmitOutcome::Added);
    698     assert!(ledger.pending().is_empty());
    699     assert_eq!(ledger.orphan_transactions().len(), 1);
    700 }
    701 
    702 #[test]
    703 fn vdf_retarget_observed_block_time_is_clamped() {
    704     assert_eq!(
    705         clamped_vdf_retarget_observed_block_ms(1),
    706         MIN_VDF_RETARGET_OBSERVED_BLOCK_MS
    707     );
    708     assert_eq!(
    709         clamped_vdf_retarget_observed_block_ms(VDF_TARGET_BLOCK_MS),
    710         VDF_TARGET_BLOCK_MS
    711     );
    712     assert_eq!(
    713         clamped_vdf_retarget_observed_block_ms(u64::MAX),
    714         MAX_VDF_RETARGET_OBSERVED_BLOCK_MS
    715     );
    716 }
    717 
    718 #[test]
    719 fn vdf_retarget_observed_block_time_ignores_ticket_fallback_ranks() {
    720     let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0);
    721     let fallback_child =
    722         vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1);
    723 
    724     assert_eq!(
    725         vdf_retarget_observed_block_ms(&parent, &fallback_child),
    726         None
    727     );
    728 }
    729 
    730 #[test]
    731 fn vdf_retarget_observed_block_time_ignores_recovery_blocks() {
    732     let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0);
    733     let recovery_child =
    734         vdf_retarget_sample_block(RECOVERY_BLOCK_DELAY_MS, FinalizerMode::Recovery, 0);
    735 
    736     assert_eq!(
    737         vdf_retarget_observed_block_ms(&parent, &recovery_child),
    738         None
    739     );
    740 }
    741 
    742 #[test]
    743 fn vdf_retarget_keeps_rounds_inside_deadband() {
    744     let current = 1_000;
    745     let low_deadband_edge =
    746         VDF_TARGET_BLOCK_MS - VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100;
    747     let high_deadband_edge =
    748         VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100;
    749 
    750     assert_eq!(retarget_vdf_rounds(current, low_deadband_edge), current);
    751     assert_eq!(retarget_vdf_rounds(current, VDF_TARGET_BLOCK_MS), current);
    752     assert_eq!(retarget_vdf_rounds(current, high_deadband_edge), current);
    753 }
    754 
    755 #[test]
    756 fn vdf_retarget_limits_each_step_to_two_percent() {
    757     let current = 1_000;
    758 
    759     assert_eq!(
    760         retarget_vdf_rounds(current, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS),
    761         1_020
    762     );
    763     assert_eq!(
    764         retarget_vdf_rounds(current, MAX_VDF_RETARGET_OBSERVED_BLOCK_MS),
    765         980
    766     );
    767 }
    768 
    769 #[test]
    770 fn vdf_rounds_retarget_below_legacy_u32_limit_after_slow_blocks() {
    771     let wallet = Wallet::from_seed("vdf-rounds-slow-above-u32");
    772     let initial_rounds = u64::from(u32::MAX);
    773     let mut allocations = BTreeMap::new();
    774     allocations.insert(wallet.address().to_string(), 1_000);
    775     let mut ledger = Ledger::new_with_genesis_burns(
    776         allocations,
    777         vec![GenesisBurn::new(wallet.address(), 1)],
    778         initial_rounds,
    779     )
    780     .unwrap();
    781 
    782     let block1 = apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
    783     assert_eq!(block1.vdf_rounds, initial_rounds);
    784     assert_eq!(ledger.vdf_rounds(), initial_rounds);
    785 
    786     let block2 = apply_preverified_burn_block_at(
    787         &mut ledger,
    788         &wallet,
    789         VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2,
    790     );
    791     assert_eq!(block2.vdf_rounds, initial_rounds);
    792 
    793     assert!(
    794         ledger.vdf_rounds() < initial_rounds,
    795         "slow blocks should retarget below the legacy u32 VDF rounds ceiling"
    796     );
    797 }
    798 
    799 #[test]
    800 fn fallback_block_is_excluded_from_vdf_retarget_observations() {
    801     let alice = Wallet::from_seed("fallback-retarget-alice");
    802     let bob = Wallet::from_seed("fallback-retarget-bob");
    803     let wallets = [&alice, &bob];
    804     let mut genesis = BTreeMap::new();
    805     genesis.insert(alice.address().to_string(), 1_000);
    806     genesis.insert(bob.address().to_string(), 1_000);
    807     let mut ledger = Ledger::new_with_genesis_burns(
    808         genesis,
    809         vec![
    810             GenesisBurn::new(alice.address(), 1),
    811             GenesisBurn::new(bob.address(), 1),
    812         ],
    813         100,
    814     )
    815     .unwrap();
    816 
    817     let primary = ledger.expected_leader_for_next_block().unwrap();
    818     let primary_wallet = wallets
    819         .into_iter()
    820         .find(|wallet| wallet.address() == primary)
    821         .unwrap();
    822     apply_preverified_burn_block_at(&mut ledger, primary_wallet, VDF_TARGET_BLOCK_MS);
    823     assert_eq!(ledger.vdf_rounds(), 100);
    824 
    825     let primary = ledger.expected_leader_for_next_block().unwrap();
    826     let fallback = wallets
    827         .into_iter()
    828         .find(|wallet| wallet.address() != primary)
    829         .unwrap();
    830     let timestamp_ms = ledger.tip().timestamp_ms + 1;
    831     let block = apply_preverified_burn_block_at(&mut ledger, fallback, timestamp_ms);
    832 
    833     assert_eq!(block.finalizer_rank, 1);
    834     assert_eq!(block.vdf_rounds, 200);
    835     assert_eq!(ledger.vdf_rounds(), 100);
    836 }
    837 
    838 #[test]
    839 fn recovery_block_is_excluded_from_vdf_retarget_observations() {
    840     let alice = Wallet::from_seed("recovery-retarget-alice");
    841     let bob = Wallet::from_seed("recovery-retarget-bob");
    842     let mut genesis = BTreeMap::new();
    843     genesis.insert(alice.address().to_string(), 1_000);
    844     genesis.insert(bob.address().to_string(), 1_000);
    845     let mut ledger =
    846         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 100)
    847             .unwrap();
    848 
    849     apply_preverified_burn_block_at(&mut ledger, &alice, VDF_TARGET_BLOCK_MS);
    850     assert_eq!(ledger.vdf_rounds(), 100);
    851 
    852     let burn = ledger.build_burn(&bob, 1, 0).unwrap();
    853     ledger.submit_transaction(burn).unwrap();
    854     let block = ledger
    855         .mine_recovery_block(&bob, VDF_TARGET_BLOCK_MS + RECOVERY_BLOCK_DELAY_MS)
    856         .unwrap();
    857     assert_eq!(block.finalizer_mode, FinalizerMode::Recovery);
    858     ledger.apply_block(block).unwrap();
    859 
    860     assert_eq!(ledger.vdf_rounds(), 100);
    861 }
    862 
    863 #[test]
    864 fn generated_vdf_retarget_decreases_after_slow_blocks_above_legacy_limit() {
    865     let legacy_limit = u64::from(u32::MAX);
    866     let slow_observed_ms = [
    867         VDF_TARGET_BLOCK_MS * 6 / 5,
    868         VDF_TARGET_BLOCK_MS * 2,
    869         VDF_TARGET_BLOCK_MS * 3,
    870         MAX_VDF_RETARGET_OBSERVED_BLOCK_MS,
    871     ];
    872 
    873     for seed in 0..16_u64 {
    874         let wallet = Wallet::from_seed(&format!("generated-vdf-retarget-{seed}"));
    875         let initial_rounds = legacy_limit + 1 + seed * 1_000_003;
    876         let mut allocations = BTreeMap::new();
    877         allocations.insert(wallet.address().to_string(), 1_000);
    878         let mut ledger = Ledger::new_with_genesis_burns(
    879             allocations,
    880             vec![GenesisBurn::new(wallet.address(), 1)],
    881             initial_rounds,
    882         )
    883         .unwrap();
    884         let observed_ms = slow_observed_ms[seed as usize % slow_observed_ms.len()];
    885 
    886         apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
    887         let second = apply_preverified_burn_block_at(
    888             &mut ledger,
    889             &wallet,
    890             VDF_TARGET_BLOCK_MS + observed_ms,
    891         );
    892 
    893         assert_eq!(second.vdf_rounds, initial_rounds);
    894         assert!(
    895             ledger.vdf_rounds() < initial_rounds,
    896             "seed {seed} with observed {observed_ms}ms should lower VDF rounds from {initial_rounds}, got {}",
    897             ledger.vdf_rounds()
    898         );
    899 
    900         let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
    901         ledger.submit_transaction(burn).unwrap();
    902         let next_work = ledger
    903             .prepare_next_block(wallet.address(), second.timestamp_ms + VDF_TARGET_BLOCK_MS)
    904             .unwrap();
    905         assert_eq!(next_work.vdf_rounds(), ledger.vdf_rounds());
    906     }
    907 }
    908 
    909 #[test]
    910 fn block_timestamp_future_check_uses_supplied_network_time() {
    911     let wallet = Wallet::from_seed("adjusted-time-domain");
    912     let mut allocations = BTreeMap::new();
    913     allocations.insert(wallet.address().to_string(), 1_000);
    914     let mut ledger = Ledger::new(allocations, 1);
    915 
    916     let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
    917     assert!(ledger.submit_transaction(burn).unwrap());
    918     let block = ledger.mine_next_block(&wallet, 10 * 60 * 1_000).unwrap();
    919 
    920     let error = ledger.apply_block_at(block, 1_000).unwrap_err();
    921 
    922     assert!(format!("{error:#}").contains("too far in the future"));
    923 }
    924 
    925 #[test]
    926 fn ticket_block_timestamp_uses_finalizer_rank_time_slot() {
    927     let alice = Wallet::from_seed("rank-slot-alice");
    928     let bob = Wallet::from_seed("rank-slot-bob");
    929     let wallets = [alice.clone(), bob.clone()];
    930     let mut allocations = BTreeMap::new();
    931     allocations.insert(alice.address().to_string(), 1_000);
    932     allocations.insert(bob.address().to_string(), 1_000);
    933     let mut ledger = Ledger::new_with_genesis_burns(
    934         allocations,
    935         vec![
    936             GenesisBurn::new(alice.address(), 1),
    937             GenesisBurn::new(bob.address(), 1),
    938         ],
    939         100,
    940     )
    941     .unwrap();
    942 
    943     let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap());
    944     let burn = ledger.build_burn(primary, 1, 0).unwrap();
    945     ledger.submit_transaction(burn).unwrap();
    946     let work = ledger.prepare_next_block(primary.address(), 1).unwrap();
    947     assert_eq!(work.timestamp_ms(), 1);
    948     let block = work.finish(primary, "preverified-vdf".to_string());
    949     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
    950 
    951     let fallback = wallets
    952         .iter()
    953         .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
    954         .expect("expected rank 1 fallback");
    955     let burn = ledger.build_burn(fallback, 1, 0).unwrap();
    956     ledger.submit_transaction(burn).unwrap();
    957     let parent_timestamp = ledger.tip().timestamp_ms;
    958     let work = ledger
    959         .prepare_next_block(fallback.address(), parent_timestamp + 1)
    960         .unwrap();
    961 
    962     assert_eq!(
    963         work.timestamp_ms(),
    964         parent_timestamp + VDF_TARGET_BLOCK_MS * 2
    965     );
    966     assert_eq!(work.vdf_rounds(), ledger.vdf_rounds() * 2);
    967 }
    968 
    969 #[test]
    970 fn required_anchor_burn_is_selected_before_higher_fee_burns_when_block_is_full() {
    971     let wallet = Wallet::from_seed("required-anchor-priority-wallet");
    972     let mut allocations = BTreeMap::new();
    973     allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
    974     let mut ledger = Ledger::new_with_genesis_burns(
    975         allocations,
    976         vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
    977         1,
    978     )
    979     .unwrap();
    980     let high_fee_outpoint = named_test_outpoint("required-anchor-priority-high-fee");
    981     let anchor_outpoint = named_test_outpoint("required-anchor-priority-anchor");
    982     ledger.utxos.insert(
    983         high_fee_outpoint.clone(),
    984         TxOutput {
    985             address: wallet.address().to_string(),
    986             amount: 3,
    987         },
    988     );
    989     ledger.utxos.insert(
    990         anchor_outpoint.clone(),
    991         TxOutput {
    992             address: wallet.address().to_string(),
    993             amount: 2,
    994         },
    995     );
    996     let high_fee_burn = ledger
    997         .build_burn_with_inputs(&wallet, 1, 1, &[high_fee_outpoint])
    998         .unwrap();
    999     let anchor_burn = ledger
   1000         .build_burn_with_inputs(&wallet, 1, 0, &[anchor_outpoint])
   1001         .unwrap();
   1002     ledger.submit_transaction(high_fee_burn).unwrap();
   1003     ledger.submit_transaction(anchor_burn.clone()).unwrap();
   1004     ledger.launch_profile.max_block_transactions = 1;
   1005 
   1006     let work = ledger
   1007         .prepare_next_block_with_required_burn_and_reveal_bundles(
   1008             wallet.address(),
   1009             1,
   1010             Vec::new(),
   1011             Some(anchor_burn.signature()),
   1012         )
   1013         .unwrap();
   1014     let block = work.finish(&wallet, "preverified-vdf".to_string());
   1015 
   1016     assert_eq!(block.transactions.len(), 1);
   1017     assert_eq!(block.transactions[0].signature(), anchor_burn.signature());
   1018 }
   1019 
   1020 #[test]
   1021 fn late_ticket_vdf_completion_is_visible_to_retarget() {
   1022     let wallet = Wallet::from_seed("late-ticket-vdf-wallet");
   1023     let mut allocations = BTreeMap::new();
   1024     allocations.insert(wallet.address().to_string(), 1_000);
   1025     let mut ledger = Ledger::new_with_genesis_burns(
   1026         allocations,
   1027         vec![GenesisBurn::new(wallet.address(), 1)],
   1028         100,
   1029     )
   1030     .unwrap();
   1031 
   1032     apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
   1033     assert_eq!(ledger.vdf_rounds(), 100);
   1034 
   1035     let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
   1036     ledger.submit_transaction(burn).unwrap();
   1037     let work = ledger
   1038         .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
   1039         .unwrap();
   1040     let scheduled_timestamp = work.timestamp_ms();
   1041     let late_timestamp = ledger.tip().timestamp_ms + VDF_TARGET_BLOCK_MS * 3;
   1042     assert!(late_timestamp > scheduled_timestamp);
   1043 
   1044     let block = work.finish_at(&wallet, "preverified-vdf".to_string(), late_timestamp);
   1045     assert_eq!(block.timestamp_ms, late_timestamp);
   1046     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
   1047 
   1048     assert!(
   1049         ledger.vdf_rounds() < 100,
   1050         "late VDF completion should lower future VDF rounds"
   1051     );
   1052 }
   1053 
   1054 #[test]
   1055 fn block_before_finalizer_rank_time_slot_is_rejected() {
   1056     let alice = Wallet::from_seed("rank-slot-reject-alice");
   1057     let bob = Wallet::from_seed("rank-slot-reject-bob");
   1058     let wallets = [alice.clone(), bob.clone()];
   1059     let mut allocations = BTreeMap::new();
   1060     allocations.insert(alice.address().to_string(), 1_000);
   1061     allocations.insert(bob.address().to_string(), 1_000);
   1062     let mut ledger = Ledger::new_with_genesis_burns(
   1063         allocations,
   1064         vec![
   1065             GenesisBurn::new(alice.address(), 1),
   1066             GenesisBurn::new(bob.address(), 1),
   1067         ],
   1068         100,
   1069     )
   1070     .unwrap();
   1071 
   1072     let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap());
   1073     let burn = ledger.build_burn(primary, 1, 0).unwrap();
   1074     ledger.submit_transaction(burn).unwrap();
   1075     let work = ledger.prepare_next_block(primary.address(), 1).unwrap();
   1076     let block = work.finish(primary, "preverified-vdf".to_string());
   1077     ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
   1078 
   1079     let fallback = wallets
   1080         .iter()
   1081         .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
   1082         .expect("expected rank 1 fallback");
   1083     let burn = ledger.build_burn(fallback, 1, 0).unwrap();
   1084     ledger.submit_transaction(burn).unwrap();
   1085     let parent_timestamp = ledger.tip().timestamp_ms;
   1086     let work = ledger
   1087         .prepare_next_block(fallback.address(), parent_timestamp + 1)
   1088         .unwrap();
   1089     let mut block = work.finish(fallback, "preverified-vdf".to_string());
   1090     block.timestamp_ms = parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - 1;
   1091     block.hash = block.compute_hash();
   1092 
   1093     let error = ledger
   1094         .apply_preverified_block_at(block, u64::MAX)
   1095         .unwrap_err();
   1096 
   1097     assert!(format!("{error:#}").contains("before finalizer rank 1 time slot"));
   1098 }
   1099 
   1100 #[test]
   1101 fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() {
   1102     let alice = Wallet::from_seed("oversized-select-alice");
   1103     let bob = Wallet::from_seed("oversized-select-bob");
   1104     let carol = Wallet::from_seed("oversized-select-carol");
   1105     let mut allocations = BTreeMap::new();
   1106     allocations.insert(alice.address().to_string(), 1);
   1107     allocations.insert(bob.address().to_string(), 300_000);
   1108     allocations.insert(carol.address().to_string(), 300_000);
   1109     let mut ledger =
   1110         Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10)
   1111             .unwrap();
   1112     let burn = ledger.build_burn(&alice, 1, 0).unwrap();
   1113     ledger.submit_transaction(burn).unwrap();
   1114     let oversized =
   1115         transfer_with_extra_zero_outputs(&ledger, &bob, alice.address(), 1, 100_000, 4_000);
   1116     let fitting = ledger
   1117         .build_transfer(&carol, alice.address(), 1, 5)
   1118         .unwrap();
   1119     assert!(oversized.serialized_size_bytes().unwrap() > MAX_BLOCK_BYTES);
   1120     ledger.submit_transaction(oversized.clone()).unwrap();
   1121     ledger.submit_transaction(fitting.clone()).unwrap();
   1122 
   1123     let block = ledger.mine_next_block(&alice, 1).unwrap();
   1124     let signatures = block
   1125         .transactions
   1126         .iter()
   1127         .map(|tx| tx.signature().to_string())
   1128         .collect::<Vec<_>>();
   1129 
   1130     assert!(!signatures.contains(&oversized.signature().to_string()));
   1131     assert!(signatures.contains(&fitting.signature().to_string()));
   1132     assert!(block.serialized_size_bytes().unwrap() <= MAX_BLOCK_BYTES);
   1133 }
   1134 
   1135 #[test]
   1136 fn transfer_can_spend_selected_utxos_when_they_cover_amount_and_fee() {
   1137     let alice = Wallet::from_seed("selected-utxos-alice");
   1138     let bob = Wallet::from_seed("selected-utxos-bob");
   1139     let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
   1140     let selected = vec![test_utxo_outpoint(2)];
   1141 
   1142     let tx = ledger
   1143         .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected)
   1144         .unwrap();
   1145 
   1146     let Transaction::Transfer {
   1147         inputs, outputs, ..
   1148     } = &tx
   1149     else {
   1150         panic!("expected transfer");
   1151     };
   1152     assert_eq!(inputs.len(), 1);
   1153     assert_eq!(inputs[0].outpoint, selected[0]);
   1154     assert_eq!(
   1155         outputs,
   1156         &[
   1157             TxOutput {
   1158                 address: bob.address().to_string(),
   1159                 amount: 2
   1160             },
   1161             TxOutput {
   1162                 address: alice.address().to_string(),
   1163                 amount: 2
   1164             }
   1165         ]
   1166     );
   1167 
   1168     ledger.submit_transaction(tx).unwrap();
   1169     let balances = pending_balances(&ledger);
   1170     assert_eq!(balances.get(bob.address()).copied(), Some(2));
   1171     assert_eq!(balances.get(alice.address()).copied(), Some(7));
   1172 }
   1173 
   1174 #[test]
   1175 fn burn_can_spend_selected_utxos_when_they_cover_amount_and_fee() {
   1176     let alice = Wallet::from_seed("selected-burn-utxos-alice");
   1177     let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
   1178     let selected = vec![test_utxo_outpoint(1)];
   1179 
   1180     let tx = ledger
   1181         .build_burn_with_inputs(&alice, 1, 1, &selected)
   1182         .unwrap();
   1183 
   1184     let Transaction::Burn {
   1185         inputs,
   1186         change,
   1187         amount,
   1188         fee,
   1189         ..
   1190     } = &tx
   1191     else {
   1192         panic!("expected burn");
   1193     };
   1194     assert_eq!(*amount, 1);
   1195     assert_eq!(*fee, 1);
   1196     assert_eq!(inputs.len(), 1);
   1197     assert_eq!(inputs[0].outpoint, selected[0]);
   1198     assert_eq!(
   1199         change,
   1200         &[TxOutput {
   1201             address: alice.address().to_string(),
   1202             amount: 1
   1203         }]
   1204     );
   1205 
   1206     ledger.submit_transaction(tx).unwrap();
   1207     let balances = pending_balances(&ledger);
   1208     assert_eq!(balances.get(alice.address()).copied(), Some(8));
   1209 }
   1210 
   1211 #[test]
   1212 fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() {
   1213     let alice = Wallet::from_seed("selected-utxos-insufficient-alice");
   1214     let bob = Wallet::from_seed("selected-utxos-insufficient-bob");
   1215     let ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
   1216     let selected = vec![test_utxo_outpoint(0)];
   1217 
   1218     let error = ledger
   1219         .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected)
   1220         .unwrap_err();
   1221 
   1222     assert!(format!("{error:#}").contains("selected UTXOs do not cover"));
   1223 }
   1224 
   1225 #[test]
   1226 fn transfer_rejects_selected_utxos_owned_by_someone_else() {
   1227     let alice = Wallet::from_seed("selected-utxos-owner-alice");
   1228     let bob = Wallet::from_seed("selected-utxos-owner-bob");
   1229     let carol = Wallet::from_seed("selected-utxos-owner-carol");
   1230     let mut ledger = ledger_with_wallet_utxos(&alice, &[5]);
   1231     ledger.utxos.insert(
   1232         named_test_outpoint("carol"),
   1233         TxOutput {
   1234             address: carol.address().to_string(),
   1235             amount: 5,
   1236         },
   1237     );
   1238     let selected = vec![named_test_outpoint("carol")];
   1239 
   1240     let error = ledger
   1241         .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected)
   1242         .unwrap_err();
   1243 
   1244     assert!(format!("{error:#}").contains("is not owned"));
   1245 }
   1246 
   1247 #[test]
   1248 fn transfer_rejects_when_combined_utxos_do_not_cover_amount_plus_fee() {
   1249     let alice = Wallet::from_seed("combine-insufficient-alice");
   1250     let bob = Wallet::from_seed("combine-insufficient-bob");
   1251     let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]);
   1252 
   1253     let error = ledger
   1254         .build_transfer(&alice, bob.address(), 3, 1)
   1255         .unwrap_err();
   1256 
   1257     assert!(format!("{error:#}").contains("insufficient funds"));
   1258 }
   1259 
   1260 #[test]
   1261 fn pending_change_from_combined_utxos_can_fund_next_transaction() {
   1262     let alice = Wallet::from_seed("combine-pending-change-alice");
   1263     let bob = Wallet::from_seed("combine-pending-change-bob");
   1264     let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]);
   1265 
   1266     let first = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap();
   1267     let first_signature = first.signature().to_string();
   1268     ledger.submit_transaction(first).unwrap();
   1269 
   1270     let second = ledger.build_transfer(&alice, bob.address(), 1, 0).unwrap();
   1271     let Transaction::Transfer { inputs, .. } = &second else {
   1272         panic!("expected transfer");
   1273     };
   1274     assert_eq!(inputs.len(), 1);
   1275     assert_eq!(inputs[0].outpoint.txid, first_signature);
   1276     assert_eq!(inputs[0].outpoint.index, 1);
   1277 
   1278     ledger.submit_transaction(second).unwrap();
   1279     let balances = pending_balances(&ledger);
   1280     assert_eq!(
   1281         balances.get(alice.address()).copied().unwrap_or_default(),
   1282         0
   1283     );
   1284     assert_eq!(balances.get(bob.address()).copied(), Some(4));
   1285 }
   1286 
   1287 #[test]
   1288 fn winning_burn_ticket_is_consumed_even_when_window_remains() {
   1289     let parent = ticket_test_block(3, FinalizerMode::Ticket, 0, "parent");
   1290     let mut tickets = vec![
   1291         BurnTicket {
   1292             id: "high-burn".to_string(),
   1293             owner: "alice".to_string(),
   1294             amount: 10_000,
   1295             eligible_from_height: 4,
   1296             eligible_until_height: 6,
   1297         },
   1298         BurnTicket {
   1299             id: "small-burn".to_string(),
   1300             owner: "bob".to_string(),
   1301             amount: 1,
   1302             eligible_from_height: 5,
   1303             eligible_until_height: 7,
   1304         },
   1305     ];
   1306     let block = ticket_test_child(&parent, 4, "alice", 0, "high-burn");
   1307 
   1308     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1309 
   1310     assert!(
   1311         tickets.iter().all(|ticket| ticket.id != "high-burn"),
   1312         "a winning burn must not remain eligible for the rest of its window"
   1313     );
   1314     assert!(
   1315         tickets.iter().any(|ticket| ticket.id == "small-burn"),
   1316         "unselected future tickets should remain pending"
   1317     );
   1318 }
   1319 
   1320 fn ticket_test_block(
   1321     height: u64,
   1322     finalizer_mode: FinalizerMode,
   1323     finalizer_rank: u32,
   1324     seed: &str,
   1325 ) -> Block {
   1326     let mut block = Block {
   1327         height,
   1328         prev_hash: hex_hash(format!("ticket-test-prev:{seed}:{height}")),
   1329         timestamp_ms: height,
   1330         miner: seed.to_string(),
   1331         finalizer_mode,
   1332         finalizer_rank,
   1333         reward: BLOCK_REWARD,
   1334         vdf_rounds: 1,
   1335         vdf_output: hex_hash(format!("ticket-test-vdf:{seed}:{height}")),
   1336         leader_proof: None,
   1337         blinded_transactions: Vec::new(),
   1338         reveal_bundle_section: RevealBundleSection::default(),
   1339         transactions: Vec::new(),
   1340         hash: String::new(),
   1341     };
   1342     block.hash = block.compute_hash();
   1343     block
   1344 }
   1345 
   1346 fn ticket_test_child(
   1347     parent: &Block,
   1348     height: u64,
   1349     miner: &str,
   1350     finalizer_rank: u32,
   1351     ticket_id: &str,
   1352 ) -> Block {
   1353     let mut block = ticket_test_block(height, FinalizerMode::Ticket, finalizer_rank, miner);
   1354     block.prev_hash = parent.hash.clone();
   1355     block.leader_proof = Some(LeaderProof {
   1356         ticket_id: ticket_id.to_string(),
   1357         public_key: miner.to_string(),
   1358         signature: "signature".to_string(),
   1359     });
   1360     block.hash = block.compute_hash();
   1361     block
   1362 }
   1363 
   1364 fn fallback_invalidation_tickets(height: u64) -> Vec<BurnTicket> {
   1365     vec![
   1366         BurnTicket {
   1367             id: "alice-main".to_string(),
   1368             owner: "alice".to_string(),
   1369             amount: 10,
   1370             eligible_from_height: height,
   1371             eligible_until_height: height + 2,
   1372         },
   1373         BurnTicket {
   1374             id: "alice-other-eligible".to_string(),
   1375             owner: "alice".to_string(),
   1376             amount: 7,
   1377             eligible_from_height: height,
   1378             eligible_until_height: height + 2,
   1379         },
   1380         BurnTicket {
   1381             id: "bob-main".to_string(),
   1382             owner: "bob".to_string(),
   1383             amount: 9,
   1384             eligible_from_height: height,
   1385             eligible_until_height: height + 2,
   1386         },
   1387         BurnTicket {
   1388             id: "bob-other-eligible".to_string(),
   1389             owner: "bob".to_string(),
   1390             amount: 6,
   1391             eligible_from_height: height,
   1392             eligible_until_height: height + 2,
   1393         },
   1394         BurnTicket {
   1395             id: "carol-main".to_string(),
   1396             owner: "carol".to_string(),
   1397             amount: 8,
   1398             eligible_from_height: height,
   1399             eligible_until_height: height + 2,
   1400         },
   1401         BurnTicket {
   1402             id: "future-same-owner".to_string(),
   1403             owner: "alice".to_string(),
   1404             amount: 5,
   1405             eligible_from_height: height + 1,
   1406             eligible_until_height: height + 3,
   1407         },
   1408     ]
   1409 }
   1410 
   1411 #[test]
   1412 fn primary_ticket_at_activation_height_only_consumes_winning_ticket() {
   1413     let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT;
   1414     let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "primary-parent");
   1415     let mut tickets = fallback_invalidation_tickets(height);
   1416     let ranked = ranked_tickets_for_height(&parent, height, &tickets);
   1417     let leader = &ranked[0];
   1418     let leader_id = leader.id.clone();
   1419     let leader_owner = leader.owner.clone();
   1420     let eligible_before = tickets
   1421         .iter()
   1422         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1423         .count();
   1424     let block = ticket_test_child(&parent, height, &leader_owner, 0, &leader_id);
   1425 
   1426     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1427 
   1428     assert!(tickets.iter().all(|ticket| ticket.id != leader_id));
   1429     let eligible_after = tickets
   1430         .iter()
   1431         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1432         .count();
   1433     assert_eq!(eligible_after, eligible_before - 1);
   1434 }
   1435 
   1436 #[test]
   1437 fn fallback_ticket_before_activation_height_only_consumes_winning_ticket() {
   1438     let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT - 1;
   1439     let parent = ticket_test_block(
   1440         height - 1,
   1441         FinalizerMode::Ticket,
   1442         0,
   1443         "legacy-fallback-parent",
   1444     );
   1445     let mut tickets = fallback_invalidation_tickets(height);
   1446     let ranked = ranked_tickets_for_height(&parent, height, &tickets);
   1447     let fallback = &ranked[1];
   1448     let fallback_id = fallback.id.clone();
   1449     let fallback_owner = fallback.owner.clone();
   1450     let eligible_before = tickets
   1451         .iter()
   1452         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1453         .count();
   1454     let block = ticket_test_child(&parent, height, &fallback_owner, 1, &fallback_id);
   1455 
   1456     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1457 
   1458     assert!(tickets.iter().all(|ticket| ticket.id != fallback_id));
   1459     let eligible_after = tickets
   1460         .iter()
   1461         .filter(|ticket| ticket_is_eligible_for_height(ticket, height))
   1462         .count();
   1463     assert_eq!(eligible_after, eligible_before - 1);
   1464 }
   1465 
   1466 #[test]
   1467 fn fallback_ticket_invalidates_missed_ranks_and_current_sibling_tickets_from_height_300() {
   1468     let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT;
   1469     let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "fallback-parent");
   1470     let mut tickets = fallback_invalidation_tickets(height);
   1471     let ranked = ranked_tickets_for_height(&parent, height, &tickets);
   1472     let prefix_owners = ranked
   1473         .iter()
   1474         .take(2)
   1475         .map(|ticket| ticket.owner.clone())
   1476         .collect::<BTreeSet<_>>();
   1477     let fallback = &ranked[1];
   1478     let block = ticket_test_child(&parent, height, &fallback.owner, 1, &fallback.id);
   1479 
   1480     consume_leader_ticket(&parent, &block, &mut tickets).unwrap();
   1481 
   1482     assert!(
   1483         tickets.iter().all(|ticket| {
   1484             !ticket_is_eligible_for_height(ticket, height) || !prefix_owners.contains(&ticket.owner)
   1485         }),
   1486         "eligible tickets from missed ranks and the fallback finalizer should be invalidated"
   1487     );
   1488     assert!(
   1489         tickets
   1490             .iter()
   1491             .any(|ticket| ticket.id == "future-same-owner"),
   1492         "future tickets from invalidated owners should stay pending"
   1493     );
   1494 }
   1495 
   1496 #[test]
   1497 fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() {
   1498     let alice = Wallet::from_seed("burn-rank-alice");
   1499     let bob = Wallet::from_seed("burn-rank-bob");
   1500     let mut allocations = BTreeMap::new();
   1501     allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
   1502     allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA);
   1503     let ledger = Ledger::new_with_genesis_burns(
   1504         allocations,
   1505         vec![
   1506             GenesisBurn::new(alice.address(), MICRO_IUNA),
   1507             GenesisBurn::new(bob.address(), MICRO_IUNA),
   1508         ],
   1509         1,
   1510     )
   1511     .unwrap();
   1512 
   1513     let ranks = ledger.burn_leader_ranks_for_block(1).unwrap();
   1514     let leader = ledger.expected_leader_for_next_block().unwrap();
   1515 
   1516     assert_eq!(ranks.len(), 2);
   1517     assert_eq!(ranks[0].rank, 0);
   1518     assert_eq!(ranks[0].owner, leader);
   1519     assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA));
   1520     assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new());
   1521 
   1522     let batch = ledger.burn_leader_ranks_for_blocks([0, 1]).unwrap();
   1523     assert!(batch.get(&0).unwrap().is_empty());
   1524     assert_eq!(batch.get(&1), Some(&ranks));
   1525 }
   1526 
   1527 #[test]
   1528 fn mine_recipient_is_bound_to_proof_hash() {
   1529     let alice = Wallet::from_seed("mine-proof-alice");
   1530     let bob = Wallet::from_seed("mine-proof-bob");
   1531     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1532     let mut forged = unsigned_mine(&ledger, alice.address());
   1533     if let Transaction::Mine { recipient, .. } = &mut forged {
   1534         *recipient = bob.address().to_string();
   1535     }
   1536 
   1537     let error = ledger.submit_transaction(forged).unwrap_err();
   1538 
   1539     assert!(format!("{error:#}").contains("proof hash is invalid"));
   1540 }
   1541 
   1542 #[test]
   1543 fn mine_action_uses_fixed_reward_and_fixed_finalizer_fee() {
   1544     let alice = Wallet::from_seed("mine-fixed-reward-alice");
   1545     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1546 
   1547     let mine = ledger.build_mine(alice.address()).unwrap();
   1548 
   1549     assert_eq!(mine.amount(), MINE_REWARD);
   1550     assert_eq!(mine.fee(), MINE_FINALIZER_FEE);
   1551     assert!(ledger.submit_transaction(mine).unwrap());
   1552 }
   1553 
   1554 #[test]
   1555 fn burn_fee_goes_to_block_finalizer() {
   1556     let alice = Wallet::from_seed("burn-fee-finalizer-alice");
   1557     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1558 
   1559     let burn_fee = 12;
   1560     let burn = ledger
   1561         .build_burn(&alice, TEST_BURN_AMOUNT, burn_fee)
   1562         .unwrap();
   1563     ledger.submit_transaction(burn).unwrap();
   1564     let prepared = ledger.prepare_next_block(alice.address(), 1).unwrap();
   1565 
   1566     assert_eq!(prepared.reward, burn_fee);
   1567 }
   1568 
   1569 #[test]
   1570 fn blinded_burn_commits_ciphertext_and_reveal_executes_later() {
   1571     let alice = Wallet::from_seed("blinded-burn-finalizer-alice");
   1572     let bob = Wallet::from_seed("blinded-burn-finalizer-bob");
   1573     let carol = Wallet::from_seed("blinded-burn-carol");
   1574     let finalizers = [alice.clone(), bob.clone()];
   1575     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   1576     let fee = 100;
   1577     let burn_amount = 3;
   1578     let before_carol = ledger.balance_of(carol.address());
   1579 
   1580     let blinded = ledger
   1581         .build_blinded_burn(&carol, burn_amount, fee, ledger.height() + 4)
   1582         .unwrap();
   1583     assert!(!blinded.transaction.ciphertext.contains("burn"));
   1584     assert!(!blinded.transaction.ciphertext.contains(carol.address()));
   1585     ledger
   1586         .submit_blinded_transaction(blinded.transaction.clone())
   1587         .unwrap();
   1588     queue_next_leader_burn(&mut ledger, &finalizers);
   1589 
   1590     let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   1591     let inclusion_finalizer = commit_block.miner.clone();
   1592     assert_eq!(
   1593         commit_block
   1594             .transactions
   1595             .iter()
   1596             .filter(|transaction| transaction.is_burn())
   1597             .count(),
   1598         1
   1599     );
   1600     assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]);
   1601     assert_eq!(commit_block.reward, 0);
   1602     let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer);
   1603 
   1604     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   1605     queue_next_leader_burn(&mut ledger, &finalizers);
   1606     let reveal_block =
   1607         mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2);
   1608     let reveal_executor = reveal_block.miner.clone();
   1609 
   1610     assert_eq!(reveal_block.all_blinded_reveals().len(), 1);
   1611     assert_eq!(
   1612         ledger.balance_of(carol.address()),
   1613         before_carol - burn_amount - fee
   1614     );
   1615     assert!(ledger.tickets.iter().any(|ticket| {
   1616         ticket.owner == carol.address()
   1617             && ticket.amount == burn_amount
   1618             && ticket.eligible_from_height
   1619                 == reveal_block.height + ledger.launch_profile.ticket_maturity_delay_heights
   1620     }));
   1621     let reveal_plaintext_burn_spent_by_inclusion_finalizer = reveal_block
   1622         .transactions
   1623         .iter()
   1624         .filter(|transaction| {
   1625             transaction.is_burn() && transaction.sender() == inclusion_finalizer.as_str()
   1626         })
   1627         .fold(0_u64, |total, transaction| {
   1628             total + transaction.amount() + transaction.fee()
   1629         });
   1630     let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
   1631     let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1632         fee,
   1633         reveal_block.included_reveal_bundle_count(),
   1634         ledger
   1635             .burn_leader_ranks_for_block(reveal_block.height)
   1636             .unwrap()
   1637             .len(),
   1638     );
   1639     let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS);
   1640     let commitment = &commit_block.blinded_transactions[0].commitment;
   1641     assert_eq!(
   1642         ledger
   1643             .utxos
   1644             .get(&blinded_committer_fee_outpoint(commitment))
   1645             .unwrap(),
   1646         &TxOutput {
   1647             address: inclusion_finalizer.clone(),
   1648             amount: committer_fee,
   1649         }
   1650     );
   1651     assert!(
   1652         !ledger
   1653             .utxos
   1654             .contains_key(&blinded_executor_fee_outpoint(commitment))
   1655     );
   1656     assert_eq!(reveal_block.reward, reveal_finalizer_fee);
   1657     assert_eq!(
   1658         ledger
   1659             .utxos
   1660             .get(&reward_outpoint(&reveal_block.hash))
   1661             .unwrap(),
   1662         &TxOutput {
   1663             address: reveal_executor.clone(),
   1664             amount: reveal_finalizer_fee,
   1665         }
   1666     );
   1667     for signature in &reveal_block.reveal_bundle_section.signatures {
   1668         assert_eq!(
   1669             ledger
   1670                 .utxos
   1671                 .get(&blinded_reveal_bundle_signer_fee_outpoint(
   1672                     commitment,
   1673                     signature.slot
   1674                 ))
   1675                 .unwrap(),
   1676             &TxOutput {
   1677                 address: signature.member.clone(),
   1678                 amount: reveal_bundle_signer_fee,
   1679             }
   1680         );
   1681     }
   1682     let mut inclusion_finalizer_fee = committer_fee;
   1683     if inclusion_finalizer == reveal_executor {
   1684         inclusion_finalizer_fee += reveal_finalizer_fee;
   1685     }
   1686     inclusion_finalizer_fee += reveal_block
   1687         .reveal_bundle_section
   1688         .signatures
   1689         .iter()
   1690         .filter(|signature| signature.member == inclusion_finalizer)
   1691         .count() as u64
   1692         * reveal_bundle_signer_fee;
   1693     assert_eq!(
   1694         ledger.balance_of(&inclusion_finalizer),
   1695         before_inclusion_finalizer + inclusion_finalizer_fee
   1696             - reveal_plaintext_burn_spent_by_inclusion_finalizer
   1697     );
   1698 }
   1699 
   1700 #[test]
   1701 fn blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() {
   1702     let alice = Wallet::from_seed("aggregated-finalizer-fee-alice");
   1703     let bob = Wallet::from_seed("aggregated-finalizer-fee-bob");
   1704     let carol = Wallet::from_seed("aggregated-finalizer-fee-carol");
   1705     let dave = Wallet::from_seed("aggregated-finalizer-fee-dave");
   1706     let finalizers = [alice.clone(), bob.clone()];
   1707     let mut ledger = ledger_with_finalizers(
   1708         &finalizers,
   1709         &[(&carol, 10 * MICRO_IUNA), (&dave, 10 * MICRO_IUNA)],
   1710     );
   1711     let first_fee = 100;
   1712     let second_fee = 200;
   1713     let first_blinded = ledger
   1714         .build_blinded_burn(&carol, 3, first_fee, ledger.height() + 4)
   1715         .unwrap();
   1716     let second_blinded = ledger
   1717         .build_blinded_burn(&dave, 4, second_fee, ledger.height() + 4)
   1718         .unwrap();
   1719     let first_commitment = first_blinded.transaction.commitment.clone();
   1720     let second_commitment = second_blinded.transaction.commitment.clone();
   1721     ledger
   1722         .submit_blinded_transaction(first_blinded.transaction)
   1723         .unwrap();
   1724     ledger
   1725         .submit_blinded_transaction(second_blinded.transaction)
   1726         .unwrap();
   1727     queue_next_leader_burn(&mut ledger, &finalizers);
   1728     let commit_timestamp_ms = ledger
   1729         .tip()
   1730         .timestamp_ms
   1731         .saturating_add(VDF_TARGET_BLOCK_MS);
   1732     let commit_block =
   1733         mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms);
   1734     assert_eq!(commit_block.height, 1);
   1735 
   1736     ledger.submit_blinded_reveal(first_blinded.reveal).unwrap();
   1737     ledger.submit_blinded_reveal(second_blinded.reveal).unwrap();
   1738     queue_next_leader_burn(&mut ledger, &finalizers);
   1739     let reveal_timestamp_ms = ledger
   1740         .tip()
   1741         .timestamp_ms
   1742         .saturating_add(VDF_TARGET_BLOCK_MS);
   1743     let reveal_block = prepare_preverified_as_next_leader_with_reveal_bundles(
   1744         &ledger,
   1745         &finalizers,
   1746         reveal_timestamp_ms,
   1747     );
   1748     let first_reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1749         first_fee,
   1750         reveal_block.included_reveal_bundle_count(),
   1751         ledger
   1752             .burn_leader_ranks_for_block(reveal_block.height)
   1753             .unwrap()
   1754             .len(),
   1755     );
   1756     let second_reveal_finalizer_fee = blinded_reveal_finalizer_fee(
   1757         second_fee,
   1758         reveal_block.included_reveal_bundle_count(),
   1759         ledger
   1760             .burn_leader_ranks_for_block(reveal_block.height)
   1761             .unwrap()
   1762             .len(),
   1763     );
   1764     let aggregate_reveal_finalizer_fee = first_reveal_finalizer_fee
   1765         .checked_add(second_reveal_finalizer_fee)
   1766         .unwrap();
   1767 
   1768     assert_eq!(reveal_block.height, 2);
   1769     assert_eq!(reveal_block.reward, aggregate_reveal_finalizer_fee);
   1770     let mut plain_fee_reward_block = reveal_block.clone();
   1771     plain_fee_reward_block.reward = fee_reward(&plain_fee_reward_block.transactions).unwrap();
   1772     plain_fee_reward_block.hash = plain_fee_reward_block.compute_hash();
   1773     let error = ledger
   1774         .clone()
   1775         .apply_preverified_block_at(plain_fee_reward_block, u64::MAX)
   1776         .unwrap_err();
   1777     assert!(format!("{error:#}").contains("block reward is invalid"));
   1778 
   1779     ledger
   1780         .apply_preverified_block_at(reveal_block.clone(), u64::MAX)
   1781         .unwrap();
   1782     assert!(
   1783         !ledger
   1784             .utxos
   1785             .contains_key(&blinded_executor_fee_outpoint(&first_commitment))
   1786     );
   1787     assert!(
   1788         !ledger
   1789             .utxos
   1790             .contains_key(&blinded_executor_fee_outpoint(&second_commitment))
   1791     );
   1792     assert_eq!(
   1793         ledger.utxos.get(&reward_outpoint(&reveal_block.hash)),
   1794         Some(&TxOutput {
   1795             address: reveal_block.miner.clone(),
   1796             amount: aggregate_reveal_finalizer_fee,
   1797         })
   1798     );
   1799 }
   1800 
   1801 #[test]
   1802 fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() {
   1803     let alice = Wallet::from_seed("blinded-lock-alice");
   1804     let bob = Wallet::from_seed("blinded-lock-bob");
   1805     let mut ledger = ledger_with_wallet_utxos(&alice, &[10]);
   1806     let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap();
   1807     let visible_inputs = transfer.inputs().to_vec();
   1808 
   1809     let blinded = ledger
   1810         .build_blinded_transaction(&alice, transfer, ledger.height() + 4)
   1811         .unwrap();
   1812 
   1813     assert_eq!(blinded.transaction.inputs.len(), visible_inputs.len());
   1814     assert_eq!(
   1815         unsigned_inputs(&blinded.transaction.inputs),
   1816         unsigned_inputs(&visible_inputs)
   1817     );
   1818     ledger
   1819         .submit_blinded_transaction(blinded.transaction)
   1820         .unwrap();
   1821     let error = ledger
   1822         .build_transfer(&alice, bob.address(), 1, 0)
   1823         .unwrap_err();
   1824     assert!(format!("{error:#}").contains("insufficient funds"));
   1825 }
   1826 
   1827 #[test]
   1828 fn blinded_payload_omits_visible_inputs_and_reconstructs_transaction_on_reveal() {
   1829     let alice = Wallet::from_seed("blinded-compact-payload-alice");
   1830     let bob = Wallet::from_seed("blinded-compact-payload-bob");
   1831     let ledger = ledger_with_wallet_utxos(&alice, &[10]);
   1832     let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap();
   1833     let full_transaction_bytes = serde_json::to_vec(&transfer).unwrap().len();
   1834     let blinded = ledger
   1835         .build_blinded_transaction(&alice, transfer.clone(), ledger.height() + 4)
   1836         .unwrap();
   1837     let key = decode_hex_array::<BLINDED_KEY_BYTES>(&blinded.reveal.key).unwrap();
   1838     let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&blinded.transaction.nonce).unwrap();
   1839     let ciphertext = decode_hex(&blinded.transaction.ciphertext).unwrap();
   1840 
   1841     let plaintext = decrypt_blinded_payload(
   1842         &key,
   1843         &nonce,
   1844         &signed_blinded_inputs(&unsigned_inputs(&blinded.transaction.inputs), ""),
   1845         blinded.transaction.fee,
   1846         blinded.transaction.expires_at_height,
   1847         &ciphertext,
   1848     )
   1849     .unwrap();
   1850     let payload: serde_json::Value = serde_json::from_slice(&plaintext).unwrap();
   1851     let revealed = decrypt_blinded_transaction(&blinded.transaction, &blinded.reveal).unwrap();
   1852 
   1853     assert_eq!(
   1854         payload.get("kind").and_then(|kind| kind.as_str()),
   1855         Some("transfer")
   1856     );
   1857     assert!(payload.get("inputs").is_none());
   1858     assert!(plaintext.len() < full_transaction_bytes);
   1859     assert_eq!(revealed, transfer);
   1860 }
   1861 
   1862 #[test]
   1863 fn unrevealed_blinded_utxo_commit_burns_fee_and_returns_change() {
   1864     let alice = Wallet::from_seed("blinded-expiry-alice");
   1865     let bob = Wallet::from_seed("blinded-expiry-bob");
   1866     let carol = Wallet::from_seed("blinded-expiry-carol");
   1867     let finalizers = [alice.clone(), bob.clone()];
   1868     let carol_balance = 10 * MICRO_IUNA;
   1869     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, carol_balance)]);
   1870     let fee = 100;
   1871     let blinded = ledger
   1872         .build_blinded_burn(&carol, 3, fee, ledger.height() + 2)
   1873         .unwrap();
   1874     let commitment = blinded.transaction.commitment.clone();
   1875     ledger
   1876         .submit_blinded_transaction(blinded.transaction)
   1877         .unwrap();
   1878     queue_next_leader_burn(&mut ledger, &finalizers);
   1879     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   1880 
   1881     assert_eq!(ledger.balance_of(carol.address()), 0);
   1882     queue_next_leader_burn(&mut ledger, &finalizers);
   1883     mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
   1884 
   1885     assert_eq!(
   1886         ledger
   1887             .utxos
   1888             .get(&blinded_committer_fee_outpoint(&commitment)),
   1889         None
   1890     );
   1891     assert_eq!(
   1892         ledger
   1893             .utxos
   1894             .get(&blinded_expiry_change_outpoint(&commitment)),
   1895         Some(&TxOutput {
   1896             address: carol.address().to_string(),
   1897             amount: carol_balance - fee,
   1898         })
   1899     );
   1900     assert_eq!(ledger.balance_of(carol.address()), carol_balance - fee);
   1901 }
   1902 
   1903 #[test]
   1904 fn fee_bearing_blinded_commit_without_inputs_is_rejected() {
   1905     let alice = Wallet::from_seed("blinded-no-input-fee-alice");
   1906     let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
   1907     let mut blinded = ledger
   1908         .build_blinded_burn(&alice, 1, 1, ledger.height() + 4)
   1909         .unwrap()
   1910         .transaction;
   1911     blinded.inputs.clear();
   1912     blinded.commitment = blinded_transaction_commitment(&blinded).unwrap();
   1913 
   1914     let error = ledger.submit_blinded_transaction(blinded).unwrap_err();
   1915 
   1916     assert!(format!("{error:#}").contains("must lock visible inputs"));
   1917 }
   1918 
   1919 #[test]
   1920 fn mine_actions_cannot_be_blinded() {
   1921     let alice = Wallet::from_seed("blinded-mine-collateral-alice");
   1922     let ledger = ledger_with_finalizers(&[alice.clone()], &[]);
   1923     let mine = ledger.build_mine(alice.address()).unwrap();
   1924     let error = ledger
   1925         .build_blinded_transaction(&alice, mine, ledger.height() + 4)
   1926         .unwrap_err();
   1927 
   1928     assert!(format!("{error:#}").contains("mine actions are public"));
   1929 }
   1930 
   1931 #[test]
   1932 fn reveal_bundle_hashes_are_bound_to_next_block_vdf_seed() {
   1933     let alice = Wallet::from_seed("bundle-seed-alice");
   1934     let bob = Wallet::from_seed("bundle-seed-bob");
   1935     let carol = Wallet::from_seed("bundle-seed-carol");
   1936     let finalizers = [alice.clone(), bob.clone()];
   1937     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   1938     let blinded = ledger
   1939         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   1940         .unwrap();
   1941     ledger
   1942         .submit_blinded_transaction(blinded.transaction.clone())
   1943         .unwrap();
   1944     queue_next_leader_burn(&mut ledger, &finalizers);
   1945     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   1946     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   1947     queue_next_leader_burn(&mut ledger, &finalizers);
   1948     let leader = ledger.expected_leader_for_next_block().unwrap();
   1949     let leader_wallet = wallet_for_address(&finalizers, &leader);
   1950     let bundles = ledger
   1951         .reveal_committee_for_next_block()
   1952         .into_iter()
   1953         .filter_map(|member| {
   1954             let wallet = wallet_for_address(&finalizers, &member.owner);
   1955             ledger.build_reveal_bundle(wallet).unwrap()
   1956         })
   1957         .collect::<Vec<_>>();
   1958     if bundles.len() > 1 {
   1959         let mut reversed = bundles.clone();
   1960         reversed.reverse();
   1961         let error = ledger
   1962             .validate_next_block_reveal_bundles(reversed)
   1963             .unwrap_err();
   1964         assert!(format!("{error:#}").contains("reveal bundles are not in slot order"));
   1965     }
   1966 
   1967     let without_bundles = ledger
   1968         .prepare_next_block(leader_wallet.address(), ledger.tip().timestamp_ms + 1)
   1969         .unwrap();
   1970     let with_bundles = ledger
   1971         .prepare_next_block_with_reveal_bundles(
   1972             leader_wallet.address(),
   1973             ledger.tip().timestamp_ms + 1,
   1974             bundles,
   1975         )
   1976         .unwrap();
   1977 
   1978     assert_ne!(without_bundles.vdf_seed(), with_bundles.vdf_seed());
   1979 }
   1980 
   1981 #[test]
   1982 fn reveal_committee_includes_next_block_finalizer_as_slot_zero() {
   1983     let alice = Wallet::from_seed("bundle-finalizer-slot-alice");
   1984     let bob = Wallet::from_seed("bundle-finalizer-slot-bob");
   1985     let carol = Wallet::from_seed("bundle-finalizer-slot-carol");
   1986     let dave = Wallet::from_seed("bundle-finalizer-slot-dave");
   1987     let erin = Wallet::from_seed("bundle-finalizer-slot-erin");
   1988     let finalizers = [alice.clone(), bob.clone(), carol.clone(), dave.clone()];
   1989     let mut ledger = ledger_with_finalizers(&finalizers, &[(&erin, 10 * MICRO_IUNA)]);
   1990     let blinded = ledger
   1991         .build_blinded_burn(&erin, 3, 7, ledger.height() + 4)
   1992         .unwrap();
   1993     let commitment = blinded.transaction.commitment.clone();
   1994     ledger
   1995         .submit_blinded_transaction(blinded.transaction)
   1996         .unwrap();
   1997     queue_next_leader_burn(&mut ledger, &finalizers);
   1998     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   1999     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2000     queue_next_leader_burn(&mut ledger, &finalizers);
   2001 
   2002     let leader = ledger.expected_leader_for_next_block().unwrap();
   2003     let committee = ledger.reveal_committee_for_next_block();
   2004     let leader_wallet = wallet_for_address(&finalizers, &leader);
   2005     let bundle = ledger.build_reveal_bundle(leader_wallet).unwrap().unwrap();
   2006 
   2007     assert_eq!(committee.first().map(|member| member.slot), Some(0));
   2008     assert_eq!(committee.first().map(|member| member.rank), Some(0));
   2009     assert_eq!(
   2010         committee.first().map(|member| member.owner.as_str()),
   2011         Some(leader.as_str())
   2012     );
   2013     assert_eq!(bundle.slot, 0);
   2014     assert_eq!(bundle.member, leader);
   2015     assert!(
   2016         bundle
   2017             .reveals
   2018             .iter()
   2019             .any(|reveal| reveal.commitment == commitment)
   2020     );
   2021 }
   2022 
   2023 #[test]
   2024 fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() {
   2025     let alice = Wallet::from_seed("bundle-compact-alice");
   2026     let bob = Wallet::from_seed("bundle-compact-bob");
   2027     let carol = Wallet::from_seed("bundle-compact-carol");
   2028     let dave = Wallet::from_seed("bundle-compact-dave");
   2029     let finalizers = [alice.clone(), bob.clone(), carol.clone()];
   2030     let mut ledger = ledger_with_finalizers(&finalizers, &[(&dave, 10 * MICRO_IUNA)]);
   2031     let blinded = ledger
   2032         .build_blinded_burn(&dave, 3, 7, ledger.height() + 4)
   2033         .unwrap();
   2034     ledger
   2035         .submit_blinded_transaction(blinded.transaction.clone())
   2036         .unwrap();
   2037     queue_next_leader_burn(&mut ledger, &finalizers);
   2038     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2039     ledger
   2040         .submit_blinded_reveal(blinded.reveal.clone())
   2041         .unwrap();
   2042     queue_next_leader_burn(&mut ledger, &finalizers);
   2043 
   2044     let mut bundles = ledger
   2045         .reveal_committee_for_next_block()
   2046         .into_iter()
   2047         .filter_map(|member| {
   2048             let wallet = wallet_for_address(&finalizers, &member.owner);
   2049             ledger.build_reveal_bundle(wallet).unwrap()
   2050         })
   2051         .collect::<Vec<_>>();
   2052     assert!(bundles.len() >= 2);
   2053     bundles.truncate(2);
   2054     let expected_hashes = reveal_bundle_hashes(&bundles);
   2055     let expected_mask = bundles
   2056         .iter()
   2057         .fold(0_u8, |mask, bundle| mask | (1_u8 << bundle.slot));
   2058 
   2059     let leader = ledger.expected_leader_for_next_block().unwrap();
   2060     let leader_wallet = wallet_for_address(&finalizers, &leader);
   2061     let prepared = ledger
   2062         .prepare_next_block_with_reveal_bundles(
   2063             leader_wallet.address(),
   2064             ledger.tip().timestamp_ms + 1,
   2065             bundles.clone(),
   2066         )
   2067         .unwrap();
   2068     let block = prepared.finish(leader_wallet, "preverified-vdf".to_string());
   2069 
   2070     assert_eq!(block.reveal_bundle_section.signatures.len(), 2);
   2071     assert_eq!(block.reveal_bundle_section.reveals.len(), 1);
   2072     assert_eq!(
   2073         block.reveal_bundle_section.reveals[0].bundle_mask,
   2074         expected_mask
   2075     );
   2076     assert_eq!(block.all_blinded_reveals(), vec![&blinded.reveal]);
   2077     assert_eq!(block.reveal_bundle_hashes(), expected_hashes);
   2078     assert_eq!(
   2079         block
   2080             .reveal_bundle_section
   2081             .expand(block.height, &block.prev_hash),
   2082         bundles
   2083     );
   2084 }
   2085 
   2086 #[test]
   2087 fn reveal_bundle_validation_rejects_wrong_signature_and_slot() {
   2088     let alice = Wallet::from_seed("bundle-invalid-alice");
   2089     let bob = Wallet::from_seed("bundle-invalid-bob");
   2090     let carol = Wallet::from_seed("bundle-invalid-carol");
   2091     let finalizers = [alice.clone(), bob.clone()];
   2092     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2093     let blinded = ledger
   2094         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2095         .unwrap();
   2096     ledger
   2097         .submit_blinded_transaction(blinded.transaction.clone())
   2098         .unwrap();
   2099     queue_next_leader_burn(&mut ledger, &finalizers);
   2100     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2101     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2102     let member = ledger.reveal_committee_for_next_block()[0].clone();
   2103     let wallet = wallet_for_address(&finalizers, &member.owner);
   2104     let bundle = ledger.build_reveal_bundle(wallet).unwrap().unwrap();
   2105 
   2106     let mut wrong_signature = bundle.clone();
   2107     wrong_signature.signature = "00".repeat(SIGNATURE_BYTES);
   2108     let error = ledger
   2109         .validate_next_block_reveal_bundles(vec![wrong_signature])
   2110         .unwrap_err();
   2111     assert!(format!("{error:#}").contains("reveal bundle signature is invalid"));
   2112 
   2113     let mut wrong_slot = bundle;
   2114     wrong_slot.slot = REVEAL_COMMITTEE_SIZE as u8 - 1;
   2115     let error = ledger
   2116         .validate_next_block_reveal_bundles(vec![wrong_slot])
   2117         .unwrap_err();
   2118     assert!(
   2119         format!("{error:#}").contains("reveal bundle slot is not assigned")
   2120             || format!("{error:#}").contains("reveal bundle member is not assigned to slot")
   2121     );
   2122 }
   2123 
   2124 #[test]
   2125 fn blinded_reveal_with_wrong_key_is_rejected_in_block() {
   2126     let alice = Wallet::from_seed("blinded-wrong-key-finalizer-alice");
   2127     let bob = Wallet::from_seed("blinded-wrong-key-finalizer-bob");
   2128     let carol = Wallet::from_seed("blinded-wrong-key-carol");
   2129     let finalizers = [alice.clone(), bob.clone()];
   2130     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2131     let blinded = ledger
   2132         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2133         .unwrap();
   2134     ledger
   2135         .submit_blinded_transaction(blinded.transaction.clone())
   2136         .unwrap();
   2137     queue_next_leader_burn(&mut ledger, &finalizers);
   2138     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2139 
   2140     let leader = ledger.expected_leader_for_next_block().unwrap();
   2141     let wallet = wallet_for_address(&finalizers, &leader);
   2142     let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
   2143     ledger.submit_transaction(filler_burn).unwrap();
   2144     let mut prepared = ledger
   2145         .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
   2146         .unwrap();
   2147     let committee_member = ledger.reveal_committee_for_next_block()[0].clone();
   2148     let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner);
   2149     let wrong_reveal = BlindedReveal {
   2150         commitment: blinded.transaction.commitment,
   2151         key: "00".repeat(BLINDED_KEY_BYTES),
   2152     };
   2153     let wrong_bundle = committee_wallet.reveal_bundle(RevealBundlePayload {
   2154         height: prepared.height,
   2155         prev_hash: prepared.prev_hash.clone(),
   2156         slot: committee_member.slot,
   2157         member: committee_wallet.address().to_string(),
   2158         reveals: vec![wrong_reveal],
   2159     });
   2160     prepared.reveal_bundle_section = ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle]);
   2161     prepared.reward = blinded_reveal_finalizer_fee(
   2162         blinded.transaction.fee,
   2163         prepared.reveal_bundle_section.signatures.len(),
   2164         ledger.reveal_committee_for_next_block().len(),
   2165     );
   2166     let block = prepared.finish(wallet, "preverified-vdf".to_string());
   2167 
   2168     let error = ledger
   2169         .apply_preverified_block_at(block, u64::MAX)
   2170         .unwrap_err();
   2171 
   2172     assert!(
   2173         format!("{error:#}").contains("decrypt blinded transaction"),
   2174         "{error:#}"
   2175     );
   2176 }
   2177 
   2178 #[test]
   2179 fn expired_blinded_reveal_is_not_selected() {
   2180     let alice = Wallet::from_seed("blinded-expire-finalizer-alice");
   2181     let bob = Wallet::from_seed("blinded-expire-finalizer-bob");
   2182     let carol = Wallet::from_seed("blinded-expire-carol");
   2183     let finalizers = [alice.clone(), bob.clone()];
   2184     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2185     let blinded = ledger
   2186         .build_blinded_burn(&carol, 3, 7, ledger.height() + 2)
   2187         .unwrap();
   2188     ledger
   2189         .submit_blinded_transaction(blinded.transaction.clone())
   2190         .unwrap();
   2191     queue_next_leader_burn(&mut ledger, &finalizers);
   2192     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2193 
   2194     let leader = ledger.expected_leader_for_next_block().unwrap();
   2195     let wallet = wallet_for_address(&finalizers, &leader);
   2196     let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
   2197     ledger.submit_transaction(filler_burn).unwrap();
   2198     mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
   2199 
   2200     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2201     assert!(ledger.valid_pending_blinded_reveals().is_empty());
   2202 }
   2203 
   2204 #[test]
   2205 fn recovery_block_includes_pending_blinded_transactions_when_space_allows() {
   2206     let alice = Wallet::from_seed("recovery-blinded-commit-alice");
   2207     let bob = Wallet::from_seed("recovery-blinded-commit-bob");
   2208     let carol = Wallet::from_seed("recovery-blinded-commit-carol");
   2209     let mut ledger = ledger_with_finalizers(
   2210         &[alice],
   2211         &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)],
   2212     );
   2213     let blinded = ledger
   2214         .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4)
   2215         .unwrap();
   2216     ledger
   2217         .submit_blinded_transaction(blinded.transaction.clone())
   2218         .unwrap();
   2219     let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
   2220     ledger.submit_transaction(recovery_burn).unwrap();
   2221 
   2222     let block = ledger
   2223         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   2224         .unwrap();
   2225 
   2226     assert!(
   2227         block
   2228             .blinded_transactions
   2229             .iter()
   2230             .any(|transaction| transaction.commitment == blinded.transaction.commitment)
   2231     );
   2232 }
   2233 
   2234 #[test]
   2235 fn recovery_block_size_selection_uses_recovery_skeleton() {
   2236     let alice = Wallet::from_seed("recovery-size-commit-alice");
   2237     let bob = Wallet::from_seed("recovery-size-commit-bob");
   2238     let carol = Wallet::from_seed("recovery-size-commit-carol");
   2239     let mut ledger = ledger_with_finalizers(
   2240         &[alice],
   2241         &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)],
   2242     );
   2243     let blinded = ledger
   2244         .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4)
   2245         .unwrap();
   2246     ledger
   2247         .submit_blinded_transaction(blinded.transaction.clone())
   2248         .unwrap();
   2249     let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
   2250     ledger.submit_transaction(recovery_burn.clone()).unwrap();
   2251     let recovery_selection = BlockSelection {
   2252         transactions: vec![recovery_burn],
   2253         blinded_transactions: vec![blinded.transaction.clone()],
   2254     };
   2255     let recovery_estimate =
   2256         estimated_block_selection_size_bytes(&recovery_selection, true).unwrap();
   2257     let ticket_estimate = estimated_block_selection_size_bytes(&recovery_selection, false).unwrap();
   2258     assert!(recovery_estimate < ticket_estimate);
   2259 
   2260     ledger.launch_profile.max_block_bytes = recovery_estimate;
   2261     let tight_block = ledger
   2262         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   2263         .unwrap();
   2264 
   2265     assert!(
   2266         tight_block
   2267             .blinded_transactions
   2268             .iter()
   2269             .any(|transaction| transaction.commitment == blinded.transaction.commitment)
   2270     );
   2271 }
   2272 
   2273 #[test]
   2274 fn recovery_block_includes_pending_blinded_reveals_when_space_allows() {
   2275     let alice = Wallet::from_seed("recovery-blinded-reveal-alice");
   2276     let bob = Wallet::from_seed("recovery-blinded-reveal-bob");
   2277     let carol = Wallet::from_seed("recovery-blinded-reveal-carol");
   2278     let finalizers = [alice.clone(), bob.clone()];
   2279     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2280     let blinded = ledger
   2281         .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4)
   2282         .unwrap();
   2283     ledger
   2284         .submit_blinded_transaction(blinded.transaction.clone())
   2285         .unwrap();
   2286     queue_next_leader_burn(&mut ledger, &finalizers);
   2287     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2288     ledger
   2289         .submit_blinded_reveal(blinded.reveal.clone())
   2290         .unwrap();
   2291     let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
   2292     ledger.submit_transaction(recovery_burn).unwrap();
   2293 
   2294     let bundles = ledger
   2295         .reveal_committee_for_next_block()
   2296         .into_iter()
   2297         .filter_map(|member| {
   2298             let wallet = wallet_for_address(&finalizers, &member.owner);
   2299             ledger.build_reveal_bundle(wallet).unwrap()
   2300         })
   2301         .collect::<Vec<_>>();
   2302     let prepared = ledger
   2303         .prepare_recovery_block_with_reveal_bundles(
   2304             bob.address(),
   2305             ledger.recovery_block_min_timestamp(),
   2306             bundles,
   2307         )
   2308         .unwrap();
   2309     let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
   2310     let block = prepared.finish(&bob, vdf_output);
   2311 
   2312     assert!(
   2313         block
   2314             .all_blinded_reveals()
   2315             .iter()
   2316             .any(|reveal| reveal.commitment == blinded.transaction.commitment)
   2317     );
   2318 }
   2319 
   2320 #[test]
   2321 fn blinded_transaction_expiring_at_next_height_is_not_selected() {
   2322     let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice");
   2323     let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob");
   2324     let carol = Wallet::from_seed("blinded-next-expire-carol");
   2325     let finalizers = [alice.clone(), bob.clone()];
   2326     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2327     let blinded = ledger
   2328         .build_blinded_burn(&carol, 3, 7, ledger.height() + 1)
   2329         .unwrap();
   2330     ledger
   2331         .submit_blinded_transaction(blinded.transaction)
   2332         .unwrap();
   2333 
   2334     let leader = ledger.expected_leader_for_next_block().unwrap();
   2335     let wallet = wallet_for_address(&finalizers, &leader);
   2336     let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err();
   2337 
   2338     assert!(format!("{error:#}").contains("block must include at least one burn transaction"));
   2339 }
   2340 
   2341 #[test]
   2342 fn blinded_transaction_expiry_cannot_exceed_protocol_window() {
   2343     let alice = Wallet::from_seed("blinded-window-finalizer-alice");
   2344     let bob = Wallet::from_seed("blinded-window-finalizer-bob");
   2345     let carol = Wallet::from_seed("blinded-window-carol");
   2346     let finalizers = [alice, bob];
   2347     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2348     let max_expiry = ledger
   2349         .height()
   2350         .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS);
   2351 
   2352     ledger.build_blinded_burn(&carol, 3, 7, max_expiry).unwrap();
   2353 
   2354     let error = ledger
   2355         .build_blinded_burn(&carol, 3, 7, max_expiry + 1)
   2356         .unwrap_err();
   2357     assert!(format!("{error:#}").contains("expiry is too far in the future"));
   2358 
   2359     let mut forged = ledger
   2360         .build_blinded_burn(&carol, 3, 7, max_expiry)
   2361         .unwrap()
   2362         .transaction;
   2363     forged.expires_at_height = max_expiry + 1;
   2364     forged.commitment = blinded_transaction_commitment(&forged).unwrap();
   2365     let error = ledger.submit_blinded_transaction(forged).unwrap_err();
   2366     assert!(format!("{error:#}").contains("expiry is too far in the future"));
   2367 }
   2368 
   2369 #[test]
   2370 fn blinded_transaction_does_not_satisfy_plaintext_burn_requirement() {
   2371     let alice = Wallet::from_seed("blinded-no-burn-finalizer-alice");
   2372     let bob = Wallet::from_seed("blinded-no-burn-finalizer-bob");
   2373     let carol = Wallet::from_seed("blinded-no-burn-carol");
   2374     let finalizers = [alice.clone(), bob.clone()];
   2375     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2376     let blinded = ledger
   2377         .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
   2378         .unwrap();
   2379     ledger
   2380         .submit_blinded_transaction(blinded.transaction)
   2381         .unwrap();
   2382 
   2383     let leader = ledger.expected_leader_for_next_block().unwrap();
   2384     let wallet = wallet_for_address(&finalizers, &leader);
   2385     let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err();
   2386 
   2387     assert!(format!("{error:#}").contains("block must include at least one burn transaction"));
   2388 }
   2389 
   2390 #[test]
   2391 fn revealed_blinded_transaction_cannot_be_included_again() {
   2392     let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice");
   2393     let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob");
   2394     let carol = Wallet::from_seed("blinded-duplicate-carol");
   2395     let finalizers = [alice.clone(), bob.clone()];
   2396     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2397     let blinded = ledger
   2398         .build_blinded_burn(&carol, 3, 7, ledger.height() + 6)
   2399         .unwrap();
   2400     ledger
   2401         .submit_blinded_transaction(blinded.transaction.clone())
   2402         .unwrap();
   2403     queue_next_leader_burn(&mut ledger, &finalizers);
   2404     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2405     ledger
   2406         .submit_blinded_reveal(blinded.reveal.clone())
   2407         .unwrap();
   2408     queue_next_leader_burn(&mut ledger, &finalizers);
   2409     mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2);
   2410 
   2411     let leader = ledger.expected_leader_for_next_block().unwrap();
   2412     let wallet = wallet_for_address(&finalizers, &leader);
   2413     let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap();
   2414     ledger.submit_transaction(filler_burn).unwrap();
   2415     let mut prepared = ledger
   2416         .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
   2417         .unwrap();
   2418     prepared
   2419         .blinded_transactions
   2420         .push(blinded.transaction.clone());
   2421     let block = prepared.finish(wallet, "preverified-vdf".to_string());
   2422 
   2423     let error = ledger
   2424         .apply_preverified_block_at(block, u64::MAX)
   2425         .unwrap_err();
   2426 
   2427     assert!(format!("{error:#}").contains("blinded transaction is already on chain"));
   2428 }
   2429 
   2430 #[test]
   2431 fn pending_blinded_reveals_expose_revealed_transaction_data() {
   2432     let alice = Wallet::from_seed("pending-reveal-data-finalizer-alice");
   2433     let bob = Wallet::from_seed("pending-reveal-data-finalizer-bob");
   2434     let carol = Wallet::from_seed("pending-reveal-data-carol");
   2435     let finalizers = [alice.clone(), bob.clone()];
   2436     let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2437     let blinded = ledger
   2438         .build_blinded_burn(&carol, 3, 7, ledger.height() + 6)
   2439         .unwrap();
   2440     let commitment = blinded.transaction.commitment.clone();
   2441     ledger
   2442         .submit_blinded_transaction(blinded.transaction)
   2443         .unwrap();
   2444     queue_next_leader_burn(&mut ledger, &finalizers);
   2445     mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
   2446 
   2447     ledger.submit_blinded_reveal(blinded.reveal).unwrap();
   2448 
   2449     let revealed = ledger.pending_revealed_blinded_transactions();
   2450     assert_eq!(revealed.len(), 1);
   2451     assert_eq!(revealed[0].commitment, commitment);
   2452     assert_eq!(revealed[0].height, ledger.height() + 1);
   2453     assert_eq!(revealed[0].transaction.amount(), 3);
   2454     assert_eq!(revealed[0].transaction.fee(), 7);
   2455     assert!(revealed[0].transaction.is_burn());
   2456 }
   2457 
   2458 #[test]
   2459 fn abandoned_fork_blinded_transactions_return_to_mempool() {
   2460     let alice = Wallet::from_seed("blinded-reorg-finalizer-alice");
   2461     let bob = Wallet::from_seed("blinded-reorg-finalizer-bob");
   2462     let carol = Wallet::from_seed("blinded-reorg-carol");
   2463     let finalizers = [alice.clone(), bob.clone()];
   2464     let mut local = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
   2465     let mut remote = local.clone();
   2466     let blinded = local
   2467         .build_blinded_burn(&carol, 3, 7, local.height() + 8)
   2468         .unwrap();
   2469     local
   2470         .submit_blinded_transaction(blinded.transaction.clone())
   2471         .unwrap();
   2472     queue_next_leader_burn(&mut local, &finalizers);
   2473     mine_preverified_as_next_leader(&mut local, &finalizers, 1);
   2474 
   2475     for timestamp_ms in [1, 2] {
   2476         let leader = remote.expected_leader_for_next_block().unwrap();
   2477         let wallet = wallet_for_address(&finalizers, &leader);
   2478         let burn = remote.build_burn(wallet, 1, 0).unwrap();
   2479         remote.submit_transaction(burn).unwrap();
   2480         mine_preverified_as_next_leader(&mut remote, &finalizers, timestamp_ms);
   2481     }
   2482 
   2483     assert!(
   2484         local
   2485             .extend_from_preverified_snapshot_at(remote.snapshot(), u64::MAX)
   2486             .unwrap()
   2487     );
   2488     assert!(local.has_blinded_transaction(&blinded.transaction.commitment));
   2489     assert_eq!(
   2490         local.pending_blinded_transactions(),
   2491         std::slice::from_ref(&blinded.transaction)
   2492     );
   2493 }
   2494 
   2495 #[test]
   2496 fn block_selection_includes_mine_action_after_required_block_burn() {
   2497     let alice = Wallet::from_seed("mine-fixed-reward-select-alice");
   2498     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2499 
   2500     let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap();
   2501     ledger.submit_transaction(burn).unwrap();
   2502     let mine = ledger.build_mine(alice.address()).unwrap();
   2503     ledger.submit_transaction(mine.clone()).unwrap();
   2504 
   2505     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2506 
   2507     assert_eq!(
   2508         block.transactions.iter().filter(|tx| tx.is_burn()).count(),
   2509         1
   2510     );
   2511     assert!(
   2512         block
   2513             .transactions
   2514             .iter()
   2515             .any(|tx| tx.signature() == mine.signature())
   2516     );
   2517     assert_eq!(block.reward, MINE_FINALIZER_FEE);
   2518 }
   2519 
   2520 #[test]
   2521 fn block_selection_can_skip_mine_action_when_space_is_limited() {
   2522     let alice = Wallet::from_seed("mine-space-limit-alice");
   2523     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2524     ledger.launch_profile.max_block_transactions = 2;
   2525 
   2526     let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
   2527     ledger.submit_transaction(burn).unwrap();
   2528     let first_mine = ledger.build_mine(alice.address()).unwrap();
   2529     ledger.submit_transaction(first_mine).unwrap();
   2530     let second_mine = ledger.build_mine(alice.address()).unwrap();
   2531     ledger.submit_transaction(second_mine).unwrap();
   2532 
   2533     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2534 
   2535     assert_eq!(block.transactions.len(), 2);
   2536     assert!(block.transactions.iter().any(Transaction::is_burn));
   2537     assert_eq!(block.reward, MINE_FINALIZER_FEE);
   2538 }
   2539 
   2540 #[test]
   2541 fn pending_mine_outputs_are_not_spendable_until_confirmed() {
   2542     let alice = Wallet::from_seed("pending-mine-spend-alice");
   2543     let bob = Wallet::from_seed("pending-mine-spend-bob");
   2544     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2545 
   2546     let mine = ledger.build_mine(alice.address()).unwrap();
   2547     let mine_outpoint = OutPoint {
   2548         txid: mine.signature().to_string(),
   2549         index: 0,
   2550     };
   2551     ledger.submit_transaction(mine.clone()).unwrap();
   2552 
   2553     assert!(
   2554         !ledger
   2555             .available_utxos_for_address(alice.address())
   2556             .unwrap()
   2557             .iter()
   2558             .any(|(outpoint, _)| outpoint == &mine_outpoint)
   2559     );
   2560     let pending_error = ledger
   2561         .build_transfer_with_inputs(
   2562             &alice,
   2563             bob.address(),
   2564             TEST_BURN_AMOUNT,
   2565             0,
   2566             std::slice::from_ref(&mine_outpoint),
   2567         )
   2568         .unwrap_err();
   2569     assert!(format!("{pending_error:#}").contains("not spendable"));
   2570 
   2571     let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap();
   2572     ledger.submit_transaction(burn).unwrap();
   2573     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2574     assert!(
   2575         block
   2576             .transactions
   2577             .iter()
   2578             .any(|tx| tx.signature() == mine.signature())
   2579     );
   2580     ledger.apply_locally_mined_block(block).unwrap();
   2581 
   2582     assert!(
   2583         ledger
   2584             .available_utxos_for_address(alice.address())
   2585             .unwrap()
   2586             .iter()
   2587             .any(|(outpoint, _)| outpoint == &mine_outpoint)
   2588     );
   2589     ledger
   2590         .build_transfer_with_inputs(
   2591             &alice,
   2592             bob.address(),
   2593             TEST_BURN_AMOUNT,
   2594             0,
   2595             std::slice::from_ref(&mine_outpoint),
   2596         )
   2597         .unwrap();
   2598 }
   2599 
   2600 #[test]
   2601 fn burns_built_after_pending_mine_do_not_spend_pending_mine_output() {
   2602     let alice = Wallet::from_seed("pending-mine-burn-alice");
   2603     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2604 
   2605     let mine = ledger.build_mine(alice.address()).unwrap();
   2606     let mine_outpoint = OutPoint {
   2607         txid: mine.signature().to_string(),
   2608         index: 0,
   2609     };
   2610     ledger.submit_transaction(mine).unwrap();
   2611 
   2612     let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap();
   2613 
   2614     let Transaction::Burn { inputs, .. } = &burn else {
   2615         panic!("expected burn transaction");
   2616     };
   2617     assert!(!inputs.iter().any(|input| input.outpoint == mine_outpoint));
   2618 }
   2619 
   2620 #[test]
   2621 fn pending_blinded_transactions_with_spent_inputs_are_pruned_after_block_apply() {
   2622     let alice = Wallet::from_seed("pending-blind-spent-prune-alice");
   2623     let mut mempool_ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2624     let mut block_ledger = mempool_ledger.clone();
   2625     let amount = mempool_ledger.balance_of(alice.address());
   2626     let blinded = mempool_ledger
   2627         .build_blinded_burn(&alice, amount, 0, mempool_ledger.height() + 4)
   2628         .unwrap();
   2629     mempool_ledger
   2630         .submit_blinded_transaction(blinded.transaction.clone())
   2631         .unwrap();
   2632 
   2633     let burn = block_ledger.build_burn(&alice, amount, 0).unwrap();
   2634     let Transaction::Burn { inputs, .. } = &burn else {
   2635         panic!("expected burn transaction");
   2636     };
   2637     assert!(blinded.transaction.inputs.iter().any(|input| {
   2638         inputs
   2639             .iter()
   2640             .any(|burn_input| burn_input.outpoint == input.outpoint)
   2641     }));
   2642     block_ledger.submit_transaction(burn).unwrap();
   2643     let block = block_ledger.mine_next_block(&alice, 1).unwrap();
   2644 
   2645     mempool_ledger.apply_block(block).unwrap();
   2646 
   2647     assert!(mempool_ledger.pending_blinded_transactions().is_empty());
   2648 }
   2649 
   2650 #[test]
   2651 fn block_selection_limits_mine_actions_per_anchor() {
   2652     let alice = Wallet::from_seed("mine-anchor-limit-selection-alice");
   2653     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2654 
   2655     let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
   2656     ledger.submit_transaction(burn).unwrap();
   2657     let first_mine = ledger.build_mine(alice.address()).unwrap();
   2658     ledger.submit_transaction(first_mine.clone()).unwrap();
   2659     let second_mine = ledger.build_mine(alice.address()).unwrap();
   2660     ledger.submit_transaction(second_mine.clone()).unwrap();
   2661     assert_eq!(ledger.pending().len(), 3);
   2662     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2663 
   2664     assert_eq!(block.transactions.len(), 3);
   2665     assert!(block.transactions.iter().any(Transaction::is_burn));
   2666     let included_mines = block
   2667         .transactions
   2668         .iter()
   2669         .filter(|transaction| matches!(transaction, Transaction::Mine { .. }))
   2670         .count();
   2671     assert_eq!(included_mines, MINE_ACTIONS_PER_ANCHOR_LIMIT);
   2672     assert!(
   2673         block
   2674             .transactions
   2675             .iter()
   2676             .any(|tx| tx.signature() == first_mine.signature())
   2677     );
   2678     assert!(
   2679         block
   2680             .transactions
   2681             .iter()
   2682             .any(|tx| tx.signature() == second_mine.signature())
   2683     );
   2684     assert_ne!(first_mine.signature(), second_mine.signature());
   2685     assert_eq!(block.reward, first_mine.fee() + second_mine.fee());
   2686 }
   2687 
   2688 #[test]
   2689 fn blocks_reject_too_many_mine_actions_for_one_anchor() {
   2690     let alice = Wallet::from_seed("mine-anchor-limit-block-alice");
   2691     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2692 
   2693     let first_mine = test_mine_with_salt(&ledger, alice.address(), 1);
   2694     let second_mine = test_mine_with_salt(&ledger, alice.address(), 2);
   2695     let third_mine = test_mine_with_salt(&ledger, alice.address(), 3);
   2696     let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
   2697     ledger.submit_transaction(burn).unwrap();
   2698     let mut block = ledger
   2699         .prepare_next_block(
   2700             alice.address(),
   2701             ledger
   2702                 .tip()
   2703                 .timestamp_ms
   2704                 .saturating_add(VDF_TARGET_BLOCK_MS),
   2705         )
   2706         .unwrap()
   2707         .finish(&alice, "preverified-vdf".to_string());
   2708     block.transactions.push(first_mine);
   2709     block.transactions.push(second_mine);
   2710     block.transactions.push(third_mine);
   2711     block.reward = fee_reward(&block.transactions).unwrap();
   2712     block.hash = block.compute_hash();
   2713 
   2714     let error = ledger
   2715         .apply_preverified_block_at(block, u64::MAX)
   2716         .unwrap_err();
   2717 
   2718     assert!(format!("{error:#}").contains("mine actions per anchor limit"));
   2719 }
   2720 
   2721 #[test]
   2722 fn mempool_rejects_mine_actions_above_anchor_limit() {
   2723     let alice = Wallet::from_seed("mine-anchor-limit-mempool-alice");
   2724     let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
   2725 
   2726     let first_mine = test_mine_with_salt(&ledger, alice.address(), 1);
   2727     let second_mine = test_mine_with_salt(&ledger, alice.address(), 2);
   2728     let third_mine = test_mine_with_salt(&ledger, alice.address(), 3);
   2729     ledger.submit_transaction(first_mine).unwrap();
   2730     ledger.submit_transaction(second_mine).unwrap();
   2731     let error = ledger.submit_transaction(third_mine).unwrap_err();
   2732 
   2733     assert!(format!("{error:#}").contains("mine transaction anchor limit reached"));
   2734 }
   2735 
   2736 #[test]
   2737 fn mine_difficulty_increases_when_issuance_exceeds_target_window() {
   2738     let alice = Wallet::from_seed("mine-difficulty-up-alice");
   2739     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2740 
   2741     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2742         apply_preverified_burn_block_with_mines(&mut ledger, &alice, 2);
   2743     }
   2744 
   2745     assert_eq!(
   2746         ledger.current_mine_difficulty_bits(),
   2747         MINE_DIFFICULTY_BITS + 1
   2748     );
   2749     let mine = ledger.build_mine(alice.address()).unwrap();
   2750     let Transaction::Mine {
   2751         difficulty_bits, ..
   2752     } = mine
   2753     else {
   2754         panic!("expected mine action");
   2755     };
   2756     assert_eq!(difficulty_bits, MINE_DIFFICULTY_BITS + 1);
   2757 }
   2758 
   2759 #[test]
   2760 fn mine_difficulty_decreases_when_issuance_is_below_target_window() {
   2761     let alice = Wallet::from_seed("mine-difficulty-down-alice");
   2762     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2763 
   2764     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2765         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2766     }
   2767 
   2768     assert_eq!(
   2769         ledger.current_mine_difficulty_bits(),
   2770         MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS
   2771     );
   2772 
   2773     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2774         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2775     }
   2776 
   2777     assert_eq!(
   2778         ledger.current_mine_difficulty_bits(),
   2779         MINE_MIN_DIFFICULTY_BITS
   2780     );
   2781 
   2782     for _ in 0..MINE_RETARGET_WINDOW_BLOCKS {
   2783         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2784     }
   2785 
   2786     assert_eq!(
   2787         ledger.current_mine_difficulty_bits(),
   2788         MINE_MIN_DIFFICULTY_BITS
   2789     );
   2790 }
   2791 
   2792 #[test]
   2793 fn mine_actions_expire_when_anchor_is_too_old() {
   2794     let alice = Wallet::from_seed("mine-anchor-expiry-alice");
   2795     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2796     let stale_mine = ledger.build_mine(alice.address()).unwrap();
   2797 
   2798     for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS {
   2799         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2800     }
   2801 
   2802     let error = ledger.submit_transaction(stale_mine).unwrap_err();
   2803     assert!(format!("{error:#}").contains("mine transaction anchor is too old"));
   2804 }
   2805 
   2806 #[test]
   2807 fn pending_mine_actions_are_removed_when_anchor_expires() {
   2808     let alice = Wallet::from_seed("pending-mine-anchor-expiry-alice");
   2809     let bob = Wallet::from_seed("pending-mine-anchor-expiry-bob");
   2810     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2811     ledger.launch_profile.max_block_transactions = 1;
   2812     let stale_mine = ledger.build_mine(bob.address()).unwrap();
   2813     ledger.submit_transaction(stale_mine.clone()).unwrap();
   2814 
   2815     for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS {
   2816         mine_burn_block_with_mines(&mut ledger, &alice, 0);
   2817     }
   2818 
   2819     assert!(
   2820         ledger
   2821             .pending()
   2822             .iter()
   2823             .all(|tx| tx.signature() != stale_mine.signature())
   2824     );
   2825 }
   2826 
   2827 #[test]
   2828 fn stratum_mine_header_proof_is_validated() {
   2829     let alice = Wallet::from_seed("stratum-proof-alice");
   2830     let ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2831     let anchor = ledger.tip().hash.clone();
   2832     let difficulty_bits = ledger.current_mine_difficulty_bits();
   2833     let template = ledger
   2834         .stratum_mine_template(alice.address(), anchor, 1, difficulty_bits)
   2835         .unwrap();
   2836 
   2837     let mut accepted = None;
   2838     for nonce in 0_u32..50_000 {
   2839         let result = ledger.build_stratum_mine(
   2840             template.clone(),
   2841             StratumMineShare {
   2842                 extranonce2: [0, 0, 0, 0],
   2843                 header_nonce: nonce.to_le_bytes(),
   2844             },
   2845         );
   2846         if let Ok(tx) = result {
   2847             accepted = Some(tx);
   2848             break;
   2849         }
   2850     }
   2851 
   2852     let tx = accepted.expect("expected Stratum proof within search range");
   2853     let Transaction::Mine {
   2854         proof_header,
   2855         signature,
   2856         ..
   2857     } = tx
   2858     else {
   2859         panic!("expected mine action");
   2860     };
   2861     assert_eq!(proof_header.as_deref().unwrap_or_default().len(), 160);
   2862     assert!(hash_meets_difficulty(&signature, difficulty_bits));
   2863 }
   2864 
   2865 #[test]
   2866 fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() {
   2867     let alice = Wallet::from_seed("stratum-salt-alice");
   2868     let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA);
   2869     let anchor = ledger.tip().hash.clone();
   2870     let difficulty_bits = ledger.current_mine_difficulty_bits();
   2871 
   2872     for salt in [1, 2] {
   2873         let template = ledger
   2874             .stratum_mine_template(alice.address(), anchor.clone(), salt, difficulty_bits)
   2875             .unwrap();
   2876         let mut accepted = None;
   2877         for nonce in 0_u32..50_000 {
   2878             let result = ledger.build_stratum_mine(
   2879                 template.clone(),
   2880                 StratumMineShare {
   2881                     extranonce2: [0, 0, 0, 0],
   2882                     header_nonce: nonce.to_le_bytes(),
   2883                 },
   2884             );
   2885             if let Ok(tx) = result {
   2886                 accepted = Some(tx);
   2887                 break;
   2888             }
   2889         }
   2890         let tx = accepted.expect("expected Stratum proof within search range");
   2891         assert!(ledger.submit_transaction(tx).unwrap());
   2892     }
   2893 
   2894     assert_eq!(ledger.pending().len(), 2);
   2895     let salts = ledger
   2896         .pending()
   2897         .iter()
   2898         .map(|tx| match tx {
   2899             Transaction::Mine { salt, .. } => *salt,
   2900             _ => panic!("expected mine action"),
   2901         })
   2902         .collect::<BTreeSet<_>>();
   2903     assert_eq!(salts, BTreeSet::from([1, 2]));
   2904 }