diff --git a/Cargo.lock b/Cargo.lock index 31d35d477..cbe84b457 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1076,6 +1076,7 @@ dependencies = [ "solana-transaction", "solana-transaction-status-client-types", "thiserror 2.0.18", + "time", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index d2c389421..cd008b2a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ solana-transaction-status-client-types = "3.1.8" tar = "0.4.44" tempfile = "3.26.0" thiserror = "2.0.18" +time = "0.3.47" tokio = "1.49.0" [profile.release] diff --git a/docs/src/development/deployment.md b/docs/src/development/deployment.md index 2d102a200..3cead67d7 100644 --- a/docs/src/development/deployment.md +++ b/docs/src/development/deployment.md @@ -138,6 +138,22 @@ The fees and minimum amounts follow [Section 3.3 of the design](design.md#33-fee | `nonce_accounts` | empty | Replace with the addresses created in [Create nonce accounts](#create-nonce-accounts). | | `automatic_deposit_fee` | unset | Keeps the automatic flow disabled; to be set to 0.002 SOL (2,000,000) once it is complete. | +### Enable the automatic deposit flow + +Enable the automatic deposit flow on a running minter in two upgrades. The first installs the version with the flow and leaves `automatic_deposit_fee` unset, so the flow stays disabled: `watch_deposit_address` returns `TemporarilyUnavailable` and the scan timer reads no balance. No automatic deposit is queued and no watchlist snapshot is recorded, so this upgrade can still be rolled back to the previous version. Once the minter runs as expected, a second upgrade sets the fee and enables the flow: + +```candid +( + variant { + Upgrade = record { + automatic_deposit_fee = opt (2_000_000 : nat64); + } + }, +) +``` + +From then on, a faulty version has to be fixed forward instead of rolled back, as described in [Section 3.5 of the design](design.md#35-events). + ## Ledger Initialization arguments: diff --git a/docs/src/development/design.md b/docs/src/development/design.md index eb9d9da52..787077a72 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -157,7 +157,7 @@ stateDiagram-v2 Quarantined: Quarantined, until a minter upgrade resolves the deposit ``` -The status is `Watching`, with the end of the window, the number of scans so far, the time of the last scan and the time the next scan is *due*, which is not a promise of when it runs: a scan lands on the next tick at or after that time, so the time lies in the past until then, and a client reads a next scan time in the past as a scan still pending, as long as the account is on the watchlist, and `Detected`, with the *deposit id* that identifies a sweep of the account for its whole life, as defined in [Section 3.1.2](#312-manual-flow), once a deposit is in flight for the account. As in the ckETH minter, there is no separate status endpoint: calling `watch_deposit_address` again is how a client follows a registration. Registering an account that is already watched is idempotent and returns the stored registration without extending its window; an account with a deposit in flight is not registered, since the sweep flow already owns its address, and the call returns `Detected`; and an account whose latest deposit is minted or dropped is registered anew, so that the next deposit to the same address needs nothing but another call. The id of the latest deposit is returned in every case, so that a client coming back hours after a detection can follow the deposit with `deposit_sol_status`. An account whose automatically detected deposit was dropped is reported as `Watching` again when the re-arm described below has put it back on the watchlist. +The status is `Watching`, with the end of the window, the number of scans so far, the time of the last scan and the time the next scan is *due*, which is not a promise of when it runs: a scan lands on the next tick at or after that time, so the time lies in the past until then, and a client reads a next scan time in the past as a scan still pending, as long as the account is on the watchlist, and `Detected`, with the *deposit id* that identifies a sweep of the account for its whole life, as defined in [Section 3.1.2](#312-manual-flow), once a deposit is in flight for the account. As in the ckETH minter, there is no separate status endpoint: calling `watch_deposit_address` again is how a client follows a registration. Registering an account that is already watched is idempotent and returns the stored registration without extending its window; an account with a deposit in flight is not registered, since the sweep flow already owns its address, and the call returns `Detected`; and an account whose latest deposit is minted or dropped is registered anew, so that the next deposit to the same address needs nothing but another call. The id of the latest deposit of the account, if any, is returned in every reply except `TemporarilyUnavailable`, so that a client coming back hours after a detection can follow the deposit with `deposit_sol_status`. An account whose automatically detected deposit was dropped is reported as `Watching` again when the re-arm described below has put it back on the watchlist. The call does not contact the SOL RPC canister and requires no cycles. It is rejected when the latest deposit of the account is quarantined (`Quarantined`, as for `deposit_sol`), when the call would create a new registration while the watchlist is full of live entries (`TooManyWatchedAddresses`, with the id of the latest deposit of the account, if any; an account already watched or with a deposit in flight needs no slot and gets its status), and with `TemporarilyUnavailable` when the minter's public key is not yet known or when the automatic deposit fee has not been configured. A `deposit_sol` call in progress for the same account does not prevent the registration: if that call queues a deposit, the registration is removed again, and otherwise it stays, which is what the caller wanted in either case. A registration is not recorded in the event log, see [Section 3.5](#35-events). @@ -181,7 +181,7 @@ An address whose balance is at or above the minimum deposit amount in force at t The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events the automatic flow adds to the ones a detected deposit shares with a manual one (the submission, settlement, mint and possible failure or expiry of its sweep) are the `QueuedDeposit` of the detected deposit, which requires at least the minimum deposit amount on chain, and the watchlist snapshot taken once per upgrade, whose size is bounded by the capacity, see [Section 3.5](#35-events). A full watchlist blocks honest registrations, and the manual flow remains available in that case; since filling it is free, the number of live registrations is exposed as a metric so that an alert can fire before the capacity is reached. -**Compatibility.** The automatic flow is added to a ckSOL minter that is deployed on mainnet, so it only extends the interface and the state: one new endpoint; a new optional `automatic_deposit_fee` field in the init and upgrade arguments and in `get_minter_info`, together with the collected fees; two fields of the `QueuedDeposit` event, an optional one carrying the fee of the deposit, absent if none is charged, and one carrying its origin, which events recorded before the automatic flow lack and which then reads as manual, since all of them were queued by `deposit_sol`; a new snapshot event recorded by a new `pre_upgrade` hook; and a new timer. The types of `deposit_sol` and `deposit_sol_status` and the stable memory layout are unchanged, and an event log written before the upgrade replays unchanged. +**Compatibility.** The automatic flow is added to a ckSOL minter that is deployed on mainnet, so it only extends the interface and the state: one new endpoint; a new optional `automatic_deposit_fee` field in the init and upgrade arguments and in `get_minter_info`, together with the collected fees, which `get_minter_info` always reports, zero until a sweep collects some; two fields of the `QueuedDeposit` event, an optional one carrying the fee of the deposit, absent if none is charged, and one carrying its origin, which events recorded before the automatic flow lack and which then reads as manual, since all of them were queued by `deposit_sol`; a new snapshot event recorded by a new `pre_upgrade` hook; and a new timer. The types of `deposit_sol` and `deposit_sol_status` and the stable memory layout are unchanged, and an event log written before the upgrade replays unchanged. #### 3.1.2. Manual Flow @@ -655,7 +655,7 @@ The ckSOL minter exposes the following endpoints: 4. `watch_deposit_address(opt subaccount)`: Registers the deposit address of the caller's account for the automatic flow of [Section 3.1.1](#311-automatic-flow), for free, or reports on an existing registration. Returns the deposit address, the current minimum deposit amount and automatic deposit fee as quotes, the status of the account (`Watching` with the end of the window, the number of scans, the time of the last scan and the time the next scan is due, which may lie in the past until the tick that runs it, or `Detected` with the id of the deposit in flight) and the id of the latest deposit queued for the account, if any. Otherwise, an error is returned: `Quarantined` with the id of the quarantined deposit, `TooManyWatchedAddresses` with the id of the latest deposit queued for the account, if any, or `TemporarilyUnavailable`. The owner is always the caller. 5. `withdraw_sol(opt subaccount, amount, address)`: Burns the given amount of ckSOL from the user's account and transfers the same amount minus a fee in SOL to the given user address. Returns the block index of the burn operation on the ckSOL ledger in case of success. Otherwise, an error is returned. 6. `withdraw_sol_status(block_index)`: Returns the withdrawal status (`NotFound`, `Pending`, `TxSent`, `TxFinalized`) for the withdrawal identified by the given block index. -7. `get_minter_info`: Returns information about the ckSOL minter, specifically the various fees, including the automatic deposit fee once configured, the minimum deposit and withdrawal amounts, the current balance of the ckSOL minter, the automatic deposit fees it has collected, its main address, and the addresses of its durable nonce accounts. +7. `get_minter_info`: Returns information about the ckSOL minter, specifically the various fees, including the automatic deposit fee once configured, the minimum deposit and withdrawal amounts, the current balance of the ckSOL minter, the automatic deposit fees it has collected (always reported, zero until a sweep collects some), its main address, and the addresses of its durable nonce accounts. 8. `get_events(start, length)`: Returns a page of the event log, at most 2000 events per call. This is a debug endpoint; backwards compatibility is not guaranteed for it. The authoritative interface is the Candid file [`minter/cksol_minter.did`](https://github.com/dfinity/cksol/blob/main/minter/cksol_minter.did). diff --git a/docs/src/usage/cli.md b/docs/src/usage/cli.md index cb9fc79a0..810204134 100644 --- a/docs/src/usage/cli.md +++ b/docs/src/usage/cli.md @@ -41,9 +41,27 @@ A successful response returns the deposit id, which you can use to track the dep (variant { Ok = 42 : nat64 }) ``` +## Register your deposit address for automatic deposits + +Instead of calling `deposit_sol` after each transfer, call `watch_deposit_address` to have the minter watch your deposit address for about 24 hours and queue the deposit itself once the balance reaches the minimum deposit amount. The call is free and needs no cycles, so no proxy canister is required; the automatic deposit fee reported by `get_minter_info` (`automatic_deposit_fee` field) is deducted from the minted amount instead. The owner is always your calling identity, so pass only the `subaccount` used when calling `get_deposit_address`: + +```sh +icp canister call -e prod cksol_minter watch_deposit_address '(record { subaccount = null })' +``` + +A successful response returns the deposit address, the minimum deposit amount, the automatic deposit fee, the status of the registration and the id of the latest deposit of your account, if any: + +``` +(variant { Ok = record { status = variant { Watching = record { valid_until = ...; scan_count = 0; last_scanned_at = null; next_scan_at = opt ... } }; ... } }) +``` + +Call `watch_deposit_address` again to follow the registration: the status becomes `Detected` with the deposit id once the minter has queued the deposit, which you can then track with `deposit_sol_status`. Calling it again after the deposit is minted registers the address anew. + +The last scan runs about half an hour before the window ends (`valid_until`), so a transfer finalized after it is only detected once you register again, which is free and possible as soon as the window has closed. + ## Check a deposit status -After calling `deposit_sol`, track the deposit using the deposit id returned in the response. The status moves through `Queued` → `Swept` → `Finalized` → `Minted` as the minter sweeps the deposit address and mints ckSOL: +After calling `deposit_sol`, or once `watch_deposit_address` reports a deposit as `Detected`, track the deposit using its deposit id. The status moves through `Queued` → `Swept` → `Finalized` → `Minted` as the minter sweeps the deposit address and mints ckSOL: ```sh icp canister call -e prod cksol_minter deposit_sol_status '(42 : nat64)' --query diff --git a/docs/src/usage/how-it-works.md b/docs/src/usage/how-it-works.md index bfcbdc73c..6652edfde 100644 --- a/docs/src/usage/how-it-works.md +++ b/docs/src/usage/how-it-works.md @@ -42,6 +42,18 @@ sequenceDiagram Minter-->>User: Minted { block_index, minted_amount } ``` +### Automatic deposits + +If you cannot call the minter after the transfer, for example because the SOL comes straight from an exchange, register your deposit address with `watch_deposit_address` instead of calling `deposit_sol`: + +1. **Register the deposit address.** Call `watch_deposit_address` on the minter with the same optional subaccount as in step 1; the owner is always your principal. The call is free, requires no cycles and returns the deposit address, the current minimum deposit amount and automatic deposit fee, and the status of the registration, which is `Watching` for about 24 hours with the time of the next scan. + +2. **Send SOL.** Transfer SOL to the deposit address within that window, as in step 2. The last scan runs about half an hour before the window ends, so a transfer finalized after it is only detected once you register again, which you can do for free as soon as the window has closed. + +3. **Let the minter detect the deposit.** The minter reads the balance of every registered deposit address on a schedule, frequently right after the registration and then hourly. Once the balance reaches the minimum deposit amount, the minter queues the deposit as if `deposit_sol` had been called, and the sweep and mint of step 4 follow; the automatic deposit fee in force at detection is deducted from the minted amount instead of cycles. + +4. **Track the deposit.** Call `watch_deposit_address` again: once the deposit is detected, the status is `Detected` with the deposit id, which you can follow with `deposit_sol_status` as in step 5. The id of the latest deposit of your account, if any, is returned in every reply except `TemporarilyUnavailable`. Once the deposit is minted, calling `watch_deposit_address` registers the address anew, so that the next deposit to the same address only needs another call. If the sweep fails or expires instead, `deposit_sol_status` reports `Dropped` and the minter puts the address back on the watchlist for a fresh window, if it has room, so that the funds are detected again under a new deposit id, which `watch_deposit_address` reports as above, without calling `deposit_sol`. + ## Withdrawal: ckSOL → SOL 1. **Approve the minter.** Grant the minter an [ICRC-2](https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-2/README.md) allowance on your ckSOL ledger account. diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 85f186d38..00efc8387 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -4,8 +4,8 @@ use canlog::{Log, LogEntry}; use cksol_types::{ Address, DepositSolArgs, DepositSolError, DepositSolId, DepositSolStatus, GetDepositAddressArgs, MinterInfo, WatchDepositAddressArgs, WatchDepositAddressError, - WatchDepositAddressOk, WithdrawSolArgs, WithdrawSolError, WithdrawSolOk, WithdrawSolStatus, - WithdrawSolStatusArgs, + WatchDepositAddressOk, WatchDepositAddressStatus, WithdrawSolArgs, WithdrawSolError, + WithdrawSolOk, WithdrawSolStatus, WithdrawSolStatusArgs, }; use cksol_types_internal::{ MinterArg, @@ -603,6 +603,32 @@ impl<'a> CkSolMinter<'a> { .await } + /// Polls `watch_deposit_address` on a live instance until the scan tick the caller fired + /// has detected a deposit on the address of the caller's account and returns the id of + /// that deposit. The poll never advances time, since that would also fire the sweep + /// timer on whatever is queued by then. + /// + /// # Panics + /// + /// Panics if no deposit is detected within the timeout. + pub async fn wait_for_deposit_detected(&self, subaccount: Option) -> DepositSolId { + const MAX_POLLS: usize = 45; + const POLL_INTERVAL: Duration = Duration::from_secs(1); + for _ in 0..MAX_POLLS { + let registration = self + .watch_deposit_address(subaccount) + .await + .expect("watch_deposit_address should succeed"); + if let WatchDepositAddressStatus::Detected { deposit_id } = registration.status { + return deposit_id; + } + tokio::time::sleep(POLL_INTERVAL).await; + } + panic!( + "No deposit was detected on the deposit address of subaccount {subaccount:?} within timeout" + ); + } + pub async fn withdraw_sol( &self, args: WithdrawSolArgs, diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 40f8b6e8e..701f556f4 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -7,8 +7,8 @@ use cksol_int_tests::{ validator::{FEE_PER_SIGNATURE, SolanaTestValidator, wait_for_withdrawal_finalized}, }; use cksol_types::{ - DepositSolId, DepositSolStatus, Signature, TxFinalizedStatus, WithdrawSolArgs, - WithdrawSolError, WithdrawSolStatus, + DepositSolId, DepositSolStatus, Signature, TxFinalizedStatus, WatchDepositAddressStatus, + WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus, }; use cksol_types_internal::{UpgradeArgs, event::EventType}; use icrc_ledger_types::icrc1::account::Account; @@ -577,3 +577,146 @@ async fn should_sweep_sub_rent_remainder_together_with_the_deposit() { setup.drop().await; } + +/// The manual deposits are queued while the scan tick detects the watched ones, so that no +/// sweep tick can fire in between and split the four deposits over two transactions. +#[tokio::test(flavor = "multi_thread")] +async fn should_sweep_watched_and_manual_deposits_in_one_transaction() { + const AUTOMATIC_DEPOSIT_FEE: Lamport = Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE; + + let validator = SolanaTestValidator::start().await; + let setup = validator.setup().await; + + let account = |subaccount: u8| Account { + owner: DEPOSITOR, + subaccount: Some([subaccount; 32]), + }; + let watched_accounts = [account(0x11), account(0x12)]; + let manual_accounts = [account(0x21), account(0x22)]; + let deposits: [(Account, Lamport, Lamport); 4] = [ + ( + watched_accounts[0], + 3 * LAMPORTS_PER_SOL / 10, + AUTOMATIC_DEPOSIT_FEE, + ), + ( + watched_accounts[1], + 4 * LAMPORTS_PER_SOL / 10, + AUTOMATIC_DEPOSIT_FEE, + ), + (manual_accounts[0], 5 * LAMPORTS_PER_SOL / 10, 0), + (manual_accounts[1], 6 * LAMPORTS_PER_SOL / 10, 0), + ]; + + let deposit_addresses: Vec
= + futures::future::join_all(deposits.iter().map(async |(account, amount, _)| { + validator + .fund_deposit_address(&setup, *account, *amount) + .await + })) + .await; + let minter_sol_before = validator.get_balance(&MINTER_ADDRESS).await; + + for account in &watched_accounts { + let registration = setup + .minter() + .watch_deposit_address(account.subaccount) + .await + .expect("watch_deposit_address should register the account"); + assert_matches!( + registration.status, + WatchDepositAddressStatus::Watching { + scan_count: 0, + last_scanned_at: None, + .. + } + ); + assert_eq!(registration.latest_deposit_id, None); + } + + setup.advance_time(Duration::from_mins(1)).await; + let (watched_deposit_ids, manual_deposit_ids) = tokio::join!( + futures::future::join_all(watched_accounts.iter().map(async |account| { + setup + .minter() + .wait_for_deposit_detected(account.subaccount) + .await + })), + futures::future::join_all(manual_accounts.iter().map(async |&account| { + setup + .minter() + .deposit_sol(account) + .await + .expect("deposit_sol should queue a sweep") + })) + ); + let deposit_ids: Vec = [watched_deposit_ids, manual_deposit_ids].concat(); + for (&deposit_id, (_, amount, _)) in deposit_ids.iter().zip(&deposits) { + assert_eq!( + setup.minter().deposit_sol_status(deposit_id).await, + DepositSolStatus::Queued { + sweepable_amount: amount - RENT_EXEMPTION_THRESHOLD + } + ); + } + + let total_sweepable_amount: Lamport = deposits + .iter() + .map(|(_, amount, _)| amount - RENT_EXEMPTION_THRESHOLD) + .sum(); + let sweep_fee = deposits.len() as Lamport * FEE_PER_SIGNATURE; + setup.advance_time(Duration::from_mins(1)).await; + validator + .wait_for_finalized_balance( + &MINTER_ADDRESS, + minter_sol_before + total_sweepable_amount - sweep_fee, + ) + .await; + + let minter_transactions = validator.get_signatures_for_address(&MINTER_ADDRESS).await; + let [swept_by] = minter_transactions.as_slice() else { + panic!("Expected a single sweep transaction, got {minter_transactions:?}"); + }; + let sweep_signature = Signature::from(*swept_by); + for deposit_address in &deposit_addresses { + assert_eq!( + validator.get_balance(deposit_address).await, + RENT_EXEMPTION_THRESHOLD + ); + } + for (&deposit_id, account) in deposit_ids.iter().zip(&watched_accounts) { + assert_eq!( + setup.minter().deposit_sol_status(deposit_id).await, + DepositSolStatus::Swept { + signature: sweep_signature.clone() + } + ); + let during_sweep = setup + .minter() + .watch_deposit_address(account.subaccount) + .await + .expect("watch_deposit_address should report the deposit in flight"); + assert_eq!( + during_sweep.status, + WatchDepositAddressStatus::Detected { deposit_id } + ); + } + + for (&deposit_id, (account, amount, fee)) in deposit_ids.iter().zip(&deposits) { + let expected_minted_amount = amount - RENT_EXEMPTION_THRESHOLD - FEE_PER_SIGNATURE - fee; + let minted_amount = setup.wait_for_deposit_minted(deposit_id).await; + assert_eq!(minted_amount, expected_minted_amount); + assert_eq!( + setup.ledger().balance_of(*account).await, + expected_minted_amount + ); + } + let minter_info = setup.minter().get_minter_info().await; + assert_eq!( + minter_info.collected_automatic_deposit_fees, + Some(watched_accounts.len() as Lamport * AUTOMATIC_DEPOSIT_FEE) + ); + assert_eq!(minter_info.balance, total_sweepable_amount - sweep_fee); + + setup.drop().await; +} diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 53ba6ae5f..ef26a4bd8 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -10,9 +10,9 @@ use cksol_int_tests::{ validator::FEE_PER_SIGNATURE, }; use cksol_types::{ - DepositSolArgs, DepositSolError, DepositSolStatus, GetDepositAddressArgs, Lamport, MinterInfo, - TxFinalizedStatus, WatchDepositAddressError, WatchDepositAddressStatus, WithdrawSolArgs, - WithdrawSolError, WithdrawSolStatus, + DepositSolArgs, DepositSolError, DepositSolId, DepositSolStatus, GetDepositAddressArgs, + Lamport, MinterInfo, Signature, TxFinalizedStatus, WatchDepositAddressError, + WatchDepositAddressStatus, WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus, }; use cksol_types_internal::{ UpgradeArgs, @@ -49,7 +49,14 @@ async fn deposit_and_credit_funds(setup: &Setup) { .await .expect("deposit_sol should queue a sweep"); - // Sweep + let sweep_signature = sweep_deposit(setup, deposit_id).await; + finalize_and_credit_sweep(setup, &sweep_signature, SWEEPABLE_AMOUNT).await; +} + +/// Runs the sweep timer on the deposit queued by the first batch of HTTP outcalls (IDs 0 to +/// 3, a `getBalance` of `deposit_sol` or a `getMultipleAccounts` of the scan timer) and +/// returns the signature of its sweep. +async fn sweep_deposit(setup: &Setup, deposit_id: DepositSolId) -> Signature { setup.advance_time(SWEEP_DEPOSITS_DELAY).await; setup .execute_http_mocks( @@ -58,19 +65,23 @@ async fn deposit_and_credit_funds(setup: &Setup) { .build(), ) .await; - let sweep_signature = assert_matches!( + assert_matches!( setup.minter().deposit_sol_status(deposit_id).await, DepositSolStatus::Swept { signature } => signature - ); + ) +} - // Finalize and credit +/// Runs the finalization timer on the sweep submitted by [`sweep_deposit`], reporting that +/// the minter's main account received `received`, which credits the sweep if it matches +/// its plan. +async fn finalize_and_credit_sweep(setup: &Setup, sweep_signature: &Signature, received: Lamport) { setup.advance_time(FINALIZE_TRANSACTIONS_DELAY).await; - let sweep = setup.minter().signed_transaction(&sweep_signature).await; + let sweep = setup.minter().signed_transaction(sweep_signature).await; setup .execute_http_mocks( MockBuilder::with_start_id(16) - .finalize_transaction(&sweep_signature, SUBMISSION_BLOCK_HEIGHT) - .get_sweep_transaction(&sweep, SWEEPABLE_AMOUNT) + .finalize_transaction(sweep_signature, SUBMISSION_BLOCK_HEIGHT) + .get_sweep_transaction(&sweep, received) .build(), ) .await; @@ -975,19 +986,7 @@ mod deposit_sol_tests { .await .expect("deposit_sol should queue a sweep"); - setup.advance_time(SWEEP_DEPOSITS_DELAY).await; - setup - .execute_http_mocks( - MockBuilder::with_start_id(4) - .submit_transaction(SUBMISSION_BLOCK_HEIGHT) - .build(), - ) - .await; - - let sweep_signature = assert_matches!( - setup.minter().deposit_sol_status(deposit_id).await, - DepositSolStatus::Swept { signature } => signature - ); + let sweep_signature = sweep_deposit(&setup, deposit_id).await; setup.minter().assert_that_events().await.satisfy(|events| { check!(events.iter().any(|e| matches!( e, @@ -999,16 +998,7 @@ mod deposit_sol_tests { ))); }); - setup.advance_time(FINALIZE_TRANSACTIONS_DELAY).await; - let sweep = setup.minter().signed_transaction(&sweep_signature).await; - setup - .execute_http_mocks( - MockBuilder::with_start_id(16) - .finalize_transaction(&sweep_signature, SUBMISSION_BLOCK_HEIGHT) - .get_sweep_transaction(&sweep, SWEEPABLE_AMOUNT) - .build(), - ) - .await; + finalize_and_credit_sweep(&setup, &sweep_signature, SWEEPABLE_AMOUNT).await; setup.wait_for_deposit_minted(deposit_id).await; @@ -1041,18 +1031,7 @@ mod deposit_sol_tests { .deposit_sol(DEFAULT_CALLER_ACCOUNT) .await .expect("deposit_sol should queue a sweep"); - setup.advance_time(SWEEP_DEPOSITS_DELAY).await; - setup - .execute_http_mocks( - MockBuilder::with_start_id(4) - .submit_transaction(SUBMISSION_BLOCK_HEIGHT) - .build(), - ) - .await; - let sweep_signature = assert_matches!( - setup.minter().deposit_sol_status(deposit_id).await, - DepositSolStatus::Swept { signature } => signature - ); + let sweep_signature = sweep_deposit(&setup, deposit_id).await; setup.advance_time(FINALIZE_TRANSACTIONS_DELAY).await; setup @@ -1078,7 +1057,8 @@ mod deposit_sol_tests { let setup = setup .check_metrics() .await - .assert_contains_metric_matching(r"dropped_deposits 1 \d+") + .assert_contains_metric_matching(r#"dropped_deposits\{origin="manual"\} 1 \d+"#) + .assert_contains_metric_matching(r#"dropped_deposits\{origin="automatic"\} 0 \d+"#) .into(); let deposit_id_after_drop = setup @@ -1109,30 +1089,10 @@ mod deposit_sol_tests { .deposit_sol(DEFAULT_CALLER_ACCOUNT) .await .expect("deposit_sol should queue a sweep"); - setup.advance_time(SWEEP_DEPOSITS_DELAY).await; - setup - .execute_http_mocks( - MockBuilder::with_start_id(4) - .submit_transaction(SUBMISSION_BLOCK_HEIGHT) - .build(), - ) - .await; - let sweep_signature = assert_matches!( - setup.minter().deposit_sol_status(deposit_id).await, - DepositSolStatus::Swept { signature } => signature - ); + let sweep_signature = sweep_deposit(&setup, deposit_id).await; - setup.advance_time(FINALIZE_TRANSACTIONS_DELAY).await; let amount_not_in_the_plan = BALANCE_ABOVE_MINIMUM - RENT_EXEMPTION_THRESHOLD + 1; - let sweep = setup.minter().signed_transaction(&sweep_signature).await; - setup - .execute_http_mocks( - MockBuilder::with_start_id(16) - .finalize_transaction(&sweep_signature, SUBMISSION_BLOCK_HEIGHT) - .get_sweep_transaction(&sweep, amount_not_in_the_plan) - .build(), - ) - .await; + finalize_and_credit_sweep(&setup, &sweep_signature, amount_not_in_the_plan).await; assert_eq!( setup.minter().deposit_sol_status(deposit_id).await, @@ -1192,6 +1152,7 @@ mod automatic_deposit_sol_tests { const SCAN_WATCHED_ADDRESSES_DELAY: Duration = Duration::from_mins(1); const BALANCE_ABOVE_MINIMUM: Lamport = Setup::DEFAULT_MINIMUM_DEPOSIT_AMOUNT + 1; + const BALANCE_BELOW_MINIMUM: Lamport = Setup::DEFAULT_MINIMUM_DEPOSIT_AMOUNT - 1; #[tokio::test] async fn should_register_the_caller() { @@ -1387,11 +1348,106 @@ mod automatic_deposit_sol_tests { setup .check_metrics() .await + .assert_contains_metric_matching(r#"watched_deposit_addresses 0 \d+"#) .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="queued"\} 1 \d+"#) .into() .drop() .await; } + + #[tokio::test] + async fn should_sweep_finalize_and_mint_the_detected_deposit_minus_the_automatic_fee() { + const SWEEPABLE_AMOUNT: Lamport = BALANCE_ABOVE_MINIMUM - RENT_EXEMPTION_THRESHOLD; + const FEE: Lamport = Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE; + let setup = SetupBuilder::new().build().await; + setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should register the caller"); + setup.advance_time(SCAN_WATCHED_ADDRESSES_DELAY).await; + setup + .execute_http_mocks( + MockBuilder::new() + .get_multiple_accounts(&[BALANCE_ABOVE_MINIMUM]) + .build(), + ) + .await; + + let sweep_signature = sweep_deposit(&setup, 0).await; + finalize_and_credit_sweep(&setup, &sweep_signature, SWEEPABLE_AMOUNT).await; + setup.wait_for_deposit_minted(0).await; + + let minted_amount = SWEEPABLE_AMOUNT - FEE_PER_SIGNATURE - FEE; + assert_eq!( + setup.minter().deposit_sol_status(0).await, + DepositSolStatus::Minted { + block_index: 0, + minted_amount, + } + ); + assert_eq!( + setup.ledger().balance_of(DEFAULT_CALLER_ACCOUNT).await, + minted_amount + ); + assert_eq!( + setup + .minter() + .get_minter_info() + .await + .collected_automatic_deposit_fees, + Some(FEE) + ); + setup + .check_metrics() + .await + .assert_contains_metric_matching(format!(r"collected_automatic_deposit_fees {FEE} \d+")) + .into() + .drop() + .await; + } + + #[tokio::test] + async fn should_report_a_due_scan_in_the_metrics_until_it_runs() { + let setup = SetupBuilder::new().build().await; + setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should register the caller"); + + // The tick starts the scan, whose call stays pending until the mocks below answer it. + setup.advance_time(SCAN_WATCHED_ADDRESSES_DELAY).await; + setup.tick().await; + let setup = setup + .check_metrics() + .await + .assert_contains_metric_matching(r#"watched_deposit_addresses 1 \d+"#) + .assert_contains_metric_matching( + r#"oldest_due_deposit_address_scan_age_seconds 60 \d+"#, + ) + .into(); + + setup + .execute_http_mocks( + MockBuilder::new() + .get_multiple_accounts(&[BALANCE_BELOW_MINIMUM]) + .build(), + ) + .await; + + setup + .check_metrics() + .await + .assert_contains_metric_matching(r#"watched_deposit_addresses 1 \d+"#) + .assert_contains_metric_matching(r#"oldest_due_deposit_address_scan_age_seconds 0 \d+"#) + .assert_contains_metric_matching( + r#"deposit_address_scans\{outcome="below_minimum"\} 1 \d+"#, + ) + .into() + .drop() + .await; + } } mod anonymous_caller_tests { @@ -1497,6 +1553,24 @@ mod metrics_tests { .assert_contains_metric_matching(r#"minter_balance 0 \d+"#) .assert_contains_metric_matching(r#"oldest_in_flight_deposit_age_seconds 0 \d+"#) .assert_contains_metric_matching(r#"oldest_pending_mint_age_seconds 0 \d+"#) + .assert_contains_metric_matching(r#"watched_deposit_addresses 0 \d+"#) + .assert_contains_metric_matching(r#"watched_deposit_address_capacity 1000 \d+"#) + .assert_contains_metric_matching(r#"oldest_due_deposit_address_scan_age_seconds 0 \d+"#) + .assert_contains_metric_matching(r#"collected_automatic_deposit_fees 0 \d+"#) + .assert_contains_metric_matching(r"# TYPE collected_automatic_deposit_fees counter") + .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="queued"\} 0 \d+"#) + .assert_contains_metric_matching( + r#"deposit_address_scans\{outcome="below_minimum"\} 0 \d+"#, + ) + .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="failed"\} 0 \d+"#) + .assert_contains_metric_matching( + r#"automatic_deposit_rearms\{outcome="rearmed"\} 0 \d+"#, + ) + .assert_contains_metric_matching( + r#"automatic_deposit_rearms\{outcome="watchlist_full"\} 0 \d+"#, + ) + .assert_contains_metric_matching(r#"dropped_deposits\{origin="manual"\} 0 \d+"#) + .assert_contains_metric_matching(r#"dropped_deposits\{origin="automatic"\} 0 \d+"#) .assert_contains_metric_matching( r#"failed_credit_attempts\{reason="not_found"\} 0 \d+"#, ) diff --git a/libs/types/CHANGELOG.md b/libs/types/CHANGELOG.md index bfc64e866..26b9bc005 100644 --- a/libs/types/CHANGELOG.md +++ b/libs/types/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Automatic deposit types: `WatchDepositAddressArgs`, `WatchDepositAddressOk`, `WatchDepositAddressStatus` and `WatchDepositAddressError` ([#280](https://github.com/dfinity/cksol/pull/280)) + +### Changed + +- **Breaking:** `MinterInfo` gains the optional `automatic_deposit_fee` and `collected_automatic_deposit_fees` fields, which a struct literal must now set ([#277](https://github.com/dfinity/cksol/pull/277), [#281](https://github.com/dfinity/cksol/pull/281)) + ## [0.1.0] - 2026-10-09 First release of the Candid types of the ckSOL minter's public interface. diff --git a/minter/CHANGELOG.md b/minter/CHANGELOG.md index cc8f242d3..465ea40eb 100644 --- a/minter/CHANGELOG.md +++ b/minter/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +The automatic deposit flow is disabled until `automatic_deposit_fee` is set. Enable it with a second upgrade once the version with the flow runs as expected, as described under "Enable the automatic deposit flow" in the deployment guide: until then the upgrade can be rolled back, afterwards a faulty version has to be fixed forward. + +### Added + +- Automatic deposits: `watch_deposit_address` registers the deposit address of the caller's account for free and reports on the registration, so that SOL sent straight from an exchange is credited without a further call. A timer scans the watched addresses with `getMultipleAccounts` on a schedule spread over a day and queues a deposit that reaches `minimum_deposit_amount` exactly as `deposit_sol` would, deducting the new `automatic_deposit_fee` init and upgrade parameter from the minted amount; `get_minter_info` reports the fee and the collected fees ([#277](https://github.com/dfinity/cksol/pull/277), [#279](https://github.com/dfinity/cksol/pull/279), [#280](https://github.com/dfinity/cksol/pull/280), [#281](https://github.com/dfinity/cksol/pull/281), [#282](https://github.com/dfinity/cksol/pull/282), [#283](https://github.com/dfinity/cksol/pull/283), [#291](https://github.com/dfinity/cksol/pull/291), [#274](https://github.com/dfinity/cksol/pull/274)) +- A sweep of automatic deposits that fails or expires puts their addresses back on the watchlist for a fresh window, if it has room, so that the funds are swept again under a new deposit id without any call ([#284](https://github.com/dfinity/cksol/pull/284)) +- The watchlist is recorded in a new event before every upgrade and restored on replay ([#285](https://github.com/dfinity/cksol/pull/285)) +- Observability: metrics for the watched addresses against the capacity of the watchlist, the age of the earliest due scan, the scans by outcome, the re-arms of dropped automatic deposits by outcome and the collected fees, and on the dashboard the fee, the collected fees and the watched addresses ([#287](https://github.com/dfinity/cksol/pull/287)) + +### Changed + +- `deposit_sol` returns the deposit that a scan queued for the same account during its balance read, charging only the read, so that the same balance is never queued twice ([#286](https://github.com/dfinity/cksol/pull/286)) +- The `dropped_deposits` metric is split by the `origin` of the deposit, `manual` or `automatic` ([#287](https://github.com/dfinity/cksol/pull/287)) + ## [0.1.0] - 2026-10-09 First release of the ckSOL minter, which mints ckSOL on the ckSOL ledger for SOL deposited on Solana and sends SOL back when ckSOL is burned. It is deployed on staging against Solana Devnet as ckDevnetSOL. diff --git a/minter/Cargo.toml b/minter/Cargo.toml index 11a661ccc..6cd08bb32 100644 --- a/minter/Cargo.toml +++ b/minter/Cargo.toml @@ -57,6 +57,7 @@ solana-system-interface = { workspace = true, features = ["bincode"] } solana-transaction = { workspace = true, features = ["bincode", "verify"] } solana-transaction-status-client-types = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } [dev-dependencies] agave-reserved-account-keys = { workspace = true } diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 499dc7483..8695826a0 100644 --- a/minter/src/dashboard/mod.rs +++ b/minter/src/dashboard/mod.rs @@ -1,6 +1,7 @@ use crate::{ address::minter_address, state::{QuarantineCause, State}, + utils::timed_sized_map::Timestamp, }; use askama::Template; use candid::Principal; @@ -21,6 +22,23 @@ pub fn lamports_to_sol(lamports: u64) -> String { } } +/// Formats a time as a UTC date and time to the second, e.g. `2023-11-14T22:13:20+00:00`, as +/// on the dashboard of the ckETH minter. +fn format_timestamp(timestamp: Timestamp) -> String { + let datetime = + time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp.as_nanos())) + .expect("BUG: nanoseconds since the epoch that fit a u64 are a valid time"); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}+00:00", + datetime.year(), + u8::from(datetime.month()), + datetime.day(), + datetime.hour(), + datetime.minute(), + datetime.second() + ) +} + fn solscan_cluster_suffix(network: SolanaNetwork) -> &'static str { match network { SolanaNetwork::Mainnet => "", @@ -41,6 +59,7 @@ pub struct DashboardPaginationParameters { pub quarantined_swept_deposits_start: usize, pub minted_sweeps_start: usize, pub withdrawals_start: usize, + pub watched_deposit_addresses_start: usize, } impl DashboardPaginationParameters { @@ -57,6 +76,7 @@ impl DashboardPaginationParameters { quarantined_swept_deposits_start: parse(req, "quarantined_swept_deposits_start")?, minted_sweeps_start: parse(req, "minted_sweeps_start")?, withdrawals_start: parse(req, "withdrawals_start")?, + watched_deposit_addresses_start: parse(req, "watched_deposit_addresses_start")?, }) } @@ -69,6 +89,10 @@ impl DashboardPaginationParameters { ), ("minted_sweeps_start", self.minted_sweeps_start), ("withdrawals_start", self.withdrawals_start), + ( + "watched_deposit_addresses_start", + self.watched_deposit_addresses_start, + ), ] .into_iter() .filter(|(name, _)| *name != exclude) @@ -229,6 +253,20 @@ pub struct DashboardQuarantinedDeposit { pub amount_to_mint: String, } +/// A deposit address registered for the automatic deposit flow, with its scan progress. +#[derive(Clone)] +pub struct DashboardWatchedAddress { + pub account: String, + pub address: String, + pub valid_until: String, + pub scan_count: String, + /// The time of the last scan, or `-` before the first scan. + pub last_scanned_at: String, + /// When the next scan is due, or `-` once no slot is left before the window closes: a + /// time well in the past shows that the scan timer is behind. + pub next_scan_at: String, +} + /// A swept deposit whose ckSOL mint landed on the ledger. #[derive(Clone)] pub struct DashboardMintedSweep { @@ -251,13 +289,20 @@ pub struct DashboardTemplate { pub minimum_deposit_amount: String, pub minimum_withdrawal_amount: String, pub balance: String, + pub automatic_deposit_fee: String, + pub collected_automatic_deposit_fees: String, pub quarantined_swept_deposits_table: DashboardPaginatedTable, pub minted_sweeps_table: DashboardPaginatedTable, pub withdrawals_table: DashboardPaginatedTable, + pub watched_deposit_addresses_table: DashboardPaginatedTable, } impl DashboardTemplate { - pub fn from_state(state: &State, pagination: DashboardPaginationParameters) -> Self { + pub fn from_state( + state: &State, + now: Timestamp, + pagination: DashboardPaginationParameters, + ) -> Self { let minter_address = state .minter_public_key() .map(|key| minter_address(key).to_string()) @@ -383,6 +428,32 @@ impl DashboardTemplate { pagination.other_params("withdrawals_start"), ); + let watched_deposit_addresses: Vec = state + .watchlist() + .live(now) + .map(|watched| DashboardWatchedAddress { + account: watched.account.to_string(), + address: watched.address.to_string(), + valid_until: format_timestamp(watched.valid_until), + scan_count: watched.scan_count.to_string(), + last_scanned_at: watched + .last_scanned_at + .map_or_else(|| "-".to_string(), format_timestamp), + next_scan_at: watched + .next_scan_at + .map_or_else(|| "-".to_string(), format_timestamp), + }) + .collect(); + let watched_deposit_addresses_table = DashboardPaginatedTable::from_items( + &watched_deposit_addresses, + pagination.watched_deposit_addresses_start, + DEFAULT_PAGE_SIZE, + 6, + "watched-deposit-addresses", + "watched_deposit_addresses_start", + pagination.other_params("watched_deposit_addresses_start"), + ); + let network = state.solana_network(); DashboardTemplate { solana_cluster: format!("{:?}", network), @@ -395,9 +466,16 @@ impl DashboardTemplate { minimum_deposit_amount: lamports_to_sol(state.minimum_deposit_amount()), minimum_withdrawal_amount: lamports_to_sol(state.minimum_withdrawal_amount()), balance: lamports_to_sol(state.balance()), + automatic_deposit_fee: state + .automatic_deposit_fee() + .map_or_else(|| "N/A".to_string(), lamports_to_sol), + collected_automatic_deposit_fees: lamports_to_sol( + state.collected_automatic_deposit_fees(), + ), quarantined_swept_deposits_table, minted_sweeps_table, withdrawals_table, + watched_deposit_addresses_table, } } } diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index 0aeb4622a..1ac76dc64 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -1,24 +1,30 @@ use crate::dashboard::{DashboardPaginationParameters, DashboardTemplate, lamports_to_sol}; -use crate::state::read_state; +use crate::state::{mutate_state, read_state}; use crate::test_fixtures::{ - MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, WITHDRAWAL_FEE, account, + AUTOMATIC_DEPOSIT_FEE, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, + MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, at, deposit_address, events::{ accept_withdrawal, fail_transaction, quarantine_sweep, queue_deposit, submit_sweep, submit_withdrawal, succeed_transaction, }, flow::deposit::DepositFlow, init_balance, init_schnorr_master_key, init_state, init_state_with_args, ledger_canister_id, - signature, sol_rpc_canister_id, valid_init_args, + signature, sol_rpc_canister_id, valid_init_args, watch, }; +use crate::utils::timed_sized_map::Timestamp; use askama::Template; -use cksol_types_internal::SolanaNetwork; +use cksol_types_internal::{InitArgs, SolanaNetwork}; fn dashboard() -> DashboardTemplate { dashboard_with_pagination(DashboardPaginationParameters::default()) } fn dashboard_with_pagination(pagination: DashboardPaginationParameters) -> DashboardTemplate { - read_state(|state| DashboardTemplate::from_state(state, pagination)) + dashboard_at(at(REGISTERED_AT), pagination) +} + +fn dashboard_at(now: Timestamp, pagination: DashboardPaginationParameters) -> DashboardTemplate { + read_state(|state| DashboardTemplate::from_state(state, now, pagination)) } fn init_state_with_network(network: SolanaNetwork) { @@ -56,16 +62,177 @@ fn should_display_metadata() { "#minimum-withdrawal-amount > td", &lamports_to_sol(MINIMUM_WITHDRAWAL_AMOUNT), "wrong minimum withdrawal amount", + ) + .has_string_value( + "#automatic-deposit-fee > td", + &lamports_to_sol(AUTOMATIC_DEPOSIT_FEE), + "wrong automatic deposit fee", + ) + .has_string_value( + "#collected-automatic-deposit-fees > td", + "0", + "wrong collected automatic deposit fees", ); } +#[test] +fn should_display_an_unconfigured_automatic_deposit_fee_as_not_available() { + init_state_with_args(InitArgs { + automatic_deposit_fee: None, + ..valid_init_args() + }); + + DashboardAssert::assert_that(dashboard()).has_string_value( + "#automatic-deposit-fee > td", + "N/A", + "expected N/A when the automatic deposit fee is not configured", + ); +} + +#[test] +fn should_display_the_collected_automatic_deposit_fees() { + init_state(); + init_schnorr_master_key(); + DepositFlow::queue_automatic(account(1), 400_000_000, AUTOMATIC_DEPOSIT_FEE) + .sweep(signature(0xAA)) + .succeed() + .credit(); + + DashboardAssert::assert_that(dashboard()).has_string_value( + "#collected-automatic-deposit-fees > td", + &lamports_to_sol(AUTOMATIC_DEPOSIT_FEE), + "wrong collected automatic deposit fees", + ); +} + +#[test] +fn should_display_watched_deposit_addresses_by_ascending_window_end() { + init_state(); + init_schnorr_master_key(); + watch(at(REGISTERED_AT + 1), account(1)); + watch(at(REGISTERED_AT), account(2)); + watch(at(REGISTERED_AT + 2), account(3)); + mutate_state(|s| { + s.watchlist_mut() + .record_scan(at(REGISTERED_AT + MINUTE_NANOS), &account(2)); + s.watchlist_mut() + .record_scan(at(REGISTERED_AT + WATCH_WINDOW_NANOS), &account(3)); + }); + + DashboardAssert::assert_that(dashboard()) + .has_table_row_value( + "#watched-deposit-addresses + table > tbody > tr:nth-child(1)", + &[ + &account(2).to_string(), + &deposit_address(account(2)).to_string(), + "2023-11-15T22:13:20+00:00", + "1", + "2023-11-14T22:14:20+00:00", + "2023-11-14T22:15:20+00:00", + ], + "watched deposit addresses", + ) + .has_table_row_value( + "#watched-deposit-addresses + table > tbody > tr:nth-child(2)", + &[ + &account(1).to_string(), + &deposit_address(account(1)).to_string(), + "2023-11-15T22:13:20+00:00", + "0", + "-", + "2023-11-14T22:13:20+00:00", + ], + "watched deposit addresses", + ) + .has_table_row_value( + "#watched-deposit-addresses + table > tbody > tr:nth-child(3)", + &[ + &account(3).to_string(), + &deposit_address(account(3)).to_string(), + "2023-11-15T22:13:20+00:00", + "1", + "2023-11-15T22:13:20+00:00", + "-", + ], + "watched deposit addresses", + ) + .has_link_matching( + "#watched-deposit-addresses + table > tbody > tr:nth-child(1) a", + |href| href == format!("https://solscan.io/account/{}", deposit_address(account(2))), + ); +} + +#[test] +fn should_display_only_the_live_watched_deposit_addresses() { + init_state(); + init_schnorr_master_key(); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT + HOUR_NANOS), account(2)); + let expired_at = REGISTERED_AT + WATCH_WINDOW_NANOS + 1; + + let page = dashboard_at(at(expired_at), DashboardPaginationParameters::default()); + + let listed: Vec<&str> = page + .watched_deposit_addresses_table + .current_page + .iter() + .map(|watched| watched.account.as_str()) + .collect(); + assert_eq!(listed, vec![account(2).to_string()]); + assert_eq!(read_state(|s| s.watchlist().len()), 2); + DashboardAssert::assert_that(dashboard_at( + at(expired_at + HOUR_NANOS), + DashboardPaginationParameters::default(), + )) + .has_no_elements_matching("#watched-deposit-addresses + table"); +} + +#[test] +fn should_paginate_watched_deposit_addresses_across_multiple_pages() { + use crate::dashboard::DEFAULT_PAGE_SIZE; + + init_state(); + init_schnorr_master_key(); + for index in 0..DEFAULT_PAGE_SIZE + 1 { + watch(at(REGISTERED_AT), account(index)); + } + + let page1 = dashboard(); + assert_eq!( + page1.watched_deposit_addresses_table.current_page.len(), + DEFAULT_PAGE_SIZE + ); + assert_eq!( + page1.watched_deposit_addresses_table.pagination.pages.len(), + 2 + ); + assert_eq!( + page1.watched_deposit_addresses_table.pagination.table_width, + 6 + ); + + let page2 = dashboard_with_pagination(DashboardPaginationParameters { + watched_deposit_addresses_start: DEFAULT_PAGE_SIZE, + ..Default::default() + }); + assert_eq!(page2.watched_deposit_addresses_table.current_page.len(), 1); + assert_eq!( + page2 + .watched_deposit_addresses_table + .pagination + .current_page_index, + 2 + ); +} + #[test] fn should_display_empty_state() { init_state(); DashboardAssert::assert_that(dashboard()) .has_no_elements_matching("#minted-sweeps + table") - .has_no_elements_matching("#withdrawals + table"); + .has_no_elements_matching("#withdrawals + table") + .has_no_elements_matching("#watched-deposit-addresses + table"); } #[test] diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs index 7963482e9..bf601abcb 100644 --- a/minter/src/deposit/automatic/scan/mod.rs +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -90,8 +90,8 @@ pub async fn scan_watched_addresses(runtime: R) { "Failed to read the balances of {} watched deposit addresses, which advance to their next scan: {e}", chunk.len() ); - record_failed_read(scan, chunk); - record_deposit_address_scans(ScanOutcome::Failed, chunk.len() as u64); + let advanced = record_failed_read(scan, chunk); + record_deposit_address_scans(ScanOutcome::Failed, advanced); } } } @@ -161,16 +161,22 @@ fn apply_balance_read( }) } -fn record_failed_read(scan: Scan, chunk: &[WatchedAddress]) { +/// Advances the registrations of a chunk whose read failed and returns how many there were, +/// leaving out those removed, replaced or expired while the read was in flight, as a +/// successful read does. +fn record_failed_read(scan: Scan, chunk: &[WatchedAddress]) -> u64 { mutate_state(|state| { + let mut advanced = 0; for watched in chunk { if is_same_registration(state, scan.read_at, watched) { state .watchlist_mut() .record_scan(scan.scanned_at, &watched.account); + advanced += 1; } } - }); + advanced + }) } fn is_same_registration(state: &State, now: Timestamp, watched: &WatchedAddress) -> bool { diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 5573e3086..abe8762fa 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -252,11 +252,13 @@ fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { &renewed, MINIMUM_DEPOSIT_AMOUNT, ); - record_failed_read(scan_at(TICK_AT), &[stale]); - record_failed_read(read_after_expiry, &[renewed]); + let failed_after_replacement = record_failed_read(scan_at(TICK_AT), &[stale]); + let failed_after_expiry = record_failed_read(read_after_expiry, &[renewed]); assert_eq!(after_replacement, None); assert_eq!(after_expiry, None); + assert_eq!(failed_after_replacement, 0); + assert_eq!(failed_after_expiry, 0); EventsAssert::assert_no_events_recorded(); assert_eq!( read_state(|s| s.watchlist().get(at(TICK_AT), &account(1))), diff --git a/minter/src/main.rs b/minter/src/main.rs index f0eb836c3..6d187010f 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -12,6 +12,7 @@ use cksol_minter::{ monitor::{FINALIZE_TRANSACTIONS_DELAY, finalize_transactions}, runtime::IcCanisterRuntime, state::read_state, + utils::timed_sized_map::Timestamp, withdraw::{WITHDRAWAL_PROCESSING_DELAY, process_pending_withdrawals}, }; use cksol_types::{ @@ -166,7 +167,9 @@ fn http_request(request: HttpRequest) -> HttpResponse { .build(); } }; - let dashboard = read_state(|state| DashboardTemplate::from_state(state, pagination)); + let now = Timestamp::from_nanos(ic_cdk::api::time()); + let dashboard = + read_state(|state| DashboardTemplate::from_state(state, now, pagination)); HttpResponseBuilder::ok() .header("Content-Type", "text/html; charset=utf-8") .with_body_and_content_length(dashboard.render().unwrap()) diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index ad207ffa2..5f550d439 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -1,7 +1,9 @@ -use crate::state::{QuarantineCause, State}; +use crate::state::{DepositOrigin, QuarantineCause, State}; use crate::storage::{ - self, FailedCreditReason, FailedMintReason, ScanOutcome, UndecidedWithdrawalReason, + self, FailedCreditReason, FailedMintReason, RearmOutcome, ScanOutcome, + UndecidedWithdrawalReason, }; +use crate::utils::timed_sized_map::Timestamp; use ic_metrics_encoder::MetricsEncoder; const WASM_PAGE_SIZE_IN_BYTES: usize = 65536; @@ -48,11 +50,20 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re s.deposits().minted().len().metric_value(), "Number of swept deposits whose ckSOL mint landed on the ledger.", )?; - w.encode_gauge( + let mut dropped_manual: usize = 0; + let mut dropped_automatic: usize = 0; + for dropped in s.deposits().dropped().values() { + match dropped.deposit.origin { + DepositOrigin::Manual => dropped_manual += 1, + DepositOrigin::Automatic => dropped_automatic += 1, + } + } + w.gauge_vec( "dropped_deposits", - s.deposits().dropped().len().metric_value(), - "Number of deposits whose sweep transaction failed or expired.", - )?; + "Number of deposits whose sweep transaction failed or expired, by origin: queued by deposit_sol or by a balance scan of the automatic flow.", + )? + .value(&[("origin", "manual")], dropped_manual.metric_value())? + .value(&[("origin", "automatic")], dropped_automatic.metric_value())?; let mut sweep_unreadable: usize = 0; let mut mint_unresolved: usize = 0; for quarantined in s.deposits().quarantined().values() { @@ -114,6 +125,32 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re age_seconds(s.deposits().oldest_pending_mint_created_at()).metric_value(), "Age of the oldest pending ckSOL mint in seconds, from the created_at_time the ledger deduplicates it by. Returns 0 if there are no pending mints.", )?; + let now = Timestamp::from_nanos(ic_cdk::api::time()); + w.encode_gauge( + "watched_deposit_addresses", + s.watchlist().live_len(now).metric_value(), + "Number of deposit addresses registered for the automatic deposit flow whose window is still open.", + )?; + w.encode_gauge( + "watched_deposit_address_capacity", + s.watchlist().capacity().metric_value(), + "Number of live registrations the watchlist of the automatic deposit flow admits at most.", + )?; + w.encode_gauge( + "oldest_due_deposit_address_scan_age_seconds", + age_seconds( + s.watchlist() + .oldest_due_scan_at(now) + .map(Timestamp::as_nanos), + ) + .metric_value(), + "Time in seconds since the earliest due scan of a watched deposit address that has not run yet. Returns 0 if no scan is due.", + )?; + w.encode_counter( + "collected_automatic_deposit_fees", + s.collected_automatic_deposit_fees().metric_value(), + "Sum in Lamports of the automatic deposit fees deducted from the mints of credited sweeps.", + )?; let mut failed_credit_attempts = w.counter_vec( "failed_credit_attempts", "Number of failed attempts to credit the deposits of a finalized sweep, by reason.", @@ -144,6 +181,16 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re storage::deposit_address_scan_count(outcome).metric_value(), )?; } + let mut automatic_deposit_rearms = w.counter_vec( + "automatic_deposit_rearms", + "Number of automatic deposits dropped with their sweep since the last upgrade, by outcome: their account put back on the watchlist for a fresh window, or not because the watchlist was full.", + )?; + for outcome in RearmOutcome::ALL { + automatic_deposit_rearms = automatic_deposit_rearms.value( + &[("outcome", outcome.label())], + storage::automatic_deposit_rearm_count(outcome).metric_value(), + )?; + } w.encode_gauge( "minter_balance", s.balance().metric_value(), diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 5c3e0cfea..c35c10dac 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -48,17 +48,23 @@ pub fn process_dropped_sweep( 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 - ), + Ok(_) => { + storage::record_automatic_deposit_rearm(storage::RearmOutcome::Rearmed); + 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 - ), + Err(RearmError::WatchlistFull) => { + storage::record_automatic_deposit_rearm(storage::RearmOutcome::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 + ) + } } } } diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index c45cf423a..00e625063 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -4,6 +4,7 @@ use crate::{ rpc::BlockHeight, sol_transfer::MAX_SIGNATURES, state::{audit::process_event, read_state}, + storage::{RearmOutcome, automatic_deposit_rearm_count}, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, WITHDRAWAL_FEE, account, address, @@ -347,6 +348,7 @@ mod swept_deposits { let automatic = DepositFlow::queue_automatic(account(2), 20_000_000, AUTOMATIC_DEPOSIT_FEE); SweepFlow::of([manual, automatic]).submit(sweep_signature); + let rearms_before = automatic_deposit_rearm_count(RearmOutcome::Rearmed); mutate_state(|state| { process_dropped_sweep( @@ -377,6 +379,16 @@ mod swept_deposits { "{drop_event:?}" ); }); + assert_eq!( + automatic_deposit_rearm_count(RearmOutcome::Rearmed), + rearms_before + 1, + "{drop_event:?}" + ); + assert_eq!( + automatic_deposit_rearm_count(RearmOutcome::WatchlistFull), + 0, + "{drop_event:?}" + ); let mut live_without_rearm = read_state(State::clone); *live_without_rearm.watchlist_mut() = Watchlist::default(); assert_eq!( @@ -412,6 +424,11 @@ mod swept_deposits { assert_eq!(s.watchlist().len(), MAX_WATCHED_ADDRESSES.get()); assert_eq!(s.deposits().in_flight_id(&account(0)), None); }); + assert_eq!( + automatic_deposit_rearm_count(RearmOutcome::WatchlistFull), + 1 + ); + assert_eq!(automatic_deposit_rearm_count(RearmOutcome::Rearmed), 0); } #[test] diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 98dc6247d..42fcb1afd 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -260,11 +260,28 @@ impl Watchlist { self.entries.is_empty() } + /// The number of live registrations the watchlist admits, which is + /// [`MAX_WATCHED_ADDRESSES`] after a rebuild too. + pub fn capacity(&self) -> usize { + self.entries.capacity().get() + } + pub fn live_len(&self, now: Timestamp) -> usize { self.live(now).count() } - fn live(&self, now: Timestamp) -> impl Iterator { + /// When the earliest due scan was due, if any scan is due: since the due time of a + /// registration only moves when a scan is recorded, this falls behind `now` by more than a + /// tick only when the scan timer does not keep up. + pub fn oldest_due_scan_at(&self, now: Timestamp) -> Option { + self.due(now) + .into_iter() + .filter_map(|watched| watched.next_scan_at) + .min() + } + + /// The live registrations, by ascending window end. + pub fn live(&self, now: Timestamp) -> impl Iterator { self.entries .iter_by_expiry() .filter(move |(_, entry)| entry.is_live(now)) diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index d44a57113..89cba90df 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -57,6 +57,7 @@ fn should_reject_a_registration_when_full_of_live_entries() { assert_eq!(result, Err(WatchlistFull)); assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); + assert_eq!(watchlist.capacity(), MAX_WATCHED_ADDRESSES.get()); } #[test] @@ -178,6 +179,44 @@ fn should_count_the_live_registrations() { assert_eq!(watchlist.len(), 2); } +#[test] +fn should_report_the_earliest_due_scan_until_it_is_recorded() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + register(&mut watchlist, at(REGISTERED_AT + MINUTE_NANOS), account(2)); + + assert_eq!( + watchlist.oldest_due_scan_at(at(REGISTERED_AT + HOUR_NANOS)), + Some(at(REGISTERED_AT)) + ); + + watchlist.record_scan(at(REGISTERED_AT + HOUR_NANOS), &account(1)); + + assert_eq!( + watchlist.oldest_due_scan_at(at(REGISTERED_AT + HOUR_NANOS)), + Some(at(REGISTERED_AT + MINUTE_NANOS)) + ); + + watchlist.record_scan(at(REGISTERED_AT + HOUR_NANOS), &account(2)); + + assert_eq!( + watchlist.oldest_due_scan_at(at(REGISTERED_AT + HOUR_NANOS)), + None + ); +} + +#[test] +fn should_report_no_due_scan_of_an_expired_registration() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + + assert_eq!( + watchlist.oldest_due_scan_at(at(VALID_UNTIL)), + Some(at(REGISTERED_AT)) + ); + assert_eq!(watchlist.oldest_due_scan_at(at(VALID_UNTIL + 1)), None); +} + #[test] fn should_rearm_an_account_for_a_fresh_window_from_the_first_slot() { let mut watchlist = Watchlist::default(); @@ -295,6 +334,7 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { let mut rebuilt = Watchlist::from_snapshot(&snapshot); assert_eq!(rebuilt.len(), entries.len()); + assert_eq!(rebuilt.capacity(), MAX_WATCHED_ADDRESSES.get()); assert_eq!( rebuilt.register( at(REGISTERED_AT), diff --git a/minter/src/storage/mod.rs b/minter/src/storage/mod.rs index c09e01ad2..b54a15aba 100644 --- a/minter/src/storage/mod.rs +++ b/minter/src/storage/mod.rs @@ -41,6 +41,7 @@ pub(crate) struct Metrics { pub withdrawal_transaction_rebroadcasts: u64, pub undecided_withdrawal_transactions: BTreeMap, pub deposit_address_scans: BTreeMap, + pub automatic_deposit_rearms: BTreeMap, } impl Metrics { @@ -52,6 +53,7 @@ impl Metrics { withdrawal_transaction_rebroadcasts: 0, undecided_withdrawal_transactions: BTreeMap::new(), deposit_address_scans: BTreeMap::new(), + automatic_deposit_rearms: BTreeMap::new(), } } } @@ -84,6 +86,38 @@ pub(crate) fn deposit_address_scan_count(outcome: ScanOutcome) -> u64 { with_unstable_metrics(|m| m.deposit_address_scans.get(&outcome).copied().unwrap_or(0)) } +/// Whether the account of an automatic deposit dropped with its sweep was put back on the +/// watchlist. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum RearmOutcome { + Rearmed, + WatchlistFull, +} + +impl RearmOutcome { + pub const ALL: [Self; 2] = [Self::Rearmed, Self::WatchlistFull]; + + pub fn label(self) -> &'static str { + match self { + Self::Rearmed => "rearmed", + Self::WatchlistFull => "watchlist_full", + } + } +} + +pub(crate) fn record_automatic_deposit_rearm(outcome: RearmOutcome) { + with_unstable_metrics_mut(|m| *m.automatic_deposit_rearms.entry(outcome).or_insert(0) += 1); +} + +pub(crate) fn automatic_deposit_rearm_count(outcome: RearmOutcome) -> u64 { + with_unstable_metrics(|m| { + m.automatic_deposit_rearms + .get(&outcome) + .copied() + .unwrap_or(0) + }) +} + /// Why an attempt to credit the deposits of a finalized sweep failed. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum FailedCreditReason { diff --git a/minter/templates/dashboard.html b/minter/templates/dashboard.html index 7f681029b..393433032 100644 --- a/minter/templates/dashboard.html +++ b/minter/templates/dashboard.html @@ -93,8 +93,45 @@

Metadata

Balance (SOL) {{ balance }} + + Automatic deposit fee (SOL) + {{ automatic_deposit_fee }} + + + Collected automatic deposit fees (SOL) + {{ collected_automatic_deposit_fees }} + + + + + {% if !watched_deposit_addresses_table.is_empty() %} +

Watched Deposit Addresses

+ + + + + + + + + + + + + {% for watched in watched_deposit_addresses_table.current_page %} + + + + + + + + + {% endfor %} + {% if watched_deposit_addresses_table.has_more_than_one_page() %}{{ watched_deposit_addresses_table.pagination|safe }}{% endif %}
AccountDeposit AddressValid UntilScansLast Scanned AtNext Scan At
{{ watched.account }}{% call solscan_address_link(watched.address) %}{{ watched.valid_until }}{{ watched.scan_count }}{{ watched.last_scanned_at }}{{ watched.next_scan_at }}
+ {% endif %} {% if !quarantined_swept_deposits_table.is_empty() %}

Quarantined Swept Deposits