From bcf34bffa45c6f22d5c1146c989cbc09b0de83f8 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 11 Mar 2026 16:35:54 +0100 Subject: [PATCH 01/47] feat: create scheduled task to consolidate deposit funds --- Cargo.lock | 24 +++++++++---- Cargo.toml | 1 + integration_tests/src/lib.rs | 5 +++ integration_tests/tests/tests.rs | 16 +++++++++ minter/Cargo.toml | 1 + minter/src/consolidate/mod.rs | 61 ++++++++++++++++++++++++++++++++ minter/src/guard/mod.rs | 31 +++++++++++++++- minter/src/lib.rs | 1 + minter/src/runtime/mod.rs | 27 ++++++++++++-- minter/src/state/mod.rs | 15 ++++++++ minter/src/state/tests.rs | 1 + minter/src/update_balance/mod.rs | 4 +++ 12 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 minter/src/consolidate/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f9ad52df..728f4b24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,6 +772,7 @@ dependencies = [ "hex", "ic-canister-runtime", "ic-cdk", + "ic-cdk-timers", "ic-dummy-getrandom-for-wasm", "ic-ed25519", "ic-http-types", @@ -1408,7 +1409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2044,6 +2045,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ic-cdk-timers" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6852b9c1d4a82ff50fc7318599298aee8bfb082bd7e9fe7e5c1420692b2170f7" +dependencies = [ + "ic-cdk-executor", + "ic0", + "slotmap", +] + [[package]] name = "ic-certification" version = "3.1.0" @@ -2790,7 +2802,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3747,7 +3759,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3805,7 +3817,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6159,7 +6171,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6983,7 +6995,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1d30b82e..385ceecf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ derive_more = { version = "2.1.1", features = ["from"] } hex = "0.4.3" ic-canister-runtime = "0.2.0" ic-cdk = "0.19.0" +ic-cdk-timers = "1.0.0" ic-dummy-getrandom-for-wasm = "0.1.0" ic-ed25519 = "0.6.0" ic-http-types = "0.1.0" diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index a692c143..9a2f164b 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -24,6 +24,7 @@ use pocket_ic::{PocketIcBuilder, RejectResponse, nonblocking::PocketIc}; use serde::de::DeserializeOwned; use sol_rpc_client::SolRpcClient; use sol_rpc_types::{Lamport, Mode, RpcAccess}; +use std::time::Duration; use std::{default::Default, env::var, fs, path::PathBuf, vec}; pub mod events; @@ -248,6 +249,10 @@ impl Setup { self.env.as_ref().unwrap().tick().await } + pub async fn advance_time(&self, duration: Duration) -> () { + self.env.as_ref().unwrap().advance_time(duration).await + } + pub async fn drop(self) { let mut setup = self; if let Some(env) = setup.env.take() { diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 14b28d2d..e69e90cf 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -561,6 +561,8 @@ mod withdraw_sol_tests { mod update_balance_tests { use super::*; + use cksol_int_tests::fixtures::DEPOSIT_AMOUNT; + use std::time::Duration; #[tokio::test] async fn should_fail_if_transaction_not_found() { @@ -711,6 +713,20 @@ mod update_balance_tests { let balance_after = setup.ledger().balance_of(DEFAULT_CALLER_ACCOUNT).await; assert_eq!(balance_after, EXPECTED_MINT_AMOUNT); + // Deposit consolidation should be scheduled after deposit + let num_events_before = setup.minter().get_all_events().await.len(); + + setup.advance_time(Duration::from_mins(10)).await; + setup.tick().await; + + setup.minter().assert_that_events().await.satisfy(|events| { + assert_eq!(events.len(), num_events_before + 1); + check!(matches!( + &events[num_events_before], + EventType::ConsolidatedDeposits { deposits } if deposits == &vec![(DEFAULT_CALLER_ACCOUNT, DEPOSIT_AMOUNT)] + )); + }); + setup.drop().await; } diff --git a/minter/Cargo.toml b/minter/Cargo.toml index 51e4a5e3..dc67f99f 100644 --- a/minter/Cargo.toml +++ b/minter/Cargo.toml @@ -23,6 +23,7 @@ derive_more = { workspace = true } hex = { workspace = true } ic-canister-runtime = { workspace = true } ic-cdk = { workspace = true } +ic-cdk-timers = { workspace = true } ic-dummy-getrandom-for-wasm = { workspace = true } ic-ed25519 = { workspace = true } ic-http-types = { workspace = true } diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs new file mode 100644 index 00000000..33aba857 --- /dev/null +++ b/minter/src/consolidate/mod.rs @@ -0,0 +1,61 @@ +use crate::{ + guard::TimerGuard, + runtime::CanisterRuntime, + state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, +}; +use std::time::Duration; + +const DEPOSIT_CONSOLIDATION_DELAY: Duration = Duration::from_mins(10); +// The maximum number of transfer instructions we can safely fit inside a single Solana transaction. +const MAX_CONSOLIDATIONS_PER_TRANSACTION: usize = 10; + +// TODO DEFI-2670: Consider smarter scheduling of the consolidation task, e.g. making sure only +// scheduling the task if it is not already scheduled, or only if there are a certain number of +// non-consolidated deposits. +pub fn schedule_deposit_consolidation(runtime: R) { + runtime.set_timer( + DEPOSIT_CONSOLIDATION_DELAY, + consolidate_deposits(runtime.clone()), + ); +} + +async fn consolidate_deposits(runtime: R) { + let reschedule_task_guard = scopeguard::guard(runtime.clone(), |runtime| { + schedule_deposit_consolidation(runtime) + }); + + let _guard = match TimerGuard::new(TaskType::DepositConsolidation) { + Ok(guard) => guard, + Err(_) => return, + }; + + let funds_to_consolidate: Vec<_> = read_state(|state| { + state + .funds_to_consolidate() + .iter() + .take(MAX_CONSOLIDATIONS_PER_TRANSACTION) + .map(|(account, amount)| (*account, *amount)) + .collect() + }); + // Note that this should not happen since the task should not have been scheduled in this case. + if funds_to_consolidate.is_empty() { + return; + } + + // TODO DEFI-2670: Build and submit consolidation transaction + + mutate_state(|state| { + process_event( + state, + EventType::ConsolidatedDeposits { + deposits: funds_to_consolidate, + }, + &runtime, + ) + }); + + if read_state(|state| state.funds_to_consolidate().is_empty()) { + // No more deposits to consolidate, defuse guard + scopeguard::ScopeGuard::into_inner(reschedule_task_guard); + } +} diff --git a/minter/src/guard/mod.rs b/minter/src/guard/mod.rs index 3a2931c9..7ba9efa8 100644 --- a/minter/src/guard/mod.rs +++ b/minter/src/guard/mod.rs @@ -1,4 +1,4 @@ -use crate::state::{State, mutate_state}; +use crate::state::{State, TaskType, mutate_state}; use cksol_types::{UpdateBalanceError, WithdrawSolError}; use icrc_ledger_types::icrc1::account::Account; use std::{collections::BTreeSet, marker::PhantomData}; @@ -103,3 +103,32 @@ pub fn withdraw_sol_guard( ) -> Result, GuardError> { Guard::new(account) } + +#[derive(Eq, PartialEq, Debug)] +pub enum TimerGuardError { + AlreadyProcessing, +} + +#[derive(Eq, PartialEq, Debug)] +pub struct TimerGuard { + task: TaskType, +} + +impl TimerGuard { + pub fn new(task: TaskType) -> Result { + mutate_state(|s| { + if !s.active_tasks_mut().insert(task) { + return Err(TimerGuardError::AlreadyProcessing); + } + Ok(Self { task }) + }) + } +} + +impl Drop for TimerGuard { + fn drop(&mut self) { + mutate_state(|s| { + s.active_tasks_mut().remove(&self.task); + }); + } +} diff --git a/minter/src/lib.rs b/minter/src/lib.rs index 6c382917..13bb97c5 100644 --- a/minter/src/lib.rs +++ b/minter/src/lib.rs @@ -1,4 +1,5 @@ pub mod address; +mod consolidate; mod guard; mod ledger; pub mod lifecycle; diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index c9bc426c..aa1445ef 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -5,16 +5,22 @@ use std::{ fmt::Debug, iter, sync::{Arc, Mutex}, + time::Duration, }; -pub trait CanisterRuntime { +pub trait CanisterRuntime: Clone + 'static { fn inter_canister_call_runtime(&self) -> impl Runtime; fn time(&self) -> u64; fn instruction_counter(&self) -> u64; fn msg_cycles_available(&self) -> u128; + fn set_timer( + &self, + delay: Duration, + future: impl Future + 'static, + ) -> ic_cdk_timers::TimerId; } -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug)] pub struct IcCanisterRuntime(IcRuntime); impl IcCanisterRuntime { @@ -39,9 +45,18 @@ impl CanisterRuntime for IcCanisterRuntime { fn msg_cycles_available(&self) -> u128 { ic_cdk::api::msg_cycles_available() } + + fn set_timer( + &self, + delay: Duration, + future: impl Future + 'static, + ) -> ic_cdk_timers::TimerId { + ic_cdk_timers::set_timer(delay, future) + } } // TODO DEFI-2643: Move to test code. +#[derive(Clone)] pub struct TestCanisterRuntime { inter_canister_call_runtime: StubRuntime, times: Arc + Send + Sync>>, @@ -119,4 +134,12 @@ impl CanisterRuntime for TestCanisterRuntime { .pop_front() .expect("No more stub `msg_cycles_available`!") } + + fn set_timer( + &self, + _delay: Duration, + _future: impl Future + 'static, + ) -> ic_cdk_timers::TimerId { + Default::default() + } } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index d59e1518..36ca19b3 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -86,6 +86,7 @@ pub struct State { pending_withdrawal_requests: BTreeMap, funds_to_consolidate: BTreeMap, submitted_transactions: BTreeMap, + active_tasks: BTreeSet, } impl State { @@ -138,6 +139,10 @@ impl State { self.update_balance_required_cycles } + pub fn funds_to_consolidate(&self) -> &BTreeMap { + &self.funds_to_consolidate + } + pub fn deposit_status(&self, deposit_id: &DepositId) -> Option { if self.quarantined_deposits.contains_key(deposit_id) { return Some(DepositStatus::Quarantined(deposit_id.signature.into())); @@ -188,6 +193,10 @@ impl State { &mut self.pending_withdraw_sol_requests } + pub fn active_tasks_mut(&mut self) -> &mut BTreeSet { + &mut self.active_tasks + } + fn validate(&self) -> Result<(), InvalidStateError> { let canister_ids: BTreeSet<_> = [self.sol_rpc_canister_id, self.ledger_canister_id] .into_iter() @@ -415,6 +424,7 @@ impl TryFrom for State { pending_withdrawal_requests: BTreeMap::new(), funds_to_consolidate: BTreeMap::new(), submitted_transactions: BTreeMap::new(), + active_tasks: BTreeSet::new(), }; state.validate()?; Ok(state) @@ -432,3 +442,8 @@ pub struct MintedDeposit { block_index: LedgerMintIndex, minted_amount: Lamport, } + +#[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum TaskType { + DepositConsolidation, +} diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 34991833..23316bf2 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -50,6 +50,7 @@ mod state_from_init_args { pending_withdrawal_requests: BTreeMap::new(), funds_to_consolidate: BTreeMap::new(), submitted_transactions: BTreeMap::new(), + active_tasks: BTreeSet::new(), } ); } diff --git a/minter/src/update_balance/mod.rs b/minter/src/update_balance/mod.rs index 3de36791..b1ccf1a7 100644 --- a/minter/src/update_balance/mod.rs +++ b/minter/src/update_balance/mod.rs @@ -1,3 +1,4 @@ +use crate::consolidate::schedule_deposit_consolidation; use crate::{ address::get_deposit_address, guard::update_balance_guard, @@ -105,5 +106,8 @@ async fn try_accept_deposit( runtime, ) }); + + schedule_deposit_consolidation(runtime.clone()); + Ok(amount_to_mint) } From e47b9e4fca6df318fc3863d3bcccbd1ec5d715ea Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Thu, 12 Mar 2026 12:02:15 +0100 Subject: [PATCH 02/47] Schedule global timer for transaction consolidation --- minter/src/address.rs | 2 +- minter/src/consolidate/mod.rs | 59 ++++++++------------------------ minter/src/lib.rs | 2 +- minter/src/main.rs | 19 ++++++++-- minter/src/state/mod.rs | 13 +++++-- minter/src/update_balance/mod.rs | 4 --- 6 files changed, 45 insertions(+), 54 deletions(-) diff --git a/minter/src/address.rs b/minter/src/address.rs index 254a51f5..bcb595c5 100644 --- a/minter/src/address.rs +++ b/minter/src/address.rs @@ -17,7 +17,7 @@ pub async fn get_deposit_address(account: Account) -> Address { Address::from(public_key.serialize_raw()) } -async fn lazy_get_schnorr_master_key() -> SchnorrPublicKey { +pub async fn lazy_get_schnorr_master_key() -> SchnorrPublicKey { if let Some(public_key) = read_state(|s| s.minter_public_key().cloned()) { return public_key; } diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 33aba857..56ce0e2c 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -5,57 +5,28 @@ use crate::{ }; use std::time::Duration; -const DEPOSIT_CONSOLIDATION_DELAY: Duration = Duration::from_mins(10); +pub const DEPOSIT_CONSOLIDATION_DELAY: Duration = Duration::from_mins(10); // The maximum number of transfer instructions we can safely fit inside a single Solana transaction. const MAX_CONSOLIDATIONS_PER_TRANSACTION: usize = 10; -// TODO DEFI-2670: Consider smarter scheduling of the consolidation task, e.g. making sure only -// scheduling the task if it is not already scheduled, or only if there are a certain number of -// non-consolidated deposits. -pub fn schedule_deposit_consolidation(runtime: R) { - runtime.set_timer( - DEPOSIT_CONSOLIDATION_DELAY, - consolidate_deposits(runtime.clone()), - ); -} - -async fn consolidate_deposits(runtime: R) { - let reschedule_task_guard = scopeguard::guard(runtime.clone(), |runtime| { - schedule_deposit_consolidation(runtime) - }); - +pub async fn consolidate_deposits(runtime: R) { let _guard = match TimerGuard::new(TaskType::DepositConsolidation) { Ok(guard) => guard, Err(_) => return, }; - let funds_to_consolidate: Vec<_> = read_state(|state| { - state - .funds_to_consolidate() - .iter() - .take(MAX_CONSOLIDATIONS_PER_TRANSACTION) - .map(|(account, amount)| (*account, *amount)) - .collect() - }); - // Note that this should not happen since the task should not have been scheduled in this case. - if funds_to_consolidate.is_empty() { - return; - } - - // TODO DEFI-2670: Build and submit consolidation transaction - - mutate_state(|state| { - process_event( - state, - EventType::ConsolidatedDeposits { - deposits: funds_to_consolidate, - }, - &runtime, - ) - }); - - if read_state(|state| state.funds_to_consolidate().is_empty()) { - // No more deposits to consolidate, defuse guard - scopeguard::ScopeGuard::into_inner(reschedule_task_guard); + while let Some(funds_to_consolidate) = + read_state(|state| state.next_funds_to_consolidate(MAX_CONSOLIDATIONS_PER_TRANSACTION)) + { + mutate_state(|state| { + process_event( + state, + EventType::ConsolidatedDeposits { + deposits: funds_to_consolidate, + }, + &runtime, + ) + }); + // TODO DEFI-2670: Build and submit consolidation transaction } } diff --git a/minter/src/lib.rs b/minter/src/lib.rs index 13bb97c5..fe1ffd27 100644 --- a/minter/src/lib.rs +++ b/minter/src/lib.rs @@ -1,5 +1,5 @@ pub mod address; -mod consolidate; +pub mod consolidate; mod guard; mod ledger; pub mod lifecycle; diff --git a/minter/src/main.rs b/minter/src/main.rs index 24b31b66..a61d881a 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -1,6 +1,9 @@ use candid::Principal; use canlog::{Log, Sort}; -use cksol_minter::{runtime::IcCanisterRuntime, state::read_state}; +use cksol_minter::consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits}; +use cksol_minter::{ + address::lazy_get_schnorr_master_key, runtime::IcCanisterRuntime, state::read_state, +}; use cksol_types::{ Address, DepositStatus, GetDepositAddressArgs, MinterInfo, UpdateBalanceArgs, UpdateBalanceError, WithdrawSolArgs, WithdrawSolError, WithdrawSolOk, WithdrawSolStatus, @@ -8,7 +11,7 @@ use cksol_types::{ use cksol_types_internal::{MinterArg, log::Priority}; use ic_http_types::{HttpRequest, HttpResponse, HttpResponseBuilder}; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; -use std::str::FromStr; +use std::{str::FromStr, time::Duration}; #[ic_cdk::init] fn init(args: MinterArg) { @@ -20,6 +23,7 @@ fn init(args: MinterArg) { ic_cdk::trap("cannot init canister state with upgrade args"); } } + setup_timers(); } #[ic_cdk::post_upgrade] @@ -35,6 +39,7 @@ fn post_upgrade(args: Option) { cksol_minter::lifecycle::post_upgrade(None, IcCanisterRuntime::new()); } } + setup_timers(); } #[ic_cdk::update] @@ -245,6 +250,16 @@ fn assert_non_anonymous_account( Account { owner, subaccount } } +fn setup_timers() { + ic_cdk_timers::set_timer(Duration::from_secs(0), async { + // Initialize the minter's Ed25519 public key + let _ = lazy_get_schnorr_master_key().await; + }); + ic_cdk_timers::set_timer_interval(DEPOSIT_CONSOLIDATION_DELAY, async || { + consolidate_deposits(IcCanisterRuntime::new()).await; + }); +} + fn main() {} #[test] diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 36ca19b3..15c470a3 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -139,8 +139,17 @@ impl State { self.update_balance_required_cycles } - pub fn funds_to_consolidate(&self) -> &BTreeMap { - &self.funds_to_consolidate + pub fn next_funds_to_consolidate(&self, size: usize) -> Option> { + if self.funds_to_consolidate.is_empty() { + return None; + } + Some( + self.funds_to_consolidate + .iter() + .take(size) + .map(|(account, amount)| (*account, *amount)) + .collect(), + ) } pub fn deposit_status(&self, deposit_id: &DepositId) -> Option { diff --git a/minter/src/update_balance/mod.rs b/minter/src/update_balance/mod.rs index b1ccf1a7..3de36791 100644 --- a/minter/src/update_balance/mod.rs +++ b/minter/src/update_balance/mod.rs @@ -1,4 +1,3 @@ -use crate::consolidate::schedule_deposit_consolidation; use crate::{ address::get_deposit_address, guard::update_balance_guard, @@ -106,8 +105,5 @@ async fn try_accept_deposit( runtime, ) }); - - schedule_deposit_consolidation(runtime.clone()); - Ok(amount_to_mint) } From b55b8a29278f93e929bc88ac5816ba99b91c3714 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 13 Mar 2026 10:39:32 +0100 Subject: [PATCH 03/47] Clippy --- integration_tests/tests/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index fa9c148c..cfdad852 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -18,6 +18,7 @@ use cksol_types_internal::{UpgradeArgs, event::EventType, log::Priority}; use ic_pocket_canister_runtime::{JsonRpcResponse, MockHttpOutcalls, MockHttpOutcallsBuilder}; use icrc_ledger_types::icrc1::account::Subaccount; use serde_json::json; +use std::time::Duration; use tokio::join; mod get_deposit_address_tests { @@ -556,8 +557,6 @@ mod withdraw_sol_tests { mod update_balance_tests { use super::*; - use cksol_int_tests::fixtures::DEPOSIT_AMOUNT; - use std::time::Duration; #[tokio::test] async fn should_fail_if_transaction_not_found() { From d787c749c48c118bbfc3537df68ec65dc5759787 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Fri, 13 Mar 2026 11:50:10 +0100 Subject: [PATCH 04/47] schedule task for processing withdrawals --- minter/src/main.rs | 4 ++ minter/src/state/mod.rs | 17 +++++++++ minter/src/withdraw_sol/mod.rs | 23 ++++++++++-- minter/src/withdraw_sol/tests.rs | 64 ++++++++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/minter/src/main.rs b/minter/src/main.rs index a61d881a..587be21d 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -1,6 +1,7 @@ use candid::Principal; use canlog::{Log, Sort}; use cksol_minter::consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits}; +use cksol_minter::withdraw_sol::{WITHDRAWAL_PROCESSING_DELAY, process_pending_withdrawals}; use cksol_minter::{ address::lazy_get_schnorr_master_key, runtime::IcCanisterRuntime, state::read_state, }; @@ -258,6 +259,9 @@ fn setup_timers() { ic_cdk_timers::set_timer_interval(DEPOSIT_CONSOLIDATION_DELAY, async || { consolidate_deposits(IcCanisterRuntime::new()).await; }); + ic_cdk_timers::set_timer_interval(WITHDRAWAL_PROCESSING_DELAY, async || { + process_pending_withdrawals(IcCanisterRuntime::new()).await; + }); } fn main() {} diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index c68ffe62..d83513ca 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -332,6 +332,22 @@ impl State { WithdrawSolStatus::NotFound } + pub fn next_pending_withdrawal_requests( + &self, + size: usize, + ) -> Option> { + if self.pending_withdrawal_requests.is_empty() { + return None; + } + Some( + self.pending_withdrawal_requests + .values() + .take(size) + .cloned() + .collect(), + ) + } + fn process_accepted_withdrawal(&mut self, request: &WithdrawSolRequest) { assert_eq!( self.pending_withdrawal_requests @@ -470,4 +486,5 @@ pub struct MintedDeposit { #[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum TaskType { DepositConsolidation, + WithdrawalProcessing, } diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index f6d9ea03..c32831a6 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -1,4 +1,5 @@ use std::str::FromStr; +use std::time::Duration; use candid::Principal; use cksol_types::{WithdrawSolError, WithdrawSolOk}; @@ -8,16 +9,21 @@ use num_traits::ToPrimitive; use solana_address::Address; use crate::{ - guard::withdraw_sol_guard, + guard::{TimerGuard, withdraw_sol_guard}, ledger::burn, runtime::CanisterRuntime, state::{ + TaskType, audit::process_event, event::{EventType, WithdrawSolRequest}, mutate_state, read_state, }, }; +pub const WITHDRAWAL_PROCESSING_DELAY: Duration = Duration::from_mins(1); +// The maximum number of withdrawal requests to process in a single timer invocation. +const MAX_WITHDRAWALS_PER_BATCH: usize = 10; + #[cfg(test)] mod tests; @@ -107,7 +113,18 @@ pub async fn withdraw_sol( ) }); - // TODO DEFI-2671: trigger the timer to process pending withdrawals. - Ok(WithdrawSolOk { block_index }) } + +pub async fn process_pending_withdrawals(_runtime: R) { + let _guard = match TimerGuard::new(TaskType::WithdrawalProcessing) { + Ok(guard) => guard, + Err(_) => return, + }; + + if let Some(_pending_requests) = + read_state(|state| state.next_pending_withdrawal_requests(MAX_WITHDRAWALS_PER_BATCH)) + { + // TODO DEFI-2671: Build and submit withdrawal transactions + } +} diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 118e2168..40ef32ca 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -1,7 +1,8 @@ use crate::{ - guard::withdraw_sol_guard, - test_fixtures::{MINTER_ACCOUNT, init_state, runtime::TestCanisterRuntime}, - withdraw_sol::withdraw_sol, + guard::{TimerGuard, withdraw_sol_guard}, + state::TaskType, + test_fixtures::{MINTER_ACCOUNT, WITHDRAWAL_FEE, init_state, runtime::TestCanisterRuntime}, + withdraw_sol::{process_pending_withdrawals, withdraw_sol}, }; use assert_matches::assert_matches; use candid::{Nat, Principal}; @@ -233,3 +234,60 @@ async fn should_return_error_if_already_processing() { assert_eq!(result, Err(WithdrawSolError::AlreadyProcessing)); } + +mod process_pending_withdrawals_tests { + use super::*; + + #[tokio::test] + async fn should_do_nothing_if_no_pending_withdrawals() { + init_state(); + + let runtime = TestCanisterRuntime::new(); + process_pending_withdrawals(runtime).await; + } + + #[tokio::test] + async fn should_skip_if_already_processing() { + init_state(); + + let _guard = TimerGuard::new(TaskType::WithdrawalProcessing).unwrap(); + + let runtime = TestCanisterRuntime::new(); + process_pending_withdrawals(runtime).await; + } + + #[tokio::test] + async fn should_acquire_and_release_guard() { + init_state(); + + let runtime = TestCanisterRuntime::new(); + process_pending_withdrawals(runtime).await; + + // Guard should be released, so we can acquire it again + let _guard = TimerGuard::new(TaskType::WithdrawalProcessing).unwrap(); + } + + #[tokio::test] + async fn should_process_when_pending_withdrawals_exist() { + init_state(); + + // Create a pending withdrawal by accepting one + let runtime = TestCanisterRuntime::new() + .add_stub_response(Ok::(Nat::from(1u64))) + .with_increasing_time(); + + let _ = withdraw_sol( + runtime, + MINTER_ACCOUNT, + test_caller(), + None, + WITHDRAWAL_FEE + 1, + VALID_ADDRESS.to_string(), + ) + .await + .unwrap(); + + let runtime = TestCanisterRuntime::new(); + process_pending_withdrawals(runtime).await; + } +} From 11329e997a8a6ae94fbdc5fdc4935b832685e002 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Fri, 13 Mar 2026 11:54:33 +0100 Subject: [PATCH 05/47] clippy --- minter/src/state/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index d83513ca..097bba55 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -332,10 +332,7 @@ impl State { WithdrawSolStatus::NotFound } - pub fn next_pending_withdrawal_requests( - &self, - size: usize, - ) -> Option> { + pub fn next_pending_withdrawal_requests(&self, size: usize) -> Option> { if self.pending_withdrawal_requests.is_empty() { return None; } From 6140a01d07b2f30084f9ab8ddd53ec14db910e42 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Mon, 16 Mar 2026 11:30:29 +0100 Subject: [PATCH 06/47] creating and signing the transaction --- libs/types-internal/src/event.rs | 11 +++++ minter/cksol-minter.did | 11 +++++ minter/src/main.rs | 11 +++++ minter/src/state/audit.rs | 7 +++ minter/src/state/event.rs | 13 ++++++ minter/src/state/mod.rs | 43 ++++++++++++++++--- minter/src/state/tests.rs | 1 + minter/src/test_fixtures/mod.rs | 7 +++ minter/src/withdraw_sol/mod.rs | 73 ++++++++++++++++++++++++++++++-- 9 files changed, 169 insertions(+), 8 deletions(-) diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index b935b1dd..1759569f 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -87,6 +87,17 @@ pub enum EventType { /// and the amount consolidated from each account. deposits: Vec<(Account, Lamport)>, }, + /// A withdrawal transaction was signed and is ready to be sent to the network. + SentWithdrawalTransaction { + /// The burn transaction index on the ckSOL ledger. + burn_block_index: u64, + /// The destination Solana address. + solana_address: [u8; 32], + /// The transaction signature. + signature: Signature, + /// The serialized (unsigned) transaction message. + transaction: Vec, + }, } /// Arguments for the `get_events` endpoint. diff --git a/minter/cksol-minter.did b/minter/cksol-minter.did index 2eebdfb9..bd6429b0 100644 --- a/minter/cksol-minter.did +++ b/minter/cksol-minter.did @@ -285,6 +285,17 @@ type EventType = variant { // and the amount consolidated from each account. deposits: vec record { Account; Lamport }; }; + // A withdrawal transaction was signed and is ready to be sent to the network. + SentWithdrawalTransaction : record { + // The burn transaction index on the ckSOL ledger. + burn_block_index: nat64; + // The destination Solana address. + solana_address: blob; + // The transaction signature. + signature: Signature; + // The serialized (unsigned) transaction message. + transaction: blob; + }; }; // Represents a Solana transaction depositing funds to a ckSOL account. diff --git a/minter/src/main.rs b/minter/src/main.rs index 587be21d..c0fbf522 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -149,6 +149,17 @@ fn get_events( EventType::ConsolidatedDeposits { deposits } => { event::EventType::ConsolidatedDeposits { deposits } } + EventType::SentWithdrawalTransaction { + request, + signature, + transaction, + } => event::EventType::SentWithdrawalTransaction { + burn_block_index: *request.burn_block_index.get(), + solana_address: request.solana_address, + signature: signature.into(), + transaction: bincode::serialize(&transaction) + .expect("serializing transaction should succeed"), + }, } } diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index fb15cc14..dd66cac0 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -50,6 +50,13 @@ fn apply_state_transition(state: &mut State, payload: &EventType) { EventType::ConsolidatedDeposits { deposits } => { state.process_consolidated_deposits(deposits); } + EventType::SentWithdrawalTransaction { + request, + signature, + .. + } => { + state.process_sent_withdrawal_transaction(request, signature); + } } } diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index e6717fc4..e9c42130 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -79,6 +79,19 @@ pub enum EventType { #[n(0)] deposits: Vec<(Account, Lamport)>, }, + /// A withdrawal transaction was signed and is ready to be sent to the network. + #[n(8)] + SentWithdrawalTransaction { + /// The withdrawal request included in this transaction. + #[n(0)] + request: WithdrawSolRequest, + /// The transaction signature. + #[cbor(n(1), with = "cbor::signature")] + signature: Signature, + /// The transaction message. + #[cbor(n(2), with = "cbor::message")] + transaction: Message, + }, } /// Payload of the `AcceptedWithdrawSolRequest` event. diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index b259b06a..820b87f2 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -4,7 +4,7 @@ use crate::{ state::event::{DepositId, WithdrawSolRequest}, }; use candid::Principal; -use cksol_types::{DepositStatus, WithdrawSolStatus}; +use cksol_types::{DepositStatus, SolTransaction, WithdrawSolStatus}; use cksol_types_internal::{Ed25519KeyName, InitArgs, UpgradeArgs}; use ic_canister_runtime::Runtime; use ic_ed25519::PublicKey; @@ -84,6 +84,7 @@ pub struct State { quarantined_deposits: BTreeMap, minted_deposits: BTreeMap, pending_withdrawal_requests: BTreeMap, + sent_withdrawal_requests: BTreeMap, funds_to_consolidate: BTreeMap, submitted_transactions: BTreeMap, active_tasks: BTreeSet, @@ -314,12 +315,15 @@ impl State { } pub fn withdrawal_status(&self, block_index: u64) -> WithdrawSolStatus { - if self - .pending_withdrawal_requests - .contains_key(&LedgerBurnIndex::from(block_index)) - { + let burn_index = LedgerBurnIndex::from(block_index); + if self.pending_withdrawal_requests.contains_key(&burn_index) { return WithdrawSolStatus::Pending; } + if let Some(signature) = self.sent_withdrawal_requests.get(&burn_index) { + return WithdrawSolStatus::TxSent(SolTransaction { + transaction_hash: signature.to_string(), + }); + } WithdrawSolStatus::NotFound } @@ -379,6 +383,34 @@ impl State { ); } + fn process_sent_withdrawal_transaction( + &mut self, + request: &WithdrawSolRequest, + signature: &Signature, + ) { + let removed = self + .pending_withdrawal_requests + .remove(&request.burn_block_index) + .unwrap_or_else(|| { + panic!( + "Attempted to send transaction for unknown withdrawal request: {:?}", + request.burn_block_index + ) + }); + assert_eq!( + removed, *request, + "Withdrawal request mismatch for burn index {:?}", + request.burn_block_index + ); + assert_eq!( + self.sent_withdrawal_requests + .insert(request.burn_block_index, *signature), + None, + "Attempted to send transaction for already sent withdrawal request: {:?}", + request.burn_block_index + ); + } + fn process_consolidated_deposits(&mut self, deposits: &[(Account, Lamport)]) { for (account, amount) in deposits { let remaining = self @@ -444,6 +476,7 @@ impl TryFrom for State { quarantined_deposits: BTreeMap::new(), minted_deposits: BTreeMap::new(), pending_withdrawal_requests: BTreeMap::new(), + sent_withdrawal_requests: BTreeMap::new(), funds_to_consolidate: BTreeMap::new(), submitted_transactions: BTreeMap::new(), active_tasks: BTreeSet::new(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 151ad270..faf13ee6 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -47,6 +47,7 @@ mod state_from_init_args { quarantined_deposits: BTreeMap::new(), minted_deposits: BTreeMap::new(), pending_withdrawal_requests: BTreeMap::new(), + sent_withdrawal_requests: BTreeMap::new(), funds_to_consolidate: BTreeMap::new(), submitted_transactions: BTreeMap::new(), active_tasks: BTreeSet::new(), diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index c2b75127..8ef9f641 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -274,6 +274,13 @@ pub mod arb { }), prop::collection::vec((arb_account(), any::()), 1..10) .prop_map(|deposits| EventType::ConsolidatedDeposits { deposits }), + (arb_withdraw_sol_request(), arb_signature(), arb_message()).prop_map( + |(request, signature, transaction)| EventType::SentWithdrawalTransaction { + request, + signature, + transaction, + }, + ), ] } diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index c32831a6..bf1fb258 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -8,10 +8,14 @@ use icrc_ledger_types::icrc2::transfer_from::TransferFromError; use num_traits::ToPrimitive; use solana_address::Address; +use canlog::log; +use cksol_types_internal::log::Priority; + use crate::{ guard::{TimerGuard, withdraw_sol_guard}, ledger::burn, runtime::CanisterRuntime, + sol_transfer::{IcSchnorrSigner, create_signed_transfer_transaction}, state::{ TaskType, audit::process_event, @@ -116,15 +120,78 @@ pub async fn withdraw_sol( Ok(WithdrawSolOk { 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(_) => return, }; - if let Some(_pending_requests) = + let Some(pending_requests) = read_state(|state| state.next_pending_withdrawal_requests(MAX_WITHDRAWALS_PER_BATCH)) + else { + return; + }; + + let master_public_key = match read_state(|s| s.minter_public_key().cloned()) { + Some(key) => key, + None => { + log!(Priority::Debug, "Minter public key not yet available, skipping withdrawal processing"); + return; + } + }; + + let recent_blockhash = match read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) + .estimate_recent_blockhash() + .send() + .await { - // TODO DEFI-2671: Build and submit withdrawal transactions + Ok(blockhash) => blockhash, + Err(errors) => { + log!(Priority::Debug, "Failed to estimate recent blockhash: {errors:?}"); + return; + } + }; + + let minter_account: Account = ic_cdk::api::canister_self().into(); + let signer = IcSchnorrSigner; + + for request in pending_requests { + let destination = Address::from(request.solana_address); + let transfer_amount = request + .withdrawal_amount + .checked_sub(request.withdrawal_fee) + .expect("BUG: withdrawal_amount must be >= withdrawal_fee"); + + let transaction = match create_signed_transfer_transaction( + &master_public_key, + &[(minter_account, transfer_amount)], + destination, + recent_blockhash, + &signer, + ) + .await + { + Ok(tx) => tx, + Err(e) => { + log!(Priority::Debug, "Failed to create withdrawal transaction for burn index {:?}: {e}", request.burn_block_index); + continue; + } + }; + + let signature = transaction.signatures[0]; + + mutate_state(|state| { + process_event( + state, + EventType::SentWithdrawalTransaction { + request: request.clone(), + signature, + transaction: transaction.message.clone(), + }, + &runtime, + ) + }); + + // TODO: Send the transaction to the Solana network via RPC } } From f6bf4910df3e4caae650cf95147b9d36cdffb996 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Mon, 16 Mar 2026 11:45:03 +0100 Subject: [PATCH 07/47] store the whole request --- minter/src/state/audit.rs | 4 ++-- minter/src/state/mod.rs | 24 +++++++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index dd66cac0..8783e1c0 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -53,9 +53,9 @@ fn apply_state_transition(state: &mut State, payload: &EventType) { EventType::SentWithdrawalTransaction { request, signature, - .. + transaction, } => { - state.process_sent_withdrawal_transaction(request, signature); + state.process_sent_withdrawal_transaction(request, signature, transaction); } } } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 820b87f2..e810565c 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -84,7 +84,7 @@ pub struct State { quarantined_deposits: BTreeMap, minted_deposits: BTreeMap, pending_withdrawal_requests: BTreeMap, - sent_withdrawal_requests: BTreeMap, + sent_withdrawal_requests: BTreeMap, funds_to_consolidate: BTreeMap, submitted_transactions: BTreeMap, active_tasks: BTreeSet, @@ -319,9 +319,9 @@ impl State { if self.pending_withdrawal_requests.contains_key(&burn_index) { return WithdrawSolStatus::Pending; } - if let Some(signature) = self.sent_withdrawal_requests.get(&burn_index) { + if let Some(sent) = self.sent_withdrawal_requests.get(&burn_index) { return WithdrawSolStatus::TxSent(SolTransaction { - transaction_hash: signature.to_string(), + transaction_hash: sent.signature.to_string(), }); } WithdrawSolStatus::NotFound @@ -387,6 +387,7 @@ impl State { &mut self, request: &WithdrawSolRequest, signature: &Signature, + transaction: &Message, ) { let removed = self .pending_withdrawal_requests @@ -403,8 +404,14 @@ impl State { request.burn_block_index ); assert_eq!( - self.sent_withdrawal_requests - .insert(request.burn_block_index, *signature), + self.sent_withdrawal_requests.insert( + request.burn_block_index, + SentWithdrawalTransaction { + request: request.clone(), + signature: *signature, + transaction: transaction.clone(), + } + ), None, "Attempted to send transaction for already sent withdrawal request: {:?}", request.burn_block_index @@ -504,6 +511,13 @@ pub struct MintedDeposit { pub deposit: Deposit, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SentWithdrawalTransaction { + pub request: WithdrawSolRequest, + pub signature: Signature, + pub transaction: Message, +} + #[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum TaskType { DepositConsolidation, From 122cd02334c4c9c08dc2cab87614edddf273f4cc Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Mon, 16 Mar 2026 11:53:10 +0100 Subject: [PATCH 08/47] remove txcreated --- libs/types/src/lib.rs | 4 ---- minter/cksol-minter.did | 4 ---- 2 files changed, 8 deletions(-) diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 47495edf..a70fdb0a 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -185,10 +185,6 @@ pub enum WithdrawSolStatus { /// Withdrawal request is waiting to be processed. Pending, - /// Transaction fees were estimated and a Solana transaction was created. - /// Transaction is not signed yet. - TxCreated, - /// Solana transaction was signed and is sent to the network. TxSent(SolTransaction), diff --git a/minter/cksol-minter.did b/minter/cksol-minter.did index bd6429b0..e125b11f 100644 --- a/minter/cksol-minter.did +++ b/minter/cksol-minter.did @@ -198,10 +198,6 @@ type WithdrawSolStatus = variant { // Withdrawal request is waiting to be processed. Pending; - // Transaction fees were estimated and a Solana transaction was created. - // Transaction is not signed yet. - TxCreated; - // Solana transaction was signed and is sent to the network. TxSent : SolTransaction; From ac07f5460217d5ed49d5bff577bdad120a92d807 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Mon, 16 Mar 2026 13:14:58 +0100 Subject: [PATCH 09/47] log errors --- libs/types-internal/src/log.rs | 5 +++++ minter/src/main.rs | 2 ++ minter/src/withdraw_sol/mod.rs | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/libs/types-internal/src/log.rs b/libs/types-internal/src/log.rs index 5c185e30..e38ab4b1 100644 --- a/libs/types-internal/src/log.rs +++ b/libs/types-internal/src/log.rs @@ -7,6 +7,9 @@ use std::{fmt, fmt::Formatter, str::FromStr}; /// The priority level of a log entry. #[derive(LogPriorityLevels, Serialize, Deserialize, PartialEq, Debug, Copy, Clone)] pub enum Priority { + /// Error log entries. + #[log_level(capacity = 1000, name = "ERROR")] + Error, /// Informational log entries. #[log_level(capacity = 1000, name = "INFO")] Info, @@ -26,6 +29,7 @@ impl FromStr for Priority { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { + "error" => Ok(Priority::Error), "info" => Ok(Priority::Info), "debug" => Ok(Priority::Debug), _ => Err("could not recognize priority".to_string()), @@ -36,6 +40,7 @@ impl FromStr for Priority { impl fmt::Display for Priority { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { + Priority::Error => write!(f, "ERROR"), Priority::Info => write!(f, "INFO"), Priority::Debug => write!(f, "DEBUG"), } diff --git a/minter/src/main.rs b/minter/src/main.rs index c0fbf522..b03eee16 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -209,10 +209,12 @@ fn http_request(request: HttpRequest) -> HttpResponse { match request.raw_query_param("priority").map(Priority::from_str) { Some(Ok(priority)) => match priority { + Priority::Error => log.push_logs(Priority::Error), Priority::Info => log.push_logs(Priority::Info), Priority::Debug => log.push_logs(Priority::Debug), }, Some(Err(_)) | None => { + log.push_logs(Priority::Error); log.push_logs(Priority::Info); log.push_logs(Priority::Debug); } diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index bf1fb258..97ea115c 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -135,7 +135,7 @@ pub async fn process_pending_withdrawals(runtime: R) { let master_public_key = match read_state(|s| s.minter_public_key().cloned()) { Some(key) => key, None => { - log!(Priority::Debug, "Minter public key not yet available, skipping withdrawal processing"); + log!(Priority::Error, "Minter public key not yet available, skipping withdrawal processing"); return; } }; @@ -147,7 +147,7 @@ pub async fn process_pending_withdrawals(runtime: R) { { Ok(blockhash) => blockhash, Err(errors) => { - log!(Priority::Debug, "Failed to estimate recent blockhash: {errors:?}"); + log!(Priority::Error, "Failed to estimate recent blockhash: {errors:?}"); return; } }; @@ -173,7 +173,7 @@ pub async fn process_pending_withdrawals(runtime: R) { { Ok(tx) => tx, Err(e) => { - log!(Priority::Debug, "Failed to create withdrawal transaction for burn index {:?}: {e}", request.burn_block_index); + log!(Priority::Error, "Failed to create withdrawal transaction for burn index {:?}: {e}", request.burn_block_index); continue; } }; From 025f4665fd0e425a13c8ca7bff590c24e94ac56e Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 13:38:04 +0100 Subject: [PATCH 10/47] change create_signed_transfer_transaction target from Account to Address --- minter/src/consolidate/mod.rs | 9 ++++++- minter/src/sol_transfer/mod.rs | 3 +-- minter/src/sol_transfer/tests.rs | 32 +++++++++++++++--------- minter/src/withdraw_sol/mod.rs | 42 ++++++++++++++++---------------- 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index ecf3bb6d..a9c474d4 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -1,4 +1,5 @@ use crate::{ + address::{derivation_path, derive_public_key}, guard::TimerGuard, runtime::CanisterRuntime, sol_transfer::{ @@ -11,6 +12,7 @@ use canlog::log; use cksol_types_internal::log::Priority; use icrc_ledger_types::icrc1::account::Account; use sol_rpc_types::Lamport; +use solana_address::Address; use solana_hash::Hash; use solana_signature::Signature; use std::time::Duration; @@ -87,10 +89,15 @@ async fn submit_consolidation_transaction( owner: ic_cdk::api::canister_self(), subaccount: None, }; + let master_key = read_state(|s| s.minter_public_key().cloned().unwrap()); + let minter_address = Address::from( + derive_public_key(&master_key, derivation_path(&minter_account)).serialize_raw(), + ); + let transaction = create_signed_transfer_transaction( minter_account, &funds_to_consolidate, - minter_account, + minter_address, recent_blockhash, &IcSchnorrSigner, ) diff --git a/minter/src/sol_transfer/mod.rs b/minter/src/sol_transfer/mod.rs index 09e716d1..f82f17c5 100644 --- a/minter/src/sol_transfer/mod.rs +++ b/minter/src/sol_transfer/mod.rs @@ -73,7 +73,7 @@ impl SchnorrSigner for IcSchnorrSigner { pub async fn create_signed_transfer_transaction( fee_payer_account: Account, sources: &[(Account, Lamport)], - destination_account: Account, + target_address: Address, recent_blockhash: Hash, signer: &impl SchnorrSigner, ) -> Result { @@ -85,7 +85,6 @@ pub async fn create_signed_transfer_transaction( (derivation_path, Address::from(public_key.serialize_raw())) }; - let (_, target_address) = derive_address(&destination_account); let (fee_payer_derivation_path, fee_payer_address) = derive_address(&fee_payer_account); let (source_derivation_paths, source_addresses): (Vec<_>, Vec<_>) = sources diff --git a/minter/src/sol_transfer/tests.rs b/minter/src/sol_transfer/tests.rs index c61e1f0f..14664e8d 100644 --- a/minter/src/sol_transfer/tests.rs +++ b/minter/src/sol_transfer/tests.rs @@ -69,7 +69,7 @@ async fn should_create_signed_transaction_with_single_source() { let tx = create_signed_transfer_transaction( source_account, &[(source_account, amount)], - target_account, + target_address, blockhash, &signer, ) @@ -125,7 +125,7 @@ async fn should_create_signed_transaction_with_multiple_sources() { let tx = create_signed_transfer_transaction( account_1, &[(account_1, amount), (account_2, amount)], - target_account, + derive_address(&target_account), blockhash, &signer, ) @@ -177,7 +177,7 @@ async fn should_fail_when_signing_is_rejected() { let result = create_signed_transfer_transaction( source_account, &[(source_account, 500_000_000)], - target_account, + derive_address(&target_account), blockhash, &signer, ) @@ -213,7 +213,7 @@ async fn should_fail_when_second_signing_fails() { let result = create_signed_transfer_transaction( account_1, &[(account_1, 100_000_000), (account_2, 100_000_000)], - target_account, + derive_address(&target_account), blockhash, &signer, ) @@ -252,9 +252,14 @@ async fn should_fail_when_too_many_signatures() { subaccount: None, }; - let result = - create_signed_transfer_transaction(fee_payer, &sources, target_account, blockhash, &signer) - .await; + let result = create_signed_transfer_transaction( + fee_payer, + &sources, + derive_address(&target_account), + blockhash, + &signer, + ) + .await; assert!( matches!(result, Err(CreateTransferError::TooManySignatures { max: MAX_SIGNATURES, got }) if got == MAX_SIGNATURES + 1) @@ -292,9 +297,14 @@ async fn should_not_fail_for_max_signatures() { let signer = MockSchnorrSigner::with_signatures(vec![[0x11u8; 64]; MAX_SIGNATURES as usize]); - let result = - create_signed_transfer_transaction(fee_payer, &sources, target_account, blockhash, &signer) - .await; + let result = create_signed_transfer_transaction( + fee_payer, + &sources, + derive_address(&target_account), + blockhash, + &signer, + ) + .await; assert!(result.is_ok()); } @@ -331,7 +341,7 @@ async fn should_create_signed_transaction_with_fee_payer() { let tx = create_signed_transfer_transaction( fee_payer_account, &sources, - target_account, + derive_address(&target_account), blockhash, &signer, ) diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 97ea115c..de960e3a 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -132,25 +132,21 @@ pub async fn process_pending_withdrawals(runtime: R) { return; }; - let master_public_key = match read_state(|s| s.minter_public_key().cloned()) { - Some(key) => key, - None => { - log!(Priority::Error, "Minter public key not yet available, skipping withdrawal processing"); - return; - } - }; - - let recent_blockhash = match read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) - .estimate_recent_blockhash() - .send() - .await - { - Ok(blockhash) => blockhash, - Err(errors) => { - log!(Priority::Error, "Failed to estimate recent blockhash: {errors:?}"); - return; - } - }; + let recent_blockhash = + match read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) + .estimate_recent_blockhash() + .send() + .await + { + Ok(blockhash) => blockhash, + Err(errors) => { + log!( + Priority::Error, + "Failed to estimate recent blockhash: {errors:?}" + ); + return; + } + }; let minter_account: Account = ic_cdk::api::canister_self().into(); let signer = IcSchnorrSigner; @@ -163,7 +159,7 @@ pub async fn process_pending_withdrawals(runtime: R) { .expect("BUG: withdrawal_amount must be >= withdrawal_fee"); let transaction = match create_signed_transfer_transaction( - &master_public_key, + minter_account, &[(minter_account, transfer_amount)], destination, recent_blockhash, @@ -173,7 +169,11 @@ pub async fn process_pending_withdrawals(runtime: R) { { Ok(tx) => tx, Err(e) => { - log!(Priority::Error, "Failed to create withdrawal transaction for burn index {:?}: {e}", request.burn_block_index); + log!( + Priority::Error, + "Failed to create withdrawal transaction for burn index {:?}: {e}", + request.burn_block_index + ); continue; } }; From 4b936d5c59a650fb8359d737167aed4986be2930 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 13:56:38 +0100 Subject: [PATCH 11/47] canister_self in runtime --- minter/src/consolidate/mod.rs | 2 +- minter/src/runtime/mod.rs | 6 ++++++ minter/src/test_fixtures/runtime.rs | 13 ++++++++++++- minter/src/withdraw_sol/mod.rs | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index a9c474d4..6bca89ea 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -86,7 +86,7 @@ async fn submit_consolidation_transaction( recent_blockhash: Hash, ) -> Result { let minter_account = Account { - owner: ic_cdk::api::canister_self(), + owner: runtime.canister_self(), subaccount: None, }; let master_key = read_state(|s| s.minter_public_key().cloned().unwrap()); diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index 7637316e..408f2a31 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -1,3 +1,4 @@ +use candid::Principal; use ic_canister_runtime::{IcRuntime, Runtime}; use std::{fmt::Debug, time::Duration}; @@ -13,6 +14,7 @@ pub trait CanisterRuntime: Clone + 'static { delay: Duration, future: impl Future + 'static, ) -> ic_cdk_timers::TimerId; + fn canister_self(&self) -> Principal; } #[derive(Clone, Default, Debug)] @@ -56,4 +58,8 @@ impl CanisterRuntime for IcCanisterRuntime { ) -> ic_cdk_timers::TimerId { ic_cdk_timers::set_timer(delay, future) } + + fn canister_self(&self) -> Principal { + ic_cdk::api::canister_self() + } } diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 64066656..12e4e088 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,5 +1,5 @@ use crate::runtime::CanisterRuntime; -use candid::CandidType; +use candid::{CandidType, Principal}; use ic_canister_runtime::{IcError, Runtime, StubRuntime}; use std::{ iter, @@ -15,6 +15,7 @@ pub struct TestCanisterRuntime { msg_cycles_accept: Stubs, msg_cycles_available: Stubs, msg_cycles_refunded: Stubs, + canister_self: Option, } impl TestCanisterRuntime { @@ -52,6 +53,11 @@ impl TestCanisterRuntime { self.msg_cycles_refunded = self.msg_cycles_refunded.add(value); self } + + pub fn with_canister_self(mut self, canister_self: Principal) -> Self { + self.canister_self = Some(canister_self); + self + } } impl CanisterRuntime for TestCanisterRuntime { @@ -88,6 +94,11 @@ impl CanisterRuntime for TestCanisterRuntime { ) -> ic_cdk_timers::TimerId { Default::default() } + + fn canister_self(&self) -> Principal { + self.canister_self + .expect("TestCanisterRuntime was not initialized with canister_self") + } } #[derive(Clone)] diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index de960e3a..6464e2a9 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -148,7 +148,7 @@ pub async fn process_pending_withdrawals(runtime: R) { } }; - let minter_account: Account = ic_cdk::api::canister_self().into(); + let minter_account: Account = runtime.canister_self().into(); let signer = IcSchnorrSigner; for request in pending_requests { From f7b8aa2c4e6ca50faae0d97aa6b4383007cf229f Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 14:19:53 +0100 Subject: [PATCH 12/47] add block hash and slot stubs --- minter/src/withdraw_sol/mod.rs | 12 ++++-- minter/src/withdraw_sol/tests.rs | 69 ++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 6464e2a9..c483f1f1 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -15,7 +15,7 @@ use crate::{ guard::{TimerGuard, withdraw_sol_guard}, ledger::burn, runtime::CanisterRuntime, - sol_transfer::{IcSchnorrSigner, create_signed_transfer_transaction}, + sol_transfer::{IcSchnorrSigner, SchnorrSigner, create_signed_transfer_transaction}, state::{ TaskType, audit::process_event, @@ -121,6 +121,13 @@ pub async fn withdraw_sol( } pub async fn process_pending_withdrawals(runtime: R) { + process_pending_withdrawals_with_signer(runtime, &IcSchnorrSigner).await; +} + +pub async fn process_pending_withdrawals_with_signer( + runtime: R, + signer: &impl SchnorrSigner, +) { let _guard = match TimerGuard::new(TaskType::WithdrawalProcessing) { Ok(guard) => guard, Err(_) => return, @@ -149,7 +156,6 @@ pub async fn process_pending_withdrawals(runtime: R) { }; let minter_account: Account = runtime.canister_self().into(); - let signer = IcSchnorrSigner; for request in pending_requests { let destination = Address::from(request.solana_address); @@ -163,7 +169,7 @@ pub async fn process_pending_withdrawals(runtime: R) { &[(minter_account, transfer_amount)], destination, recent_blockhash, - &signer, + signer, ) .await { diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 40ef32ca..520651c4 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -1,8 +1,11 @@ use crate::{ guard::{TimerGuard, withdraw_sol_guard}, state::TaskType, - test_fixtures::{MINTER_ACCOUNT, WITHDRAWAL_FEE, init_state, runtime::TestCanisterRuntime}, - withdraw_sol::{process_pending_withdrawals, withdraw_sol}, + test_fixtures::{ + MINTER_ACCOUNT, WITHDRAWAL_FEE, init_schnorr_master_key, init_state, + runtime::TestCanisterRuntime, + }, + withdraw_sol::{process_pending_withdrawals, process_pending_withdrawals_with_signer, withdraw_sol}, }; use assert_matches::assert_matches; use candid::{Nat, Principal}; @@ -237,6 +240,38 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; + use crate::sol_transfer::SchnorrSigner; + use crate::address::DerivationPath; + use ic_cdk::management_canister::SignCallError; + use std::cell::RefCell; + use std::collections::VecDeque; + + struct MockSchnorrSigner { + responses: RefCell, SignCallError>>>, + } + + impl MockSchnorrSigner { + fn with_signatures(signatures: Vec<[u8; 64]>) -> Self { + Self { + responses: RefCell::new( + signatures.into_iter().map(|sig| Ok(sig.to_vec())).collect(), + ), + } + } + } + + impl SchnorrSigner for MockSchnorrSigner { + async fn sign( + &self, + _message: Vec, + _derivation_path: DerivationPath, + ) -> Result, SignCallError> { + self.responses + .borrow_mut() + .pop_front() + .expect("MockSchnorrSigner: no more stub responses") + } + } #[tokio::test] async fn should_do_nothing_if_no_pending_withdrawals() { @@ -270,10 +305,14 @@ mod process_pending_withdrawals_tests { #[tokio::test] async fn should_process_when_pending_withdrawals_exist() { init_state(); + init_schnorr_master_key(); + + let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); // Create a pending withdrawal by accepting one let runtime = TestCanisterRuntime::new() .add_stub_response(Ok::(Nat::from(1u64))) + .with_canister_self(minter_self) .with_increasing_time(); let _ = withdraw_sol( @@ -287,7 +326,29 @@ mod process_pending_withdrawals_tests { .await .unwrap(); - let runtime = TestCanisterRuntime::new(); - process_pending_withdrawals(runtime).await; + type SendSlotResult = sol_rpc_types::MultiRpcResult; + type SendBlockResult = sol_rpc_types::MultiRpcResult; + + let runtime = TestCanisterRuntime::new() + // estimate_recent_blockhash: getSlot + getBlock + .add_stub_response(SendSlotResult::Consistent(Ok(1))) + .add_stub_response(SendBlockResult::Consistent(Ok( + sol_rpc_types::ConfirmedBlock { + previous_blockhash: Default::default(), + blockhash: solana_hash::Hash::new_from_array([0x42; 32]).into(), + parent_slot: 0, + block_time: None, + block_height: None, + signatures: None, + rewards: None, + num_reward_partitions: None, + transactions: None, + }, + ))) + .with_canister_self(minter_self) + .with_increasing_time(); + + let signer = MockSchnorrSigner::with_signatures(vec![[0x42; 64]]); + process_pending_withdrawals_with_signer(runtime, &signer).await; } } From 9af949dbd970f62dd8d0f4fafdf3b5768e7ec271 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 14:35:21 +0100 Subject: [PATCH 13/47] add schnorr signer to runtime --- minter/src/runtime/mod.rs | 7 ++++ minter/src/test_fixtures/runtime.rs | 50 ++++++++++++++++++++++++++++- minter/src/withdraw_sol/mod.rs | 12 ++----- minter/src/withdraw_sol/tests.rs | 38 ++-------------------- 4 files changed, 62 insertions(+), 45 deletions(-) diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index 408f2a31..09c2087c 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -2,6 +2,8 @@ use candid::Principal; use ic_canister_runtime::{IcRuntime, Runtime}; use std::{fmt::Debug, time::Duration}; +use crate::sol_transfer::{IcSchnorrSigner, SchnorrSigner}; + pub trait CanisterRuntime: Clone + 'static { fn inter_canister_call_runtime(&self) -> impl Runtime; fn time(&self) -> u64; @@ -15,6 +17,7 @@ pub trait CanisterRuntime: Clone + 'static { future: impl Future + 'static, ) -> ic_cdk_timers::TimerId; fn canister_self(&self) -> Principal; + fn schnorr_signer(&self) -> impl SchnorrSigner; } #[derive(Clone, Default, Debug)] @@ -62,4 +65,8 @@ impl CanisterRuntime for IcCanisterRuntime { fn canister_self(&self) -> Principal { ic_cdk::api::canister_self() } + + fn schnorr_signer(&self) -> impl SchnorrSigner { + IcSchnorrSigner + } } diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 12e4e088..d3224fae 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,7 +1,9 @@ -use crate::runtime::CanisterRuntime; +use crate::{address::DerivationPath, runtime::CanisterRuntime, sol_transfer::SchnorrSigner}; use candid::{CandidType, Principal}; use ic_canister_runtime::{IcError, Runtime, StubRuntime}; +use ic_cdk::management_canister::SignCallError; use std::{ + collections::VecDeque, iter, sync::{Arc, Mutex}, time::Duration, @@ -16,6 +18,7 @@ pub struct TestCanisterRuntime { msg_cycles_available: Stubs, msg_cycles_refunded: Stubs, canister_self: Option, + schnorr_signer: MockSchnorrSigner, } impl TestCanisterRuntime { @@ -58,6 +61,11 @@ impl TestCanisterRuntime { self.canister_self = Some(canister_self); self } + + pub fn add_schnorr_signature(mut self, signature: [u8; 64]) -> Self { + self.schnorr_signer.responses.add(Ok(signature.to_vec())); + self + } } impl CanisterRuntime for TestCanisterRuntime { @@ -99,6 +107,46 @@ impl CanisterRuntime for TestCanisterRuntime { self.canister_self .expect("TestCanisterRuntime was not initialized with canister_self") } + + fn schnorr_signer(&self) -> impl SchnorrSigner { + self.schnorr_signer.clone() + } +} + +#[derive(Clone, Default)] +pub struct MockSchnorrSigner { + responses: SharedVecDeque, SignCallError>>, +} + +impl SchnorrSigner for MockSchnorrSigner { + async fn sign( + &self, + _message: Vec, + _derivation_path: DerivationPath, + ) -> Result, SignCallError> { + self.responses + .pop_front() + .expect("MockSchnorrSigner: no more stub responses") + } +} + +#[derive(Clone)] +struct SharedVecDeque(Arc>>); + +impl Default for SharedVecDeque { + fn default() -> Self { + Self(Arc::new(Mutex::new(VecDeque::new()))) + } +} + +impl SharedVecDeque { + fn add(&mut self, value: T) { + self.0.try_lock().unwrap().push_back(value); + } + + fn pop_front(&self) -> Option { + self.0.try_lock().unwrap().pop_front() + } } #[derive(Clone)] diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index c483f1f1..5400fac5 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -15,7 +15,7 @@ use crate::{ guard::{TimerGuard, withdraw_sol_guard}, ledger::burn, runtime::CanisterRuntime, - sol_transfer::{IcSchnorrSigner, SchnorrSigner, create_signed_transfer_transaction}, + sol_transfer::create_signed_transfer_transaction, state::{ TaskType, audit::process_event, @@ -121,13 +121,6 @@ pub async fn withdraw_sol( } pub async fn process_pending_withdrawals(runtime: R) { - process_pending_withdrawals_with_signer(runtime, &IcSchnorrSigner).await; -} - -pub async fn process_pending_withdrawals_with_signer( - runtime: R, - signer: &impl SchnorrSigner, -) { let _guard = match TimerGuard::new(TaskType::WithdrawalProcessing) { Ok(guard) => guard, Err(_) => return, @@ -156,6 +149,7 @@ pub async fn process_pending_withdrawals_with_signer( }; let minter_account: Account = runtime.canister_self().into(); + let signer = runtime.schnorr_signer(); for request in pending_requests { let destination = Address::from(request.solana_address); @@ -169,7 +163,7 @@ pub async fn process_pending_withdrawals_with_signer( &[(minter_account, transfer_amount)], destination, recent_blockhash, - signer, + &signer, ) .await { diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 520651c4..1254dc0f 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -5,7 +5,7 @@ use crate::{ MINTER_ACCOUNT, WITHDRAWAL_FEE, init_schnorr_master_key, init_state, runtime::TestCanisterRuntime, }, - withdraw_sol::{process_pending_withdrawals, process_pending_withdrawals_with_signer, withdraw_sol}, + withdraw_sol::{process_pending_withdrawals, withdraw_sol}, }; use assert_matches::assert_matches; use candid::{Nat, Principal}; @@ -240,38 +240,6 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; - use crate::sol_transfer::SchnorrSigner; - use crate::address::DerivationPath; - use ic_cdk::management_canister::SignCallError; - use std::cell::RefCell; - use std::collections::VecDeque; - - struct MockSchnorrSigner { - responses: RefCell, SignCallError>>>, - } - - impl MockSchnorrSigner { - fn with_signatures(signatures: Vec<[u8; 64]>) -> Self { - Self { - responses: RefCell::new( - signatures.into_iter().map(|sig| Ok(sig.to_vec())).collect(), - ), - } - } - } - - impl SchnorrSigner for MockSchnorrSigner { - async fn sign( - &self, - _message: Vec, - _derivation_path: DerivationPath, - ) -> Result, SignCallError> { - self.responses - .borrow_mut() - .pop_front() - .expect("MockSchnorrSigner: no more stub responses") - } - } #[tokio::test] async fn should_do_nothing_if_no_pending_withdrawals() { @@ -345,10 +313,10 @@ mod process_pending_withdrawals_tests { transactions: None, }, ))) + .add_schnorr_signature([0x42; 64]) .with_canister_self(minter_self) .with_increasing_time(); - let signer = MockSchnorrSigner::with_signatures(vec![[0x42; 64]]); - process_pending_withdrawals_with_signer(runtime, &signer).await; + process_pending_withdrawals(runtime).await; } } From a788455cf7b42d8903ff56500a5db9a2349e1872 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 14:43:15 +0100 Subject: [PATCH 14/47] refactor MockSchnorrSigner into one implementation --- minter/src/sol_transfer/tests.rs | 34 +---------------------------- minter/src/test_fixtures/runtime.rs | 20 +++++++++++++++++ 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/minter/src/sol_transfer/tests.rs b/minter/src/sol_transfer/tests.rs index 14664e8d..ea599852 100644 --- a/minter/src/sol_transfer/tests.rs +++ b/minter/src/sol_transfer/tests.rs @@ -1,40 +1,8 @@ use super::*; -use crate::test_fixtures::{init_schnorr_master_key, init_state}; +use crate::test_fixtures::{init_schnorr_master_key, init_state, runtime::MockSchnorrSigner}; use candid::Principal; use ic_cdk::{call::CallRejected, management_canister::SignCallError}; use solana_address::Address; -use std::{cell::RefCell, collections::VecDeque}; - -struct MockSchnorrSigner { - responses: RefCell, SignCallError>>>, -} - -impl MockSchnorrSigner { - fn with_signatures(signatures: Vec<[u8; 64]>) -> Self { - Self { - responses: RefCell::new(signatures.into_iter().map(|sig| Ok(sig.to_vec())).collect()), - } - } - - fn with_responses(responses: Vec, SignCallError>>) -> Self { - Self { - responses: RefCell::new(responses.into()), - } - } -} - -impl SchnorrSigner for MockSchnorrSigner { - async fn sign( - &self, - _message: Vec, - _derivation_path: DerivationPath, - ) -> Result, SignCallError> { - self.responses - .borrow_mut() - .pop_front() - .expect("MockSchnorrSigner: no more stub responses") - } -} fn setup() { init_state(); diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index d3224fae..24e8f845 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -118,6 +118,22 @@ pub struct MockSchnorrSigner { responses: SharedVecDeque, SignCallError>>, } +impl MockSchnorrSigner { + pub fn with_signatures(signatures: Vec<[u8; 64]>) -> Self { + Self { + responses: SharedVecDeque::from_iter( + signatures.into_iter().map(|sig| Ok(sig.to_vec())), + ), + } + } + + pub fn with_responses(responses: Vec, SignCallError>>) -> Self { + Self { + responses: SharedVecDeque::from_iter(responses), + } + } +} + impl SchnorrSigner for MockSchnorrSigner { async fn sign( &self, @@ -140,6 +156,10 @@ impl Default for SharedVecDeque { } impl SharedVecDeque { + fn from_iter(iter: impl IntoIterator) -> Self { + Self(Arc::new(Mutex::new(iter.into_iter().collect()))) + } + fn add(&mut self, value: T) { self.0.try_lock().unwrap().push_back(value); } From 94339b8cd04c4db18f7a5dddd62bfa226b255ba8 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 15:02:44 +0100 Subject: [PATCH 15/47] check events generated in the test --- minter/src/main.rs | 4 +- minter/src/withdraw_sol/mod.rs | 10 ++--- minter/src/withdraw_sol/tests.rs | 77 +++++++++++++++++++------------- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/minter/src/main.rs b/minter/src/main.rs index b03eee16..fa516461 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -72,7 +72,7 @@ async fn withdraw_sol(args: WithdrawSolArgs) -> Result( - runtime: R, + runtime: &R, minter_account: Account, caller: Principal, from_subaccount: Option, @@ -53,7 +53,7 @@ pub async fn withdraw_sol( let solana_address = Address::from_str(&address) .map_err(|e| WithdrawSolError::MalformedAddress(e.to_string()))?; - let block_index = burn(&runtime, minter_account, from, amount, solana_address) + let block_index = burn(runtime, minter_account, from, amount, solana_address) .await .map_err(|e| match e { crate::ledger::BurnError::IcError(ic_error) => { @@ -113,14 +113,14 @@ pub async fn withdraw_sol( withdrawal_amount: amount, withdrawal_fee, }), - &runtime, + runtime, ) }); Ok(WithdrawSolOk { 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(_) => return, @@ -188,7 +188,7 @@ pub async fn process_pending_withdrawals(runtime: R) { signature, transaction: transaction.message.clone(), }, - &runtime, + runtime, ) }); diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 1254dc0f..a0e255c2 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -26,7 +26,7 @@ async fn should_return_error_if_calling_ledger_fails() { let runtime = TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -50,7 +50,7 @@ async fn should_return_error_if_ledger_unavailable() { )); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -78,7 +78,7 @@ async fn should_return_error_if_insufficient_allowance() { )); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -104,7 +104,7 @@ async fn should_return_error_if_insufficient_funds() { )); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -131,7 +131,7 @@ async fn should_return_generic_error() { )); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -158,7 +158,7 @@ async fn should_return_ok_if_burn_succeeds() { .with_increasing_time(); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -182,7 +182,7 @@ async fn should_return_error_if_address_malformed() { let runtime = TestCanisterRuntime::new(); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, test_caller(), None, @@ -202,7 +202,7 @@ async fn should_panic_if_caller_is_anonymous() { let runtime = TestCanisterRuntime::new(); let _ = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, Principal::anonymous(), None, @@ -226,7 +226,7 @@ async fn should_return_error_if_already_processing() { let runtime = TestCanisterRuntime::new(); let result = withdraw_sol( - runtime, + &runtime, MINTER_ACCOUNT, caller, None, @@ -240,13 +240,16 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; + use crate::state::event::EventType; + use crate::test_fixtures::EventsAssert; + use assert_matches::assert_matches; #[tokio::test] async fn should_do_nothing_if_no_pending_withdrawals() { init_state(); let runtime = TestCanisterRuntime::new(); - process_pending_withdrawals(runtime).await; + process_pending_withdrawals(&runtime).await; } #[tokio::test] @@ -256,7 +259,7 @@ mod process_pending_withdrawals_tests { let _guard = TimerGuard::new(TaskType::WithdrawalProcessing).unwrap(); let runtime = TestCanisterRuntime::new(); - process_pending_withdrawals(runtime).await; + process_pending_withdrawals(&runtime).await; } #[tokio::test] @@ -264,7 +267,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(); @@ -280,25 +283,6 @@ mod process_pending_withdrawals_tests { // Create a pending withdrawal by accepting one let runtime = TestCanisterRuntime::new() .add_stub_response(Ok::(Nat::from(1u64))) - .with_canister_self(minter_self) - .with_increasing_time(); - - let _ = withdraw_sol( - runtime, - MINTER_ACCOUNT, - test_caller(), - None, - WITHDRAWAL_FEE + 1, - VALID_ADDRESS.to_string(), - ) - .await - .unwrap(); - - type SendSlotResult = sol_rpc_types::MultiRpcResult; - type SendBlockResult = sol_rpc_types::MultiRpcResult; - - let runtime = TestCanisterRuntime::new() - // estimate_recent_blockhash: getSlot + getBlock .add_stub_response(SendSlotResult::Consistent(Ok(1))) .add_stub_response(SendBlockResult::Consistent(Ok( sol_rpc_types::ConfirmedBlock { @@ -317,6 +301,35 @@ mod process_pending_withdrawals_tests { .with_canister_self(minter_self) .with_increasing_time(); - process_pending_withdrawals(runtime).await; + let _ = withdraw_sol( + &runtime, + MINTER_ACCOUNT, + test_caller(), + None, + WITHDRAWAL_FEE + 1, + VALID_ADDRESS.to_string(), + ) + .await + .unwrap(); + + type SendSlotResult = sol_rpc_types::MultiRpcResult; + type SendBlockResult = sol_rpc_types::MultiRpcResult; + + process_pending_withdrawals(&runtime).await; + + EventsAssert::from_recorded() + .expect_event(|e| { + assert_matches!(e, EventType::AcceptedWithdrawSolRequest(req) => { + assert_eq!(req.withdrawal_amount, WITHDRAWAL_FEE + 1); + assert_eq!(req.withdrawal_fee, WITHDRAWAL_FEE); + }); + }) + .expect_event(|e| { + assert_matches!(e, EventType::SentWithdrawalTransaction { request, .. } => { + assert_eq!(request.withdrawal_amount, WITHDRAWAL_FEE + 1); + assert_eq!(request.withdrawal_fee, WITHDRAWAL_FEE); + }); + }) + .assert_no_more_events(); } } From 3ee817964d9d96c8de936c2cf1496afc9da58bde Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 15:13:26 +0100 Subject: [PATCH 16/47] assert no events are recorded --- minter/src/withdraw_sol/tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index a0e255c2..32755f60 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -250,6 +250,8 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new(); process_pending_withdrawals(&runtime).await; + + EventsAssert::assert_no_events_recorded(); } #[tokio::test] @@ -260,6 +262,8 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new(); process_pending_withdrawals(&runtime).await; + + EventsAssert::assert_no_events_recorded(); } #[tokio::test] From aa0c0acf13d8ea9606a7f57b57783a1704e23584 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 15:27:21 +0100 Subject: [PATCH 17/47] test for failing hash calculation --- minter/src/withdraw_sol/tests.rs | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 32755f60..d7e899b8 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -240,9 +240,12 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; + use canlog::Log; use crate::state::event::EventType; use crate::test_fixtures::EventsAssert; use assert_matches::assert_matches; + use cksol_types_internal::log::Priority; + use sol_rpc_types::{MultiRpcResult, RpcError}; #[tokio::test] async fn should_do_nothing_if_no_pending_withdrawals() { @@ -336,4 +339,63 @@ mod process_pending_withdrawals_tests { }) .assert_no_more_events(); } + + #[tokio::test] + async fn should_log_error_when_blockhash_fetch_fails() { + init_state(); + + let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); + + // Create a pending withdrawal + let runtime = TestCanisterRuntime::new() + .add_stub_response(Ok::(Nat::from(1u64))) + .with_canister_self(minter_self) + .with_increasing_time(); + + let _ = withdraw_sol( + &runtime, + MINTER_ACCOUNT, + test_caller(), + None, + WITHDRAWAL_FEE + 1, + VALID_ADDRESS.to_string(), + ) + .await + .unwrap(); + + type SendSlotResult = MultiRpcResult; + + // estimate_recent_blockhash retries getSlot 3 times before giving up + let runtime = TestCanisterRuntime::new() + .add_stub_response(SendSlotResult::Consistent(Err( + RpcError::ValidationError("slot unavailable".to_string()), + ))) + .add_stub_response(SendSlotResult::Consistent(Err( + RpcError::ValidationError("slot unavailable".to_string()), + ))) + .add_stub_response(SendSlotResult::Consistent(Err( + RpcError::ValidationError("slot unavailable".to_string()), + ))) + .with_canister_self(minter_self); + + process_pending_withdrawals(&runtime).await; + + // No withdrawal transaction event should be recorded + EventsAssert::from_recorded() + .expect_event(|e| { + assert_matches!(e, EventType::AcceptedWithdrawSolRequest(_)); + }) + .assert_no_more_events(); + + // An error should be logged + let mut log: Log = Log::default(); + log.push_logs(Priority::Error); + assert!( + log.entries + .iter() + .any(|e| e.message.contains("Failed to estimate recent blockhash")), + "Expected error log about blockhash failure, got: {:?}", + log.entries + ); + } } From 7eb11f0be05aa761d4b932f710a9029cdb070b3a Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 15:35:33 +0100 Subject: [PATCH 18/47] refactor --- minter/src/withdraw_sol/tests.rs | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index d7e899b8..319490e1 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -240,10 +240,10 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; - use canlog::Log; use crate::state::event::EventType; use crate::test_fixtures::EventsAssert; use assert_matches::assert_matches; + use canlog::Log; use cksol_types_internal::log::Priority; use sol_rpc_types::{MultiRpcResult, RpcError}; @@ -346,9 +346,21 @@ mod process_pending_withdrawals_tests { let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); + type SendSlotResult = MultiRpcResult; + // Create a pending withdrawal let runtime = TestCanisterRuntime::new() .add_stub_response(Ok::(Nat::from(1u64))) + // estimate_recent_blockhash retries getSlot 3 times before giving up + .add_stub_response(SendSlotResult::Consistent(Err(RpcError::ValidationError( + "slot unavailable".to_string(), + )))) + .add_stub_response(SendSlotResult::Consistent(Err(RpcError::ValidationError( + "slot unavailable".to_string(), + )))) + .add_stub_response(SendSlotResult::Consistent(Err(RpcError::ValidationError( + "slot unavailable".to_string(), + )))) .with_canister_self(minter_self) .with_increasing_time(); @@ -363,21 +375,6 @@ mod process_pending_withdrawals_tests { .await .unwrap(); - type SendSlotResult = MultiRpcResult; - - // estimate_recent_blockhash retries getSlot 3 times before giving up - let runtime = TestCanisterRuntime::new() - .add_stub_response(SendSlotResult::Consistent(Err( - RpcError::ValidationError("slot unavailable".to_string()), - ))) - .add_stub_response(SendSlotResult::Consistent(Err( - RpcError::ValidationError("slot unavailable".to_string()), - ))) - .add_stub_response(SendSlotResult::Consistent(Err( - RpcError::ValidationError("slot unavailable".to_string()), - ))) - .with_canister_self(minter_self); - process_pending_withdrawals(&runtime).await; // No withdrawal transaction event should be recorded From ac29698138cbaef537a324462d33f06c5912922a Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 18 Mar 2026 18:07:38 +0100 Subject: [PATCH 19/47] add comment --- minter/src/withdraw_sol/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 60f699c3..51146142 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -153,6 +153,12 @@ pub async fn process_pending_withdrawals(runtime: &R) { for request in pending_requests { let destination = Address::from(request.solana_address); + + // TODO: we need to check whether the minter has enough funds in the main account. + // We probably need to add a state.minter_balance variable and update it + // here and while consolidating funds. + // If there are not enough funds for the withdrawal we simply continue. + let transfer_amount = request .withdrawal_amount .checked_sub(request.withdrawal_fee) From 6c576c87b28352b3ae185d5417a2a31877cf0ee5 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 11:37:51 +0100 Subject: [PATCH 20/47] check for status in tests --- minter/src/main.rs | 4 ++-- minter/src/withdraw_sol/mod.rs | 6 +++++- minter/src/withdraw_sol/tests.rs | 14 +++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/minter/src/main.rs b/minter/src/main.rs index fa516461..ec82092a 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -83,8 +83,8 @@ async fn withdraw_sol(args: WithdrawSolArgs) -> Result WithdrawSolStatus { - read_state(|s| s.withdrawal_status(block_index)) +fn withdraw_sol_status(block_index: u64) -> WithdrawSolStatus { + cksol_minter::withdraw_sol::withdraw_sol_status(block_index) } #[ic_cdk::query] diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 51146142..56ac76aa 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use std::time::Duration; use candid::Principal; -use cksol_types::{WithdrawSolError, WithdrawSolOk}; +use cksol_types::{WithdrawSolError, WithdrawSolOk, WithdrawSolStatus}; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; use icrc_ledger_types::icrc2::transfer_from::TransferFromError; use num_traits::ToPrimitive; @@ -201,3 +201,7 @@ pub async fn process_pending_withdrawals(runtime: &R) { // TODO: Send the transaction to the Solana network via RPC } } + +pub fn withdraw_sol_status(block_index: u64) -> WithdrawSolStatus { + read_state(|s| s.withdrawal_status(block_index)) +} diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 319490e1..1cf90f96 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -240,10 +240,11 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; - use crate::state::event::EventType; use crate::test_fixtures::EventsAssert; + use crate::{state::event::EventType, withdraw_sol::withdraw_sol_status}; use assert_matches::assert_matches; use canlog::Log; + use cksol_types::WithdrawSolStatus; use cksol_types_internal::log::Priority; use sol_rpc_types::{MultiRpcResult, RpcError}; @@ -308,7 +309,7 @@ mod process_pending_withdrawals_tests { .with_canister_self(minter_self) .with_increasing_time(); - let _ = withdraw_sol( + let WithdrawSolOk { block_index } = withdraw_sol( &runtime, MINTER_ACCOUNT, test_caller(), @@ -338,6 +339,11 @@ mod process_pending_withdrawals_tests { }); }) .assert_no_more_events(); + + assert_matches!( + withdraw_sol_status(block_index), + WithdrawSolStatus::TxSent(_) + ); } #[tokio::test] @@ -364,7 +370,7 @@ mod process_pending_withdrawals_tests { .with_canister_self(minter_self) .with_increasing_time(); - let _ = withdraw_sol( + let WithdrawSolOk { block_index } = withdraw_sol( &runtime, MINTER_ACCOUNT, test_caller(), @@ -394,5 +400,7 @@ mod process_pending_withdrawals_tests { "Expected error log about blockhash failure, got: {:?}", log.entries ); + + assert_eq!(withdraw_sol_status(block_index), WithdrawSolStatus::Pending); } } From 79fa9a46b47b72489c6243b42e160148253291e8 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 14:14:28 +0100 Subject: [PATCH 21/47] test failed signing --- minter/src/test_fixtures/runtime.rs | 5 + minter/src/withdraw_sol/tests.rs | 136 +++++++++++++++++++++++----- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 24e8f845..84aad43b 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -66,6 +66,11 @@ impl TestCanisterRuntime { self.schnorr_signer.responses.add(Ok(signature.to_vec())); self } + + pub fn add_schnorr_signing_error(mut self, error: SignCallError) -> Self { + self.schnorr_signer.responses.add(Err(error)); + self + } } impl CanisterRuntime for TestCanisterRuntime { diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 1cf90f96..211ac529 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -246,7 +246,12 @@ mod process_pending_withdrawals_tests { use canlog::Log; use cksol_types::WithdrawSolStatus; use cksol_types_internal::log::Priority; - use sol_rpc_types::{MultiRpcResult, RpcError}; + use ic_cdk::call::CallRejected; + use ic_cdk::management_canister::SignCallError; + use sol_rpc_types::{ConfirmedBlock, MultiRpcResult, RpcError, Slot}; + + type SendSlotResult = MultiRpcResult; + type SendBlockResult = MultiRpcResult; #[tokio::test] async fn should_do_nothing_if_no_pending_withdrawals() { @@ -288,23 +293,13 @@ mod process_pending_withdrawals_tests { let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - // Create a pending withdrawal by accepting one let runtime = TestCanisterRuntime::new() + // ledger burn response for withdraw_sol .add_stub_response(Ok::(Nat::from(1u64))) + // responses for recent block hash .add_stub_response(SendSlotResult::Consistent(Ok(1))) - .add_stub_response(SendBlockResult::Consistent(Ok( - sol_rpc_types::ConfirmedBlock { - previous_blockhash: Default::default(), - blockhash: solana_hash::Hash::new_from_array([0x42; 32]).into(), - parent_slot: 0, - block_time: None, - block_height: None, - signatures: None, - rewards: None, - num_reward_partitions: None, - transactions: None, - }, - ))) + .add_stub_response(SendBlockResult::Consistent(Ok(get_confirmed_block()))) + // schnorr signing response .add_schnorr_signature([0x42; 64]) .with_canister_self(minter_self) .with_increasing_time(); @@ -320,9 +315,6 @@ mod process_pending_withdrawals_tests { .await .unwrap(); - type SendSlotResult = sol_rpc_types::MultiRpcResult; - type SendBlockResult = sol_rpc_types::MultiRpcResult; - process_pending_withdrawals(&runtime).await; EventsAssert::from_recorded() @@ -352,10 +344,8 @@ mod process_pending_withdrawals_tests { let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - type SendSlotResult = MultiRpcResult; - - // Create a pending withdrawal let runtime = TestCanisterRuntime::new() + // ledger burn response for withdraw_sol .add_stub_response(Ok::(Nat::from(1u64))) // estimate_recent_blockhash retries getSlot 3 times before giving up .add_stub_response(SendSlotResult::Consistent(Err(RpcError::ValidationError( @@ -403,4 +393,108 @@ mod process_pending_withdrawals_tests { assert_eq!(withdraw_sol_status(block_index), WithdrawSolStatus::Pending); } + + #[tokio::test] + async fn should_not_process_pending_withdrawal_sig_error() { + init_state(); + init_schnorr_master_key(); + + let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); + + type SendSlotResult = sol_rpc_types::MultiRpcResult; + type SendBlockResult = sol_rpc_types::MultiRpcResult; + + let runtime = TestCanisterRuntime::new() + // responses for burn blocks + .add_stub_response(Ok::(Nat::from(1u64))) + .add_stub_response(Ok::(Nat::from(2u64))) + // responses for recent block hash + .add_stub_response(SendSlotResult::Consistent(Ok(1))) + .add_stub_response(SendBlockResult::Consistent(Ok(get_confirmed_block()))) + // one successful signature and one failed + .add_schnorr_signature([0x42; 64]) + .add_schnorr_signing_error(SignCallError::CallFailed( + CallRejected::with_rejection(4, "signing service unavailable".to_string()).into(), + )) + .with_canister_self(minter_self) + .with_increasing_time(); + + let caller_1 = Principal::from_slice(&[1]); + let caller_2 = Principal::from_slice(&[1]); + + let WithdrawSolOk { block_index: idx1 } = withdraw_sol( + &runtime, + MINTER_ACCOUNT, + caller_1, + None, + WITHDRAWAL_FEE + 1, + VALID_ADDRESS.to_string(), + ) + .await + .unwrap(); + + let WithdrawSolOk { block_index: idx2 } = withdraw_sol( + &runtime, + MINTER_ACCOUNT, + caller_2, + None, + WITHDRAWAL_FEE + 1, + VALID_ADDRESS.to_string(), + ) + .await + .unwrap(); + + process_pending_withdrawals(&runtime).await; + + EventsAssert::from_recorded() + .expect_event(|e| { + assert_matches!(e, EventType::AcceptedWithdrawSolRequest(req) => { + assert_eq!(req.withdrawal_amount, WITHDRAWAL_FEE + 1); + assert_eq!(req.withdrawal_fee, WITHDRAWAL_FEE); + assert_eq!(req.account, caller_1.into()); + }); + }) + .expect_event(|e| { + assert_matches!(e, EventType::AcceptedWithdrawSolRequest(req) => { + assert_eq!(req.withdrawal_amount, WITHDRAWAL_FEE + 1); + assert_eq!(req.withdrawal_fee, WITHDRAWAL_FEE); + assert_eq!(req.account, caller_2.into()); + }); + }) + .expect_event(|e| { + assert_matches!(e, EventType::SentWithdrawalTransaction { request, .. } => { + assert_eq!(request.withdrawal_amount, WITHDRAWAL_FEE + 1); + assert_eq!(request.withdrawal_fee, WITHDRAWAL_FEE); + }); + }) + .assert_no_more_events(); + + // An error should be logged + let mut log: Log = Log::default(); + log.push_logs(Priority::Error); + assert!( + log.entries.iter().any(|e| e.message.contains(&format!( + "Failed to create withdrawal transaction for burn index {idx2}" + ))), + "Expected error log about sig failure, got: {:?}", + log.entries + ); + + assert_matches!(withdraw_sol_status(idx1), WithdrawSolStatus::TxSent(_)); + assert_matches!(withdraw_sol_status(idx2), WithdrawSolStatus::Pending); + } + + fn get_confirmed_block() -> sol_rpc_types::ConfirmedBlock { + sol_rpc_types::ConfirmedBlock { + previous_blockhash: Default::default(), + blockhash: solana_hash::Hash::new_from_array([0x42; 32]).into(), + parent_slot: 0, + block_time: None, + block_height: None, + signatures: None, + rewards: None, + num_reward_partitions: None, + transactions: None, + } + } } From a4ec8539e979a53868a6392f3e87cacae6c5e81c Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 14:59:56 +0100 Subject: [PATCH 22/47] up to max test --- minter/src/withdraw_sol/tests.rs | 151 ++++++++++++++++++------------- 1 file changed, 88 insertions(+), 63 deletions(-) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 211ac529..fedd89f2 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -241,6 +241,7 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; use crate::test_fixtures::EventsAssert; + use crate::withdraw_sol::MAX_WITHDRAWALS_PER_BATCH; use crate::{state::event::EventType, withdraw_sol::withdraw_sol_status}; use assert_matches::assert_matches; use canlog::Log; @@ -286,6 +287,21 @@ mod process_pending_withdrawals_tests { let _guard = TimerGuard::new(TaskType::WithdrawalProcessing).unwrap(); } + async fn withdraw(runtime: &TestCanisterRuntime, count: u8) { + for i in 1..count + 1 { + let _ = withdraw_sol( + runtime, + MINTER_ACCOUNT, + Principal::from_slice(&[1, i]).into(), + None, + WITHDRAWAL_FEE + 1, + VALID_ADDRESS.to_string(), + ) + .await + .unwrap(); + } + } + #[tokio::test] async fn should_process_when_pending_withdrawals_exist() { init_state(); @@ -293,6 +309,8 @@ mod process_pending_withdrawals_tests { let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); + let fake_sig = [0x42; 64]; + let runtime = TestCanisterRuntime::new() // ledger burn response for withdraw_sol .add_stub_response(Ok::(Nat::from(1u64))) @@ -300,20 +318,11 @@ mod process_pending_withdrawals_tests { .add_stub_response(SendSlotResult::Consistent(Ok(1))) .add_stub_response(SendBlockResult::Consistent(Ok(get_confirmed_block()))) // schnorr signing response - .add_schnorr_signature([0x42; 64]) + .add_schnorr_signature(fake_sig) .with_canister_self(minter_self) .with_increasing_time(); - let WithdrawSolOk { block_index } = withdraw_sol( - &runtime, - MINTER_ACCOUNT, - test_caller(), - None, - WITHDRAWAL_FEE + 1, - VALID_ADDRESS.to_string(), - ) - .await - .unwrap(); + withdraw(&runtime, 1).await; process_pending_withdrawals(&runtime).await; @@ -325,17 +334,15 @@ mod process_pending_withdrawals_tests { }); }) .expect_event(|e| { - assert_matches!(e, EventType::SentWithdrawalTransaction { request, .. } => { + assert_matches!(e, EventType::SentWithdrawalTransaction {request, signature, .. } => { assert_eq!(request.withdrawal_amount, WITHDRAWAL_FEE + 1); assert_eq!(request.withdrawal_fee, WITHDRAWAL_FEE); + assert_eq!(signature, fake_sig.into()); }); }) .assert_no_more_events(); - assert_matches!( - withdraw_sol_status(block_index), - WithdrawSolStatus::TxSent(_) - ); + assert_matches!(withdraw_sol_status(1), WithdrawSolStatus::TxSent(_)); } #[tokio::test] @@ -360,16 +367,7 @@ mod process_pending_withdrawals_tests { .with_canister_self(minter_self) .with_increasing_time(); - let WithdrawSolOk { block_index } = withdraw_sol( - &runtime, - MINTER_ACCOUNT, - test_caller(), - None, - WITHDRAWAL_FEE + 1, - VALID_ADDRESS.to_string(), - ) - .await - .unwrap(); + withdraw(&runtime, 1).await; process_pending_withdrawals(&runtime).await; @@ -391,7 +389,7 @@ mod process_pending_withdrawals_tests { log.entries ); - assert_eq!(withdraw_sol_status(block_index), WithdrawSolStatus::Pending); + assert_eq!(withdraw_sol_status(1), WithdrawSolStatus::Pending); } #[tokio::test] @@ -401,9 +399,6 @@ mod process_pending_withdrawals_tests { let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - type SendSlotResult = sol_rpc_types::MultiRpcResult; - type SendBlockResult = sol_rpc_types::MultiRpcResult; - let runtime = TestCanisterRuntime::new() // responses for burn blocks .add_stub_response(Ok::(Nat::from(1u64))) @@ -419,30 +414,7 @@ mod process_pending_withdrawals_tests { .with_canister_self(minter_self) .with_increasing_time(); - let caller_1 = Principal::from_slice(&[1]); - let caller_2 = Principal::from_slice(&[1]); - - let WithdrawSolOk { block_index: idx1 } = withdraw_sol( - &runtime, - MINTER_ACCOUNT, - caller_1, - None, - WITHDRAWAL_FEE + 1, - VALID_ADDRESS.to_string(), - ) - .await - .unwrap(); - - let WithdrawSolOk { block_index: idx2 } = withdraw_sol( - &runtime, - MINTER_ACCOUNT, - caller_2, - None, - WITHDRAWAL_FEE + 1, - VALID_ADDRESS.to_string(), - ) - .await - .unwrap(); + withdraw(&runtime, 2).await; process_pending_withdrawals(&runtime).await; @@ -451,20 +423,22 @@ mod process_pending_withdrawals_tests { assert_matches!(e, EventType::AcceptedWithdrawSolRequest(req) => { assert_eq!(req.withdrawal_amount, WITHDRAWAL_FEE + 1); assert_eq!(req.withdrawal_fee, WITHDRAWAL_FEE); - assert_eq!(req.account, caller_1.into()); + assert_eq!(req.account, Principal::from_slice(&[1, 1]).into()); }); }) .expect_event(|e| { assert_matches!(e, EventType::AcceptedWithdrawSolRequest(req) => { assert_eq!(req.withdrawal_amount, WITHDRAWAL_FEE + 1); assert_eq!(req.withdrawal_fee, WITHDRAWAL_FEE); - assert_eq!(req.account, caller_2.into()); + assert_eq!(req.account, Principal::from_slice(&[1, 2]).into()); }); }) .expect_event(|e| { assert_matches!(e, EventType::SentWithdrawalTransaction { request, .. } => { assert_eq!(request.withdrawal_amount, WITHDRAWAL_FEE + 1); assert_eq!(request.withdrawal_fee, WITHDRAWAL_FEE); + assert_eq!(request.account, Principal::from_slice(&[1, 1]).into()); + }); }) .assert_no_more_events(); @@ -473,19 +447,70 @@ mod process_pending_withdrawals_tests { let mut log: Log = Log::default(); log.push_logs(Priority::Error); assert!( - log.entries.iter().any(|e| e.message.contains(&format!( - "Failed to create withdrawal transaction for burn index {idx2}" - ))), + log.entries.iter().any(|e| e + .message + .contains("Failed to create withdrawal transaction for burn index 2")), "Expected error log about sig failure, got: {:?}", log.entries ); - assert_matches!(withdraw_sol_status(idx1), WithdrawSolStatus::TxSent(_)); - assert_matches!(withdraw_sol_status(idx2), WithdrawSolStatus::Pending); + assert_matches!(withdraw_sol_status(1), WithdrawSolStatus::TxSent(_)); + assert_matches!(withdraw_sol_status(2), WithdrawSolStatus::Pending); + } + + #[tokio::test] + async fn should_process_up_to_max() { + init_state(); + init_schnorr_master_key(); + + let request_count = MAX_WITHDRAWALS_PER_BATCH as u64 + 1; + + let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); + + let mut runtime = TestCanisterRuntime::new() + .with_canister_self(minter_self) + .with_increasing_time(); + // withdraw ledger burn responses + for i in 0..request_count { + runtime = runtime.add_stub_response(Ok::(Nat::from(i))); + } + // responses for recent block hash + runtime = runtime + .add_stub_response(SendSlotResult::Consistent(Ok(1))) + .add_stub_response(SendBlockResult::Consistent(Ok(get_confirmed_block()))); + // signatures for the first batch of withdrawals + for _ in 0..MAX_WITHDRAWALS_PER_BATCH { + runtime = runtime.add_schnorr_signature([0x42; 64]); + } + // responses for recent block hash and signature for the second batch + runtime = runtime + .add_stub_response(SendSlotResult::Consistent(Ok(1))) + .add_stub_response(SendBlockResult::Consistent(Ok(get_confirmed_block()))) + .add_schnorr_signature([0x42; 64]); + + withdraw(&runtime, request_count as u8).await; + + process_pending_withdrawals(&runtime).await; + + for i in 0..MAX_WITHDRAWALS_PER_BATCH { + assert_matches!(withdraw_sol_status(i as u64), WithdrawSolStatus::TxSent(_)); + } + // last withdrawal was not yet processed + assert_matches!( + withdraw_sol_status(MAX_WITHDRAWALS_PER_BATCH as u64), + WithdrawSolStatus::Pending + ); + + process_pending_withdrawals(&runtime).await; + + // all withdrawals are now processed + for i in 0..request_count { + assert_matches!(withdraw_sol_status(i as u64), WithdrawSolStatus::TxSent(_)); + } } - fn get_confirmed_block() -> sol_rpc_types::ConfirmedBlock { - sol_rpc_types::ConfirmedBlock { + fn get_confirmed_block() -> ConfirmedBlock { + ConfirmedBlock { previous_blockhash: Default::default(), blockhash: solana_hash::Hash::new_from_array([0x42; 32]).into(), parent_slot: 0, From b921445cc591dff2bb4c748a0f007818d0c2e590 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 15:04:35 +0100 Subject: [PATCH 23/47] log info about guard, test --- minter/src/withdraw_sol/mod.rs | 9 ++++++++- minter/src/withdraw_sol/tests.rs | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 87ccd710..4bac148b 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -121,11 +121,18 @@ pub async fn withdraw_sol( } pub async fn process_pending_withdrawals(runtime: &R) { + log!(Priority::Info, "processing pending withdrawals"); + let _guard = match TimerGuard::new(TaskType::WithdrawalProcessing) { Ok(guard) => guard, - Err(_) => return, + Err(_) => { + log!(Priority::Info, "failed to obtain guard, exiting"); + return; + } }; + log!(Priority::Info, "guard obtained"); + let Some(pending_requests) = read_state(|state| state.next_pending_withdrawal_requests(MAX_WITHDRAWALS_PER_BATCH)) else { diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index fedd89f2..a6b521dc 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -273,7 +273,15 @@ mod process_pending_withdrawals_tests { let runtime = TestCanisterRuntime::new(); process_pending_withdrawals(&runtime).await; - EventsAssert::assert_no_events_recorded(); + let mut log: Log = Log::default(); + log.push_logs(Priority::Info); + assert!( + log.entries + .iter() + .any(|e| e.message.contains("failed to obtain guard, exiting")), + "Expected info about failing to obtain guard, got: {:?}", + log.entries + ); } #[tokio::test] From d3ea570112adc6403bbb4543be0ec89d56b2660d Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 15:20:17 +0100 Subject: [PATCH 24/47] clippy --- minter/src/withdraw_sol/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index a6b521dc..21923d9a 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -300,7 +300,7 @@ mod process_pending_withdrawals_tests { let _ = withdraw_sol( runtime, MINTER_ACCOUNT, - Principal::from_slice(&[1, i]).into(), + Principal::from_slice(&[1, i]), None, WITHDRAWAL_FEE + 1, VALID_ADDRESS.to_string(), @@ -513,7 +513,7 @@ mod process_pending_withdrawals_tests { // all withdrawals are now processed for i in 0..request_count { - assert_matches!(withdraw_sol_status(i as u64), WithdrawSolStatus::TxSent(_)); + assert_matches!(withdraw_sol_status(i), WithdrawSolStatus::TxSent(_)); } } From 290a1ea33705043b24ad79795d1ed8705a7452c5 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 15:24:34 +0100 Subject: [PATCH 25/47] test if timer is started --- integration_tests/tests/tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index e6686867..16b0153f 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -19,6 +19,7 @@ use ic_pocket_canister_runtime::{JsonRpcResponse, MockHttpOutcalls, MockHttpOutc use icrc_ledger_types::icrc1::account::Subaccount; use serde_json::json; use sol_rpc_types::{CommitmentLevel, ConsensusStrategy, GetTransactionEncoding, RpcConfig}; +use std::time::Duration; use tokio::join; mod get_deposit_address_tests { @@ -554,6 +555,27 @@ mod withdraw_sol_tests { setup.drop().await; } + + #[tokio::test] + async fn should_start_withdrawal_processing_timer() { + let setup = SetupBuilder::new().build().await; + + setup + .advance_time(Duration::from_mins(1) + Duration::from_secs(1)) + .await; + setup.tick().await; + + let logs = setup.minter().retrieve_logs(&Priority::Info).await; + + assert!( + logs.iter() + .any(|e| e.message.contains("processing pending withdrawals")), + "Expected info about processing pending withdrawals, got: {:?}", + logs + ); + + setup.drop().await; + } } mod update_balance_tests { From 0832d69d3de3a116acc9d85bcb24889afd2d9da0 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 15:50:57 +0100 Subject: [PATCH 26/47] await master key --- minter/src/consolidate/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index d70af6ce..375c3ce7 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -1,5 +1,5 @@ use crate::{ - address::{derivation_path, derive_public_key}, + address::{derivation_path, derive_public_key, lazy_get_schnorr_master_key}, guard::TimerGuard, runtime::CanisterRuntime, sol_transfer::{ @@ -89,7 +89,7 @@ async fn submit_consolidation_transaction( owner: runtime.canister_self(), subaccount: None, }; - let master_key = read_state(|s| s.minter_public_key().cloned().unwrap()); + let master_key = lazy_get_schnorr_master_key().await; let minter_address = Address::from( derive_public_key(&master_key, derivation_path(&minter_account)).serialize_raw(), ); From 1cefdc3bcd0aed744bc703abbe18db6cb5167f45 Mon Sep 17 00:00:00 2001 From: maciejdfinity <122265298+maciejdfinity@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:59:41 +0100 Subject: [PATCH 27/47] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- minter/src/test_fixtures/runtime.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 84aad43b..5aef7865 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -166,11 +166,11 @@ impl SharedVecDeque { } fn add(&mut self, value: T) { - self.0.try_lock().unwrap().push_back(value); + self.0.lock().unwrap().push_back(value); } fn pop_front(&self) -> Option { - self.0.try_lock().unwrap().pop_front() + self.0.lock().unwrap().pop_front() } } @@ -180,7 +180,7 @@ struct Stubs(Arc + Send>>>); impl Stubs { pub fn next(&self) -> T { self.0 - .try_lock() + .lock() .unwrap() .next() .expect("No more stub values!") From 0e7644d3bef151385e56fdffe240d10423f5b845 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 16:00:11 +0100 Subject: [PATCH 28/47] clippy --- minter/src/test_fixtures/runtime.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 5aef7865..6c97a79b 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -179,11 +179,7 @@ struct Stubs(Arc + Send>>>); impl Stubs { pub fn next(&self) -> T { - self.0 - .lock() - .unwrap() - .next() - .expect("No more stub values!") + self.0.lock().unwrap().next().expect("No more stub values!") } pub fn chain(self, other: I) -> Self From fcaac8850690faad80482109a6ad9c358b392031 Mon Sep 17 00:00:00 2001 From: maciejdfinity <122265298+maciejdfinity@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:22:46 +0100 Subject: [PATCH 29/47] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- minter/src/consolidate/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index 375c3ce7..b7506e42 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -94,12 +94,13 @@ async fn submit_consolidation_transaction( derive_public_key(&master_key, derivation_path(&minter_account)).serialize_raw(), ); + let signer = runtime.schnorr_signer(); let (transaction, signers) = create_signed_transfer_transaction( minter_account, &funds_to_consolidate, minter_address, recent_blockhash, - &IcSchnorrSigner, + signer, ) .await?; From 865a179e8f393e793a539a5d9a53b4dd1a82be91 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 16:31:54 +0100 Subject: [PATCH 30/47] clippy --- minter/src/consolidate/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/minter/src/consolidate/mod.rs b/minter/src/consolidate/mod.rs index b7506e42..ca456bfb 100644 --- a/minter/src/consolidate/mod.rs +++ b/minter/src/consolidate/mod.rs @@ -2,9 +2,7 @@ use crate::{ address::{derivation_path, derive_public_key, lazy_get_schnorr_master_key}, guard::TimerGuard, runtime::CanisterRuntime, - sol_transfer::{ - CreateTransferError, IcSchnorrSigner, MAX_SIGNATURES, create_signed_transfer_transaction, - }, + sol_transfer::{CreateTransferError, MAX_SIGNATURES, create_signed_transfer_transaction}, state::{TaskType, audit::process_event, event::EventType, mutate_state, read_state}, transaction::{SubmitTransactionError, get_recent_blockhash, submit_transaction}, }; @@ -100,7 +98,7 @@ async fn submit_consolidation_transaction( &funds_to_consolidate, minter_address, recent_blockhash, - signer, + &signer, ) .await?; From 8345a638626a4d772324c634b79735665a7e8e2b Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 17:15:29 +0100 Subject: [PATCH 31/47] remove empty line and log statement --- minter/src/runtime/mod.rs | 3 +-- minter/src/withdraw_sol/mod.rs | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index 09c2087c..3be0cb64 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -1,9 +1,8 @@ +use crate::sol_transfer::{IcSchnorrSigner, SchnorrSigner}; use candid::Principal; use ic_canister_runtime::{IcRuntime, Runtime}; use std::{fmt::Debug, time::Duration}; -use crate::sol_transfer::{IcSchnorrSigner, SchnorrSigner}; - pub trait CanisterRuntime: Clone + 'static { fn inter_canister_call_runtime(&self) -> impl Runtime; fn time(&self) -> u64; diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 4bac148b..c0ffc472 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -131,8 +131,6 @@ pub async fn process_pending_withdrawals(runtime: &R) { } }; - log!(Priority::Info, "guard obtained"); - let Some(pending_requests) = read_state(|state| state.next_pending_withdrawal_requests(MAX_WITHDRAWALS_PER_BATCH)) else { From c07cc541c0e9d1f3780354b2d980331af0378821 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 17:18:28 +0100 Subject: [PATCH 32/47] top level imports --- minter/src/withdraw_sol/tests.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 21923d9a..353f66b1 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -1,3 +1,5 @@ +use crate::test_fixtures::EventsAssert; +use crate::withdraw_sol::MAX_WITHDRAWALS_PER_BATCH; use crate::{ guard::{TimerGuard, withdraw_sol_guard}, state::TaskType, @@ -7,11 +9,18 @@ use crate::{ }, withdraw_sol::{process_pending_withdrawals, withdraw_sol}, }; +use crate::{state::event::EventType, withdraw_sol::withdraw_sol_status}; use assert_matches::assert_matches; use candid::{Nat, Principal}; +use canlog::Log; +use cksol_types::WithdrawSolStatus; use cksol_types::{WithdrawSolError, WithdrawSolOk}; +use cksol_types_internal::log::Priority; use ic_canister_runtime::IcError; +use ic_cdk::call::CallRejected; +use ic_cdk::management_canister::SignCallError; use icrc_ledger_types::{icrc1::account::Account, icrc2::transfer_from::TransferFromError}; +use sol_rpc_types::{ConfirmedBlock, MultiRpcResult, RpcError, Slot}; const VALID_ADDRESS: &str = "E4MpwNnMWs2XtW5gVrxZvyS7fMq31QD5HvbxmwP45Tz3"; @@ -240,16 +249,6 @@ async fn should_return_error_if_already_processing() { mod process_pending_withdrawals_tests { use super::*; - use crate::test_fixtures::EventsAssert; - use crate::withdraw_sol::MAX_WITHDRAWALS_PER_BATCH; - use crate::{state::event::EventType, withdraw_sol::withdraw_sol_status}; - use assert_matches::assert_matches; - use canlog::Log; - use cksol_types::WithdrawSolStatus; - use cksol_types_internal::log::Priority; - use ic_cdk::call::CallRejected; - use ic_cdk::management_canister::SignCallError; - use sol_rpc_types::{ConfirmedBlock, MultiRpcResult, RpcError, Slot}; type SendSlotResult = MultiRpcResult; type SendBlockResult = MultiRpcResult; From 4dc482e00bb7064b26791ed82768ea7eb9278f47 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Thu, 19 Mar 2026 17:27:01 +0100 Subject: [PATCH 33/47] sign transactions in parallel --- minter/src/withdraw_sol/mod.rs | 57 +++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index c0ffc472..7a797338 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -156,28 +156,41 @@ pub async fn process_pending_withdrawals(runtime: &R) { let minter_account: Account = runtime.canister_self().into(); let signer = runtime.schnorr_signer(); - for request in pending_requests { - let destination = Address::from(request.solana_address); - - // TODO: we need to check whether the minter has enough funds in the main account. - // We probably need to add a state.minter_balance variable and update it - // here and while consolidating funds. - // If there are not enough funds for the withdrawal we simply continue. - - let transfer_amount = request - .withdrawal_amount - .checked_sub(request.withdrawal_fee) - .expect("BUG: withdrawal_amount must be >= withdrawal_fee"); - - let transaction = match create_signed_transfer_transaction( - minter_account, - &[(minter_account, transfer_amount)], - destination, - recent_blockhash, - &signer, - ) - .await - { + // TODO: we need to check whether the minter has enough funds in the main account. + // We probably need to add a state.minter_balance variable and update it + // here and while consolidating funds. + // If there are not enough funds for the withdrawal we simply continue. + + let withdrawal_params: Vec<_> = pending_requests + .iter() + .map(|request| { + let destination = Address::from(request.solana_address); + let transfer_amount = request + .withdrawal_amount + .checked_sub(request.withdrawal_fee) + .expect("BUG: withdrawal_amount must be >= withdrawal_fee"); + let sources = vec![(minter_account, transfer_amount)]; + (sources, destination) + }) + .collect(); + + let sign_futures: Vec<_> = withdrawal_params + .iter() + .map(|(sources, destination)| { + create_signed_transfer_transaction( + minter_account, + sources, + *destination, + recent_blockhash, + &signer, + ) + }) + .collect(); + + let results = futures::future::join_all(sign_futures).await; + + for (request, result) in pending_requests.into_iter().zip(results) { + let transaction = match result { Ok(tx) => tx, Err(e) => { log!( From 27c930e55fbb06afb5429c93406b12e3d42f2ff4 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Fri, 20 Mar 2026 15:00:08 +0100 Subject: [PATCH 34/47] integration test for processing the withdrawal --- integration_tests/src/lib.rs | 6 ++ integration_tests/tests/tests.rs | 101 +++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index e7533575..cdefbc9a 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -293,6 +293,12 @@ impl Setup { self.env.as_ref().unwrap().tick().await } + pub async fn tick_with_http_mocks(&self, mut mocks: impl ExecuteHttpOutcallMocks) -> () { + let env = self.env.as_ref().unwrap(); + env.tick().await; + mocks.execute_http_outcall_mocks(env).await; + } + pub async fn advance_time(&self, duration: Duration) -> () { self.env.as_ref().unwrap().advance_time(duration).await } diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 16b0153f..1e92bd94 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -15,7 +15,9 @@ use cksol_types::{ UpdateBalanceArgs, UpdateBalanceError, WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus, }; use cksol_types_internal::{UpgradeArgs, event::EventType, log::Priority}; -use ic_pocket_canister_runtime::{JsonRpcResponse, MockHttpOutcalls, MockHttpOutcallsBuilder}; +use ic_pocket_canister_runtime::{ + JsonRpcRequestMatcher, JsonRpcResponse, MockHttpOutcalls, MockHttpOutcallsBuilder, +}; use icrc_ledger_types::icrc1::account::Subaccount; use serde_json::json; use sol_rpc_types::{CommitmentLevel, ConsensusStrategy, GetTransactionEncoding, RpcConfig}; @@ -557,25 +559,100 @@ mod withdraw_sol_tests { } #[tokio::test] - async fn should_start_withdrawal_processing_timer() { - let setup = SetupBuilder::new().build().await; + async fn should_process_pending_withdrawals() { + const WITHDRAWAL_AMOUNT: u64 = 100_000_000; + const WITHDRAWAL_ADDRESS: &str = "E4MpwNnMWs2XtW5gVrxZvyS7fMq31QD5HvbxmwP45Tz3"; + let withdrawal_address_bytes = Address::from_str(WITHDRAWAL_ADDRESS) + .expect("failed to decode address") + .to_bytes(); + + let setup = SetupBuilder::new() + .with_initial_ledger_balances(vec![( + DEFAULT_CALLER_ACCOUNT, + Nat::from(10 * WITHDRAWAL_AMOUNT), + )]) + .build() + .await; setup - .advance_time(Duration::from_mins(1) + Duration::from_secs(1)) + .ledger() + .approve( + None, + WITHDRAWAL_AMOUNT, + Account { + owner: setup.minter_canister_id(), + subaccount: None, + }, + ) .await; - setup.tick().await; - let logs = setup.minter().retrieve_logs(&Priority::Info).await; + let WithdrawSolOk { block_index } = setup + .minter() + .withdraw_sol(WithdrawSolArgs { + from_subaccount: None, + amount: WITHDRAWAL_AMOUNT, + address: WITHDRAWAL_ADDRESS.to_string(), + }) + .await + .expect("withdraw_sol should succeed"); - assert!( - logs.iter() - .any(|e| e.message.contains("processing pending withdrawals")), - "Expected info about processing pending withdrawals, got: {:?}", - logs - ); + setup + .advance_time(Duration::from_mins(1) + Duration::from_secs(1)) + .await; + setup + .tick_with_http_mocks(estimate_blockhash_http_mocks()) + .await; + + setup.minter().assert_that_events().await.satisfy(|events| { + check!(events.iter().any(|e| matches!( + e, + EventType::SentWithdrawalTransaction { + burn_block_index, + solana_address, + .. + } if *burn_block_index == block_index && *solana_address == withdrawal_address_bytes + ))); + }); setup.drop().await; } + + fn estimate_blockhash_http_mocks() -> MockHttpOutcalls { + let get_slot_response = || { + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "result": 1, + "id": 0 + })) + }; + + let get_block_response = || { + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "result": { + "blockHeight": 1, + "blockTime": 1700000000_u64, + "blockhash": "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn", + "parentSlot": 0, + "previousBlockhash": "11111111111111111111111111111111" + }, + "id": 0 + })) + }; + + let mut builder = MockHttpOutcallsBuilder::new(); + for id in 0..4u64 { + builder = builder + .given(JsonRpcRequestMatcher::with_method("getSlot").with_id(id)) + .respond_with(get_slot_response().with_id(id)) + } + for id in 4..8u64 { + builder = builder + .given(JsonRpcRequestMatcher::with_method("getBlock").with_id(id)) + .respond_with(get_block_response().with_id(id)) + } + builder.build() + } } mod update_balance_tests { From dcb84955b56176391b89251c9d2d8851b25b2a1b Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Fri, 20 Mar 2026 15:24:37 +0100 Subject: [PATCH 35/47] build fix --- minter/src/test_fixtures/runtime.rs | 63 ++--------------------------- 1 file changed, 4 insertions(+), 59 deletions(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index bf442150..c6b5b5e4 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,7 +1,8 @@ use super::{signer::MockSchnorrSigner, stubs::Stubs}; use crate::{runtime::CanisterRuntime, signer::SchnorrSigner}; -use candid::CandidType; +use candid::{CandidType, Principal}; use ic_canister_runtime::{IcError, Runtime, StubRuntime}; +use ic_cdk::management_canister::SignCallError; use std::time::Duration; #[derive(Clone, Default)] @@ -59,12 +60,12 @@ impl TestCanisterRuntime { } pub fn add_schnorr_signature(mut self, signature: [u8; 64]) -> Self { - self.schnorr_signer.responses.add(Ok(signature.to_vec())); + self.schnorr_signer = self.schnorr_signer.add_signature(signature); self } pub fn add_schnorr_signing_error(mut self, error: SignCallError) -> Self { - self.schnorr_signer.responses.add(Err(error)); + self.schnorr_signer = self.schnorr_signer.add_response(Err(error)); self } } @@ -117,59 +118,3 @@ impl CanisterRuntime for TestCanisterRuntime { self.schnorr_signer.clone() } } - -#[derive(Clone, Default)] -pub struct MockSchnorrSigner { - responses: SharedVecDeque, SignCallError>>, -} - -impl MockSchnorrSigner { - pub fn with_signatures(signatures: Vec<[u8; 64]>) -> Self { - Self { - responses: SharedVecDeque::from_iter( - signatures.into_iter().map(|sig| Ok(sig.to_vec())), - ), - } - } - - pub fn with_responses(responses: Vec, SignCallError>>) -> Self { - Self { - responses: SharedVecDeque::from_iter(responses), - } - } -} - -impl SchnorrSigner for MockSchnorrSigner { - async fn sign( - &self, - _message: Vec, - _derivation_path: DerivationPath, - ) -> Result, SignCallError> { - self.responses - .pop_front() - .expect("MockSchnorrSigner: no more stub responses") - } -} - -#[derive(Clone)] -struct SharedVecDeque(Arc>>); - -impl Default for SharedVecDeque { - fn default() -> Self { - Self(Arc::new(Mutex::new(VecDeque::new()))) - } -} - -impl SharedVecDeque { - fn from_iter(iter: impl IntoIterator) -> Self { - Self(Arc::new(Mutex::new(iter.into_iter().collect()))) - } - - fn add(&mut self, value: T) { - self.0.lock().unwrap().push_back(value); - } - - fn pop_front(&self) -> Option { - self.0.lock().unwrap().pop_front() - } -} From d2775ca47dadb82190dbe6382d9eab05a4756167 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Mon, 23 Mar 2026 13:41:04 +0100 Subject: [PATCH 36/47] refactor event to contain only necessary fields --- integration_tests/tests/tests.rs | 6 +--- libs/types-internal/src/event.rs | 4 --- minter/cksol-minter.did | 4 --- minter/src/main.rs | 8 ++---- minter/src/state/audit.rs | 5 ++-- minter/src/state/event.rs | 9 ++---- minter/src/state/mod.rs | 47 +++++++++----------------------- minter/src/test_fixtures/mod.rs | 7 ++--- minter/src/withdraw_sol/mod.rs | 3 +- minter/src/withdraw_sol/tests.rs | 12 +++----- 10 files changed, 29 insertions(+), 76 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 1e92bd94..c4c74d93 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -562,9 +562,6 @@ mod withdraw_sol_tests { async fn should_process_pending_withdrawals() { const WITHDRAWAL_AMOUNT: u64 = 100_000_000; const WITHDRAWAL_ADDRESS: &str = "E4MpwNnMWs2XtW5gVrxZvyS7fMq31QD5HvbxmwP45Tz3"; - let withdrawal_address_bytes = Address::from_str(WITHDRAWAL_ADDRESS) - .expect("failed to decode address") - .to_bytes(); let setup = SetupBuilder::new() .with_initial_ledger_balances(vec![( @@ -608,9 +605,8 @@ mod withdraw_sol_tests { e, EventType::SentWithdrawalTransaction { burn_block_index, - solana_address, .. - } if *burn_block_index == block_index && *solana_address == withdrawal_address_bytes + } if *burn_block_index == block_index ))); }); diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 202a52e5..309306af 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -95,12 +95,8 @@ pub enum EventType { SentWithdrawalTransaction { /// The burn transaction index on the ckSOL ledger. burn_block_index: u64, - /// The destination Solana address. - solana_address: [u8; 32], /// The transaction signature. signature: Signature, - /// The serialized (unsigned) transaction message. - transaction: Vec, }, /// A previously submitted transaction was resubmitted with a new signature. ResubmittedTransaction { diff --git a/minter/cksol-minter.did b/minter/cksol-minter.did index 88c77ffb..ac4f9490 100644 --- a/minter/cksol-minter.did +++ b/minter/cksol-minter.did @@ -299,12 +299,8 @@ type EventType = variant { SentWithdrawalTransaction : record { // The burn transaction index on the ckSOL ledger. burn_block_index: nat64; - // The destination Solana address. - solana_address: blob; // The transaction signature. signature: Signature; - // The serialized (unsigned) transaction message. - transaction: blob; }; // A previously submitted transaction was resubmitted with a new signature. ResubmittedTransaction : record { diff --git a/minter/src/main.rs b/minter/src/main.rs index a7c40572..a2fd8169 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -154,15 +154,11 @@ fn get_events( event::EventType::ConsolidatedDeposits { deposits } } EventType::SentWithdrawalTransaction { - request, + burn_block_index, signature, - transaction, } => event::EventType::SentWithdrawalTransaction { - burn_block_index: *request.burn_block_index.get(), - solana_address: request.solana_address, + burn_block_index: *burn_block_index.get(), signature: signature.into(), - transaction: bincode::serialize(&transaction) - .expect("serializing transaction should succeed"), }, EventType::ResubmittedTransaction { old_signature, diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index b74ef9c5..4fd031b2 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -53,11 +53,10 @@ fn apply_state_transition(state: &mut State, payload: &EventType) { state.process_consolidated_deposits(deposits); } EventType::SentWithdrawalTransaction { - request, + burn_block_index, signature, - transaction, } => { - state.process_sent_withdrawal_transaction(request, signature, transaction); + state.process_sent_withdrawal_transaction(burn_block_index, signature); } EventType::ResubmittedTransaction { old_signature, diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index e3ac2e48..90da1a99 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -102,15 +102,12 @@ pub enum EventType { /// A withdrawal transaction was signed and is ready to be sent to the network. #[n(9)] SentWithdrawalTransaction { - /// The withdrawal request included in this transaction. - #[n(0)] - request: WithdrawSolRequest, + /// The burn transaction index on the ckSOL ledger. + #[cbor(n(0), with = "cbor::id")] + burn_block_index: LedgerBurnIndex, /// The transaction signature. #[cbor(n(1), with = "cbor::signature")] signature: Signature, - /// The transaction message. - #[cbor(n(2), with = "cbor::message")] - transaction: Message, }, } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 387cbb8b..eae7321d 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -88,7 +88,7 @@ pub struct State { quarantined_deposits: BTreeMap, minted_deposits: BTreeMap, pending_withdrawal_requests: BTreeMap, - sent_withdrawal_requests: BTreeMap, + sent_withdrawal_requests: BTreeMap, funds_to_consolidate: BTreeMap, submitted_transactions: BTreeMap, active_tasks: BTreeSet, @@ -325,9 +325,9 @@ impl State { if self.pending_withdrawal_requests.contains_key(&burn_index) { return WithdrawSolStatus::Pending; } - if let Some(sent) = self.sent_withdrawal_requests.get(&burn_index) { + if let Some(sent_signature) = self.sent_withdrawal_requests.get(&burn_index) { return WithdrawSolStatus::TxSent(SolTransaction { - transaction_hash: sent.signature.to_string(), + transaction_hash: sent_signature.to_string(), }); } WithdrawSolStatus::NotFound @@ -403,36 +403,22 @@ impl State { fn process_sent_withdrawal_transaction( &mut self, - request: &WithdrawSolRequest, + burn_block_index: &LedgerBurnIndex, signature: &Signature, - transaction: &Message, ) { - let removed = self - .pending_withdrawal_requests - .remove(&request.burn_block_index) - .unwrap_or_else(|| { - panic!( - "Attempted to send transaction for unknown withdrawal request: {:?}", - request.burn_block_index - ) - }); - assert_eq!( - removed, *request, - "Withdrawal request mismatch for burn index {:?}", - request.burn_block_index + assert!( + self.pending_withdrawal_requests + .remove(burn_block_index) + .is_some(), + "Attempted to send transaction for unknown withdrawal request: {:?}", + burn_block_index ); assert_eq!( - self.sent_withdrawal_requests.insert( - request.burn_block_index, - SentWithdrawalTransaction { - request: request.clone(), - signature: *signature, - transaction: transaction.clone(), - } - ), + self.sent_withdrawal_requests + .insert(*burn_block_index, *signature), None, "Attempted to send transaction for already sent withdrawal request: {:?}", - request.burn_block_index + burn_block_index ); } @@ -554,13 +540,6 @@ pub struct MintedDeposit { pub deposit: Deposit, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SentWithdrawalTransaction { - pub request: WithdrawSolRequest, - pub signature: Signature, - pub transaction: Message, -} - #[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum TaskType { DepositConsolidation, diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 1059694f..9b3055db 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -285,11 +285,10 @@ pub mod arb { }), prop::collection::vec((arb_account(), any::()), 1..10) .prop_map(|deposits| EventType::ConsolidatedDeposits { deposits }), - (arb_withdraw_sol_request(), arb_signature(), arb_message()).prop_map( - |(request, signature, transaction)| EventType::SentWithdrawalTransaction { - request, + (arb_ledger_burn_index(), arb_signature()).prop_map( + |(burn_block_index, signature)| EventType::SentWithdrawalTransaction { + burn_block_index, signature, - transaction, } ), (arb_signature(), arb_signature(), any::()).prop_map( diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 7a797338..6b859156 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -208,9 +208,8 @@ pub async fn process_pending_withdrawals(runtime: &R) { process_event( state, EventType::SentWithdrawalTransaction { - request: request.clone(), + burn_block_index: request.burn_block_index, signature, - transaction: transaction.0.message.clone(), }, runtime, ) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 353f66b1..7bca09e9 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -1,3 +1,4 @@ +use crate::numeric::LedgerBurnIndex; use crate::test_fixtures::EventsAssert; use crate::withdraw_sol::MAX_WITHDRAWALS_PER_BATCH; use crate::{ @@ -341,9 +342,7 @@ mod process_pending_withdrawals_tests { }); }) .expect_event(|e| { - assert_matches!(e, EventType::SentWithdrawalTransaction {request, signature, .. } => { - assert_eq!(request.withdrawal_amount, WITHDRAWAL_FEE + 1); - assert_eq!(request.withdrawal_fee, WITHDRAWAL_FEE); + assert_matches!(e, EventType::SentWithdrawalTransaction { signature, .. } => { assert_eq!(signature, fake_sig.into()); }); }) @@ -441,11 +440,8 @@ mod process_pending_withdrawals_tests { }); }) .expect_event(|e| { - assert_matches!(e, EventType::SentWithdrawalTransaction { request, .. } => { - assert_eq!(request.withdrawal_amount, WITHDRAWAL_FEE + 1); - assert_eq!(request.withdrawal_fee, WITHDRAWAL_FEE); - assert_eq!(request.account, Principal::from_slice(&[1, 1]).into()); - + assert_matches!(e, EventType::SentWithdrawalTransaction { burn_block_index, .. } => { + assert_eq!(burn_block_index, LedgerBurnIndex::from(1u64)); }); }) .assert_no_more_events(); From 94502053a0e92393c6684a81df69f2a0a541e362 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Mon, 23 Mar 2026 13:45:22 +0100 Subject: [PATCH 37/47] clippy --- minter/src/test_fixtures/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 9b3055db..8aad7eae 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -285,12 +285,12 @@ pub mod arb { }), prop::collection::vec((arb_account(), any::()), 1..10) .prop_map(|deposits| EventType::ConsolidatedDeposits { deposits }), - (arb_ledger_burn_index(), arb_signature()).prop_map( - |(burn_block_index, signature)| EventType::SentWithdrawalTransaction { + (arb_ledger_burn_index(), arb_signature()).prop_map(|(burn_block_index, signature)| { + EventType::SentWithdrawalTransaction { burn_block_index, signature, } - ), + }), (arb_signature(), arb_signature(), any::()).prop_map( |(old_signature, new_signature, new_slot)| { EventType::ResubmittedTransaction { From 5a548dc32adf6feb7f670cef6362800209d548f9 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Tue, 24 Mar 2026 13:11:46 +0100 Subject: [PATCH 38/47] remove duplicate signer --- minter/src/runtime/mod.rs | 5 ----- minter/src/test_fixtures/runtime.rs | 9 ++------- minter/src/withdraw_sol/mod.rs | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/minter/src/runtime/mod.rs b/minter/src/runtime/mod.rs index 8ba7aa4e..40c1905e 100644 --- a/minter/src/runtime/mod.rs +++ b/minter/src/runtime/mod.rs @@ -17,7 +17,6 @@ pub trait CanisterRuntime: Clone + 'static { future: impl Future + 'static, ) -> ic_cdk_timers::TimerId; fn canister_self(&self) -> Principal; - fn schnorr_signer(&self) -> impl SchnorrSigner; } #[derive(Clone, Default, Debug)] @@ -69,8 +68,4 @@ impl CanisterRuntime for IcCanisterRuntime { fn canister_self(&self) -> Principal { ic_cdk::api::canister_self() } - - fn schnorr_signer(&self) -> impl SchnorrSigner { - IcSchnorrSigner - } } diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index c6b5b5e4..e0e3a12d 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -15,7 +15,6 @@ pub struct TestCanisterRuntime { msg_cycles_available: Stubs, msg_cycles_refunded: Stubs, canister_self: Option, - schnorr_signer: MockSchnorrSigner, } impl TestCanisterRuntime { @@ -60,12 +59,12 @@ impl TestCanisterRuntime { } pub fn add_schnorr_signature(mut self, signature: [u8; 64]) -> Self { - self.schnorr_signer = self.schnorr_signer.add_signature(signature); + self.signer = self.signer.add_signature(signature); self } pub fn add_schnorr_signing_error(mut self, error: SignCallError) -> Self { - self.schnorr_signer = self.schnorr_signer.add_response(Err(error)); + self.signer = self.signer.add_response(Err(error)); self } } @@ -113,8 +112,4 @@ impl CanisterRuntime for TestCanisterRuntime { self.canister_self .expect("TestCanisterRuntime was not initialized with canister_self") } - - fn schnorr_signer(&self) -> impl SchnorrSigner { - self.schnorr_signer.clone() - } } diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index 6b859156..ddbeb609 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -154,7 +154,7 @@ pub async fn process_pending_withdrawals(runtime: &R) { }; let minter_account: Account = runtime.canister_self().into(); - let signer = runtime.schnorr_signer(); + let signer = runtime.signer(); // TODO: we need to check whether the minter has enough funds in the main account. // We probably need to add a state.minter_balance variable and update it From 61543a5d17c29cc93e0d94a46ec24845f82e0a18 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Tue, 24 Mar 2026 14:09:51 +0100 Subject: [PATCH 39/47] use constant for canister self --- minter/src/test_fixtures/runtime.rs | 11 +++-------- minter/src/withdraw_sol/tests.rs | 15 +-------------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index e0e3a12d..312ac965 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -5,6 +5,8 @@ use ic_canister_runtime::{IcError, Runtime, StubRuntime}; use ic_cdk::management_canister::SignCallError; use std::time::Duration; +pub const TEST_CANISTER_ID: Principal = Principal::from_slice(&[0xCA; 10]); + #[derive(Clone, Default)] pub struct TestCanisterRuntime { inter_canister_call_runtime: StubRuntime, @@ -14,7 +16,6 @@ pub struct TestCanisterRuntime { msg_cycles_accept: Stubs, msg_cycles_available: Stubs, msg_cycles_refunded: Stubs, - canister_self: Option, } impl TestCanisterRuntime { @@ -53,11 +54,6 @@ impl TestCanisterRuntime { self } - pub fn with_canister_self(mut self, canister_self: Principal) -> Self { - self.canister_self = Some(canister_self); - self - } - pub fn add_schnorr_signature(mut self, signature: [u8; 64]) -> Self { self.signer = self.signer.add_signature(signature); self @@ -109,7 +105,6 @@ impl CanisterRuntime for TestCanisterRuntime { } fn canister_self(&self) -> Principal { - self.canister_self - .expect("TestCanisterRuntime was not initialized with canister_self") + TEST_CANISTER_ID } } diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 7bca09e9..a8c8d60d 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -315,8 +315,6 @@ mod process_pending_withdrawals_tests { init_state(); init_schnorr_master_key(); - let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - let fake_sig = [0x42; 64]; let runtime = TestCanisterRuntime::new() @@ -327,7 +325,6 @@ mod process_pending_withdrawals_tests { .add_stub_response(SendBlockResult::Consistent(Ok(get_confirmed_block()))) // schnorr signing response .add_schnorr_signature(fake_sig) - .with_canister_self(minter_self) .with_increasing_time(); withdraw(&runtime, 1).await; @@ -355,8 +352,6 @@ mod process_pending_withdrawals_tests { async fn should_log_error_when_blockhash_fetch_fails() { init_state(); - let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - let runtime = TestCanisterRuntime::new() // ledger burn response for withdraw_sol .add_stub_response(Ok::(Nat::from(1u64))) @@ -370,7 +365,6 @@ mod process_pending_withdrawals_tests { .add_stub_response(SendSlotResult::Consistent(Err(RpcError::ValidationError( "slot unavailable".to_string(), )))) - .with_canister_self(minter_self) .with_increasing_time(); withdraw(&runtime, 1).await; @@ -403,8 +397,6 @@ mod process_pending_withdrawals_tests { init_state(); init_schnorr_master_key(); - let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - let runtime = TestCanisterRuntime::new() // responses for burn blocks .add_stub_response(Ok::(Nat::from(1u64))) @@ -417,7 +409,6 @@ mod process_pending_withdrawals_tests { .add_schnorr_signing_error(SignCallError::CallFailed( CallRejected::with_rejection(4, "signing service unavailable".to_string()).into(), )) - .with_canister_self(minter_self) .with_increasing_time(); withdraw(&runtime, 2).await; @@ -468,11 +459,7 @@ mod process_pending_withdrawals_tests { let request_count = MAX_WITHDRAWALS_PER_BATCH as u64 + 1; - let minter_self = Principal::from_slice(&[0, 1, 2, 3, 4]); - - let mut runtime = TestCanisterRuntime::new() - .with_canister_self(minter_self) - .with_increasing_time(); + 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))); From dc334d2783fedff2e83bdde8cd3632195fae59b0 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Tue, 24 Mar 2026 15:33:48 +0100 Subject: [PATCH 40/47] update comment --- minter/src/sol_transfer/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/minter/src/sol_transfer/mod.rs b/minter/src/sol_transfer/mod.rs index 26dc946c..e1888282 100644 --- a/minter/src/sol_transfer/mod.rs +++ b/minter/src/sol_transfer/mod.rs @@ -29,8 +29,8 @@ pub enum CreateTransferError { mod tests; /// Creates a signed Solana transaction that transfers lamports from -/// each minter-controlled address (identified by its account) to the -/// destination account's derived address. +/// each minter-controlled address (identified by its account) +/// to `target_address` Solana address. /// /// Returns the signed transaction and the list of signer accounts /// (in signature order: fee payer first, then sources). From a0e3a37e8cf01d5fa8c9ffa3eb7b51d5900f2e65 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Tue, 24 Mar 2026 15:54:08 +0100 Subject: [PATCH 41/47] remove chatty log, adapt guard log message --- minter/src/withdraw_sol/mod.rs | 7 ++++--- minter/src/withdraw_sol/tests.rs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index ddbeb609..c4598859 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -121,12 +121,13 @@ pub async fn withdraw_sol( } pub async fn process_pending_withdrawals(runtime: &R) { - log!(Priority::Info, "processing pending withdrawals"); - let _guard = match TimerGuard::new(TaskType::WithdrawalProcessing) { Ok(guard) => guard, Err(_) => { - log!(Priority::Info, "failed to obtain guard, exiting"); + log!( + Priority::Info, + "failed to obtain WithdrawalProcessing guard, exiting" + ); return; } }; diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index ed8685fa..57ba8dd6 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -276,9 +276,9 @@ mod process_pending_withdrawals_tests { let mut log: Log = Log::default(); log.push_logs(Priority::Info); assert!( - log.entries - .iter() - .any(|e| e.message.contains("failed to obtain guard, exiting")), + log.entries.iter().any(|e| e + .message + .contains("failed to obtain WithdrawalProcessing guard, exiting")), "Expected info about failing to obtain guard, got: {:?}", log.entries ); From 25cabd23372cc8fbe5610d1eb0a065c7728051d8 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 25 Mar 2026 10:53:19 +0100 Subject: [PATCH 42/47] use execute_http_mocks --- integration_tests/src/lib.rs | 6 ------ integration_tests/tests/tests.rs | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index d250134e..33e67ce7 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -296,12 +296,6 @@ impl Setup { self.env.as_ref().unwrap().tick().await } - pub async fn tick_with_http_mocks(&self, mut mocks: impl ExecuteHttpOutcallMocks) -> () { - let env = self.env.as_ref().unwrap(); - env.tick().await; - mocks.execute_http_outcall_mocks(env).await; - } - pub async fn advance_time(&self, duration: Duration) -> () { self.env.as_ref().unwrap().advance_time(duration).await } diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 1a4b0c50..0c2e8043 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -598,7 +598,7 @@ mod withdraw_sol_tests { .advance_time(Duration::from_mins(1) + Duration::from_secs(1)) .await; setup - .tick_with_http_mocks(estimate_blockhash_http_mocks()) + .execute_http_mocks(estimate_blockhash_http_mocks()) .await; setup.minter().assert_that_events().await.satisfy(|events| { From 6aa53e57fab794fc2b4188149e92f7126f48e874 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 25 Mar 2026 10:57:51 +0100 Subject: [PATCH 43/47] WITHDRAWAL_PROCESSING_DELAY --- integration_tests/tests/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 0c2e8043..c1799754 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -211,6 +211,8 @@ mod withdraw_sol_tests { use super::*; + const WITHDRAWAL_PROCESSING_DELAY: Duration = Duration::from_mins(1); + #[tokio::test] async fn should_validate_solana_address() { let setup = SetupBuilder::new().build().await; @@ -594,9 +596,7 @@ mod withdraw_sol_tests { .await .expect("withdraw_sol should succeed"); - setup - .advance_time(Duration::from_mins(1) + Duration::from_secs(1)) - .await; + setup.advance_time(WITHDRAWAL_PROCESSING_DELAY).await; setup .execute_http_mocks(estimate_blockhash_http_mocks()) .await; From 90d334e9c8101346279784fd67b0edec55934fd3 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 25 Mar 2026 11:06:30 +0100 Subject: [PATCH 44/47] use ready methods --- integration_tests/tests/tests.rs | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index c1799754..e0128915 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -615,38 +615,18 @@ mod withdraw_sol_tests { } fn estimate_blockhash_http_mocks() -> MockHttpOutcalls { - let get_slot_response = || { - JsonRpcResponse::from(json!({ - "jsonrpc": "2.0", - "result": 1, - "id": 0 - })) - }; - - let get_block_response = || { - JsonRpcResponse::from(json!({ - "jsonrpc": "2.0", - "result": { - "blockHeight": 1, - "blockTime": 1700000000_u64, - "blockhash": "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn", - "parentSlot": 0, - "previousBlockhash": "11111111111111111111111111111111" - }, - "id": 0 - })) - }; - let mut builder = MockHttpOutcallsBuilder::new(); for id in 0..4u64 { builder = builder .given(JsonRpcRequestMatcher::with_method("getSlot").with_id(id)) - .respond_with(get_slot_response().with_id(id)) + .respond_with(get_slot_response(1).with_id(id)) } for id in 4..8u64 { builder = builder .given(JsonRpcRequestMatcher::with_method("getBlock").with_id(id)) - .respond_with(get_block_response().with_id(id)) + .respond_with( + get_block_response("4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn").with_id(id), + ) } builder.build() } From db65a1fa868703c1a29f32dd9e6523fe63c36b70 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 25 Mar 2026 11:45:13 +0100 Subject: [PATCH 45/47] change to vec of withdrawals --- integration_tests/tests/tests.rs | 4 +-- libs/types-internal/src/event.rs | 6 ++--- minter/cksol-minter.did | 6 ++--- minter/src/main.rs | 15 +++++------ minter/src/state/audit.rs | 9 +++---- minter/src/state/event.rs | 9 +++---- minter/src/state/event/cbor/mod.rs | 40 ++++++++++++++++++++++++++++++ minter/src/test_fixtures/mod.rs | 9 +++---- minter/src/withdraw_sol/mod.rs | 3 +-- minter/src/withdraw_sol/tests.rs | 10 +++++--- 10 files changed, 71 insertions(+), 40 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index e0128915..1ebde00c 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -605,9 +605,9 @@ mod withdraw_sol_tests { check!(events.iter().any(|e| matches!( e, EventType::SentWithdrawalTransaction { - burn_block_index, + transactions, .. - } if *burn_block_index == block_index + } if transactions.iter().any(|(idx, _)| *idx == block_index) ))); }); diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 06bb2a5f..18b08fbb 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -93,10 +93,8 @@ pub enum EventType { }, /// A withdrawal transaction was signed and is ready to be sent to the network. SentWithdrawalTransaction { - /// The burn transaction index on the ckSOL ledger. - burn_block_index: u64, - /// The transaction signature. - signature: Signature, + /// The burn block indices and corresponding transaction signatures. + transactions: Vec<(u64, Signature)>, }, /// A previously submitted transaction was resubmitted with a new signature. ResubmittedTransaction { diff --git a/minter/cksol-minter.did b/minter/cksol-minter.did index 85106aec..d3c6c514 100644 --- a/minter/cksol-minter.did +++ b/minter/cksol-minter.did @@ -297,10 +297,8 @@ type EventType = variant { }; // A withdrawal transaction was signed and is ready to be sent to the network. SentWithdrawalTransaction : record { - // The burn transaction index on the ckSOL ledger. - burn_block_index: nat64; - // The transaction signature. - signature: Signature; + // The burn block indices and corresponding transaction signatures. + transactions: vec record { nat64; Signature }; }; // A previously submitted transaction was resubmitted with a new signature. ResubmittedTransaction : record { diff --git a/minter/src/main.rs b/minter/src/main.rs index cbcd7890..c57e5a27 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -154,13 +154,14 @@ fn get_events( EventType::ConsolidatedDeposits { deposits } => { event::EventType::ConsolidatedDeposits { deposits } } - EventType::SentWithdrawalTransaction { - burn_block_index, - signature, - } => event::EventType::SentWithdrawalTransaction { - burn_block_index: *burn_block_index.get(), - signature: signature.into(), - }, + EventType::SentWithdrawalTransaction { transactions } => { + event::EventType::SentWithdrawalTransaction { + transactions: transactions + .iter() + .map(|(idx, sig)| (*idx.get(), sig.into())) + .collect(), + } + } EventType::ResubmittedTransaction { old_signature, new_signature, diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 968e0661..362ecac5 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -52,11 +52,10 @@ fn apply_state_transition(state: &mut State, payload: &EventType) { EventType::ConsolidatedDeposits { deposits } => { state.process_consolidated_deposits(deposits); } - EventType::SentWithdrawalTransaction { - burn_block_index, - signature, - } => { - state.process_sent_withdrawal_transaction(burn_block_index, signature); + EventType::SentWithdrawalTransaction { transactions } => { + for (burn_block_index, signature) in transactions { + state.process_sent_withdrawal_transaction(burn_block_index, signature); + } } EventType::ResubmittedTransaction { old_signature, diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 67b5cb26..f78c1ed4 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -109,12 +109,9 @@ pub enum EventType { /// A withdrawal transaction was signed and is ready to be sent to the network. #[n(10)] SentWithdrawalTransaction { - /// The burn transaction index on the ckSOL ledger. - #[cbor(n(0), with = "cbor::id")] - burn_block_index: LedgerBurnIndex, - /// The transaction signature. - #[cbor(n(1), with = "cbor::signature")] - signature: Signature, + /// The burn block indices and corresponding transaction signatures. + #[cbor(n(0), with = "cbor::burn_index_signature_vec")] + transactions: Vec<(LedgerBurnIndex, Signature)>, }, } diff --git a/minter/src/state/event/cbor/mod.rs b/minter/src/state/event/cbor/mod.rs index fcc89a0f..7128abe5 100644 --- a/minter/src/state/event/cbor/mod.rs +++ b/minter/src/state/event/cbor/mod.rs @@ -53,6 +53,46 @@ pub mod signature { } } +pub mod burn_index_signature_vec { + use crate::numeric::LedgerBurnIndex; + use minicbor::{ + decode::{Decoder, Error}, + encode::{Encoder, Write}, + }; + use solana_signature::Signature; + + pub fn decode( + d: &mut Decoder<'_>, + _ctx: &mut Ctx, + ) -> Result, Error> { + let len = d.array()?.ok_or_else(|| Error::message("expected definite-length array"))?; + let mut result = Vec::with_capacity(len as usize); + for _ in 0..len { + d.array()?; + let burn_index = LedgerBurnIndex::new(d.u64()?); + let sig_bytes = d.bytes()?; + let signature = + Signature::try_from(sig_bytes).map_err(|e| Error::message(e.to_string()))?; + result.push((burn_index, signature)); + } + Ok(result) + } + + pub fn encode( + v: &Vec<(LedgerBurnIndex, Signature)>, + e: &mut Encoder, + _ctx: &mut Ctx, + ) -> Result<(), minicbor::encode::Error> { + e.array(v.len() as u64)?; + for (burn_index, signature) in v { + e.array(2)?; + e.u64(*burn_index.get())?; + e.bytes(signature.as_ref())?; + } + Ok(()) + } +} + pub mod message { use minicbor::{ decode::{Decoder, Error}, diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 2c03b3dd..f91dcad5 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -285,12 +285,9 @@ pub mod arb { }), prop::collection::vec((arb_account(), any::()), 1..10) .prop_map(|deposits| EventType::ConsolidatedDeposits { deposits }), - (arb_ledger_burn_index(), arb_signature()).prop_map(|(burn_block_index, signature)| { - EventType::SentWithdrawalTransaction { - burn_block_index, - signature, - } - }), + prop::collection::vec((arb_ledger_burn_index(), arb_signature()), 1..10).prop_map( + |transactions| EventType::SentWithdrawalTransaction { transactions }, + ), (arb_signature(), arb_signature(), any::()).prop_map( |(old_signature, new_signature, new_slot)| { EventType::ResubmittedTransaction { diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index c4598859..acce3677 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -209,8 +209,7 @@ pub async fn process_pending_withdrawals(runtime: &R) { process_event( state, EventType::SentWithdrawalTransaction { - burn_block_index: request.burn_block_index, - signature, + transactions: vec![(request.burn_block_index, signature)], }, runtime, ) diff --git a/minter/src/withdraw_sol/tests.rs b/minter/src/withdraw_sol/tests.rs index 57ba8dd6..ea9e29d7 100644 --- a/minter/src/withdraw_sol/tests.rs +++ b/minter/src/withdraw_sol/tests.rs @@ -339,8 +339,9 @@ mod process_pending_withdrawals_tests { }); }) .expect_event(|e| { - assert_matches!(e, EventType::SentWithdrawalTransaction { signature, .. } => { - assert_eq!(signature, fake_sig.into()); + assert_matches!(e, EventType::SentWithdrawalTransaction { transactions, .. } => { + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].1, fake_sig.into()); }); }) .assert_no_more_events(); @@ -431,8 +432,9 @@ mod process_pending_withdrawals_tests { }); }) .expect_event(|e| { - assert_matches!(e, EventType::SentWithdrawalTransaction { burn_block_index, .. } => { - assert_eq!(burn_block_index, LedgerBurnIndex::from(1u64)); + assert_matches!(e, EventType::SentWithdrawalTransaction { transactions, .. } => { + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].0, LedgerBurnIndex::from(1u64)); }); }) .assert_no_more_events(); From 9aeca1ea8358211999cf7b6cc83d990fb52cb010 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 25 Mar 2026 11:59:46 +0100 Subject: [PATCH 46/47] remove next_pending_withdrawal_requests --- minter/src/state/mod.rs | 13 ++----------- minter/src/withdraw_sol/mod.rs | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index e7d5bd1b..f21b3c97 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -337,17 +337,8 @@ impl State { WithdrawSolStatus::NotFound } - pub fn next_pending_withdrawal_requests(&self, size: usize) -> Option> { - if self.pending_withdrawal_requests.is_empty() { - return None; - } - Some( - self.pending_withdrawal_requests - .values() - .take(size) - .cloned() - .collect(), - ) + pub fn pending_withdrawal_requests(&self) -> &BTreeMap { + &self.pending_withdrawal_requests } fn process_accepted_withdrawal(&mut self, request: &WithdrawSolRequest) { diff --git a/minter/src/withdraw_sol/mod.rs b/minter/src/withdraw_sol/mod.rs index acce3677..240989fc 100644 --- a/minter/src/withdraw_sol/mod.rs +++ b/minter/src/withdraw_sol/mod.rs @@ -132,11 +132,22 @@ pub async fn process_pending_withdrawals(runtime: &R) { } }; - let Some(pending_requests) = - read_state(|state| state.next_pending_withdrawal_requests(MAX_WITHDRAWALS_PER_BATCH)) - else { + // TODO: we should batch requests into up to N chunks of size M, each chunk + // should be a separate transaction containing multiple withdrawal requests. + // M is the max withdrawals per tx, N is max tx per round. + + let pending_requests: Vec = read_state(|state| { + state + .pending_withdrawal_requests() + .values() + .take(MAX_WITHDRAWALS_PER_BATCH) + .cloned() + .collect() + }); + + if pending_requests.is_empty() { return; - }; + } let recent_blockhash = match read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) From cfcf254283fbb830aa6233897ab6cf4646725c06 Mon Sep 17 00:00:00 2001 From: Maciej Modelski Date: Wed, 25 Mar 2026 13:41:39 +0100 Subject: [PATCH 47/47] clippy --- minter/src/state/event/cbor/mod.rs | 4 +++- minter/src/test_fixtures/mod.rs | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/minter/src/state/event/cbor/mod.rs b/minter/src/state/event/cbor/mod.rs index 7128abe5..220883c7 100644 --- a/minter/src/state/event/cbor/mod.rs +++ b/minter/src/state/event/cbor/mod.rs @@ -65,7 +65,9 @@ pub mod burn_index_signature_vec { d: &mut Decoder<'_>, _ctx: &mut Ctx, ) -> Result, Error> { - let len = d.array()?.ok_or_else(|| Error::message("expected definite-length array"))?; + let len = d + .array()? + .ok_or_else(|| Error::message("expected definite-length array"))?; let mut result = Vec::with_capacity(len as usize); for _ in 0..len { d.array()?; diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index f91dcad5..ec05b55a 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -285,9 +285,8 @@ pub mod arb { }), prop::collection::vec((arb_account(), any::()), 1..10) .prop_map(|deposits| EventType::ConsolidatedDeposits { deposits }), - prop::collection::vec((arb_ledger_burn_index(), arb_signature()), 1..10).prop_map( - |transactions| EventType::SentWithdrawalTransaction { transactions }, - ), + prop::collection::vec((arb_ledger_burn_index(), arb_signature()), 1..10) + .prop_map(|transactions| EventType::SentWithdrawalTransaction { transactions },), (arb_signature(), arb_signature(), any::()).prop_map( |(old_signature, new_signature, new_slot)| { EventType::ResubmittedTransaction {