Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions integration_tests/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down
57 changes: 55 additions & 2 deletions integration_tests/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
}
64 changes: 63 additions & 1 deletion minter/src/deposit/automatic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -267,6 +271,64 @@ async fn process_signature<R: CanisterRuntime>(
}
}

/// 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<R: CanisterRuntime>(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<R: CanisterRuntime>(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<Signature> {
PENDING_SIGNATURES.with(|p| {
Expand Down
130 changes: 130 additions & 0 deletions minter/src/deposit/automatic/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<BlockIndex, TransferError>(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::<BlockIndex, TransferError>(
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::<BlockIndex, TransferError>(
(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();
}
}
6 changes: 5 additions & 1 deletion minter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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() {}
Expand Down
19 changes: 19 additions & 0 deletions minter/src/test_fixtures/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading