iuna

iuna

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

app.rs (24164B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     sync::{
      4         Arc,
      5         atomic::{AtomicBool, Ordering},
      6     },
      7     time::{SystemTime, UNIX_EPOCH},
      8 };
      9 
     10 use anyhow::Result;
     11 use tokio::sync::Mutex;
     12 
     13 use crate::domain::{
     14     Amount, BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, Ledger,
     15     MINE_ACTIONS_PER_ANCHOR_LIMIT, PreparedBlock, RevealBundle, Transaction, run_vdf,
     16 };
     17 
     18 mod automatic_mining;
     19 mod gossip;
     20 mod helpers;
     21 mod in_memory_network;
     22 mod ledger_view;
     23 mod node_lifecycle;
     24 mod owned_blinded;
     25 mod peer_book;
     26 mod receive;
     27 mod status;
     28 mod types;
     29 mod wallet;
     30 pub use in_memory_network::InMemoryNetwork;
     31 pub use peer_book::{PeerBook, PeerDirection, PeerInfo};
     32 pub use types::{
     33     AutoMineOutcome, AutoMinePlan, BlockInventory, ExternalMineJob, FeeEstimate, GossipEnvelope,
     34     LaunchProfileStatus, MiningStatus, NodeConfig, NodeStatus, ProtocolHello, StratumStatus,
     35 };
     36 use wallet::NodeWallet;
     37 
     38 pub type SharedNode = Arc<Mutex<NodeCore>>;
     39 pub type SharedPeerBook = Arc<Mutex<PeerBook>>;
     40 
     41 pub const DEFAULT_BURN_PER_BLOCK: Amount = 0;
     42 pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000;
     43 pub const PROTOCOL_VERSION: u32 = 1;
     44 pub const NETWORK_ID: &str = "iuna-devnet-v3";
     45 pub const BLOCK_REQUEST_LIMIT: usize = 128;
     46 pub const TRANSACTION_BATCH_LIMIT: usize = 128;
     47 const IMPORT_REBROADCAST_LIMIT: usize = 128;
     48 pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3;
     49 pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000;
     50 pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000;
     51 const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000;
     52 const AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK: u64 = 100_000;
     53 const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000;
     54 const REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000;
     55 const AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1;
     56 const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 0;
     57 static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false);
     58 
     59 pub fn set_debug_logging(enabled: bool) {
     60     DEBUG_LOGGING.store(enabled, Ordering::Relaxed);
     61 }
     62 
     63 pub fn debug_logging_enabled() -> bool {
     64     DEBUG_LOGGING.load(Ordering::Relaxed)
     65 }
     66 
     67 #[derive(Clone, Debug, Eq, PartialEq)]
     68 struct AutoPowMineCursor {
     69     anchor: String,
     70     salt: u64,
     71     next_nonce: u64,
     72     searched: u64,
     73 }
     74 
     75 #[derive(Clone, Debug)]
     76 pub struct AutoPowMineJob {
     77     ledger: Ledger,
     78     recipient: String,
     79     anchor: String,
     80     salt: u64,
     81     start_nonce: u64,
     82     max_attempts: u64,
     83 }
     84 
     85 impl AutoPowMineJob {
     86     pub fn anchor(&self) -> &str {
     87         &self.anchor
     88     }
     89 
     90     pub fn search(self) -> Result<(Self, crate::domain::MineSearchOutcome)> {
     91         let outcome = self.ledger.search_mine(
     92             self.recipient.clone(),
     93             self.salt,
     94             self.start_nonce,
     95             self.max_attempts,
     96         )?;
     97         Ok((self, outcome))
     98     }
     99 }
    100 
    101 #[derive(Clone, Debug)]
    102 pub struct NodeCore {
    103     wallet: NodeWallet,
    104     ledger: Ledger,
    105     automatic_mining_enabled: bool,
    106     pow_mining_enabled: bool,
    107     pow_mining_workers: u8,
    108     burn_per_block: Amount,
    109     burn_fee: Amount,
    110     recovery_vdf_top_rank_percent: u8,
    111     last_auto_burn_height: Option<u64>,
    112     last_auto_anchor_burn_height: Option<u64>,
    113     last_auto_finalization_status: Option<String>,
    114     last_auto_pow_mine_anchor: Option<String>,
    115     last_auto_pow_mine_status: Option<String>,
    116     auto_pow_mine_cursor: Option<AutoPowMineCursor>,
    117     owned_blinded_transactions: BTreeMap<String, BlindedTransaction>,
    118     owned_blinded_reveals: BTreeMap<String, BlindedReveal>,
    119     owned_blinded_payloads: BTreeMap<String, Transaction>,
    120     owned_blinded_outbox_version: u64,
    121     reveal_bundles: BTreeMap<(u64, u8), RevealBundle>,
    122     equivocated_reveal_bundle_slots: BTreeSet<(u64, u8)>,
    123     reveal_bundle_collection_started: Option<(u64, u64)>,
    124     local_block_anchor_burn: Option<(u64, Transaction)>,
    125     outbox: Vec<GossipEnvelope>,
    126 }
    127 
    128 pub fn now_ms() -> u64 {
    129     SystemTime::now()
    130         .duration_since(UNIX_EPOCH)
    131         .expect("system time is before unix epoch")
    132         .as_millis() as u64
    133 }
    134 
    135 #[cfg(test)]
    136 mod tests {
    137     use std::collections::BTreeMap;
    138 
    139     use crate::domain::{
    140         GenesisBurn, Ledger, MICRO_IUNA, OutPoint, Transaction, VDF_TARGET_BLOCK_MS, Wallet,
    141         run_vdf,
    142     };
    143 
    144     use super::{
    145         InMemoryNetwork, NodeCore, REVEAL_BUNDLE_COLLECTION_MS,
    146         helpers::transaction_input_outpoints,
    147     };
    148 
    149     fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet {
    150         wallets
    151             .iter()
    152             .find(|wallet| wallet.address() == address)
    153             .unwrap_or_else(|| panic!("missing wallet for address {address}"))
    154     }
    155 
    156     fn queue_auto_pow_mine_action(node: &mut NodeCore) -> Transaction {
    157         node.set_pow_mining_enabled(true);
    158         (0..10_000)
    159             .find_map(|timestamp| node.prepare_automatic_mining(timestamp).pow_mined)
    160             .expect("test node should find a PoW mine action")
    161     }
    162 
    163     fn assert_block_has_mine_action(block: &crate::domain::Block) {
    164         assert!(
    165             block
    166                 .transactions
    167                 .iter()
    168                 .any(|transaction| matches!(transaction, Transaction::Mine { .. })),
    169             "block {} should include a mine action",
    170             block.height
    171         );
    172     }
    173 
    174     #[test]
    175     fn automatic_finalization_includes_reveals_with_two_nodes_and_one_burner() {
    176         let finalizer = Wallet::from_seed("single-burner-reveal-finalizer");
    177         let wallet = Wallet::from_seed("single-burner-reveal-wallet");
    178         let mut allocations = BTreeMap::new();
    179         allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA);
    180         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
    181         let ledger = Ledger::new_with_genesis_burns(
    182             allocations,
    183             vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
    184             1,
    185         )
    186         .unwrap();
    187         let mut network = InMemoryNetwork::default();
    188         network.insert(
    189             "finalizer",
    190             NodeCore::from_ledger_with_burn_fee_and_enabled(
    191                 finalizer.clone(),
    192                 ledger.clone(),
    193                 true,
    194                 MICRO_IUNA / 10,
    195                 1,
    196             ),
    197         );
    198         network.insert("wallet", NodeCore::from_ledger(wallet.clone(), ledger, 0));
    199 
    200         let blinded = network
    201             .node_mut("wallet")
    202             .unwrap()
    203             .blinded_burn_with_fee(MICRO_IUNA / 10, 1, 4)
    204             .unwrap();
    205         network.deliver_until_idle().unwrap();
    206 
    207         let commit_plan = network
    208             .node_mut("finalizer")
    209             .unwrap()
    210             .prepare_automatic_finalization(1);
    211         let commit_work = commit_plan
    212             .work
    213             .expect("finalizer should prepare commit block");
    214         let commit_vdf = run_vdf(commit_work.vdf_seed(), commit_work.vdf_rounds());
    215         let commit_block = network
    216             .node_mut("finalizer")
    217             .unwrap()
    218             .complete_prepared_block_at(commit_work, commit_vdf, 1)
    219             .unwrap();
    220         let wallet_commitment = blinded.commitment.clone();
    221         assert!(
    222             commit_block
    223                 .blinded_transactions
    224                 .iter()
    225                 .any(|transaction| transaction.commitment == wallet_commitment),
    226             "first block should commit the wallet's blinded burn"
    227         );
    228         network.deliver_until_idle().unwrap();
    229         assert!(
    230             network
    231                 .node("finalizer")
    232                 .unwrap()
    233                 .ledger()
    234                 .pending_blinded_reveals()
    235                 .iter()
    236                 .any(|reveal| reveal.commitment == wallet_commitment),
    237             "finalizer should have received the reveal before building the next block"
    238         );
    239         assert!(
    240             network
    241                 .node("wallet")
    242                 .unwrap()
    243                 .ledger()
    244                 .pending_blinded_reveals()
    245                 .iter()
    246                 .any(|reveal| reveal.commitment == wallet_commitment),
    247             "wallet node should also keep the reveal in its mempool"
    248         );
    249 
    250         let reveal_plan = network
    251             .node_mut("finalizer")
    252             .unwrap()
    253             .prepare_automatic_finalization(2);
    254         assert!(reveal_plan.work.is_none());
    255         assert!(
    256             reveal_plan
    257                 .skipped_reason
    258                 .as_deref()
    259                 .unwrap_or_default()
    260                 .contains("collecting blinded reveals")
    261         );
    262         let reveal_plan = network
    263             .node_mut("finalizer")
    264             .unwrap()
    265             .prepare_automatic_finalization(REVEAL_BUNDLE_COLLECTION_MS + 2);
    266         let reveal_work = reveal_plan
    267             .work
    268             .expect("finalizer should prepare reveal block");
    269         let reveal_vdf = run_vdf(reveal_work.vdf_seed(), reveal_work.vdf_rounds());
    270         let reveal_block = network
    271             .node_mut("finalizer")
    272             .unwrap()
    273             .complete_prepared_block_at(reveal_work, reveal_vdf, 2)
    274             .unwrap();
    275 
    276         assert!(
    277             reveal_block
    278                 .all_blinded_reveals()
    279                 .iter()
    280                 .any(|reveal| reveal.commitment == wallet_commitment),
    281             "automatic finalization should include the pending reveal without requiring an extra mempool poll"
    282         );
    283     }
    284 
    285     #[test]
    286     fn genesis_transfer_arriving_during_vdf_with_peer_mines_does_not_stall_following_blocks() {
    287         let miner = Wallet::from_seed("during-vdf-miner");
    288         let (_finalizer, finalizer_node, block2_work) = (0..1_000)
    289             .find_map(|seed_index| {
    290                 let finalizer = Wallet::from_seed(&format!("during-vdf-finalizer-{seed_index}"));
    291                 let mut allocations = BTreeMap::new();
    292                 allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA);
    293                 let mut ledger = Ledger::new_with_genesis_burns(
    294                     allocations,
    295                     vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
    296                     1,
    297                 )
    298                 .unwrap();
    299                 let split = ledger
    300                     .build_transfer(&finalizer, finalizer.address(), MICRO_IUNA / 10, 0)
    301                     .ok()?;
    302                 let split_change = OutPoint {
    303                     txid: split.signature().to_string(),
    304                     index: 1,
    305                 };
    306                 ledger.submit_transaction(split).ok()?;
    307                 let burn = ledger
    308                     .build_burn_with_inputs(&finalizer, 1, 0, &[split_change])
    309                     .ok()?;
    310                 ledger.submit_transaction(burn).ok()?;
    311                 let block1 = ledger.mine_next_block(&finalizer, 1).ok()?;
    312                 assert!(block1.blinded_transactions.is_empty());
    313                 assert!(
    314                     !block1
    315                         .transactions
    316                         .iter()
    317                         .any(|transaction| matches!(transaction, Transaction::Mine { .. })),
    318                     "node B joins after block 1, so block 1 should not include B's mine action"
    319                 );
    320                 ledger.apply_block(block1).ok()?;
    321                 let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    322                     finalizer.clone(),
    323                     ledger,
    324                     true,
    325                     0,
    326                     100,
    327                 );
    328                 let block2_plan = node.prepare_automatic_finalization(2);
    329                 let block2_work = block2_plan.work?;
    330                 let (_, anchor_burn) = node.local_block_anchor_burn.clone()?;
    331                 let anchor_inputs = transaction_input_outpoints(&anchor_burn);
    332                 let anchor_total = node
    333                     .ledger()
    334                     .utxos_for_address(finalizer.address())
    335                     .iter()
    336                     .filter(|(outpoint, _)| anchor_inputs.contains(outpoint))
    337                     .map(|(_, output)| output.amount)
    338                     .sum::<u64>();
    339                 if anchor_total >= 200_000 {
    340                     return None;
    341                 }
    342                 Some((finalizer, node, block2_work))
    343             })
    344             .expect("test should find a seed where block 2 anchor uses the small reward UTXO");
    345         let mut network = InMemoryNetwork::default();
    346         network.insert("finalizer", finalizer_node);
    347 
    348         let miner_ledger =
    349             Ledger::from_snapshot(network.node("finalizer").unwrap().chain_snapshot()).unwrap();
    350         network.insert(
    351             "miner",
    352             NodeCore::from_ledger(miner.clone(), miner_ledger, 0),
    353         );
    354 
    355         queue_auto_pow_mine_action(network.node_mut("miner").unwrap());
    356         network.deliver_until_idle().unwrap();
    357         network
    358             .node_mut("finalizer")
    359             .unwrap()
    360             .transfer_with_fee_rate(miner.address(), MICRO_IUNA / 10, 100, &[])
    361             .unwrap();
    362         let blinded = network
    363             .node("finalizer")
    364             .unwrap()
    365             .ledger()
    366             .pending_blinded_transactions()
    367             .last()
    368             .cloned()
    369             .expect("A should queue the A -> B blinded transfer while block 2 VDF is running");
    370         network.deliver_until_idle().unwrap();
    371         assert!(
    372             network
    373                 .node("finalizer")
    374                 .unwrap()
    375                 .ledger()
    376                 .pending_blinded_transactions()
    377                 .iter()
    378                 .any(|tx| tx.commitment == blinded.commitment),
    379             "the finalizer should receive the blinded tx while block 2 VDF is running"
    380         );
    381 
    382         let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds());
    383         let block2 = network
    384             .node_mut("finalizer")
    385             .unwrap()
    386             .complete_prepared_block_at(block2_work, block2_vdf, 2)
    387             .unwrap();
    388         assert!(
    389             block2.blinded_transactions.is_empty(),
    390             "block 2 work was prepared before the blinded tx arrived"
    391         );
    392         assert!(
    393             !block2
    394                 .transactions
    395                 .iter()
    396                 .any(|transaction| matches!(transaction, Transaction::Mine { .. })),
    397             "block 2 work was prepared before B's mine action arrived"
    398         );
    399         network.deliver_until_idle().unwrap();
    400 
    401         let block3_outcome = network
    402             .node_mut("finalizer")
    403             .unwrap()
    404             .automatic_mine_once(3);
    405         assert!(
    406             block3_outcome.block.is_some(),
    407             "finalizer should keep producing after the during-VDF blinded tx: {:?}",
    408             block3_outcome.skipped_reason
    409         );
    410         let block3 = block3_outcome.block.unwrap();
    411         assert!(
    412             block3
    413                 .transactions
    414                 .first()
    415                 .is_some_and(Transaction::is_burn),
    416             "the mandatory anchor burn must be selected before during-VDF mempool items"
    417         );
    418         let committed_blinded = block3
    419             .blinded_transactions
    420             .iter()
    421             .any(|tx| tx.commitment == blinded.commitment);
    422         assert_block_has_mine_action(&block3);
    423         network.deliver_until_idle().unwrap();
    424 
    425         queue_auto_pow_mine_action(network.node_mut("miner").unwrap());
    426         network.deliver_until_idle().unwrap();
    427         let block4_started_at = block3.timestamp_ms.saturating_add(1);
    428         let mut block4_outcome = network
    429             .node_mut("finalizer")
    430             .unwrap()
    431             .automatic_mine_once(block4_started_at);
    432         if block4_outcome
    433             .skipped_reason
    434             .as_deref()
    435             .is_some_and(|reason| reason.contains("collecting blinded reveals"))
    436         {
    437             block4_outcome = network.node_mut("finalizer").unwrap().automatic_mine_once(
    438                 block4_started_at.saturating_add(REVEAL_BUNDLE_COLLECTION_MS + 1),
    439             );
    440         }
    441         let block4 = block4_outcome
    442             .block
    443             .expect("finalizer should keep producing the next block");
    444         if committed_blinded {
    445             assert!(
    446                 block4
    447                     .all_blinded_reveals()
    448                     .iter()
    449                     .any(|reveal| reveal.commitment == blinded.commitment),
    450                 "the committed during-VDF blinded tx should reveal in a later block"
    451             );
    452         } else {
    453             assert!(
    454                 network
    455                     .node("finalizer")
    456                     .unwrap()
    457                     .ledger()
    458                     .pending_blinded_transactions()
    459                     .is_empty(),
    460                 "a conflicting during-VDF blinded tx should be pruned after the anchor burn spends its input"
    461             );
    462             assert!(
    463                 network
    464                     .node("finalizer")
    465                     .unwrap()
    466                     .owned_blinded_transactions()
    467                     .is_empty(),
    468                 "owned blinded state should not keep rebroadcasting a pruned tx"
    469             );
    470             assert!(
    471                 block4.all_blinded_reveals().is_empty(),
    472                 "a pruned blinded tx was never committed, so there should be no reveal"
    473             );
    474         }
    475         assert_block_has_mine_action(&block4);
    476     }
    477 
    478     #[test]
    479     fn own_blinded_transaction_arriving_during_vdf_does_not_starve_next_anchor_burn() {
    480         let recipient = Wallet::from_seed("during-vdf-own-recipient");
    481         let (mut node, block2_work, transfer_outpoint, transfer_amount) = (0..1_000)
    482             .find_map(|seed_index| {
    483                 let finalizer =
    484                     Wallet::from_seed(&format!("during-vdf-own-finalizer-{seed_index}"));
    485                 let mut allocations = BTreeMap::new();
    486                 allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA);
    487                 let ledger = Ledger::new_with_genesis_burns(
    488                     allocations,
    489                     vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
    490                     1,
    491                 )
    492                 .unwrap();
    493                 let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    494                     finalizer.clone(),
    495                     ledger,
    496                     true,
    497                     0,
    498                     1_000,
    499                 );
    500                 node.set_pow_mining_enabled(true);
    501                 let block1 = node.automatic_mine_once(1).block?;
    502                 assert!(block1.blinded_transactions.is_empty());
    503 
    504                 let block2_plan = node.prepare_automatic_finalization(2);
    505                 let block2_work = block2_plan.work?;
    506                 let (_, anchor_burn) = node.local_block_anchor_burn.clone()?;
    507                 let anchor_inputs = transaction_input_outpoints(&anchor_burn);
    508                 let utxos = node.ledger().utxos_for_address(finalizer.address());
    509                 let anchor_total = utxos
    510                     .iter()
    511                     .filter(|(outpoint, _)| anchor_inputs.contains(outpoint))
    512                     .map(|(_, output)| output.amount)
    513                     .sum::<u64>();
    514                 let (transfer_outpoint, transfer_output) =
    515                     utxos.into_iter().find(|(outpoint, output)| {
    516                         !anchor_inputs.contains(outpoint) && output.amount > MICRO_IUNA
    517                     })?;
    518                 if anchor_total >= 2_000_000 {
    519                     return None;
    520                 }
    521                 Some((
    522                     node,
    523                     block2_work,
    524                     transfer_outpoint,
    525                     transfer_output.amount.min(MICRO_IUNA) / 10,
    526                 ))
    527             })
    528             .expect("test should find a seed with live-like small-anchor/large-change UTXOs");
    529 
    530         node.transfer_with_fee_spending(
    531             recipient.address(),
    532             transfer_amount,
    533             1,
    534             &[transfer_outpoint],
    535         )
    536         .expect("wallet tx created while block 2 VDF is running");
    537         let blinded = node
    538             .ledger()
    539             .pending_blinded_transactions()
    540             .last()
    541             .cloned()
    542             .expect("wallet tx should be queued as a blinded transaction");
    543 
    544         let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds());
    545         let block2 = node
    546             .complete_prepared_block_at(block2_work, block2_vdf, 2)
    547             .unwrap();
    548         assert!(
    549             block2.blinded_transactions.is_empty(),
    550             "block 2 work was prepared before the blinded tx arrived"
    551         );
    552         assert!(
    553             node.ledger()
    554                 .pending_blinded_transactions()
    555                 .iter()
    556                 .any(|tx| tx.commitment == blinded.commitment),
    557             "the during-VDF blinded tx should remain pending for block 3"
    558         );
    559 
    560         let block3_outcome = node.automatic_mine_once(3);
    561         assert!(
    562             block3_outcome.block.is_some(),
    563             "pending own blinded tx must not starve the next anchor burn: {:?}",
    564             block3_outcome.skipped_reason
    565         );
    566         let block3 = block3_outcome.block.unwrap();
    567         assert!(
    568             block3.blinded_transactions.is_empty()
    569                 || block3
    570                     .blinded_transactions
    571                     .iter()
    572                     .any(|tx| tx.commitment == blinded.commitment),
    573             "block 3 may include the during-VDF tx, but must not stall when the anchor burn has priority"
    574         );
    575     }
    576 
    577     #[test]
    578     fn locally_produced_blocks_import_on_independent_peer_ledger() {
    579         let alice = Wallet::from_seed("producer-parity-alice");
    580         let bob = Wallet::from_seed("producer-parity-bob");
    581         let carol = Wallet::from_seed("producer-parity-carol");
    582         let wallets = [alice.clone(), bob.clone(), carol.clone()];
    583         let mut allocations = BTreeMap::new();
    584         for wallet in &wallets {
    585             allocations.insert(wallet.address().to_string(), 20 * MICRO_IUNA);
    586         }
    587         let genesis_burns = wallets
    588             .iter()
    589             .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
    590             .collect();
    591         let mut producer_ledger =
    592             Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap();
    593         let mut peer_ledger = producer_ledger.clone();
    594 
    595         for step in 0..8 {
    596             assert_eq!(
    597                 producer_ledger.status().tip_hash,
    598                 peer_ledger.status().tip_hash
    599             );
    600             let leader = producer_ledger
    601                 .expected_leader_for_next_block()
    602                 .expect("test chain should have an eligible leader");
    603             let leader_wallet = wallet_for_address(&wallets, &leader).clone();
    604             let timestamp_ms = (step + 1) as u64 * VDF_TARGET_BLOCK_MS;
    605             let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    606                 leader_wallet.clone(),
    607                 producer_ledger.clone(),
    608                 true,
    609                 MICRO_IUNA / 10,
    610                 1,
    611             );
    612             let plan = node.prepare_automatic_finalization(timestamp_ms);
    613             assert!(plan.burned.is_some());
    614 
    615             match step % 3 {
    616                 0 => {
    617                     let _ = node.blinded_burn_with_fee(1, 0, node.chain_height() + 4);
    618                 }
    619                 1 => {
    620                     let recipient = wallets[(step + 1) % wallets.len()].address();
    621                     let _ =
    622                         node.blinded_transfer_with_fee(recipient, 1, 0, node.chain_height() + 4);
    623                 }
    624                 _ => {}
    625             }
    626 
    627             let work = node
    628                 .prepare_next_block_with_local_anchor(timestamp_ms)
    629                 .unwrap();
    630             let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
    631             let block = node
    632                 .complete_prepared_block_at(work, vdf_output, timestamp_ms)
    633                 .unwrap();
    634             peer_ledger.apply_block_at(block, u64::MAX).unwrap();
    635             producer_ledger = node.clone_ledger();
    636             assert_eq!(
    637                 producer_ledger.status().tip_hash,
    638                 peer_ledger.status().tip_hash
    639             );
    640         }
    641     }
    642 }