From d653a1adc0f396736ad1ff43a31f28b935fb85e4 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Apr 2026 15:14:27 +0200 Subject: [PATCH 1/2] feat: introduce InsertionOrderedSet and use it for monitored_accounts Replace `monitored_accounts: BTreeSet` with a new `InsertionOrderedSet` that preserves insertion order. The set is backed by `InsertionOrderedMap` and exposes only the methods needed at call sites: `insert`, `remove`, `contains`, `len`, and `iter`. Call sites in `deposit::automatic` are unchanged because `InsertionOrderedSet` intentionally mirrors `BTreeSet`'s API. Co-Authored-By: Claude Sonnet 4.6 --- minter/src/state/mod.rs | 10 ++-- minter/src/state/tests.rs | 6 ++- minter/src/utils/insertion_ordered_set.rs | 60 +++++++++++++++++++++++ minter/src/utils/mod.rs | 1 + 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 minter/src/utils/insertion_ordered_set.rs diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 36e97d10..c265d456 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -3,7 +3,9 @@ use crate::{ ledger::client::LedgerClient, numeric::{LedgerBurnIndex, LedgerMintIndex}, state::event::{DepositId, TransactionPurpose, VersionedMessage, WithdrawalRequest}, - utils::insertion_ordered_map::InsertionOrderedMap, + utils::{ + insertion_ordered_map::InsertionOrderedMap, insertion_ordered_set::InsertionOrderedSet, + }, }; use candid::Principal; use cksol_types::{DepositStatus, TxFinalizedStatus, WithdrawalStatus}; @@ -89,7 +91,7 @@ pub struct State { minimum_deposit_amount: Lamport, process_deposit_required_cycles: u128, deposit_consolidation_fee: u128, - monitored_accounts: BTreeSet, + monitored_accounts: InsertionOrderedSet, pending_process_deposit_request_guards: BTreeSet, pending_withdrawal_request_guards: BTreeSet, accepted_deposits: InsertionOrderedMap, @@ -244,7 +246,7 @@ impl State { self.balance } - pub fn monitored_accounts(&self) -> &BTreeSet { + pub fn monitored_accounts(&self) -> &InsertionOrderedSet { &self.monitored_accounts } @@ -776,7 +778,7 @@ impl TryFrom for State { minimum_deposit_amount, process_deposit_required_cycles: process_deposit_required_cycles as u128, deposit_consolidation_fee: deposit_consolidation_fee as u128, - monitored_accounts: BTreeSet::new(), + monitored_accounts: InsertionOrderedSet::new(), pending_process_deposit_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), accepted_deposits: InsertionOrderedMap::new(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index a6afa737..e3ef55e3 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -17,7 +17,9 @@ use crate::{ runtime::TestCanisterRuntime, signature, sol_rpc_canister_id, valid_init_args, }, - utils::insertion_ordered_map::InsertionOrderedMap, + utils::{ + insertion_ordered_map::InsertionOrderedMap, insertion_ordered_set::InsertionOrderedSet, + }, }; use assert_matches::assert_matches; use cksol_types_internal::{Ed25519KeyName, InitArgs, SolanaNetwork, UpgradeArgs}; @@ -226,7 +228,7 @@ mod state_from_init_args { minimum_withdrawal_amount: MINIMUM_WITHDRAWAL_AMOUNT, minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, process_deposit_required_cycles: PROCESS_DEPOSIT_REQUIRED_CYCLES, - monitored_accounts: BTreeSet::new(), + monitored_accounts: InsertionOrderedSet::new(), pending_process_deposit_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), accepted_deposits: InsertionOrderedMap::new(), diff --git a/minter/src/utils/insertion_ordered_set.rs b/minter/src/utils/insertion_ordered_set.rs new file mode 100644 index 00000000..2b775aba --- /dev/null +++ b/minter/src/utils/insertion_ordered_set.rs @@ -0,0 +1,60 @@ +use super::insertion_ordered_map::InsertionOrderedMap; + +/// An insertion-ordered set backed by an [`InsertionOrderedMap`]. +/// +/// Provides O(log n) membership tests and O(log n) insertion/removal, +/// while preserving insertion order during iteration. +pub struct InsertionOrderedSet(InsertionOrderedMap); + +impl InsertionOrderedSet { + pub fn new() -> Self { + Self(InsertionOrderedMap::new()) + } + + /// Inserts `key`. Returns `true` if the key was not already present. + pub fn insert(&mut self, key: K) -> bool { + self.0.insert(key, ()).is_none() + } + + /// Removes `key`. Returns `true` if the key was present. + pub fn remove(&mut self, key: &K) -> bool { + self.0.remove(key).is_some() + } + + pub fn contains(&self, key: &K) -> bool { + self.0.contains_key(key) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Iterates over keys in insertion order. + pub fn iter(&self) -> impl Iterator { + self.0.keys() + } +} + +impl Default for InsertionOrderedSet { + fn default() -> Self { + Self::new() + } +} + +impl PartialEq for InsertionOrderedSet { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for InsertionOrderedSet {} + +impl std::fmt::Debug for InsertionOrderedSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_set().entries(self.iter()).finish() + } +} diff --git a/minter/src/utils/mod.rs b/minter/src/utils/mod.rs index cc87b6c5..5ca7437c 100644 --- a/minter/src/utils/mod.rs +++ b/minter/src/utils/mod.rs @@ -1 +1,2 @@ pub mod insertion_ordered_map; +pub mod insertion_ordered_set; From 18d4ecfa89220b2c69498034a5768eb8c236a819 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Apr 2026 15:16:28 +0200 Subject: [PATCH 2/2] feat: add pending-signatures queue for automated deposit flow - Add module-level `PENDING_SIGNATURES` thread-local to buffer discovered transaction signatures per account - Fill in `poll_account`'s signature arm: filter out failed transactions and push successful signatures into the queue - Expose `pending_signatures_for` / `reset_pending_signatures` test helpers - Replace local `sig` test helper with `test_fixtures::signature` Co-Authored-By: Claude Sonnet 4.6 --- minter/src/deposit/automatic/mod.rs | 44 ++++++++++++- minter/src/deposit/automatic/tests.rs | 89 ++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/minter/src/deposit/automatic/mod.rs b/minter/src/deposit/automatic/mod.rs index 970b894b..32ee80fd 100644 --- a/minter/src/deposit/automatic/mod.rs +++ b/minter/src/deposit/automatic/mod.rs @@ -14,7 +14,17 @@ use cksol_types::UpdateBalanceError; use cksol_types_internal::log::Priority; use icrc_ledger_types::icrc1::account::Account; use sol_rpc_types::{CommitmentLevel, GetSignaturesForAddressParams}; -use std::time::Duration; +use solana_signature::Signature; +use std::{ + cell::RefCell, + collections::{BTreeMap, VecDeque}, + time::Duration, +}; + +thread_local! { + static PENDING_SIGNATURES: RefCell>> = + RefCell::default(); +} #[cfg(test)] mod tests; @@ -123,8 +133,21 @@ async fn poll_account( "Failed to get signatures for address {deposit_address}: {e}" ); } - Ok(_signatures) => { - // TODO(DEFI-2780): Process discovered deposit signatures. + Ok(signatures) => { + let new_sigs: Vec = signatures + .into_iter() + .filter(|s| s.err.is_none()) + .map(|s| s.signature.into()) + .collect(); + if !new_sigs.is_empty() { + PENDING_SIGNATURES.with(|pending| { + pending + .borrow_mut() + .entry(account) + .or_default() + .extend(new_sigs); + }); + } } } @@ -136,3 +159,18 @@ async fn poll_account( ); }); } + +#[cfg(any(test, feature = "canbench-rs"))] +pub fn pending_signatures_for(account: &Account) -> Vec { + PENDING_SIGNATURES.with(|p| { + p.borrow() + .get(account) + .map(|q| q.iter().copied().collect()) + .unwrap_or_default() + }) +} + +#[cfg(any(test, feature = "canbench-rs"))] +pub fn reset_pending_signatures() { + PENDING_SIGNATURES.with(|p| p.borrow_mut().clear()); +} diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index 618759df..a9b2024f 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -4,13 +4,35 @@ use crate::{ state::{event::EventType, read_state}, test_fixtures::{ EventsAssert, account, events::start_monitoring_account, init_schnorr_master_key, - init_state, runtime::TestCanisterRuntime, + init_state, runtime::TestCanisterRuntime, signature, }, }; -use sol_rpc_types::{ConfirmedTransactionStatusWithSignature, MultiRpcResult}; +use sol_rpc_types::{ConfirmedTransactionStatusWithSignature, MultiRpcResult, TransactionError}; type SignaturesResult = MultiRpcResult>; +fn confirmed_tx(signature: Signature) -> ConfirmedTransactionStatusWithSignature { + ConfirmedTransactionStatusWithSignature { + signature: signature.into(), + slot: 12345, + err: None, + memo: None, + block_time: None, + confirmation_status: None, + } +} + +fn failed_tx(signature: Signature) -> ConfirmedTransactionStatusWithSignature { + ConfirmedTransactionStatusWithSignature { + signature: signature.into(), + slot: 12345, + err: Some(TransactionError::AccountNotFound), + memo: None, + block_time: None, + confirmation_status: None, + } +} + fn monitored_accounts_count() -> usize { read_state(|s| s.monitored_accounts().len()) } @@ -131,6 +153,69 @@ mod poll_monitored_addresses { } } + #[tokio::test] + async fn should_queue_discovered_signatures() { + setup(); + reset_pending_signatures(); + + let acc = account(1); + start_monitoring_account(acc); + + let s1 = signature(1); + let s2 = signature(2); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SignaturesResult::Consistent(Ok(vec![ + confirmed_tx(s1), + confirmed_tx(s2), + ]))); + + poll_monitored_addresses(runtime).await; + + assert_eq!(pending_signatures_for(&acc), vec![s1, s2]); + } + + #[tokio::test] + async fn should_not_queue_failed_transactions() { + setup(); + reset_pending_signatures(); + + let acc = account(1); + start_monitoring_account(acc); + + let s_ok = signature(1); + let s_fail = signature(2); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SignaturesResult::Consistent(Ok(vec![ + confirmed_tx(s_ok), + failed_tx(s_fail), + ]))); + + poll_monitored_addresses(runtime).await; + + assert_eq!(pending_signatures_for(&acc), vec![s_ok]); + } + + #[tokio::test] + async fn should_not_queue_signatures_if_rpc_call_fails() { + setup(); + reset_pending_signatures(); + + let acc = account(1); + start_monitoring_account(acc); + + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SignaturesResult::Consistent(Err( + sol_rpc_types::RpcError::ValidationError("RPC error".to_string()), + ))); + + poll_monitored_addresses(runtime).await; + + assert_eq!(pending_signatures_for(&acc), vec![]); + } + fn setup() { init_state(); init_schnorr_master_key();