diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index fd2cd147..9e3b8896 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -26,6 +26,8 @@ pub const MINTER_ADDRESS: Address = address!("5G64DcCfSFRTwZWSTjub1qGRYrJFLeNMkY pub const DEPOSIT_AMOUNT: Lamport = 500_000_000; pub const EXPECTED_MINT_AMOUNT: Lamport = DEPOSIT_AMOUNT - Setup::DEFAULT_MANUAL_DEPOSIT_FEE; +pub const EXPECTED_AUTOMATED_MINT_AMOUNT: Lamport = + DEPOSIT_AMOUNT - Setup::DEFAULT_AUTOMATED_DEPOSIT_FEE; /// Signature for a Solana transaction depositing [`DEPOSIT_AMOUNT`] lamports to /// the address [`DEFAULT_CALLER_DEPOSIT_ADDRESS`]. diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index a8226d8f..233282b8 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -5,8 +5,9 @@ use cksol_int_tests::{ CkSolMinter, Setup, SetupBuilder, fixtures::{ DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, - EXPECTED_MINT_AMOUNT, MockBuilder, SharedMockHttpOutcalls, default_process_deposit_args, - default_update_balance_args, deposit_signature_status_json, deposit_transaction_signature, + EXPECTED_AUTOMATED_MINT_AMOUNT, EXPECTED_MINT_AMOUNT, MockBuilder, SharedMockHttpOutcalls, + default_process_deposit_args, default_update_balance_args, deposit_signature_status_json, + deposit_transaction_signature, }, }; use cksol_types::{ @@ -32,6 +33,7 @@ const RESUBMIT_TRANSACTIONS_DELAY: Duration = Duration::from_mins(3); const DEPOSIT_CONSOLIDATION_DELAY: Duration = Duration::from_mins(10); const POLL_MONITORED_ADDRESSES_DELAY: Duration = Duration::from_mins(1); const PROCESS_PENDING_SIGNATURES_DELAY: Duration = Duration::from_secs(5); +const MINT_AUTOMATIC_DEPOSITS_DELAY: Duration = Duration::from_secs(5); /// Deposits funds into the minter via `process_deposit`, consolidates them, /// and finalizes the consolidation so the minter's internal balance is credited. @@ -1337,4 +1339,55 @@ mod automated_deposit_flow_tests { setup.drop().await; } + + #[tokio::test] + async fn should_automatically_mint_deposit() { + let setup = SetupBuilder::new().build().await; + let minter = setup.minter(); + + // Initialize the minter public key and register the account for monitoring. + assert_eq!( + minter + .get_deposit_address(default_get_deposit_address_args()) + .await + .to_string(), + DEFAULT_CALLER_DEPOSIT_ADDRESS, + ); + minter + .update_balance(default_update_balance_args()) + .await + .expect("update_balance should succeed"); + + // Poll phase: minter calls getSignaturesForAddress and discovers the deposit. + setup.advance_time(POLL_MONITORED_ADDRESSES_DELAY).await; + setup + .execute_http_mocks( + MockBuilder::with_start_id(0) + .get_signatures_for_address(vec![deposit_signature_status_json()]) + .build(), + ) + .await; + + // Process phase: minter calls getTransaction and accepts the deposit. + setup.advance_time(PROCESS_PENDING_SIGNATURES_DELAY).await; + setup + .execute_http_mocks( + MockBuilder::with_start_id(4) + .get_deposit_transaction() + .build(), + ) + .await; + + // Mint phase: minter mints ckSOL for the accepted deposit (no HTTP mocks needed — + // the ledger mint is a canister-to-canister call). + setup.advance_time(MINT_AUTOMATIC_DEPOSITS_DELAY).await; + for _ in 0..30 { + setup.tick().await; + } + + let balance = setup.ledger().balance_of(DEFAULT_CALLER_ACCOUNT).await; + assert_eq!(balance, EXPECTED_AUTOMATED_MINT_AMOUNT); + + setup.drop().await; + } } diff --git a/minter/src/deposit/automatic/mod.rs b/minter/src/deposit/automatic/mod.rs index 18ae81b4..bf81dfa9 100644 --- a/minter/src/deposit/automatic/mod.rs +++ b/minter/src/deposit/automatic/mod.rs @@ -3,6 +3,7 @@ use crate::{ constants::MAX_CONCURRENT_RPC_CALLS, deposit::fetch_and_validate_deposit, guard::TimerGuard, + ledger::mint, rpc::get_signatures_for_address, runtime::CanisterRuntime, state::{ @@ -16,7 +17,7 @@ use canlog::log; use cksol_types::UpdateBalanceError; use cksol_types_internal::log::Priority; use icrc_ledger_types::icrc1::account::Account; -use sol_rpc_types::{CommitmentLevel, GetSignaturesForAddressParams}; +use sol_rpc_types::{CommitmentLevel, GetSignaturesForAddressParams, Lamport}; use solana_signature::Signature; use std::{ cell::RefCell, @@ -44,6 +45,9 @@ pub const MAX_TRANSACTIONS_PER_ACCOUNT: usize = 10; /// How often the minter processes the pending-signatures queue. pub const PROCESS_PENDING_SIGNATURES_DELAY: Duration = Duration::from_secs(5); +/// How often the minter attempts to mint accepted automatic deposits. +pub const MINT_AUTOMATIC_DEPOSITS_DELAY: Duration = Duration::from_secs(5); + /// Registers the given account for automated deposit monitoring. /// /// Returns `Ok(())` if the account was registered (or was already being monitored). @@ -267,6 +271,64 @@ async fn process_signature( } } +/// Drains accepted automatic deposits and mints ckSOL for each. +/// +/// Processes up to [`MAX_CONCURRENT_RPC_CALLS`] deposits per round and +/// reschedules itself at `Duration::ZERO` if more remain. +pub async fn mint_automatic_deposits(runtime: R) { + let _guard = match TimerGuard::new(TaskType::Mint) { + Ok(guard) => guard, + Err(_) => return, + }; + + let to_mint: Vec<(DepositId, Lamport)> = read_state(|s| { + s.accepted_deposits() + .iter() + .filter(|(_, d)| d.source == DepositSource::Automatic) + .take(MAX_CONCURRENT_RPC_CALLS) + .map(|(deposit_id, deposit)| (*deposit_id, deposit.amount_to_mint)) + .collect() + }); + + if to_mint.is_empty() { + return; + } + + let more_to_process = read_state(|s| { + s.accepted_deposits() + .iter() + .filter(|(_, d)| d.source == DepositSource::Automatic) + .count() + > MAX_CONCURRENT_RPC_CALLS + }); + let reschedule = scopeguard::guard(runtime.clone(), |runtime| { + runtime.set_timer(Duration::ZERO, mint_automatic_deposits); + }); + + futures::future::join_all( + to_mint + .into_iter() + .map(|(deposit_id, amount_to_mint)| mint_one(&runtime, deposit_id, amount_to_mint)), + ) + .await; + + if !more_to_process { + scopeguard::ScopeGuard::into_inner(reschedule); + } +} + +async fn mint_one(runtime: &R, deposit_id: DepositId, amount_to_mint: Lamport) { + match mint(runtime, deposit_id, amount_to_mint).await { + Ok(_) => {} + Err(e) => { + log!( + Priority::Info, + "Failed to mint ckSOL for automatic deposit {deposit_id:?}: {e}. Will retry." + ); + } + } +} + #[cfg(any(test, feature = "canbench-rs"))] pub fn pending_signatures_for(account: &Account) -> Vec { PENDING_SIGNATURES.with(|p| { diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index 6cef5757..b0bfb656 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -413,3 +413,133 @@ mod process_pending_signatures_tests { init_schnorr_master_key(); } } + +mod mint_automatic_deposits_tests { + use super::*; + use crate::{ + storage::reset_events, + test_fixtures::{BLOCK_INDEX, events::accept_automatic_deposit}, + }; + use icrc_ledger_types::icrc1::transfer::{BlockIndex, TransferError}; + + #[tokio::test] + async fn should_mint_accepted_deposit() { + setup(); + + let deposit_id = DepositId { + account: DEPOSITOR_ACCOUNT, + signature: legacy_deposit_transaction_signature(), + }; + let amount_to_mint = DEPOSIT_AMOUNT - AUTOMATED_DEPOSIT_FEE; + accept_automatic_deposit(deposit_id, DEPOSIT_AMOUNT, amount_to_mint); + reset_events(); + + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(Ok::(BLOCK_INDEX.into())); + + mint_automatic_deposits(runtime).await; + + assert!( + read_state(|s| s.accepted_deposits().is_empty()), + "deposit should have been minted" + ); + EventsAssert::from_recorded() + .expect_event_eq(EventType::Minted { + deposit_id, + mint_block_index: BLOCK_INDEX.into(), + }) + .assert_no_more_events(); + } + + #[tokio::test] + async fn should_keep_deposit_on_mint_failure() { + setup(); + + let deposit_id = DepositId { + account: DEPOSITOR_ACCOUNT, + signature: legacy_deposit_transaction_signature(), + }; + let amount_to_mint = DEPOSIT_AMOUNT - AUTOMATED_DEPOSIT_FEE; + accept_automatic_deposit(deposit_id, DEPOSIT_AMOUNT, amount_to_mint); + + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(Err::( + TransferError::TemporarilyUnavailable, + )); + + mint_automatic_deposits(runtime).await; + + // Deposit should still be in accepted_deposits for retry. + assert_eq!(read_state(|s| s.accepted_deposits().len()), 1); + } + + #[tokio::test] + async fn should_do_nothing_when_no_accepted_deposits() { + setup(); + + let runtime = TestCanisterRuntime::new().with_increasing_time(); + mint_automatic_deposits(runtime).await; + + EventsAssert::assert_no_events_recorded(); + } + + #[tokio::test] + async fn should_reschedule_when_more_deposits_than_capacity() { + setup(); + + // Accept MAX_CONCURRENT_RPC_CALLS + 1 automatic deposits. + let amount_to_mint = DEPOSIT_AMOUNT - AUTOMATED_DEPOSIT_FEE; + for i in 0..=MAX_CONCURRENT_RPC_CALLS { + let deposit_id = DepositId { + account: DEPOSITOR_ACCOUNT, + signature: signature(i), + }; + accept_automatic_deposit(deposit_id, DEPOSIT_AMOUNT, amount_to_mint); + } + reset_events(); + + // Provide exactly MAX_CONCURRENT_RPC_CALLS mint stubs. + let mut runtime = TestCanisterRuntime::new().with_increasing_time(); + for i in 0..MAX_CONCURRENT_RPC_CALLS { + runtime = runtime.add_stub_response(Ok::( + (BLOCK_INDEX + i as u64).into(), + )); + } + + mint_automatic_deposits(runtime.clone()).await; + + // One deposit remains → reschedule. + assert_eq!(read_state(|s| s.accepted_deposits().len()), 1); + assert_eq!(runtime.set_timer_call_count(), 1); + } + + #[tokio::test] + async fn should_skip_manual_deposits() { + use crate::test_fixtures::events::accept_deposit; + + setup(); + + let deposit_id = DepositId { + account: DEPOSITOR_ACCOUNT, + signature: legacy_deposit_transaction_signature(), + }; + accept_deposit(deposit_id, DEPOSIT_AMOUNT); + + let runtime = TestCanisterRuntime::new().with_increasing_time(); + mint_automatic_deposits(runtime).await; + + // Manual deposit should not have been minted by this timer. + assert_eq!( + read_state(|s| s.accepted_deposits().len()), + 1, + "manual deposit should remain in accepted_deposits" + ); + } + + fn setup() { + init_state(); + init_schnorr_master_key(); + } +} diff --git a/minter/src/main.rs b/minter/src/main.rs index 3f6b96f6..642e1fc6 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -4,7 +4,8 @@ use cksol_minter::{ address::lazy_get_schnorr_master_key, consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits}, deposit::automatic::{ - POLL_MONITORED_ADDRESSES_DELAY, PROCESS_PENDING_SIGNATURES_DELAY, poll_monitored_addresses, + MINT_AUTOMATIC_DEPOSITS_DELAY, POLL_MONITORED_ADDRESSES_DELAY, + PROCESS_PENDING_SIGNATURES_DELAY, mint_automatic_deposits, poll_monitored_addresses, process_pending_signatures, }, monitor::{ @@ -383,6 +384,9 @@ fn setup_timers() { ic_cdk_timers::set_timer_interval(PROCESS_PENDING_SIGNATURES_DELAY, async || { process_pending_signatures(IcCanisterRuntime::new()).await; }); + ic_cdk_timers::set_timer_interval(MINT_AUTOMATIC_DEPOSITS_DELAY, async || { + mint_automatic_deposits(IcCanisterRuntime::new()).await; + }); } fn main() {} diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 9079598c..8f3a99a5 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -184,6 +184,25 @@ pub mod events { }); } + pub fn accept_automatic_deposit( + deposit_id: DepositId, + deposit_amount: Lamport, + amount_to_mint: Lamport, + ) { + mutate_state(|state| { + process_event( + state, + EventType::AcceptedDeposit { + deposit_id, + deposit_amount, + amount_to_mint, + source: DepositSource::Automatic, + }, + &runtime(), + ) + }); + } + pub fn quarantine_deposit(deposit_id: DepositId) { mutate_state(|state| { process_event(state, EventType::QuarantinedDeposit(deposit_id), &runtime())