Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
114 commits
Select commit Hold shift + click to select a range
cb7544c
docs(design): automatic deposit flow on top of the balance sweep
gregorydemay Oct 13, 2026
2decad6
docs(design): address review remarks on the automatic flow
gregorydemay Oct 13, 2026
d6f9701
docs(design): show the automatic flow diagram first and fix its rende…
gregorydemay Oct 13, 2026
3203fc0
docs(design): non-overlapping scan ticks and the exact fee share in t…
gregorydemay Oct 13, 2026
6c93f3a
docs(design): report the watch status from watch_deposit_address itself
gregorydemay Oct 13, 2026
c05a015
docs(design): clarify retries, single-round scans and drop the unused…
gregorydemay Oct 13, 2026
257b359
docs(design): define the scan schedule of a re-armed deposit address
gregorydemay Oct 13, 2026
4efb77d
docs(design): always snapshot the watchlist and name both queueing paths
gregorydemay Oct 13, 2026
f8a8a29
docs(design): say what happens after the last scan of a watched address
gregorydemay Oct 13, 2026
9cf5264
docs(design): apply current parameters at detection and compare depos…
gregorydemay Oct 13, 2026
46f47a2
docs(design): fix the watch window at 24 hours over the 23.5 hour sch…
gregorydemay Oct 13, 2026
e513dfd
docs(design): mention the automatic flow in the withdrawal delay sent…
gregorydemay Oct 13, 2026
ea186e2
docs(design): add the state diagram of an account in the automatic flow
gregorydemay Oct 13, 2026
4edf1df
docs(design): state replay of the recorded fee precisely and fix the …
gregorydemay Oct 13, 2026
1163210
docs(design): let the scan discard a read whose window closed meanwhile
gregorydemay Oct 13, 2026
0519a29
docs(design): make the re-arm subject to the watchlist capacity
gregorydemay Oct 13, 2026
c658da2
docs(design): align the diagram, the delay sentence and the minimum e…
gregorydemay Oct 14, 2026
790ad8e
docs(design): state the hard cost bound and the deposit_sol reply aft…
gregorydemay Oct 14, 2026
b52f2d7
docs(design): exclude the minter principal from automatic registrations
gregorydemay Oct 14, 2026
4c0715b
docs(design): qualify the re-arm, the concurrency bound, the detectio…
gregorydemay Oct 14, 2026
cb528cb
docs(design): show the full Watching reply and scope the capacity err…
gregorydemay Oct 14, 2026
3ab9a97
docs(design): define the next scan time as a due time and scope the e…
gregorydemay Oct 14, 2026
656b946
docs(design): drop the superseded proposals and name the watchlist me…
gregorydemay Oct 14, 2026
5bd1619
docs(design): fix the scan slots at the start of the schedule instead…
gregorydemay Oct 14, 2026
e0d1e67
docs(design): re-arm a dropped automatic deposit for a fresh window
gregorydemay Oct 15, 2026
3228518
refactor(minter): extract the queueing of a deposit and the in-flight…
gregorydemay Oct 14, 2026
171686f
docs(minter): describe InFlightDeposit by the account it reserves
gregorydemay Oct 14, 2026
5fc9c9c
docs(minter): state the in-flight precondition of queue_deposit
gregorydemay Oct 14, 2026
42a2ab6
test(minter): pin the in-flight variant at every deposit stage
gregorydemay Oct 14, 2026
ba1c2c5
feat(minter): add the automatic deposit fee parameter
gregorydemay Oct 14, 2026
8c5723d
refactor(minter): report the maximum automatic deposit fee in the val…
gregorydemay Oct 14, 2026
0a4ab21
refactor(minter): drop the comment on the private fee bound helper
gregorydemay Oct 14, 2026
a7bcf3b
feat(minter): add a bounded map of expiring entries
gregorydemay Oct 14, 2026
3441685
refactor(minter): wrap the timestamps of the timed sized map in a new…
gregorydemay Oct 14, 2026
fa252d0
refactor(minter): name the expiry index insertion of the timed sized …
gregorydemay Oct 14, 2026
570cb81
feat(minter): add the watchlist of deposit addresses with its scan sc…
gregorydemay Oct 14, 2026
f9aefd0
refactor(minter): convert the watchlist times at the boundary of the …
gregorydemay Oct 14, 2026
9822993
refactor(minter): take and report timestamps as a newtype in the watc…
gregorydemay Oct 14, 2026
5b8b389
refactor(minter): add saturating duration arithmetic to the timestamps
gregorydemay Oct 14, 2026
e0a39df
refactor(minter): schedule the scans from a burst of gaps and a stead…
gregorydemay Oct 14, 2026
0b7b3bb
refactor(minter): make an unscanned address due at the time of the read
gregorydemay Oct 14, 2026
6412cec
refactor(minter): inline the admission of a registration into register
gregorydemay Oct 14, 2026
a2e16fa
refactor(minter): keep the watchlist module private behind re-exports
gregorydemay Oct 14, 2026
bcb3ed3
refactor(minter): hold the watchlist in the deposits
gregorydemay Oct 14, 2026
de4c736
refactor(minter): index the burst gaps with a plain cast of the scan …
gregorydemay Oct 14, 2026
caeb73c
test(minter): drop the watchlist tests that only re-check the timed s…
gregorydemay Oct 14, 2026
826b50d
test(minter): inline the two watchlist builders into their only callers
gregorydemay Oct 14, 2026
134aed6
refactor(minter): fix the scan slots at the start of the schedule ins…
gregorydemay Oct 14, 2026
4824497
feat(minter): add the watch_deposit_address endpoint
gregorydemay Oct 14, 2026
ee22e15
refactor(minter): convert the registration times of watch_deposit_add…
gregorydemay Oct 14, 2026
82c4e0f
feat(minter): take a queued account off the watchlist
gregorydemay Oct 14, 2026
aa77558
test(minter): expect the next slot of the schedule after a scan at it…
gregorydemay Oct 14, 2026
2e75a32
test(minter): report the same registration on a repeated call before …
gregorydemay Oct 14, 2026
ed2a039
refactor(minter): register inside the status match and convert the re…
gregorydemay Oct 14, 2026
8d12662
docs(minter): point the latest deposit id at deposit_sol_status
gregorydemay Oct 14, 2026
110cc47
refactor(minter): reject the anonymous caller of watch_deposit_addres…
gregorydemay Oct 14, 2026
9672797
test(minter): compare the registration quotes with get_minter_info
gregorydemay Oct 14, 2026
4a33b69
test(minter): name the integration tests of the endpoint after the au…
gregorydemay Oct 14, 2026
dde75c0
test(minter): share the watch window in nanoseconds through the integ…
gregorydemay Oct 14, 2026
6230992
refactor(minter): derive Clone for the state and its collections
gregorydemay Oct 15, 2026
b010146
test(minter): cover the latest deposit id of an account
gregorydemay Oct 15, 2026
40a7ffa
test(minter): assert a rejected registration leaves the state untouched
gregorydemay Oct 15, 2026
505eeb0
test(minter): table-drive the in-flight stages reported as detected
gregorydemay Oct 15, 2026
966c23d
test(minter): assert the whole stored registration against the first one
gregorydemay Oct 15, 2026
7ad4b78
feat(minter): record the automatic part of a queued deposit and deduc…
gregorydemay Oct 14, 2026
4e9f34d
test: expect the collected automatic deposit fees after upgrading the…
gregorydemay Oct 14, 2026
155c282
refactor(minter): record only the automatic deposit fee with a queued…
gregorydemay Oct 15, 2026
bb4f815
refactor(minter): name the fee of a queued deposit without the flow t…
gregorydemay Oct 15, 2026
e267b74
refactor(minter): return the credited sweep and type the error of its…
gregorydemay Oct 15, 2026
991fe51
test(minter): cover the fee deduction through the deposit flow
gregorydemay Oct 15, 2026
f28f6f1
feat(minter): read deposit address balances in batches with getMultip…
gregorydemay Oct 14, 2026
2d4ecc9
build: pin the SOL RPC crates to the commit adding getMultipleAccounts
gregorydemay Oct 15, 2026
de73408
refactor(minter): read the deposit address balances with the typed ge…
gregorydemay Oct 15, 2026
0b1e28a
test(integration-tests): run against the SOL RPC canister built from …
gregorydemay Oct 15, 2026
08ee723
test(integration-tests): download the pinned SOL RPC canister instead…
gregorydemay Oct 15, 2026
b1899c0
test(minter): test only the minter's handling of the getMultipleAccou…
gregorydemay Oct 15, 2026
d1b1bf6
feat(minter): scan the watched deposit addresses on a timer and queue…
gregorydemay Oct 14, 2026
c9f61cd
refactor(minter): take the scan time as a newtype
gregorydemay Oct 14, 2026
c462a45
refactor(minter): read the watchlist limits through the state and rep…
gregorydemay Oct 14, 2026
1612a14
test(minter): expect the fixed scan slots in the scan timer tests
gregorydemay Oct 14, 2026
225a5ed
refactor(minter): record the automatic deposit fee alone when a scan …
gregorydemay Oct 15, 2026
5b094c4
refactor(minter): queue a scanned deposit with its fee under the new …
gregorydemay Oct 15, 2026
e4dfc6a
test(minter): read the scanned addresses from the typed getMultipleAc…
gregorydemay Oct 15, 2026
3d503ac
feat(minter): let deposit_sol yield to a deposit queued by a scan dur…
gregorydemay Oct 14, 2026
b95d04f
test(minter): queue the scanned deposit of the yield test with the fe…
gregorydemay Oct 15, 2026
d2ca808
test(minter): expect the scanned deposit of the yield test with its f…
gregorydemay Oct 15, 2026
132bfa6
docs: state that the balance scan uses the typed getMultipleAccounts …
gregorydemay Oct 15, 2026
6995961
feat(minter): re-arm the automatic deposits of a dropped sweep
gregorydemay Oct 14, 2026
c5638ae
refactor(minter): re-arm with the drop time as a newtype
gregorydemay Oct 14, 2026
f1bfda8
refactor(minter): re-arm through the watchlist of the deposits
gregorydemay Oct 14, 2026
2c14619
test(minter): drop the read time from the watchlist iteration in the …
gregorydemay Oct 14, 2026
3fece15
refactor(minter): re-arm a dropped automatic deposit for a fresh window
gregorydemay Oct 15, 2026
5b7036f
refactor(minter): tell a manual deposit apart by its fee under the ne…
gregorydemay Oct 15, 2026
abe8d75
Merge branch 'main' into DEFI-2780-07-get-multiple-accounts
mbjorkqvist Oct 27, 2026
8de0d0a
fix(minter): reject a getMultipleAccounts response without one accoun…
mbjorkqvist Oct 27, 2026
d6c7759
test(minter): check the getMultipleAccounts request of a balance read
mbjorkqvist Oct 27, 2026
3352ecb
refactor(minter): leave the getMultipleAccounts response size estimat…
mbjorkqvist Oct 27, 2026
987ea9e
Merge branch 'DEFI-2780-07-get-multiple-accounts' into DEFI-2780-08-s…
mbjorkqvist Oct 27, 2026
31d2976
fix(minter): record a scan at the time its reads are sent
mbjorkqvist Oct 27, 2026
d7febe9
test(minter): cover the guard of a tick and the registration check of…
mbjorkqvist Oct 27, 2026
aaa0560
test(minter): return the deposit a scan queued and minted during the …
mbjorkqvist Oct 27, 2026
bee57d8
test(minter): shorten a test name that rustfmt could not fit on one line
mbjorkqvist Oct 27, 2026
039c606
docs(minter): say which deposit deposit_sol returns when a scan queue…
mbjorkqvist Oct 27, 2026
522423d
docs: leave the response size of a balance scan to the SOL RPC canister
mbjorkqvist Oct 27, 2026
2874774
Merge branch 'DEFI-2780-08-scan-timer' into DEFI-2780-09-rearm-droppe…
mbjorkqvist Oct 30, 2026
a27df5b
refactor(minter): re-arm a dropped sweep's automatic deposits outside…
mbjorkqvist Oct 30, 2026
1b04ccb
docs(minter): document the re-arm of dropped automatic deposits
mbjorkqvist Oct 30, 2026
cd92fe4
docs: say that the re-arm of a dropped sweep is not replayed from the…
mbjorkqvist Oct 30, 2026
abc7749
test(minter): compare the whole replayed state after a re-arm with th…
mbjorkqvist Oct 30, 2026
dda2fc8
docs(minter): clarify which drops go through process_dropped_sweep
mbjorkqvist Oct 30, 2026
1f35c9d
build: depend on the SOL RPC release that adds getMultipleAccounts
mbjorkqvist Nov 3, 2026
7f83b64
Merge branch 'DEFI-2780-07-get-multiple-accounts' into DEFI-2780-08-s…
mbjorkqvist Nov 3, 2026
7b13495
Merge branch 'DEFI-2780-08-scan-timer' into DEFI-2780-09-rearm-droppe…
mbjorkqvist Nov 3, 2026
6d9b24f
Merge remote-tracking branch 'origin/main' into DEFI-2780-09-rearm-dr…
mbjorkqvist Nov 9, 2026
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: 1 addition & 1 deletion docs/src/development/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ At this rate, it would take 300B instructions / (20K instructions per second) =

Regarding the stable memory, the total size of 500 GiB is reached after **49.6 years** at a rate of 1 event per second. This is more than enough time to introduce some pruning mechanism. In other words, the limit on the stable memory size will not be the limiting factor; the upgrade instruction limit will be reached much sooner.

The automatic flow of [Section 3.1.1](#311-automatic-flow) records no event per registration: `watch_deposit_address` is free, so an event per call would let anyone grow the event log without limit. The watchlist is kept on the heap and written to the event log as a single snapshot by the `pre_upgrade` hook, following the ckETH minter. The snapshot carries the window, the capacity and every entry with its scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. It is recorded on every upgrade, even when the watchlist is empty, since the latest snapshot in the log is what replay restores, and skipping an empty one would let an older snapshot resurrect entries that have since expired and been evicted. Replaying a snapshot restores the watchlist verbatim, without evicting expired entries or enforcing the capacity of the running version, so that the state rebuilt from the log equals the state that produced it; expiry is evaluated against the current time when an entry is accessed. The `QueuedDeposit`, `FailedTransaction` and `ExpiredTransaction` events that follow a snapshot remove and re-insert entries as described in [Section 3.1.1](#311-automatic-flow). A trap in `pre_upgrade` fails the upgrade and leaves the running minter, and its watchlist, in place. A reinstallation erases the event log, and with it the watchlist like everything else (see [Section 3.2.3](#323-reinstallation)); users register again for free.
The automatic flow of [Section 3.1.1](#311-automatic-flow) records no event per registration: `watch_deposit_address` is free, so an event per call would let anyone grow the event log without limit. The watchlist is kept on the heap and written to the event log as a single snapshot by the `pre_upgrade` hook, following the ckETH minter. The snapshot carries the window, the capacity and every entry with its scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. It is recorded on every upgrade, even when the watchlist is empty, since the latest snapshot in the log is what replay restores, and skipping an empty one would let an older snapshot resurrect entries that have since expired and been evicted. Replaying a snapshot restores the watchlist verbatim, without evicting expired entries or enforcing the capacity of the running version, so that the state rebuilt from the log equals the state that produced it; expiry is evaluated against the current time when an entry is accessed. The `QueuedDeposit` events that follow a snapshot remove entries as described in [Section 3.1.1](#311-automatic-flow); the re-arm of a dropped sweep, like a registration, records no event and reaches the next upgrade through the snapshot. A trap in `pre_upgrade` fails the upgrade and leaves the running minter, and its watchlist, in place. A reinstallation erases the event log, and with it the watchlist like everything else (see [Section 3.2.3](#323-reinstallation)); users register again for free.

### 3.6. API

Expand Down
5 changes: 4 additions & 1 deletion libs/types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ pub enum DepositSolStatus {
},
/// The sweep transaction failed, or expired without ever being seen on chain, so no
/// ckSOL is owed. Calling `deposit_sol` again queues a new sweep of whatever balance
/// the deposit address still holds.
/// the deposit address still holds. A deposit detected by the automatic flow also puts
/// its deposit address back on watch for a new window, unless too many addresses are
/// watched, so that `watch_deposit_address` reports it as `Watching` and the balance
/// is swept again without a call.
Dropped {
/// The signature of the sweep transaction.
signature: Signature,
Expand Down
6 changes: 5 additions & 1 deletion minter/cksol_minter.did
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ type DepositSolStatus = variant {
};
// The sweep transaction failed, or expired without ever being seen on chain,
// so no ckSOL is owed. Calling `deposit_sol` again queues a new sweep of
// whatever balance the deposit address still holds.
// whatever balance the deposit address still holds. A deposit detected by
// the automatic flow also puts its deposit address back on watch for a new
// window, unless too many addresses are watched, so that
// `watch_deposit_address` reports it as `Watching` and the balance is swept
// again without a call.
Dropped : record {
// The signature of the sweep transaction.
signature: Signature;
Expand Down
10 changes: 6 additions & 4 deletions minter/src/monitor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ use crate::{
rpc::{BlockHeight, get_recent_block, get_signature_statuses},
runtime::CanisterRuntime,
state::{
MinterTransaction, TaskType, audit::process_event, event::EventType, mutate_state,
read_state,
MinterTransaction, TaskType,
audit::{process_dropped_sweep, process_event},
event::EventType,
mutate_state, read_state,
},
};
use canlog::log;
Expand Down Expand Up @@ -105,7 +107,7 @@ async fn check_sweep_transactions<R: CanisterRuntime>(runtime: &R) -> bool {
"Transaction {signature} finalized with on-chain error: {error}"
);
mutate_state(|state| {
process_event(
process_dropped_sweep(
state,
EventType::FailedTransaction {
signature: *signature,
Expand Down Expand Up @@ -168,7 +170,7 @@ fn expire_transactions<R: CanisterRuntime>(
}
log!(Priority::Info, "Transaction {signature} expired");
mutate_state(|state| {
process_event(
process_dropped_sweep(
state,
EventType::ExpiredTransaction {
signature: *signature,
Expand Down
53 changes: 48 additions & 5 deletions minter/src/monitor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ use crate::{
},
storage::{UndecidedWithdrawalReason, reset_events, undecided_withdrawal_transaction_count},
test_fixtures::{
EventsAssert, GetTransactionResult, MINIMUM_WITHDRAWAL_AMOUNT, NONCE_ACCOUNT, account,
confirmed_block_at_height, devnet_sweep, durable_nonce, events, failed_withdrawal_response,
finalized_status, init_balance, init_schnorr_master_key, init_state, nonce_account_info,
runtime::TestCanisterRuntime, signature, succeeded_transaction_response,
succeeded_withdrawal_response,
AUTOMATIC_DEPOSIT_FEE, EventsAssert, GetTransactionResult, MINIMUM_WITHDRAWAL_AMOUNT,
NONCE_ACCOUNT, account, automatic_deposit_of, confirmed_block_at_height, devnet_sweep,
durable_nonce, events, failed_withdrawal_response, finalized_status, init_balance,
init_schnorr_master_key, init_state, nonce_account_info, runtime::TestCanisterRuntime,
signature, succeeded_transaction_response, succeeded_withdrawal_response,
},
};
use sol_rpc_types::{
Expand Down Expand Up @@ -396,6 +396,49 @@ mod finalization {
}
}

#[tokio::test]
async fn should_rearm_the_automatic_deposit_of_a_failed_or_expired_sweep() {
let failed_status = TransactionStatus {
slot: SUBMISSION_SLOT,
status: Err(TransactionError::InsufficientFundsForFee),
err: Some(TransactionError::InsufficientFundsForFee),
confirmation_status: Some(TransactionConfirmationStatus::Finalized),
};
let cases = [
("failed", CURRENT_BLOCK_HEIGHT, Some(failed_status)),
("expired", EXPIRED_BLOCK_HEIGHT, None),
];

for (outcome, block_height, status) in cases {
reset_state();
reset_events();
setup();
let deposit_id = read_state(|state| state.deposits().next_id());
let deposit = automatic_deposit_of(account(1), 1_000_000, AUTOMATIC_DEPOSIT_FEE);
events::queue(deposit_id, deposit);
events::submit_sweep_at_height(signature(1), vec![deposit_id], block_height);
let runtime = TestCanisterRuntime::new()
.with_increasing_time()
.add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT)))
.add_stub_response(BlockResult::Consistent(Ok(current_block())))
.add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![status])));

finalize_transactions(runtime).await;

read_state(|s| {
assert_eq!(s.deposits().dropped().len(), 1, "{outcome}");
assert_eq!(
s.watchlist()
.iter()
.map(|watched| watched.account)
.collect::<Vec<_>>(),
vec![account(1)],
"{outcome}"
);
});
}
}

fn confirmed_status() -> TransactionStatus {
TransactionStatus {
slot: 0,
Expand Down
50 changes: 50 additions & 0 deletions minter/src/state/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,66 @@ use crate::{
state::{
QueuedDeposit, SchnorrPublicKey, State,
event::{Event, EventType},
watchlist::RearmError,
},
storage,
utils::timed_sized_map::Timestamp,
};
use canlog::log;
use cksol_types_internal::log::Priority;

/// Records the given event payload in the event log and updates the state to reflect the change.
pub fn process_event<R: CanisterRuntime>(state: &mut State, payload: EventType, runtime: &R) {
apply_state_transition(state, &payload, runtime.time());
storage::record_event(payload, runtime);
}

/// Records the given `FailedTransaction` or `ExpiredTransaction` event of a sweep, which
/// drops its deposits, then puts the accounts of its automatic deposits back on the
/// watchlist for a fresh window, so that the funds still on their deposit addresses are
/// swept again without the user's intervention. Every sweep drop of the minter goes through
/// here; test fixtures and benchmarks that record these events directly skip the re-arm.
///
/// The re-arm is not part of the state transition of the event: the watchlist is transient,
/// so replaying it from the event log would not see the registrations it was made against.
pub fn process_dropped_sweep<R: CanisterRuntime>(
state: &mut State,
payload: EventType,
runtime: &R,
) {
let signature = match &payload {
EventType::FailedTransaction { signature }
| EventType::ExpiredTransaction { signature } => *signature,
_ => panic!("BUG: {payload:?} does not drop a transaction"),
};
let deposits: Vec<_> = state
.deposits()
.swept()
.get(&signature)
.unwrap_or_else(|| panic!("BUG: cannot drop sweep {signature} that is not swept"))
.deposits()
.iter()
.map(|(deposit_id, deposit)| (*deposit_id, *deposit))
.collect();
process_event(state, payload, runtime);
let now = Timestamp::from_nanos(runtime.time());
for (deposit_id, deposit) in deposits {
match state.watchlist_mut().rearm(now, &deposit) {
Ok(_) => log!(
Priority::Info,
"Watching account {} again after its deposit {deposit_id} was dropped with sweep {signature}",
deposit.account
),
Err(RearmError::ManualDeposit) => {}
Err(RearmError::WatchlistFull) => log!(
Priority::Info,
"Failed to watch account {} again after its deposit {deposit_id} was dropped with sweep {signature}: the watchlist is full",
deposit.account
),
}
}
}

/// Updates the state to reflect the given state transition.
fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64) {
match payload {
Expand Down
3 changes: 2 additions & 1 deletion minter/src/state/deposits/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ mod tests;
/// transaction is what the finalization timer tracks from here on. A sweep is never
/// resubmitted: when it fails or expires, its deposits are dropped and their accounts
/// released, so that `deposit_sol` can queue a new sweep of the balance still on the
/// deposit address.
/// deposit address. The accounts of the automatic deposits are also put back on the
/// watchlist when it has room, so that a balance scan queues that sweep without a call.
/// * `finalized`: `getSignatureStatuses` reported the sweep as finalized without error. The
/// amount received by the main account is still unknown, because Solana may charge a
/// different transaction fee than the sweep was built with.
Expand Down
9 changes: 8 additions & 1 deletion minter/src/state/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ pub enum EventType {
signature: Signature,
},
/// A previously submitted Solana transaction has failed.
/// The deposits of a failed sweep transaction are dropped. For a sweep, the event is
/// recorded by [`process_dropped_sweep`], which also re-arms the automatic deposits.
///
/// [`process_dropped_sweep`]: crate::state::audit::process_dropped_sweep
#[n(6)]
FailedTransaction {
/// The signature of the failed Solana transaction.
Expand All @@ -121,7 +125,10 @@ pub enum EventType {
},
/// A previously submitted Solana transaction has an expired blockhash
/// and a null on-chain status, meaning it will never be executed.
/// The deposits of the expired sweep transaction are dropped.
/// The deposits of the expired sweep transaction are dropped. The event is recorded by
/// [`process_dropped_sweep`], which also re-arms the automatic deposits.
///
/// [`process_dropped_sweep`]: crate::state::audit::process_dropped_sweep
#[n(7)]
ExpiredTransaction {
/// The signature of the expired Solana transaction.
Expand Down
98 changes: 95 additions & 3 deletions minter/src/state/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,17 @@ mod watched_deposit_addresses {
mod swept_deposits {
use super::*;
use crate::{
state::{audit::replay_events, reset_state},
state::{
audit::{process_dropped_sweep, replay_events},
reset_state,
watchlist::{MAX_WATCHED_ADDRESSES, Watchlist},
},
storage::{reset_events, with_event_iter},
test_fixtures::{
DEFAULT_BLOCK_HEIGHT,
DEFAULT_BLOCK_HEIGHT, REGISTERED_AT, WATCH_WINDOW_NANOS, at,
events::{credit_sweep, quarantine_sweep, queue_deposits, submit_sweep},
flow::deposit::{CreditedSweepFlow, DepositFlow, SweepFlow},
replayed_recorded_log, sweep_message,
replayed_recorded_log, sweep_message, watch,
},
};

Expand Down Expand Up @@ -249,6 +253,94 @@ mod swept_deposits {
}
}

#[test]
fn should_rearm_the_automatic_deposits_of_a_dropped_sweep_without_replaying_the_rearm() {
const DROPPED_AT: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS + 1;
let sweep_signature = signature(SWEEP_SIGNATURE_INDEX);
let drop_events = [
EventType::FailedTransaction {
signature: sweep_signature,
},
EventType::ExpiredTransaction {
signature: sweep_signature,
},
];
for drop_event in drop_events {
reset_state();
reset_events();
init_state();
init_schnorr_master_key();
let manual = DepositFlow::queue(account(1), 10_000_000);
let automatic =
DepositFlow::queue_automatic(account(2), 20_000_000, AUTOMATIC_DEPOSIT_FEE);
SweepFlow::of([manual, automatic]).submit(sweep_signature);

mutate_state(|state| {
process_dropped_sweep(
state,
drop_event.clone(),
&TestCanisterRuntime::new().add_times([DROPPED_AT; 3]),
)
});

let rearmed = WatchedAddress {
account: account(2),
address: automatic.address,
valid_until: at(DROPPED_AT + WATCH_WINDOW_NANOS),
scan_count: 0,
last_scanned_at: None,
next_scan_at: Some(at(DROPPED_AT)),
};
read_state(|s| {
assert_eq!(s.deposits().dropped().len(), 2, "{drop_event:?}");
assert_eq!(
s.watchlist().iter().collect::<Vec<_>>(),
vec![rearmed],
"{drop_event:?}"
);
assert_eq!(
s.watchlist().due(at(DROPPED_AT)),
vec![rearmed],
"{drop_event:?}"
);
});
let mut live_without_rearm = read_state(State::clone);
*live_without_rearm.watchlist_mut() = Watchlist::default();
assert_eq!(
replayed_recorded_log(),
live_without_rearm,
"{drop_event:?}"
);
}
}

#[test]
fn should_not_rearm_a_dropped_automatic_deposit_when_the_watchlist_is_full() {
init_state();
init_schnorr_master_key();
let dropped = DepositFlow::queue_automatic(account(0), 20_000_000, AUTOMATIC_DEPOSIT_FEE)
.sweep(signature(SWEEP_SIGNATURE_INDEX));
for index in 1..=MAX_WATCHED_ADDRESSES.get() {
watch(at(REGISTERED_AT), account(index));
}

mutate_state(|state| {
process_dropped_sweep(
state,
EventType::FailedTransaction {
signature: dropped.signature,
},
&TestCanisterRuntime::new().add_times([REGISTERED_AT; 3]),
)
});

read_state(|s| {
assert_eq!(s.watchlist().get(at(REGISTERED_AT), &account(0)), None);
assert_eq!(s.watchlist().len(), MAX_WATCHED_ADDRESSES.get());
assert_eq!(s.deposits().in_flight_id(&account(0)), None);
});
}

#[test]
fn should_credit_the_balance_with_the_amount_received_by_the_sweep() {
init_state();
Expand Down
30 changes: 29 additions & 1 deletion minter/src/state/watchlist/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::utils::timed_sized_map::{Entry, InsertError, TimedSizedMap, Timestamp};
use crate::{
state::QueuedDeposit,
utils::timed_sized_map::{Entry, InsertError, TimedSizedMap, Timestamp},
};
use icrc_ledger_types::icrc1::account::Account;
use solana_address::Address;
use std::{num::NonZeroUsize, time::Duration};
Expand Down Expand Up @@ -99,6 +102,14 @@ pub struct WatchedAddress {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WatchlistFull;

/// Why a dropped deposit was not put back on the watchlist.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RearmError {
/// The deposit was queued by `deposit_sol`, whose caller registered nothing.
ManualDeposit,
WatchlistFull,
}

impl Default for Watchlist {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -127,6 +138,23 @@ impl Watchlist {
}
}

/// Puts the account of a dropped automatic deposit back on the watchlist for a fresh
/// window and schedule starting now, so that the funds still on its deposit address are
/// swept again without the user's intervention. The account is not watched at that point:
/// queueing the deposit removed it from the watchlist, and an account with a deposit in
/// flight is not registered.
pub fn rearm(
&mut self,
now: Timestamp,
dropped: &QueuedDeposit,
) -> Result<WatchedAddress, RearmError> {
if dropped.deposit_fee.is_none() {
Comment thread
mbjorkqvist marked this conversation as resolved.
Resolved
return Err(RearmError::ManualDeposit);
}
self.register(now, dropped.account, dropped.address)
Comment thread
mbjorkqvist marked this conversation as resolved.
Resolved
.map_err(|WatchlistFull| RearmError::WatchlistFull)
}

/// The live registration of the account, if any.
pub fn get(&self, now: Timestamp, account: &Account) -> Option<WatchedAddress> {
let entry = self.entries.get_entry(now, account)?;
Expand Down
Loading
Loading