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
26 changes: 16 additions & 10 deletions integration_tests/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use cksol_int_tests::{
CkSolMinter, Setup, SetupBuilder,
fixtures::{
DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT,
EXPECTED_MINT_AMOUNT, MockBuilder, SharedMockHttpOutcalls,
EXPECTED_MINT_AMOUNT, MockBuilder, NUM_RPC_PROVIDERS, SharedMockHttpOutcalls,
default_get_deposit_address_args, default_process_deposit_args,
default_update_balance_args, deposit_transaction_signature,
},
Expand Down Expand Up @@ -1320,15 +1320,21 @@ mod automated_deposit_flow_tests {
}));
});

// Advance time: the minter should poll getSignaturesForAddress once, then remove the account.
setup.advance_time(POLL_MONITORED_ADDRESSES_DELAY).await;
setup
.execute_http_mocks(
MockBuilder::with_start_id(0)
.get_signatures_for_address(vec![])
.build(),
)
.await;
// Advance time through all 10 polls with exponential backoff (1, 2, 4, ..., 512 minutes).
let mut delay = POLL_MONITORED_ADDRESSES_DELAY;
let mut start_id = 0u64;
for _ in 0..10 {
setup.advance_time(delay).await;
setup
Comment thread
lpahlavi marked this conversation as resolved.
.execute_http_mocks(
MockBuilder::with_start_id(start_id)
.get_signatures_for_address(vec![])
.build(),
)
.await;
start_id += NUM_RPC_PROVIDERS;
delay *= 2;
}
Comment thread
lpahlavi marked this conversation as resolved.

minter.assert_that_events().await.satisfy(|events| {
check!(events.iter().any(|e| {
Expand Down
3 changes: 3 additions & 0 deletions libs/types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ pub enum UpdateBalanceError {
/// The monitored account queue is at capacity.
#[error("The monitored account queue is at capacity")]
QueueFull,
/// The RPC call quota for this account has been exhausted.
#[error("The RPC call quota for this account has been exhausted")]
MonitoringQuotaExhausted,
}

/// Insufficient cycles attached by the caller to complete the call.
Expand Down
5 changes: 2 additions & 3 deletions minter/cksol_minter.did
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ type UpdateBalanceArgs = record {
type UpdateBalanceError = variant {
// The monitored account queue is at capacity.
QueueFull;
// The RPC call quota for this account has been exhausted.
MonitoringQuotaExhausted;
};

// The result of a call to the `update_balance` endpoint.
Expand Down Expand Up @@ -473,9 +475,6 @@ service : (MinterArg) -> {
//
// If the owner is not set, it defaults to the caller's principal.
// The resolved owner must be a non-anonymous principal.
//
// Returns Ok if the account was registered (or was already being monitored).
// Returns Err(QueueFull) if the monitored account queue is at capacity.
update_balance: (UpdateBalanceArgs) -> (UpdateBalanceResult);

// Update the ckSOL balance of the given owner with the funds
Expand Down
124 changes: 124 additions & 0 deletions minter/src/deposit/automatic/cache/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use crate::utils::sorted_key_map::SortedKeyMap;
use icrc_ledger_types::icrc1::account::Account;

/// Maximum number of `getSignaturesForAddress` calls allowed per monitored account.
pub const MAX_GET_SIGNATURES_CALLS: u32 = 10;

/// Maximum number of `getTransaction` calls allowed per monitored account.
pub const MAX_RETRIEVED_TRANSACTIONS: u32 = 50;

/// Initial backoff delay in minutes before the first poll.
pub const INITIAL_BACKOFF_DELAY_MINS: u64 = 1;

/// Per-account state for automated deposit discovery.
///
/// This cache is intentionally separate from the event log: it can be fully
/// reconstructed by redoing the RPC calls, so there is no need to replay events
/// to restore it. It lives in unstable heap memory and is reset on canister upgrade.
#[derive(Clone, Debug, PartialEq)]
pub struct AutomaticDepositCacheEntry {
/// Remaining quota for `getSignaturesForAddress` calls.
pub sig_calls_remaining: u32,
/// Remaining quota for `getTransaction` calls.
pub tx_calls_remaining: u32,
/// The delay in minutes before the next poll. Doubles after each poll.
pub next_backoff_delay_mins: u64,
}

impl Default for AutomaticDepositCacheEntry {
fn default() -> Self {
Self {
sig_calls_remaining: MAX_GET_SIGNATURES_CALLS,
tx_calls_remaining: MAX_RETRIEVED_TRANSACTIONS,
next_backoff_delay_mins: INITIAL_BACKOFF_DELAY_MINS,
}
}
}

/// Heap-memory cache storing per-account automated deposit discovery state,
/// ordered by next poll time for efficient scheduling.
///
/// Backed by a [`SortedKeyMap`] with `Account` as key and `u64` (nanosecond timestamp)
/// as the sort index.
///
/// Accounts that have been stopped are stored with `next_poll_at = u64::MAX`
/// so they are never picked up by the poll loop, but their quota is retained
/// for future `update_balance` calls.
#[derive(Default)]
pub struct AutomaticDepositCache(SortedKeyMap<Account, u64, AutomaticDepositCacheEntry>);

impl AutomaticDepositCache {
/// Returns the current poll time and entry for the given account.
pub fn get_with_index(&self, account: &Account) -> Option<(u64, AutomaticDepositCacheEntry)> {
self.0.get_with_index(account).map(|(t, e)| (*t, e.clone()))
}

/// Inserts or updates an entry, updating the poll-time index atomically.
pub fn insert(
&mut self,
account: Account,
next_poll_at: u64,
entry: AutomaticDepositCacheEntry,
) {
self.0.insert(account, next_poll_at, entry);
}

/// Iterates all `(next_poll_at, account, entry)` triples in ascending poll-time order.
pub fn iter(&self) -> impl Iterator<Item = (u64, Account, AutomaticDepositCacheEntry)> + '_ {
self.0
.iter()
.map(|(t, account, entry)| (*t, *account, entry.clone()))
}

pub fn len(&self) -> usize {
self.0.len()
}

pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

/// The monitoring lifecycle state of an account, as derived from the cache.
pub enum AccountMonitoringState {
/// No monitoring information has been recorded for this account.
Unknown,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what scenario is the state Unknown? If an account has never been seen, there simply shouldn't be any record, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, it is used in the monitoring_state function. An alternative would be to return Option<AccountMonitoringState>, right?

/// The account is actively scheduled for polling.
Active {
#[allow(dead_code)]
next_poll_at: u64,
#[allow(dead_code)]
entry: AutomaticDepositCacheEntry,
},
/// Polling was stopped after a successful deposit was found. The account
/// can be rescheduled via `update_balance`.
Stopped { entry: AutomaticDepositCacheEntry },
Comment thread
THLO marked this conversation as resolved.
/// The `getSignaturesForAddress` quota for this account has been exhausted.
/// `update_balance` will return `MonitoringQuotaExhausted` until the manual
/// flow replenishes the quota.
Exhausted {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't Exhausted mean that there is no quota left? In this case, there is no need to store an AutomaticDepositCacheEntry object, right?

#[allow(dead_code)]
entry: AutomaticDepositCacheEntry,
},
}

pub trait AutomaticDepositCacheExt {
/// Returns the current monitoring state of the given account.
fn monitoring_state(&self, account: &Account) -> AccountMonitoringState;
}

impl AutomaticDepositCacheExt for AutomaticDepositCache {
fn monitoring_state(&self, account: &Account) -> AccountMonitoringState {
match self.get_with_index(account) {
None => AccountMonitoringState::Unknown,
Some((t, entry)) if t != u64::MAX => AccountMonitoringState::Active {
next_poll_at: t,
entry,
},
Some((_, entry)) if entry.sig_calls_remaining == 0 => {
AccountMonitoringState::Exhausted { entry }
}
Some((_, entry)) => AccountMonitoringState::Stopped { entry },
}
}
}
Loading
Loading