diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index b6ec5efc..3f96cb94 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -36,33 +36,46 @@ pub async fn consolidate_deposits(runtime: R) { Err(_) => return, }; - let consolidation_rounds: Vec> = - read_state(|s| group_deposits_by_account(s.deposits_to_consolidate())) - .into_iter() - .chunks(MAX_TRANSFERS_PER_CONSOLIDATION) - .into_iter() - .map(Iterator::collect) - .collect(); - - for round in &consolidation_rounds + let all_deposits = read_state(|s| group_deposits_by_account(s.deposits_to_consolidate())); + let more_to_process = + all_deposits.len() > MAX_CONCURRENT_RPC_CALLS * MAX_TRANSFERS_PER_CONSOLIDATION; + let reschedule = scopeguard::guard(runtime.clone(), |runtime| { + runtime.set_timer(Duration::ZERO, consolidate_deposits); + }); + + let batches: Vec> = all_deposits + .into_iter() + .chunks(MAX_TRANSFERS_PER_CONSOLIDATION) .into_iter() - .chunks(MAX_CONCURRENT_RPC_CALLS) - { - let (slot, recent_blockhash) = match get_recent_slot_and_blockhash(&runtime).await { - Ok((slot, blockhash)) => (slot, blockhash), - Err(e) => { - log!(Priority::Info, "Failed to fetch recent blockhash: {e}"); - return; - } - }; - - futures::future::join_all(round.map(async |funds| { - match submit_consolidation_transaction(&runtime, funds, slot, recent_blockhash).await { - Ok(sig) => log!(Priority::Info, "Submitted consolidation transaction {sig}"), - Err(e) => log!(Priority::Info, "Deposit consolidation failed: {e}"), - } - })) - .await; + .take(MAX_CONCURRENT_RPC_CALLS) + .map(Iterator::collect) + .collect(); + + if batches.is_empty() { + // Nothing to process + scopeguard::ScopeGuard::into_inner(reschedule); + return; + } + + let (slot, recent_blockhash) = match get_recent_slot_and_blockhash(&runtime).await { + Ok(result) => result, + Err(e) => { + log!(Priority::Info, "Failed to fetch recent blockhash: {e}"); + return; + } + }; + + futures::future::join_all(batches.into_iter().map(async |funds| { + match submit_consolidation_transaction(&runtime, funds, slot, recent_blockhash).await { + Ok(sig) => log!(Priority::Info, "Submitted consolidation transaction {sig}"), + Err(e) => log!(Priority::Info, "Deposit consolidation failed: {e}"), + } + })) + .await; + + if !more_to_process { + // All work fits in this round + scopeguard::ScopeGuard::into_inner(reschedule); } } diff --git a/minter/src/consolidate/tests.rs b/minter/src/consolidate/tests.rs index 193eb03c..cb52de76 100644 --- a/minter/src/consolidate/tests.rs +++ b/minter/src/consolidate/tests.rs @@ -1,10 +1,11 @@ use super::{MAX_TRANSFERS_PER_CONSOLIDATION, consolidate_deposits}; use crate::{ + constants::MAX_CONCURRENT_RPC_CALLS, numeric::LedgerMintIndex, state::{ TaskType, event::{DepositId, EventType, TransactionPurpose}, - mutate_state, + mutate_state, read_state, }, test_fixtures::{ EventsAssert, account, confirmed_block, deposit_id, @@ -144,14 +145,14 @@ async fn should_submit_multiple_consolidation_batches() { setup(); let funds: Vec<_> = (0..NUM_DEPOSITS) - .map(|i| (deposit_id(i as u8), (i as u64 + 1) * 1_000_000_000)) + .map(|i| (deposit_id(i), (i as u64 + 1) * 1_000_000_000)) .collect(); add_funds_to_consolidate(&funds); const BATCH_1_SIZE: usize = MAX_TRANSFERS_PER_CONSOLIDATION; let fee_payer_signature_1 = signature(0); - let fee_payer_signature_2 = signature(BATCH_1_SIZE as u8); + let fee_payer_signature_2 = signature(BATCH_1_SIZE); let slot = 100; let mut runtime = TestCanisterRuntime::new() @@ -167,7 +168,7 @@ async fn should_submit_multiple_consolidation_batches() { ))); for i in 0..(2 + NUM_DEPOSITS) { - runtime = runtime.add_signature([i as u8; 64]); + runtime = runtime.add_signature(signature(i).into()); } consolidate_deposits(runtime).await; @@ -254,6 +255,61 @@ async fn should_consolidate_multiple_deposits_to_same_account_in_single_transfer .assert_no_more_events(); } +#[tokio::test] +async fn should_reschedule_until_all_deposits_consolidated() { + setup(); + + let num_deposits = MAX_TRANSFERS_PER_CONSOLIDATION * MAX_CONCURRENT_RPC_CALLS + 1; + add_funds_to_consolidate( + &(0..num_deposits) + .map(|i| (deposit_id(i), (i as u64 + 1) * 1_000_000_000)) + .collect::>(), + ); + + let slot = 100; + + // Round 1: processes MAX_CONCURRENT_RPC_CALLS batches, 1 deposit remains → reschedule + let mut runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))); + for i in 0..MAX_CONCURRENT_RPC_CALLS { + runtime = + runtime.add_stub_response(SendTransactionResult::Consistent(Ok(signature(i).into()))); + } + for i in 0..(MAX_CONCURRENT_RPC_CALLS + num_deposits) { + runtime = runtime.add_signature(signature(i).into()); + } + + consolidate_deposits(runtime.clone()).await; + + read_state(|s| { + assert_eq!(s.submitted_transactions().len(), MAX_CONCURRENT_RPC_CALLS); + assert_eq!(s.deposits_to_consolidate().len(), 1); + }); + assert_eq!(runtime.set_timer_call_count(), 1); + + // Round 2: processes the remaining 1 deposit → no reschedule + let last_sig = signature(num_deposits); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(slot))) + .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))) + .add_stub_response(SendTransactionResult::Consistent(Ok(last_sig.into()))) + .add_signature(last_sig.into()); + + consolidate_deposits(runtime.clone()).await; + + read_state(|s| { + assert_eq!( + s.submitted_transactions().len(), + MAX_CONCURRENT_RPC_CALLS + 1 + ); + assert!(s.deposits_to_consolidate().is_empty()); + }); + assert_eq!(runtime.set_timer_call_count(), 0); +} + fn setup() { init_state(); init_schnorr_master_key(); diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 39d12d5d..d3a87e78 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -1,10 +1,6 @@ /// Maximum number of concurrent calls to the SOL RPC canister. pub const MAX_CONCURRENT_RPC_CALLS: usize = 10; -/// Maximum number of rounds per timer invocation. -/// Each round issues up to [`MAX_CONCURRENT_RPC_CALLS`] parallel RPC calls. -pub const MAX_TIMER_ROUNDS: usize = 5; - /// Matches the ICP HTTPS outcall response limit for variable-length RPC calls /// such as `getTransaction` and `getSignatureStatuses`: /// https://docs.internetcomputer.org/references/ic-interface-spec#ic-http_request diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index be86c2c6..11b2b7cf 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -207,8 +207,8 @@ fn should_paginate_minted_deposits_across_multiple_pages() { let remainder = total_deposits - DEFAULT_PAGE_SIZE * 2; for i in 0..total_deposits { - accept_deposit(deposit_id(i as u8), 500_000_000); - mint_deposit(deposit_id(i as u8), i as u64); + accept_deposit(deposit_id(i), 500_000_000); + mint_deposit(deposit_id(i), i as u64); } let page1 = dashboard(); diff --git a/minter/src/main.rs b/minter/src/main.rs index 3da81bbb..33315c7d 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -1,15 +1,15 @@ use candid::Principal; use canlog::{Log, Sort}; -use cksol_minter::withdraw::{WITHDRAWAL_PROCESSING_DELAY, process_pending_withdrawals}; -use cksol_minter::{ - address::lazy_get_schnorr_master_key, runtime::IcCanisterRuntime, state::read_state, -}; use cksol_minter::{ + address::lazy_get_schnorr_master_key, consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits}, monitor::{ FINALIZE_TRANSACTIONS_DELAY, RESUBMIT_TRANSACTIONS_DELAY, finalize_transactions, resubmit_transactions, }, + runtime::IcCanisterRuntime, + state::read_state, + withdraw::{WITHDRAWAL_PROCESSING_DELAY, process_pending_withdrawals}, }; use cksol_types::{ Address, DepositStatus, GetDepositAddressArgs, MinterInfo, UpdateBalanceArgs, @@ -338,7 +338,7 @@ fn setup_timers() { consolidate_deposits(IcCanisterRuntime::new()).await; }); ic_cdk_timers::set_timer_interval(WITHDRAWAL_PROCESSING_DELAY, async || { - process_pending_withdrawals(&IcCanisterRuntime::new()).await; + process_pending_withdrawals(IcCanisterRuntime::new()).await; }); ic_cdk_timers::set_timer_interval(FINALIZE_TRANSACTIONS_DELAY, async || { finalize_transactions(IcCanisterRuntime::new()).await; diff --git a/minter/src/monitor/mod.rs b/minter/src/monitor/mod.rs index 01b30cf9..b42e1a23 100644 --- a/minter/src/monitor/mod.rs +++ b/minter/src/monitor/mod.rs @@ -1,6 +1,6 @@ use crate::{ address::derivation_path, - constants::{MAX_CONCURRENT_RPC_CALLS, MAX_TIMER_ROUNDS}, + constants::MAX_CONCURRENT_RPC_CALLS, guard::TimerGuard, runtime::CanisterRuntime, signer::sign_bytes, @@ -57,6 +57,12 @@ pub async fn finalize_transactions(runtime: R) { return; } + let more_to_process = + all_transactions.len() > MAX_CONCURRENT_RPC_CALLS * MAX_SIGNATURES_PER_STATUS_CHECK; + let reschedule = scopeguard::guard(runtime.clone(), |runtime| { + runtime.set_timer(Duration::ZERO, finalize_transactions); + }); + // Fetch the current slot before checking statuses: if a transaction finalizes // after we snapshot the slot, the status check will see it as finalized rather // than missing, so it will never be incorrectly marked as expired. @@ -113,6 +119,11 @@ pub async fn finalize_transactions(runtime: R) { }); } } + + if !more_to_process { + // All work fits in this round + scopeguard::ScopeGuard::into_inner(reschedule); + } } /// Resubmit transactions that have been marked for resubmission by @@ -133,10 +144,23 @@ pub async fn resubmit_transactions(runtime: R) { if to_resubmit.is_empty() { return; } + + let more_to_process = to_resubmit.len() > MAX_CONCURRENT_RPC_CALLS; + let reschedule = scopeguard::guard(runtime.clone(), |runtime| { + runtime.set_timer(Duration::ZERO, resubmit_transactions); + }); + resubmit_expired_transactions(&runtime, to_resubmit).await; + + if !more_to_process { + // All work fits in this round + scopeguard::ScopeGuard::into_inner(reschedule); + } } /// Result of checking transaction statuses. +// Transactions that are in-flight (Processed/Confirmed) or whose status +// check failed are implicitly excluded from the below sets. struct TransactionStatuses { /// Transactions confirmed as finalized on-chain without errors. succeeded: BTreeSet, @@ -144,8 +168,6 @@ struct TransactionStatuses { errored: BTreeMap, /// Transactions with no on-chain status (safe to resubmit if expired). not_found: BTreeSet, - // Transactions that are in-flight (Processed/Confirmed) or whose status - // check failed are implicitly excluded — they appear in neither set. } async fn check_transaction_statuses( @@ -156,6 +178,7 @@ async fn check_transaction_statuses( .into_iter() .chunks(MAX_SIGNATURES_PER_STATUS_CHECK) .into_iter() + .take(MAX_CONCURRENT_RPC_CALLS) .map(Iterator::collect) .collect(); @@ -165,36 +188,33 @@ async fn check_transaction_statuses( not_found: BTreeSet::new(), }; - for round in &batches.into_iter().chunks(MAX_CONCURRENT_RPC_CALLS) { - let batch_results: Vec<_> = futures::future::join_all(round.map(async |batch| { - match get_signature_statuses(runtime, &batch).await { - Ok(statuses) => Some((batch, statuses)), - Err(e) => { - log!(Priority::Info, "Failed to check transaction statuses: {e}"); - None - } + let batch_results: Vec<_> = futures::future::join_all(batches.into_iter().map(async |batch| { + match get_signature_statuses(runtime, &batch).await { + Ok(statuses) => Some((batch, statuses)), + Err(e) => { + log!(Priority::Info, "Failed to check transaction statuses: {e}"); + None } - })) - .await; + } + })) + .await; - for (sigs, statuses) in batch_results.into_iter().flatten() { - for (signature, status) in sigs.iter().zip(statuses) { - match status { - Some(s) - if s.confirmation_status - == Some(TransactionConfirmationStatus::Finalized) => - { - if let Some(err) = s.err { - result.errored.insert(*signature, format!("{err:?}")); - } else { - result.succeeded.insert(*signature); - } - } - Some(_) => {} // in-flight (Processed/Confirmed) - None => { - result.not_found.insert(*signature); + for (sigs, statuses) in batch_results.into_iter().flatten() { + for (signature, status) in sigs.iter().zip(statuses) { + match status { + Some(s) + if s.confirmation_status == Some(TransactionConfirmationStatus::Finalized) => + { + if let Some(err) = s.err { + result.errored.insert(*signature, format!("{err:?}")); + } else { + result.succeeded.insert(*signature); } } + Some(_) => {} // in-flight (Processed/Confirmed) + None => { + result.not_found.insert(*signature); + } } } } @@ -206,48 +226,38 @@ async fn resubmit_expired_transactions( runtime: &R, to_resubmit: Vec<(Signature, VersionedMessage, Vec)>, ) { - let rounds: Vec> = to_resubmit - .into_iter() - .chunks(MAX_CONCURRENT_RPC_CALLS) - .into_iter() - .take(MAX_TIMER_ROUNDS) - .map(Iterator::collect) - .collect(); + let (new_slot, new_blockhash) = match get_recent_slot_and_blockhash(runtime).await { + Ok(result) => result, + Err(e) => { + log!(Priority::Info, "Failed to get recent blockhash: {e}"); + return; + } + }; - for round in rounds { - let (new_slot, new_blockhash) = match get_recent_slot_and_blockhash(runtime).await { - Ok(result) => result, - Err(e) => { - log!(Priority::Info, "Failed to get recent blockhash: {e}"); - return; + futures::future::join_all(to_resubmit.into_iter().take(MAX_CONCURRENT_RPC_CALLS).map( + async |(old_signature, message, signers)| { + match try_resubmit_transaction( + runtime, + old_signature, + message, + signers, + new_slot, + new_blockhash, + ) + .await + { + Ok(new_sig) => log!( + Priority::Info, + "Resubmitted transaction {old_signature} as {new_sig}" + ), + Err(e) => log!( + Priority::Info, + "Failed to resubmit transaction {old_signature}: {e}" + ), } - }; - - futures::future::join_all(round.into_iter().map( - async |(old_signature, message, signers)| { - match try_resubmit_transaction( - runtime, - old_signature, - message, - signers, - new_slot, - new_blockhash, - ) - .await - { - Ok(new_sig) => log!( - Priority::Info, - "Resubmitted transaction {old_signature} as {new_sig}" - ), - Err(e) => log!( - Priority::Info, - "Failed to resubmit transaction {old_signature}: {e}" - ), - } - }, - )) - .await; - } + }, + )) + .await; } async fn try_resubmit_transaction( diff --git a/minter/src/monitor/tests.rs b/minter/src/monitor/tests.rs index 2a8842f7..b737f605 100644 --- a/minter/src/monitor/tests.rs +++ b/minter/src/monitor/tests.rs @@ -1,5 +1,9 @@ -use super::{MAX_BLOCKHASH_AGE, finalize_transactions, resubmit_transactions}; +use super::{ + MAX_BLOCKHASH_AGE, MAX_SIGNATURES_PER_STATUS_CHECK, finalize_transactions, + resubmit_transactions, +}; use crate::{ + constants::MAX_CONCURRENT_RPC_CALLS, state::{TaskType, event::EventType, mutate_state, read_state, reset_state}, storage::reset_events, test_fixtures::{ @@ -70,6 +74,46 @@ mod finalization { assert_eq!(events_before, events_after); } + #[tokio::test] + async fn should_reschedule_until_all_transactions_finalized() { + setup(); + + let num = MAX_CONCURRENT_RPC_CALLS * MAX_SIGNATURES_PER_STATUS_CHECK + 1; + for i in 0..num { + submit_consolidation_transaction_with_signature(i, RECENT_SLOT); + } + + // Round 1: finalizes MAX_CONCURRENT_RPC_CALLS batches, 1 transaction unchecked → reschedule + let mut runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) + .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))); + for _ in 0..MAX_CONCURRENT_RPC_CALLS { + runtime = runtime.add_stub_response(SignatureStatusesResult::Consistent(Ok( + vec![Some(finalized_status()); MAX_SIGNATURES_PER_STATUS_CHECK], + ))); + } + + finalize_transactions(runtime.clone()).await; + + assert_eq!(read_state(|s| s.submitted_transactions().len()), 1); + assert_eq!(runtime.set_timer_call_count(), 1); + + // Round 2: finalizes the remaining 1 transaction → no reschedule + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) + .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))) + .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![Some( + finalized_status(), + )]))); + + finalize_transactions(runtime.clone()).await; + + assert!(read_state(|s| s.submitted_transactions().is_empty())); + assert_eq!(runtime.set_timer_call_count(), 0); + } + #[tokio::test] async fn should_finalize_transaction_with_finalized_status() { setup(); @@ -271,25 +315,12 @@ mod resubmission { let old_signature = submit_consolidation_transaction(EXPIRED_SLOT); let new_signature = signature(0xAA); - - // Step 1: finalize_transactions fetches slot, checks status, marks expired - let finalize_runtime = TestCanisterRuntime::new() - .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![None]))); - - finalize_transactions(finalize_runtime).await; - - EventsAssert::from_recorded().expect_contains_event_eq(EventType::ExpiredTransaction { - signature: old_signature, - }); + events::expire_transaction(old_signature); read_state(|s| { assert!(s.transactions_to_resubmit().contains_key(&old_signature)); }); - // Step 2: resubmit_transactions fetches a fresh blockhash and resubmits let resubmit_runtime = TestCanisterRuntime::new() .with_increasing_time() .add_stub_response(SlotResult::Consistent(Ok(RESUBMISSION_SLOT))) @@ -348,14 +379,7 @@ mod resubmission { let old_signature = submit_consolidation_transaction(EXPIRED_SLOT); let new_signature = signature(0xAA); - - let finalize_runtime = TestCanisterRuntime::new() - .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))) - .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![None]))); - - finalize_transactions(finalize_runtime).await; + events::expire_transaction(old_signature); let resubmit_runtime = TestCanisterRuntime::new() .with_increasing_time() @@ -378,63 +402,56 @@ mod resubmission { } #[tokio::test] - async fn should_resubmit_multiple_expired_transactions_in_batches() { - use crate::constants::MAX_CONCURRENT_RPC_CALLS; - + async fn should_reschedule_until_all_transactions_resubmitted() { setup(); - let num_transactions = MAX_CONCURRENT_RPC_CALLS + 2; // 10+2 = 12 transactions require 2 rounds + let num_transactions = MAX_CONCURRENT_RPC_CALLS + 1; for i in 0..num_transactions { - submit_consolidation_transaction_with_signature(i as u8, EXPIRED_SLOT); + let sig = submit_consolidation_transaction_with_signature(i, EXPIRED_SLOT); + events::expire_transaction(sig); } - // finalize_transactions: marks all as expired - let finalize_runtime = TestCanisterRuntime::new() + // Round 1: resubmits MAX_CONCURRENT_RPC_CALLS transactions, 1 remain → reschedule + let mut runtime = TestCanisterRuntime::new() .with_increasing_time() - .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))) - // getSignatureStatuses: all not found - .add_stub_response(SignatureStatusesResult::Consistent(Ok( - vec![None; num_transactions], - ))); + .add_stub_response(SlotResult::Consistent(Ok(RESUBMISSION_SLOT))) + .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))); + for i in 0..MAX_CONCURRENT_RPC_CALLS { + runtime = runtime + .add_stub_response(SendTransactionResult::Consistent(Ok( + signature(0xA0 + i).into() + ))) + .add_signature(signature(0xA0 + i).into()); + } - finalize_transactions(finalize_runtime).await; + resubmit_transactions(runtime.clone()).await; read_state(|s| { - assert_eq!(s.transactions_to_resubmit().len(), num_transactions); + assert_eq!(s.submitted_transactions().len(), MAX_CONCURRENT_RPC_CALLS); + assert_eq!( + s.transactions_to_resubmit().len(), + num_transactions - MAX_CONCURRENT_RPC_CALLS + ); }); + assert_eq!(runtime.set_timer_call_count(), 1); - // resubmit_transactions: processes in rounds of MAX_CONCURRENT_RPC_CALLS - let mut resubmit_runtime = TestCanisterRuntime::new() + // Round 2: resubmits remaining transaction → no reschedule + let mut runtime = TestCanisterRuntime::new() .with_increasing_time() .add_stub_response(SlotResult::Consistent(Ok(RESUBMISSION_SLOT))) .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))); - - for i in 0..MAX_CONCURRENT_RPC_CALLS { - resubmit_runtime = resubmit_runtime - .add_stub_response(SendTransactionResult::Consistent(Ok(signature( - 0xA0 + i as u8, - ) - .into()))) - .add_signature([0xA0 + i as u8; 64]); - } - - resubmit_runtime = resubmit_runtime - .add_stub_response(SlotResult::Consistent(Ok(RESUBMISSION_SLOT))) - .add_stub_response(BlockResult::Consistent(Ok(confirmed_block()))); - - for i in 0..2_usize { - resubmit_runtime = resubmit_runtime - .add_stub_response(SendTransactionResult::Consistent(Ok(signature( - 0xB0 + i as u8, - ) - .into()))) - .add_signature([0xB0 + i as u8; 64]); + for i in 0..(num_transactions - MAX_CONCURRENT_RPC_CALLS) { + runtime = runtime + .add_stub_response(SendTransactionResult::Consistent(Ok( + signature(0xB0 + i).into() + ))) + .add_signature(signature(0xB0 + i).into()); } - resubmit_transactions(resubmit_runtime).await; + resubmit_transactions(runtime.clone()).await; - read_state(|s| assert_eq!(s.submitted_transactions().len(), num_transactions)); + assert!(read_state(|s| s.transactions_to_resubmit().is_empty())); + assert_eq!(runtime.set_timer_call_count(), 0); } } @@ -448,7 +465,7 @@ fn submit_consolidation_transaction(slot: Slot) -> solana_signature::Signature { } fn submit_consolidation_transaction_with_signature( - i: u8, + i: usize, slot: Slot, ) -> solana_signature::Signature { let signature = signature(i); diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index fc9660fa..6690ba94 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -1,7 +1,7 @@ use crate::signer::{IcSchnorrSigner, SchnorrSigner}; use candid::Principal; use ic_canister_runtime::{IcRuntime, Runtime}; -use std::{fmt::Debug, time::Duration}; +use std::{fmt::Debug, future::Future, time::Duration}; pub trait CanisterRuntime: Clone + 'static { fn inter_canister_call_runtime(&self) -> impl Runtime; @@ -12,11 +12,11 @@ pub trait CanisterRuntime: Clone + 'static { fn msg_cycles_accept(&self, amount: u128) -> u128; fn msg_cycles_available(&self) -> u128; fn msg_cycles_refunded(&self) -> u128; - fn set_timer( - &self, - delay: Duration, - future: impl Future + 'static, - ) -> ic_cdk_timers::TimerId; + fn set_timer(&self, delay: Duration, f: F) -> ic_cdk_timers::TimerId + where + Self: Sized, + F: FnOnce(Self) -> Fut + 'static, + Fut: Future + 'static; } #[derive(Clone, Default, Debug)] @@ -61,11 +61,13 @@ impl CanisterRuntime for IcCanisterRuntime { ic_cdk::api::msg_cycles_refunded() } - fn set_timer( - &self, - delay: Duration, - future: impl Future + 'static, - ) -> ic_cdk_timers::TimerId { - ic_cdk_timers::set_timer(delay, future) + fn set_timer(&self, delay: Duration, f: F) -> ic_cdk_timers::TimerId + where + Self: Sized, + F: FnOnce(Self) -> Fut + 'static, + Fut: Future + 'static, + { + let clone = self.clone(); + ic_cdk_timers::set_timer(delay, async move { f(clone).await }) } } diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index c4f6cb57..0a0692c3 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -427,7 +427,9 @@ fn should_track_balance_through_deposits_withdrawals_and_failures() { } fn submit_transaction(sig: Signature, num_signers: u8, purpose: TransactionPurpose) { - let signers: Vec<_> = (0..num_signers).map(|i| account(100 + i)).collect(); + let signers: Vec<_> = (0..num_signers) + .map(|i| account(100 + i as usize)) + .collect(); mutate_state(|state| { process_event( state, diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 47072fa2..812596c6 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -94,9 +94,11 @@ pub fn init_schnorr_master_key() { }); } -/// Returns a [`Signature`] filled with byte `i`, e.g. `signature(1)` → `[0x01; 64]`. -pub fn signature(i: u8) -> solana_signature::Signature { - solana_signature::Signature::from([i; 64]) +/// Returns a [`Signature`] unique for any `usize` index, derived from `i as u64` via le_bytes. +pub fn signature(i: usize) -> solana_signature::Signature { + let mut bytes = [0u8; 64]; + bytes[..8].copy_from_slice(&(i as u64).to_le_bytes()); + solana_signature::Signature::from(bytes) } /// Returns a [`ConfirmedBlock`] with a deterministic blockhash for use in RPC mock stubs. @@ -115,17 +117,19 @@ pub fn confirmed_block() -> sol_rpc_types::ConfirmedBlock { } /// Returns a [`DepositId`] with deterministic signature and account derived from `i`. -pub fn deposit_id(i: u8) -> DepositId { +pub fn deposit_id(i: usize) -> DepositId { DepositId { - signature: solana_signature::Signature::from([i; 64]), + signature: signature(i), account: account(i), } } /// Returns an [`Account`] with a deterministic principal derived from `i`. -pub fn account(i: u8) -> Account { +pub fn account(i: usize) -> Account { + let mut bytes = [0u8; 29]; + bytes[..8].copy_from_slice(&(i as u64).to_le_bytes()); Account { - owner: Principal::from_slice(&[i; 29]), + owner: Principal::from_slice(&bytes), subaccount: None, } } diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 7d6f1955..c7f2a7f3 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -3,7 +3,11 @@ use crate::{runtime::CanisterRuntime, signer::SchnorrSigner}; use candid::{CandidType, Principal}; use ic_canister_runtime::{IcError, Runtime, StubRuntime}; use ic_cdk_management_canister::SignCallError; -use std::time::Duration; +use std::{ + future::Future, + sync::{Arc, Mutex}, + time::Duration, +}; pub const TEST_CANISTER_ID: Principal = Principal::from_slice(&[0xCA; 10]); @@ -16,6 +20,7 @@ pub struct TestCanisterRuntime { msg_cycles_accept: Stubs, msg_cycles_available: Stubs, msg_cycles_refunded: Stubs, + set_timer_call_count: Arc>, } impl TestCanisterRuntime { @@ -68,6 +73,10 @@ impl TestCanisterRuntime { self.signer = self.signer.add_response(Err(error)); self } + + pub(crate) fn set_timer_call_count(&self) -> usize { + *self.set_timer_call_count.lock().unwrap() + } } impl CanisterRuntime for TestCanisterRuntime { @@ -105,11 +114,13 @@ impl CanisterRuntime for TestCanisterRuntime { self.msg_cycles_refunded.next() } - fn set_timer( - &self, - _delay: Duration, - _future: impl Future + 'static, - ) -> ic_cdk_timers::TimerId { + fn set_timer(&self, _delay: Duration, _f: F) -> ic_cdk_timers::TimerId + where + Self: Sized, + F: FnOnce(Self) -> Fut + 'static, + Fut: Future + 'static, + { + *self.set_timer_call_count.lock().unwrap() += 1; Default::default() } } diff --git a/minter/src/withdraw/mod.rs b/minter/src/withdraw/mod.rs index 933567ea..122d9c34 100644 --- a/minter/src/withdraw/mod.rs +++ b/minter/src/withdraw/mod.rs @@ -12,12 +12,11 @@ use itertools::Itertools; use sol_rpc_types::Slot; use solana_hash::Hash; -use crate::constants::{MAX_CONCURRENT_RPC_CALLS, MAX_TIMER_ROUNDS}; -use crate::ledger::BurnError; use crate::{ consolidate::consolidate_deposits, + constants::MAX_CONCURRENT_RPC_CALLS, guard::{TimerGuard, withdrawal_guard}, - ledger::burn, + ledger::{BurnError, burn}, runtime::CanisterRuntime, sol_transfer::{MAX_WITHDRAWALS_PER_TX, create_signed_batch_withdrawal_transaction}, state::{ @@ -91,7 +90,7 @@ pub async fn withdraw( Ok(WithdrawalOk { block_index }) } -pub async fn process_pending_withdrawals(runtime: &R) { +pub async fn process_pending_withdrawals(runtime: R) { let _guard = match TimerGuard::new(TaskType::WithdrawalProcessing) { Ok(guard) => guard, Err(_) => { @@ -103,15 +102,12 @@ pub async fn process_pending_withdrawals(runtime: &R) { } }; - let max_per_invocation = MAX_TIMER_ROUNDS * MAX_CONCURRENT_RPC_CALLS * MAX_WITHDRAWALS_PER_TX; - let (affordable_requests, num_pending_withdrawals) = read_state(|state| { let mut available_balance = state.balance(); let pending = state.pending_withdrawal_requests(); let affordable: Vec<_> = pending .values() - .take(max_per_invocation) .take_while(|r| { if available_balance >= r.request.withdrawal_amount { available_balance -= r.request.withdrawal_amount; @@ -131,42 +127,45 @@ pub async fn process_pending_withdrawals(runtime: &R) { Priority::Info, "Insufficient minter balance for some withdrawal requests, scheduling consolidation" ); - let runtime_clone = runtime.clone(); - runtime.set_timer(Duration::ZERO, async move { - consolidate_deposits(runtime_clone).await; - }); + runtime.set_timer(Duration::ZERO, consolidate_deposits); } - if affordable_requests.is_empty() { - return; - } + let more_to_process = + affordable_requests.len() > MAX_CONCURRENT_RPC_CALLS * MAX_WITHDRAWALS_PER_TX; + let reschedule = scopeguard::guard(runtime.clone(), |runtime| { + runtime.set_timer(Duration::ZERO, process_pending_withdrawals); + }); - let rounds: Vec>> = affordable_requests + let batches: Vec> = affordable_requests .into_iter() .chunks(MAX_WITHDRAWALS_PER_TX) .into_iter() - .map(Iterator::collect) - .collect::>>() - .into_iter() - .chunks(MAX_CONCURRENT_RPC_CALLS) - .into_iter() - .take(MAX_TIMER_ROUNDS) + .take(MAX_CONCURRENT_RPC_CALLS) .map(Iterator::collect) .collect(); - for round in rounds { - let (slot, recent_blockhash) = match get_recent_slot_and_blockhash(runtime).await { - Ok((slot, blockhash)) => (slot, blockhash), - Err(e) => { - log!(Priority::Info, "Failed to fetch recent blockhash: {e}"); - return; - } - }; - - futures::future::join_all(round.into_iter().map(async |batch| { - submit_withdrawal_transaction(runtime, batch, slot, recent_blockhash).await - })) - .await; + if batches.is_empty() { + // Nothing to process + scopeguard::ScopeGuard::into_inner(reschedule); + return; + } + + let (slot, recent_blockhash) = match get_recent_slot_and_blockhash(&runtime).await { + Ok(result) => result, + Err(e) => { + log!(Priority::Info, "Failed to fetch recent blockhash: {e}"); + return; + } + }; + + futures::future::join_all(batches.into_iter().map(async |batch| { + submit_withdrawal_transaction(&runtime, batch, slot, recent_blockhash).await + })) + .await; + + if !more_to_process { + // All work fits in this round + scopeguard::ScopeGuard::into_inner(reschedule); } } diff --git a/minter/src/withdraw/tests.rs b/minter/src/withdraw/tests.rs index 0cfaf0c1..c89f4c5c 100644 --- a/minter/src/withdraw/tests.rs +++ b/minter/src/withdraw/tests.rs @@ -1,5 +1,5 @@ use crate::{ - constants::{MAX_CONCURRENT_RPC_CALLS, MAX_TIMER_ROUNDS}, + constants::MAX_CONCURRENT_RPC_CALLS, guard::{TimerGuard, withdrawal_guard}, sol_transfer::MAX_WITHDRAWALS_PER_TX, state::{TaskType, read_state}, @@ -246,7 +246,7 @@ mod process_pending_withdrawals_tests { // We return early, therefore no RPC calls are made let runtime = TestCanisterRuntime::new(); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; EventsAssert::assert_no_events_recorded(); } @@ -259,7 +259,7 @@ mod process_pending_withdrawals_tests { // We return early, therefore no RPC calls are made let runtime = TestCanisterRuntime::new(); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; EventsAssert::assert_no_events_recorded(); } @@ -269,7 +269,7 @@ mod process_pending_withdrawals_tests { init_state(); let runtime = TestCanisterRuntime::new(); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; // Guard should be released, so we can acquire it again let _guard = TimerGuard::new(TaskType::WithdrawalProcessing).unwrap(); @@ -286,7 +286,7 @@ mod process_pending_withdrawals_tests { let events_before = EventsAssert::from_recorded(); let runtime = TestCanisterRuntime::new().with_increasing_time(); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; // No new events should be recorded let events_after = EventsAssert::from_recorded(); @@ -319,7 +319,7 @@ mod process_pending_withdrawals_tests { .add_stub_response(SendTransactionResult::Consistent(Ok(tx_signature.into()))) .with_increasing_time(); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; // First two withdrawals should be submitted, third should remain pending assert_matches!(withdrawal_status(0), WithdrawalStatus::TxSent(_)); @@ -331,19 +331,6 @@ mod process_pending_withdrawals_tests { assert_eq!(events_after.len(), events_before.len() + 1); } - async fn submit_withdrawals(runtime: &TestCanisterRuntime, count: u8) { - for i in 1..count + 1 { - let _ = withdraw( - runtime, - Principal::from_slice(&[1, i]).into(), - MINIMUM_WITHDRAWAL_AMOUNT, - VALID_ADDRESS.to_string(), - ) - .await - .unwrap(); - } - } - #[tokio::test] async fn should_process_when_pending_withdrawals_exist() { init_state(); @@ -352,22 +339,16 @@ mod process_pending_withdrawals_tests { let tx_signature = signature(0x42); let slot = 1; + events::accept_withdrawal(account(1), 1, MINIMUM_WITHDRAWAL_AMOUNT); let runtime = TestCanisterRuntime::new() - // ledger burn response for withdraw - .add_stub_response(Ok::(Nat::from(1u64))) - // get_recent_slot_and_blockhash calls + .with_increasing_time() .add_stub_response(GetSlotResult::Consistent(Ok(slot))) .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))) - // schnorr signing response .add_signature(tx_signature.into()) - // sendTransaction response - .add_stub_response(SendTransactionResult::Consistent(Ok(tx_signature.into()))) - .with_increasing_time(); - - submit_withdrawals(&runtime, 1).await; + .add_stub_response(SendTransactionResult::Consistent(Ok(tx_signature.into()))); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; assert_matches!(withdrawal_status(1), WithdrawalStatus::TxSent(_)); } @@ -377,10 +358,12 @@ mod process_pending_withdrawals_tests { init_state(); init_balance(); + events::accept_withdrawal(account(1), 1, MINIMUM_WITHDRAWAL_AMOUNT); + + let events_before = EventsAssert::from_recorded(); + let runtime = TestCanisterRuntime::new() - // ledger burn response for withdraw - .add_stub_response(Ok::(Nat::from(1u64))) - // get_recent_block retries getSlot 3 times before giving up + .with_increasing_time() .add_stub_response(GetSlotResult::Consistent(Err(RpcError::ValidationError( "slot unavailable".to_string(), )))) @@ -389,14 +372,9 @@ mod process_pending_withdrawals_tests { )))) .add_stub_response(GetSlotResult::Consistent(Err(RpcError::ValidationError( "slot unavailable".to_string(), - )))) - .with_increasing_time(); - - submit_withdrawals(&runtime, 1).await; - - let events_before = EventsAssert::from_recorded(); + )))); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; // No withdrawal transaction event should be recorded let events_after = EventsAssert::from_recorded(); @@ -422,25 +400,20 @@ mod process_pending_withdrawals_tests { init_schnorr_master_key(); let slot = 1; + events::accept_withdrawal(account(1), 1, MINIMUM_WITHDRAWAL_AMOUNT); + events::accept_withdrawal(account(2), 2, MINIMUM_WITHDRAWAL_AMOUNT); + + let events_before = EventsAssert::from_recorded(); let runtime = TestCanisterRuntime::new() - // responses for burn blocks - .add_stub_response(Ok::(Nat::from(1u64))) - .add_stub_response(Ok::(Nat::from(2u64))) - // get_recent_slot_and_blockhash calls + .with_increasing_time() .add_stub_response(GetSlotResult::Consistent(Ok(slot))) .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))) - // signing fails for the batch .add_schnorr_signing_error(SignCallError::CallFailed( CallRejected::with_rejection(4, "signing service unavailable".to_string()).into(), - )) - .with_increasing_time(); + )); - submit_withdrawals(&runtime, 2).await; - - let events_before = EventsAssert::from_recorded(); - - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; // No transaction event should be recorded (signing failed) let events_after = EventsAssert::from_recorded(); @@ -471,25 +444,20 @@ mod process_pending_withdrawals_tests { let request_count = MAX_WITHDRAWALS_PER_TX as u64 + 1; let slot = 1; - let mut runtime = TestCanisterRuntime::new().with_increasing_time(); - // withdraw ledger burn responses for i in 0..request_count { - runtime = runtime.add_stub_response(Ok::(Nat::from(i))); + events::accept_withdrawal(account(i as usize), i, MINIMUM_WITHDRAWAL_AMOUNT); } - // get_recent_slot_and_blockhash (one round: getSlot + getBlock) - runtime = runtime + + let runtime = TestCanisterRuntime::new() + .with_increasing_time() .add_stub_response(GetSlotResult::Consistent(Ok(slot))) - .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))); - // one signature per batch + sendTransaction: batch 1 (MAX_WITHDRAWALS_PER_TX) + batch 2 (1) - runtime = runtime + .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))) .add_signature(signature(1).into()) .add_stub_response(SendTransactionResult::Consistent(Ok(signature(1).into()))) .add_signature(signature(2).into()) .add_stub_response(SendTransactionResult::Consistent(Ok(signature(2).into()))); - submit_withdrawals(&runtime, request_count as u8).await; - - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime).await; // All withdrawals should be processed in a single invocation // (2 batches in 1 round, both within MAX_CONCURRENT_RPC_CALLS) @@ -502,112 +470,52 @@ mod process_pending_withdrawals_tests { } #[tokio::test] - async fn should_process_multiple_rounds_per_invocation() { + async fn should_reschedule_until_all_withdrawals_processed() { init_state(); init_balance(); init_schnorr_master_key(); - // Create more withdrawals than fit in one round - // (MAX_WITHDRAWALS_PER_TX * MAX_CONCURRENT_RPC_CALLS) but fewer than - // the per-invocation limit (rounds * concurrent * per_tx). - // This requires 2 rounds within a single invocation. - let max_per_round = (MAX_WITHDRAWALS_PER_TX * MAX_CONCURRENT_RPC_CALLS) as u64; - let request_count = max_per_round + 1; - let slot = 1; - - let mut runtime = TestCanisterRuntime::new().with_increasing_time(); - for i in 0..request_count { - runtime = runtime.add_stub_response(Ok::(Nat::from(i))); + let num_requests = MAX_WITHDRAWALS_PER_TX * MAX_CONCURRENT_RPC_CALLS + 1; + for i in 0..num_requests { + events::accept_withdrawal(account(i), i as u64, MINIMUM_WITHDRAWAL_AMOUNT); } - // Round 1: get_recent_slot_and_blockhash, then signatures + sendTransaction - runtime = runtime + let slot = 1; + + // Round 1: processes MAX_CONCURRENT_RPC_CALLS batches, 1 request remains → reschedule + let mut runtime = TestCanisterRuntime::new() + .with_increasing_time() .add_stub_response(GetSlotResult::Consistent(Ok(slot))) .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))); for i in 0..MAX_CONCURRENT_RPC_CALLS { runtime = runtime - .add_signature(signature(i as u8 + 1).into()) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature( - i as u8 + 1, - ) - .into()))); + .add_signature(signature(i + 1).into()) + .add_stub_response(SendTransactionResult::Consistent(Ok( + signature(i + 1).into() + ))); } - // Round 2: fresh get_recent_slot_and_blockhash, then 1 signature + sendTransaction - runtime = runtime - .add_stub_response(GetSlotResult::Consistent(Ok(slot))) - .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))) - .add_signature(signature(MAX_CONCURRENT_RPC_CALLS as u8 + 1).into()) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature( - MAX_CONCURRENT_RPC_CALLS as u8 + 1, - ) - .into()))); - - submit_withdrawals(&runtime, request_count as u8).await; - - // All withdrawals should be processed in a single invocation (2 rounds) - process_pending_withdrawals(&runtime).await; - - for i in 0..request_count { - assert_matches!( - withdrawal_status(i), - WithdrawalStatus::TxSent(_), - "withdrawal {i} should be TxSent" - ); - } - } + process_pending_withdrawals(runtime.clone()).await; - #[tokio::test] - async fn should_respect_max_withdrawal_rounds() { - init_state(); - init_balance(); - init_schnorr_master_key(); + read_state(|s| { + assert_eq!(s.submitted_transactions().len(), MAX_CONCURRENT_RPC_CALLS); + assert_eq!(s.pending_withdrawal_requests().len(), 1); + }); + assert_eq!(runtime.set_timer_call_count(), 1); - let slot = 1; - let max_per_round = MAX_WITHDRAWALS_PER_TX * MAX_CONCURRENT_RPC_CALLS; - // Create enough requests to fill MAX_TIMER_ROUNDS + 1 rounds. - let request_count = max_per_round * MAX_TIMER_ROUNDS + 1; - - // Insert pending withdrawal requests directly into state. - for i in 0..request_count { - events::accept_withdrawal(account(i as u8), i as u64, MINIMUM_WITHDRAWAL_AMOUNT); - } - - // Set up RPC responses for MAX_TIMER_ROUNDS rounds. - let mut runtime = TestCanisterRuntime::new().with_increasing_time(); - let mut sig_counter: u8 = 0; - for _round in 0..MAX_TIMER_ROUNDS { - runtime = runtime - .add_stub_response(GetSlotResult::Consistent(Ok(slot))) - .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))); - for _ in 0..MAX_CONCURRENT_RPC_CALLS { - sig_counter = sig_counter.wrapping_add(1); - runtime = runtime - .add_signature(signature(sig_counter).into()) - .add_stub_response(SendTransactionResult::Consistent(Ok(signature( - sig_counter, - ) - .into()))); - } - } + // Round 2: processes the remaining 1 request → no reschedule + let last_sig = signature(num_requests); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(GetSlotResult::Consistent(Ok(slot))) + .add_stub_response(GetBlockResult::Consistent(Ok(confirmed_block()))) + .add_signature(last_sig.into()) + .add_stub_response(SendTransactionResult::Consistent(Ok(last_sig.into()))); - process_pending_withdrawals(&runtime).await; + process_pending_withdrawals(runtime.clone()).await; - let processed = max_per_round * MAX_TIMER_ROUNDS; - // All requests within MAX_TIMER_ROUNDS rounds should be processed - for i in 0..processed { - assert_matches!( - withdrawal_status(i as u64), - WithdrawalStatus::TxSent(_), - "withdrawal {i} should be TxSent" - ); - } - // The extra request beyond MAX_TIMER_ROUNDS rounds should remain pending - assert_matches!( - withdrawal_status(processed as u64), - WithdrawalStatus::Pending, - "withdrawal beyond max rounds should still be Pending" - ); + assert!(read_state(|s| s.pending_withdrawal_requests().is_empty())); + assert_eq!(runtime.set_timer_call_count(), 0); } } @@ -615,7 +523,7 @@ mod withdrawal_finalization_tests { use super::*; fn setup_sent_withdrawal(burn_block_index: u64) -> Signature { - let tx_signature = signature(burn_block_index as u8 + 1); + let tx_signature = signature(burn_block_index as usize + 1); events::accept_withdrawal(MINTER_ACCOUNT, burn_block_index, MINIMUM_WITHDRAWAL_AMOUNT); events::submit_withdrawal(tx_signature, MINTER_ACCOUNT, 1, vec![burn_block_index]); tx_signature