From cb7544c7ce7d45f047c417af885d81b08c3ec820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 09:36:40 +0000 Subject: [PATCH 001/146] docs(design): automatic deposit flow on top of the balance sweep Replace the outdated section 3.1.1, which described per-transaction crediting with getSignaturesForAddress and quotas replenished by the removed process_deposit endpoint, with a design that builds on the balance sweep of deposit_sol: a free registration endpoint, a scan timer reading watched deposit addresses in batches with getMultipleAccounts, a schedule adapted from the ckETH minter, a fixed fee in SOL deducted from the minted amount, and a watchlist persisted only as a bounded snapshot at pre_upgrade. Every change to the interface, the arguments and the event log is additive, since the minter is deployed on mainnet. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 142 ++++++++++++++------------------- 1 file changed, 61 insertions(+), 81 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index ce19c2701..8a51a6406 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -4,7 +4,7 @@ - [2. Overview](#2-overview) - [3. Technical Details](#3-technical-details) - [3.1. Converting SOL to ckSOL](#31-converting-sol-to-cksol) - - [3.1.1. Automated Flow (Outdated)](#311-automated-flow-outdated) + - [3.1.1. Automatic Flow](#311-automatic-flow) - [3.1.2. Manual Flow](#312-manual-flow) - [3.1.3. Transaction Submission](#313-transaction-submission) - [3.2. Converting ckSOL to SOL](#32-converting-cksol-to-sol) @@ -68,6 +68,7 @@ The ckSOL minter interacts with the Solana blockchain via the [SOL RPC canister] - [getAccountInfo](https://solana.com/docs/rpc/http/getaccountinfo): Returns the account information for the given address. This function is used to read the state of a durable nonce account before building a withdrawal transaction and to determine whether an in-flight withdrawal transaction has landed (see [Section 3.2.1](#321-durable-nonce-accounts) and [Section 3.2.5](#325-finalization-and-re-broadcast)). - [getBalance](https://solana.com/docs/rpc/http/getbalance): Returns the balance of the given address. This function is used by `deposit_sol` to read the balance of a deposit address at the `finalized` commitment level and determine the sweepable amount. - [getBlock](https://solana.com/docs/rpc/http/getblock): Returns the block for the given slot. This function is used to get a recent block hash, which is contained in the response. Note that `transactionDetails` is set to `null` in the request. As a result, signatures and transactions are not returned. +- [getMultipleAccounts](https://solana.com/docs/rpc/http/getmultipleaccounts): Returns the account information, in particular the balance, of up to 100 addresses in a single call. This function is used by the automatic deposit flow of [Section 3.1.1](#311-automatic-flow) to read the balances of the watched deposit addresses at the `finalized` commitment level. It is not yet offered as a typed method by the SOL RPC canister; adding it, with the response context stripped so that the providers' responses can reach consensus, is a prerequisite of that flow. - [getSignaturesForAddress](https://solana.com/docs/rpc/http/getsignaturesforaddress): Returns the signatures for a given address. This function is used to learn about new transactions (in Solana, signatures are used to identify transactions, as the first signature in a transaction is considered the transaction ID). - [getSignatureStatuses](https://solana.com/docs/rpc/http/getsignaturestatuses): Returns the status of each transaction specified through its identifier, i.e., the first signature in the transaction. This function is used to learn whether a sweep transaction has been finalized or has expired, as described in [Section 3.2.5](#325-finalization-and-re-broadcast). Withdrawal transactions are not checked with it. - [getSlot](https://solana.com/docs/rpc/http/getslot): Returns the current slot. Since the slot number changes rapidly, the SOL RPC canister merely obtains a rounded and therefore slightly outdated slot number. The slot number is required to obtain a recent block hash using `getBlock`, whose block height is persisted with the transaction and later compared against the current block height to check if an unconfirmed transaction has expired. @@ -80,22 +81,23 @@ The process of converting SOL to ckSOL should be as simple as possible for the u The chosen approach is to derive a **deposit address** for a given ICRC account: The user calls the endpoint `get_deposit_address` with a principal ID and subaccount as parameters on the ckSOL minter and receives a derived SOL address in return. Note that the SOL address is derived deterministically, i.e., always the same address is returned for the same principal ID and subaccount. Since it is a deterministic function, other pieces of software, such as frontends or other canisters, can derive deposit addresses themselves instead of querying the ckSOL minter. -Concretely, the deposit address is derived from the ckSOL minter's master public key with the user's account (principal ID and subaccount) used [as the derivation path](https://github.com/dfinity/ic/blob/215615d2d08f2679126fbe074bbff2e1bf3064dc/rs/bitcoin/ckbtc/minter/src/address.rs#L102), i.e., effectively the same mechanism as for [ckBTC](https://learn.internetcomputer.org/hc/en-us/articles/44598021228564-Chain-key-Bitcoin) is used to generate a user-specific address. The user can then transfer the desired amount to this address. The minter's own main address, in contrast, is the master public key itself (the empty derivation path): since every deposit address is derived under a non-empty, schema-tagged path, the main address can never coincide with a deposit address, which would otherwise let the swept funds held by the minter be mistaken for a user deposit. Two deposit flows are described next: the automated flow, which is deferred to a post-launch upgrade and not implemented, and the manual flow, which is the one the ckSOL minter offers today. +Concretely, the deposit address is derived from the ckSOL minter's master public key with the user's account (principal ID and subaccount) used [as the derivation path](https://github.com/dfinity/ic/blob/215615d2d08f2679126fbe074bbff2e1bf3064dc/rs/bitcoin/ckbtc/minter/src/address.rs#L102), i.e., effectively the same mechanism as for [ckBTC](https://learn.internetcomputer.org/hc/en-us/articles/44598021228564-Chain-key-Bitcoin) is used to generate a user-specific address. The user can then transfer the desired amount to this address. The minter's own main address, in contrast, is the master public key itself (the empty derivation path): since every deposit address is derived under a non-empty, schema-tagged path, the main address can never coincide with a deposit address, which would otherwise let the swept funds held by the minter be mistaken for a user deposit. Two deposit flows are described next. In the manual flow, which is the one the ckSOL minter offers since its launch, the caller asks the minter to read the balance of the deposit address and pays for that read in cycles. In the automatic flow, a post-launch addition, the caller registers the deposit address for free, the minter reads its balance on a timer for about a day, and a fixed fee in SOL is deducted from the minted amount. Both flows credit a deposit the same way, by sweeping the deposit address to the minter's main account, so the automatic flow is described first in terms of how it differs from the manual one, and the manual flow then describes the sweep that both share. -#### 3.1.1. Automated Flow (Outdated) +#### 3.1.1. Automatic Flow -> [!WARNING] -> The automated flow is currently not implemented. It is deferred to a post-launch upgrade and must be revisited on top of the manual flow of [Section 3.1.2](#312-manual-flow), so that a deposit address is only ever credited by one mechanism. +The automatic flow lets a user obtain ckSOL without calling the ckSOL minter after the transfer, which is the situation of a deposit from a centralized exchange: the user withdraws SOL from the exchange to the deposit address, and nothing else happens on the user's side. It is a post-launch addition built on top of the manual flow of [Section 3.1.2](#312-manual-flow) rather than a separate crediting path: the ckSOL minter reads the balances of registered deposit addresses on a timer and, when a balance reaches the minimum deposit amount, queues exactly the sweep that `deposit_sol` would have queued. From that point on, the deposit is swept, finalized, settled and minted as described in [Section 3.1.2](#312-manual-flow), so a deposit address is only ever credited by one mechanism, the sweep, whichever way its balance was noticed. This replaces the earlier proposal of tracking the transaction signatures of each address with `getSignaturesForAddress` and `getTransaction`, which the sweep flow made obsolete. -When a user calls the endpoint `update_balance`, the ckSOL minter will check transfers to the deposit address derived for the caller's principal ID and the provided subaccount (if any) on a timer by calling the `getSignaturesForAddress` endpoint on the SOL RPC canister, filtering out failed transactions (based on the `err` field in the response). If previously unknown (finalized) signatures are returned, the ckSOL minter will call the `getTransaction` endpoint for the newly obtained signatures. The transaction data contains information about the transferred amount, which will then be minted, minus a certain fee (defined in [Section 3.3.2](#332-cksol-minter-fees)), on the ckSOL ledger using an `icrc1_transfer` call, crediting the user's account. +Compared to the manual flow, the automatic flow differs in who triggers the balance read (a timer instead of the caller), in what the caller pays (nothing at call time, and a fixed fee in SOL deducted from the minted amount instead of cycles) and in the detection latency (a schedule of scans spread over about a day instead of an immediate read). -The user must transfer at least the **minimum deposit amount**, defined in [Section 3.3.3](#333-minimum-swap-amounts). Any deposit below this amount is ignored. +**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the minimum deposit amount, the fee that will be deducted, the end of the window and the time of the next scan. The fee is recorded with the registration, so a fee change by upgrade while the address is watched does not alter what the user was told. -The returned transaction can contain SOL transfers as *top-level instructions*, i.e., the transaction (message) instructions contain instructions of type "transfer" for program "system". Alternatively, SOL transfers can be made via *inner instructions*, i.e., the transfer happens through a cross-program invocation (CPI). Such transfers can be extracted from the "meta" data of the transaction. The implementation must capture transfers of both types. +The call does not contact the SOL RPC canister and requires no cycles. It is rejected when the account already has a deposit in flight (`AlreadyDepositing`, carrying the deposit id, since the sweep flow already owns the address), when the latest deposit of the account is quarantined (`Quarantined`, as for `deposit_sol`), when the watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. Registering an account that is already watched is idempotent: the call returns the stored registration and does not extend its window. A registration is not recorded in the event log, see [Section 3.5](#35-events). -**Note**: Some transfers are not captured, e.g., transfers via the [CloseAccount](https://solana.com/docs/tokens/basics/close-account) instruction. The fact that there are corner cases where no mint occurs is accepted for now and will be addressed at a later stage. +The query `watch_deposit_address_status` reports, for a given account, whether it is being watched, with the end of the window, the number of scans so far and the times of the last and next scans, or not, together with the id of the latest deposit queued for the account, if any, so that a client coming back hours after the detection can follow the deposit with `deposit_sol_status`. `NotWatching` covers accounts that were never registered as well as expired registrations and accounts whose deposit was detected, minted, dropped or quarantined. -The flow is depicted in the following figure (fee refers to the deposit fee). +The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. + +The flow is depicted in the following figure. ```mermaid sequenceDiagram @@ -105,87 +107,58 @@ sequenceDiagram participant Minter as ckSOL Minter participant Ledger as ckSOL Ledger - User->>+Minter: get_deposit_address(principal, subaccount) - Minter-->>-User: sol_address - User->>+Minter: update_balance(subaccount) - Minter-->>-User: Ok + User->>+Minter: watch_deposit_address(subaccount) + Note over Minter: add the account to the watchlist (no event) + Minter-->>-User: Ok(deposit_address, fee, valid_until, next_scan_at) - Note over Minter: ⏱️ Timer + Note over Minter: ⏱️ Scan timer activate Minter - Minter->>+RPC: getSignaturesForAddress(sol_address) - RPC->>+Solana: getSignaturesForAddress(sol_address) - Solana-->>-RPC: [] - RPC-->>-Minter: [] - Note over Minter: No new signature, reschedule timer
with exponential back-off + Minter->>+RPC: getMultipleAccounts([deposit_address, ...], finalized) + RPC->>+Solana: getMultipleAccounts([deposit_address, ...]) + Solana-->>-RPC: [balance, ...] + RPC-->>-Minter: [balance, ...] + Note over Minter: balance < minimum deposit amount: advance the schedule deactivate Minter - User->>+Solana: transfer(sol_address, amount) + User->>+Solana: transfer(deposit_address, amount) Solana-->>-User: signature - Note over Minter: ⏱️ Timer + Note over Minter: ⏱️ Scan timer activate Minter - Minter->>+RPC: getSignaturesForAddress(sol_address) - RPC->>+Solana: getSignaturesForAddress(sol_address) - Solana-->>-RPC: [signature] - RPC-->>-Minter: [signature] - Minter->>+RPC: getTransaction(signature) - RPC->>+Solana: getTransaction(signature) - Solana-->>-RPC: transaction - RPC-->>-Minter: transaction - Minter->>+Ledger: icrc1_transfer(cksol_minter, principal, subaccount, amount - fee) - Ledger-->>-Minter: block index + Minter->>+RPC: getMultipleAccounts([deposit_address, ...], finalized) + RPC->>+Solana: getMultipleAccounts([deposit_address, ...]) + Solana-->>-RPC: [balance, ...] + RPC-->>-Minter: [balance, ...] + Note over Minter: balance ≥ minimum deposit amount: queue the deposit
with sweepable := balance - rent exemption threshold
and remove the account from the watchlist deactivate Minter -``` - -The user's account is cached, together with the derived Solana address, so that the function called on a timer to check for newly arrived funds can access the required information. The timer mechanism works as follows. After an initial waiting time, the timer executes for the first time. -The following scheme is proposed to bound the number of timer invocations per deposit address. At most `MAX_GET_SIGNATURES_CALLS = 10` calls are made, with the interval between calls doubling from **1, 2, 4, 8, 16, 32, 64, 128, 256, up to 512 minutes**, for a total of **1023 minutes**, i.e., slightly more than 17 hours. The timer is not set anymore whenever a call returns at least one new signature that results in a mint operation. + Note over Solana,Ledger: Sweep, finalization and mint as in Section 3.1.2;
the mint is sweepable - fee share - automatic deposit fee -In order to mitigate the risk of a denial-of-service attack, each account has a certain **quota** for the automatic flow, which consists of a quota for `getSignaturesForAddress` calls and a quota for `getTransaction` calls. Initially, the quotas are `MAX_GET_SIGNATURES_CALLS` and `MAX_RETRIEVED_TRANSACTIONS`, respectively. Calls of either type are only made if there is a positive remaining quota. - -Since each IC account gets a newly derived deposit address, these addresses are likely involved in deposits only, i.e., there should not be many `getTransaction` calls in vain in the common case. Whenever there is a successful call that results in a mint, both quotas are increased by 2 for the following reason: They are both bumped by 1 so that calls that result in a mint do not count against the quotas. The additional bump of each quota serves to ensure that an occasional call that does not result in a mint operation does not slowly drain the free quota. Note that RPC calls may sporadically fail for various reasons, such as network issues or RPC providers being unavailable. There is a ceiling of `MAX_GET_SIGNATURES_CALLS` and `MAX_RETRIEVED_TRANSACTIONS` for the `getSignaturesForAddress` quota and the `getTransaction` quota, respectively. - -The mechanism to replenish a depleted quota is discussed in the next section. - -The quota is meant as a deterrent but does not stop an attacker from triggering many `getTransaction` calls for different addresses. In order to limit the impact of such an attack, the constant `MAX_MONITORED_ADDRESSES` specifies the global limit on the number of addresses for which automatic deposits are allowed at any given time. This constant also limits the memory consumption. Furthermore, `update_balance` only takes a subaccount parameter, i.e., it is not possible to call the function for other principals, preventing drainage attacks against the quotas of other users. + User->>+Minter: watch_deposit_address_status(account) + Minter-->>-User: NotWatching { latest_deposit_id } + User->>+Minter: deposit_sol_status(deposit id) + Minter-->>-User: Minted { block index, amount } +``` -If a returned transaction contains one or more transfers to the user's deposit address, the sum of the transferred amounts, minus a fee, is minted in a *single* `icrc1_transfer` call to the ckSOL ledger. As mentioned before, no ckSOL is minted if the amount is below the minimum deposit amount. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends there: a transfer finalized after the last scan is not noticed, which the status reports by having no next scan. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. -The ckSOL minter must keep track of the covered range for each address for the sake of performance. Unfortunately, the `getSignaturesForAddress` endpoint does not allow a forward search starting from some transaction signature. Therefore, for each address that has ever been tracked, the covered range must be maintained. If an `update_balance` call is made and both quotas are positive, the following steps are executed. +The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. -```text -// Initially max_parsed = ⊥, gap_upper = gap_lower = genesis_sig -// limit = MAX_RETRIEVED_TRANSACTIONS -if gap_upper = gap_lower: - [s_1,...,s_n] := getSignaturesForAddress(limit, until=gap_upper) - if n < limit: // All signatures have been returned in the range - max := s_1, gap_upper := max, gap_lower := max - else: // There might be a gap between s_n and gap_lower - max := s_1, gap_upper := s_n -else: // The gap between gap_upper and gap_lower must be closed - [s_1,...,s_n] := getSignaturesForAddress(limit, before=gap_upper, until=gap_lower) - if n < limit: // All signatures have been returned in the range - gap_upper := max, gap_lower := max - else: // The gap has been reduced but may not be closed yet - gap_upper := s_n -process(s_1,...,s_n) -``` +Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window and fee, provided the window is still open, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. -The idea is to cover the whole range since genesis. Assuming that the latest transaction signature that has ever been returned in previous calls is `max`, if reading from the current block returns `MAX_RETRIEVED_TRANSACTIONS` transactions, it is unclear whether there are more transaction signatures between the oldest returned transaction and `max`. In subsequent calls, the algorithm above closes this gap before requesting transaction signatures that are more recent than `max`. +**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. -**Note**: It is possible that an address appears in many transactions that do not change the balance of the account. If there are more than `MAX_RETRIEVED_TRANSACTIONS` such transactions, the quota can be exhausted before a valid deposit is discovered. Deposits can always be processed using the manual flow, but the automatic flow may not work until all past transactions for this address have been processed. This risk is currently accepted. +An address whose balance is at or above the minimum deposit amount is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the fee and the window of the registration. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. -**Note**: There may be only one active timer per deposit address at any time across all endpoints that interact with the deposit address. +**Concurrency.** The scan and `deposit_sol` may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` checks the in-flight deposit again after `getBalance` and, if a scan queued the account in the meantime, returns the id of that deposit and charges only the RPC call. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. -Since tracking stops when a deposit is discovered, the question is how transaction signatures are treated that have been obtained via `getSignaturesForAddress` calls but have not been requested and processed. A related question is how `getTransaction` calls are scheduled if there are multiple tracked addresses with outstanding `getTransaction` calls. The ckSOL minter treats the problem of deciding which transactions to query and which transactions to request next separately. The mechanism outlined above decides which transactions are to be queried next. These are put into a map with the account as the key and the value being a queue of transaction signatures, corresponding to the transactions to be obtained and checked next. Whereas the mechanism above adds transaction signatures to the map, the ckSOL minter iterates over the monitored accounts in insertion order and collects signatures to check in a round-robin fashion. Once tracking of an account stops, the corresponding entry in the map is removed. +**Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the whole sweepable amount reaches the main account as for any sweep. The main account therefore holds the fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. -HTTPS outcalls are a scarce resource. Therefore, the number of in-flight requests should be bounded. Concretely, there should never be more than `MAX_IN_FLIGHT_HTTPS_OUTCALLS` outcalls being processed at the same time. Requests on a timer are only scheduled if there is capacity for outcalls. +**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 6.5T cycles (about 9 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight HTTPS outcalls to those of the other timers. -Proposed values for the parameters are provided in this list: +The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events of the automatic flow are the `QueuedDeposit` of a 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. -- `MAX_RETRIEVED_TRANSACTIONS`: This constant is the product of the number of transaction requests that can be packed into a JSON batch request and the maximum number of `getTransaction` calls that the ckSOL minter may make for a single `get_deposit_address` call. Batching is not yet available, so currently the only parameter is the number of transaction requests, which can initially be set to **50**. -- `MAX_MONITORED_ADDRESSES`: The number of monitored addresses must be upper bounded as well. Since HTTPS outcalls are protected by imposing an upper bound on the number of in-flight outcalls and an address does not take up too much space, a fairly large number of addresses could be monitored. The reason to keep this number on the small side is that a cycle drainage attack could be launched by having the ckSOL minter spend many cycles monitoring a large number of addresses. A compromise would be to set the parameter to a conservatively low value of **100** initially. -- `MAX_IN_FLIGHT_HTTPS_OUTCALLS`: The maximum number of in-flight HTTPS outcalls. The suggested parameter is **56**, as used in the [exchange rate canister](https://github.com/dfinity/exchange-rate-canister/blob/62f286325b6ce49233572a77479c8ca649f21e0a/src/xrc/src/rate_limiting.rs#L7). +**Compatibility.** The automatic flow is added to a ckSOL minter that is deployed on mainnet, so it only extends the interface and the state: two new endpoints; a new optional `automatic_deposit_fee` field in the init and upgrade arguments and in `get_minter_info`, together with the collected fees; an optional part of the `QueuedDeposit` event carrying the fee and window of an automatic deposit, absent for manual deposits; 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 @@ -239,7 +212,7 @@ sequenceDiagram Minter-->>-User: Minted { block index, amount } ``` -**Request.** The flow is triggered by calling `deposit_sol` with the user's account (principal ID and subaccount) as parameters. The principal may differ from the caller's, so that a frontend or another canister can pay for a user's deposit, but it must be neither the anonymous principal nor the ckSOL minter's own principal, whose deposit address is the main address. The endpoint requires cycles to be attached; the required amount is exposed as `deposit_sol_required_cycles` in `get_minter_info` and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path. +**Request.** The flow is triggered by calling `deposit_sol` with the user's account (principal ID and subaccount) as parameters. The principal may differ from the caller's, so that a frontend or another canister can pay for a user's deposit, but it must be neither the anonymous principal nor the ckSOL minter's own principal, whose deposit address is the main address. The endpoint requires cycles to be attached; the required amount is exposed as `deposit_sol_required_cycles` in `get_minter_info` and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path. The reservation does not cover the scan of the automatic flow, which may read the same address concurrently; after the balance read, the call therefore checks again whether a deposit is in flight for the account and, if a scan queued one in the meantime, returns its id and charges only the RPC call, as described in [Section 3.1.1](#311-automatic-flow). A scan queues a deposit through the same steps as this endpoint, from the balance check onwards. **Balance check.** The ckSOL minter calls the `getBalance` endpoint of the SOL RPC canister for the deposit address at the `finalized` commitment level. No `minContextSlot` is set: the SOL RPC canister returns no context slot, so the slot at which a previous sweep of the address was finalized cannot be learned, and the 3-out-of-4 provider consensus is part of the trust model. A balance that a lagging provider reports too high cannot cause an over-mint either, since ckSOL is minted only once the sweep transaction has been finalized and the amount credited is read from that transaction, and a sweep built on an inflated balance is not a valid transaction. The *sweepable amount* is the balance minus the **rent exemption threshold** (890,880 lamports for an account without data), or zero if the balance does not exceed the threshold. This threshold is deliberately left on the deposit address: Solana rejects any transaction that would leave an account with a nonzero balance below the threshold, so sweeping the whole balance would fail as soon as a small transfer arrived between the balance check and the execution of the sweep. Keeping the threshold on the address makes the sweep independent of concurrent transfers. The threshold is paid once per deposit address, since later sweeps find it already in place. @@ -251,7 +224,7 @@ If the balance is below the **minimum deposit amount** defined in [Section 3.3.3 The ckSOL minter then fetches the transaction with `getTransaction` and *settles* it against the plan the sweep was submitted with. Nothing is inferred from the outcome: the executed message must be exactly the planned one, every deposit address must have decreased by exactly its transfer, plus the fee reported in the metadata for the fee payer, and must end with at least the rent exemption threshold, since a transfer that arrived after the balance check legitimately leaves more, and the main account must have received exactly the planned amount, the sum of the sweepable amounts minus the assumed fee of `5000 * k` lamports. The reported fee may be lower than assumed, in which case the difference stays on the fee payer's address, but it may not be higher. If the `getTransaction` call fails or its result cannot be read, the deposits stay finalized and the fetch is retried on the next run of the finalization timer. If the result is readable but contradicts the plan, the ckSOL minter's model of the transaction is wrong, so nothing is minted: the deposits are *quarantined*, reusing the existing mechanism that prevents double minting, are reported on the dashboard together with the sweep signature, and the number of quarantined deposits is exposed as a metric. They are not processed further without a minter upgrade. -Once settled, the balance of the main account is increased by the amount received. The transaction fee is borne by the batch: for each deposit in the transaction, the ckSOL minter enqueues a *pending mint* of the sweepable amount minus the deposit's share of the fee, `ceil(fee / k)`, to the user's account. Rounding the share up guarantees that the total of the pending mints is never larger than the amount received on the main account, so the ckSOL supply is always covered. The amount to mint of each deposit is recorded in the event together with the amount received, so that replaying the event log restores the pending mints as they were enqueued rather than recomputing them. +Once settled, the balance of the main account is increased by the amount received. The transaction fee is borne by the batch: for each deposit in the transaction, the ckSOL minter enqueues a *pending mint* of the sweepable amount minus the deposit's share of the fee, `ceil(fee / k)`, to the user's account. Rounding the share up guarantees that the total of the pending mints is never larger than the amount received on the main account, so the ckSOL supply is always covered. For a deposit queued by the automatic flow, the pending mint is further reduced by the automatic deposit fee recorded with the deposit (see [Section 3.1.1](#311-automatic-flow)), and the fee is added to the collected fees; the constraint of [Section 3.3.4](#334-parameter-constraints) guarantees that the pending mint stays positive. The amount to mint of each deposit is recorded in the event together with the amount received, so that replaying the event log restores the pending mints as they were enqueued rather than recomputing them. Pending mints are processed on a timer: each one is a single `icrc1_transfer` call whose memo contains the sweep signature and the deposit id. The `created_at_time` of the transfer is fixed when the pending mint is enqueued and stored with it, so that every retry sends exactly the same arguments and the ckSOL ledger deduplicates it: a mint whose ledger call fails stays in the queue and is retried on the next run, and a `Duplicate` reply from the ledger is recorded as a successful mint with the block index it carries. Since the ledger only deduplicates transfers within its 24-hour window, a pending mint older than that is quarantined instead of retried. The sweep transaction itself is never resubmitted once it is finalized. @@ -262,7 +235,7 @@ Two failure cases exist before the transaction is finalized, and neither is retr 1. The transaction is finalized with an error. The funds are still on the deposit addresses, minus the fee paid by the fee payer. This should never happen with the invariants above, so it is reported as an error in the logs and in the metrics. 2. The transaction expires, i.e., its blockhash is no longer valid and it has no on-chain status. The sweep is dropped and *not* resubmitted with a new blockhash. Expiry is determined against the last valid block height persisted with the transaction, as described in [Section 3.2.5](#325-finalization-and-re-broadcast), never by counting slots; otherwise a transaction declared expired could still land, and the funds would reach the main account without being credited. Expiry only establishes that the transaction can no longer land, not that it never did, so the status check searches the available transaction history rather than the recent status cache, which holds about two minutes. Otherwise a sweep that landed while the checks had a longer gap, caused by the timer interval, by rounds skipped on RPC errors, or by an upgrade, would be reported as having no status and dropped after it had moved the funds. -In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address. For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, the retry is triggered and paid for by the caller. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. +In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for the remainder of its window, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, the retry is triggered and paid for by the caller. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. **Example.** The following example uses the parameters of [Section 3.3](#33-fees--minimum-swap-amounts), a price of 1 SOL = 100 USD, and 1T cycles = 1 XDR = 1.44 USD. Alice deposits 1 SOL and Bob deposits 0.05 SOL, each to their own deposit address, which was empty before, and both deposits are swept in the same transaction. @@ -274,7 +247,7 @@ In both cases the queued deposits are marked as *dropped*, and the number of dro | 4. Sweep transaction fee | 10,000 lamports paid on-chain | 0 | Fee payer (Alice) | Two signatures at 5,000 lamports each. Alice transfers 999,099,120 lamports, Bob transfers 49,109,120 lamports. Both deposit addresses end at 890,880 lamports; the main account receives 1,048,208,240 lamports. | | 5. Mint | 999,104,120 lamports (0.99910412 ckSOL) | 49,104,120 lamports (0.04910412 ckSOL) | | Sweepable amount minus the shortfall share of `ceil(10,000 / 2) = 5,000` lamports, the shortfall being the 1,048,218,240 lamports swept minus the 1,048,208,240 received. The total minted equals the amount received on the main account. | -For Alice, converting 1 SOL to ckSOL costs 895,880 lamports (0.0896 USD) on Solana, of which 890,880 lamports remain on her deposit address and are not charged again for her next deposit, plus 47.31B cycles (0.068 USD). Bob pays the same, so smaller deposits pay a proportionally larger share; a first deposit of exactly the minimum deposit amount of 0.02 SOL would be credited 19,104,120 lamports, i.e., 95.5% of the amount deposited. +For Alice, converting 1 SOL to ckSOL costs 895,880 lamports (0.0896 USD) on Solana, of which 890,880 lamports remain on her deposit address and are not charged again for her next deposit, plus 47.31B cycles (0.068 USD). Bob pays the same, so smaller deposits pay a proportionally larger share; a first deposit of exactly the minimum deposit amount of 0.02 SOL would be credited 19,104,120 lamports, i.e., 95.5% of the amount deposited. Had Alice used the automatic flow instead, she would have attached no cycles and been credited 997,104,120 lamports: the same sweepable amount and fee share, minus the automatic deposit fee of 2,000,000 lamports (0.2 USD) that stays on the main account. If Bob then withdraws everything, he first approves the ckSOL minter, which costs the ledger transfer fee of 500 lamports, and then withdraws the remaining 49,103,620 lamports. After the withdrawal fee of 1,000,000 lamports, the destination address receives 48,103,620 lamports (0.04810362 SOL). The whole round trip from 0.05 SOL to 0.04810362 SOL costs 1,896,380 lamports (0.19 USD), of which 890,880 lamports are still under the control of the ckSOL minter on Bob's deposit address, plus the cycles attached to `deposit_sol`. @@ -527,6 +500,7 @@ This cycles cost corresponds to 3.14 × 10⁻⁶ XDR = 4.5216 × 10⁻⁶ USD = - Manual deposit fee: the deposit's share of the sweep transaction fee, 5,000 lamports, plus the rent exemption threshold of 890,880 lamports left on the deposit address the first time it is swept - `deposit_sol` fee: 45B cycles, charged to the caller +- Automatic deposit fee: 0.002 SOL, deducted from the minted amount, on top of the manual deposit fee - Withdrawal fee: 0.001 SOL The ckSOL minter charges fees for the deposit and withdrawal of SOL, which imply lower bounds on the minimum deposit and retrieval amounts. @@ -558,8 +532,9 @@ Using the [cost estimation endpoints](https://dashboard.internetcomputer.org/can - `getBlock`: 2.34B cycles - `getBalance`: 2.31B cycles - `getSignatureStatuses`: 2.3B (1 sig.), 10.7B (256 sig., the largest batch) cycles +- `getMultipleAccounts`: about 2.3B (1 address) to 4.5B (100 addresses) cycles, assuming about 150 bytes of response per address and the same base cost as `getBalance`; to be measured once the SOL RPC canister offers the method -The **automatic deposit fee** is not derived here. The automated flow of [Section 3.1.1](#311-automated-flow-outdated) is not implemented, so there is no parameter to size, and its fee has to be derived again against the then-current RPC costs when the flow is revisited. +The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The earlier proposal of 0.01 SOL for a flow fetching every transaction of the address individually no longer applies: reading balances in batches is an order of magnitude cheaper. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. The **manual deposit fee** is not a parameter but the depositor's share of the shortfall between what the sweep transaction moved and what arrived on the main account, derived from the finalized transaction as described in [Section 3.1.2](#312-manual-flow). Under the current fee schedule that shortfall is the transaction fee of `5000 * k` lamports for `k` signatures, so the share is `ceil(5000 * k / k) = 5000` lamports. In addition, the depositor leaves the rent exemption threshold of 890,880 lamports on the deposit address the first time it is swept. The cycles consumed by the manual flow are charged to the caller of `deposit_sol`: the cost of the `getBalance` call, roughly 2.31B cycles, plus the **deposit_sol fee**. The latter must cover the threshold signature of 26.2B cycles and, for a sweep containing a single deposit, all the RPC calls of the sweep and finalization timers, i.e., `getSlot`, `getBlock`, `sendTransaction`, `getSignatureStatuses`, and `getTransaction`, for about 12.3B cycles when each of them succeeds on its first attempt. A deposit_sol fee of **45B cycles** covers that case. @@ -588,7 +563,8 @@ The following constraints regarding the parameters introduced in this section mu 1. **minimum deposit amount ≥ rent exemption threshold + 10 × Solana transfer fee**: The minimum deposit amount applies to the balance of the deposit address, which includes the rent exemption threshold left on it. The sweepable amount of a minimum deposit must still cover the fee of a full sweep transaction of 10 signatures, since the largest deposit of a batch pays the whole fee. 2. **minimum deposit amount ≥ 2 × rent exemption threshold + Solana transfer fee**: The very first sweep must leave the main account rent exempt, otherwise Solana rejects the transfer into it. A sweep of a single minimum deposit moves the balance minus the rent exemption threshold left on the deposit address and minus the fee of one signature, so that remainder must itself reach the threshold. This constraint subsumes the previous one at the current parameter values and is stated separately because the two express different requirements. -3. **deposit_sol required cycles ≥ cycles attached to the `getBalance` call (`GET_BALANCE_CYCLES`, 10B) + deposit_sol fee**: `deposit_sol` charges, after the RPC call, the attached RPC cycles minus what was refunded plus, on success, the deposit_sol fee. A caller attaching exactly the required amount must always be able to pay this, otherwise the charge traps in the reply callback. The trap undoes the call, so the caller pays nothing and the ckSOL minter pays for the `getBalance` call out of its own balance, and since the trap happens before the deposit is recorded, no deposit can ever be queued while the parameters are wrong. Since the constraint is also checked when the event log is replayed on upgrade, raising `GET_BALANCE_CYCLES` requires first confirming that the init and every upgrade event of each deployed minter still satisfy it. +3. **minimum deposit amount − rent exemption threshold − 10 × Solana transfer fee > automatic deposit fee**: An automatic deposit of exactly the minimum deposit amount must still mint a positive amount after its share of the fee of a full sweep transaction and the automatic deposit fee have been deducted. With the current values, the left-hand side is 19,059,120 lamports, leaving ample room for the fee of 2,000,000 lamports. +4. **deposit_sol required cycles ≥ cycles attached to the `getBalance` call (`GET_BALANCE_CYCLES`, 10B) + deposit_sol fee**: `deposit_sol` charges, after the RPC call, the attached RPC cycles minus what was refunded plus, on success, the deposit_sol fee. A caller attaching exactly the required amount must always be able to pay this, otherwise the charge traps in the reply callback. The trap undoes the call, so the caller pays nothing and the ckSOL minter pays for the `getBalance` call out of its own balance, and since the trap happens before the deposit is recorded, no deposit can ever be queued while the parameters are wrong. Since the constraint is also checked when the event log is replayed on upgrade, raising `GET_BALANCE_CYCLES` requires first confirming that the init and every upgrade event of each deployed minter still satisfy it. ### 3.4. OFAC Checks @@ -644,6 +620,8 @@ 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, and it is skipped when the watchlist is empty. 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` or a reinstallation loses the watchlist, which users rebuild by registering again for free; deposits already detected are unaffected, since `QueuedDeposit` is recorded at detection. + ### 3.6. API The ckSOL ledger exposes all endpoints of a standard ICRC ledger, complying with the ICRC-1, ICRC-2, and ICRC-3 standards. @@ -653,9 +631,11 @@ The ckSOL minter exposes the following endpoints: 1. `get_deposit_address(opt principal, opt subaccount)`: Returns the Solana address derived from the provided principal ID and subaccount. If no principal ID is provided, the principal ID of the caller is used. 2. `deposit_sol(opt principal, opt subaccount)`: Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, or of the sweep already in flight for the account. Otherwise, an error is returned. 3. `deposit_sol_status(deposit_id)`: Returns the status (`Queued`, `Swept`, `Finalized`, `Minted`, `Dropped`, `Quarantined`) of the deposit with the given id, or `NotFound` if the id is unknown. An explicit variant rather than an optional result lets a client that predates a later variant distinguish an unknown id from a status it cannot decode, as `withdraw_sol_status` does. -4. `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. -5. `withdraw_sol_status(block_index)`: Returns the withdrawal status (`NotFound`, `Pending`, `TxSent`, `TxFinalized`) for the withdrawal identified by the given block index. -6. `get_minter_info`: Returns information about the ckSOL minter, specifically the various fees, the minimum deposit and withdrawal amounts, the current balance of the ckSOL minter, its main address, and the addresses of its durable nonce accounts. -7. `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. +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. Returns the deposit address, the minimum deposit amount, the fee that will be deducted, the end of the window and the time of the next scan. Otherwise, an error is returned: `AlreadyDepositing` with the id of the deposit in flight, `Quarantined` with the id of the quarantined deposit, `TooManyWatchedAddresses`, or `TemporarilyUnavailable`. The owner is always the caller. +5. `watch_deposit_address_status(opt principal, opt subaccount)`: Returns `Watching`, with the end of the window, the number of scans and the times of the last and next scans, if the account is on the watchlist, and `NotWatching` otherwise, together with the id of the latest deposit queued for the account, if any. +6. `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. +7. `withdraw_sol_status(block_index)`: Returns the withdrawal status (`NotFound`, `Pending`, `TxSent`, `TxFinalized`) for the withdrawal identified by the given block index. +8. `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. +9. `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). From 2decad62d205b5bfe87f18abda298aa3845c864f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 09:43:43 +0000 Subject: [PATCH 002/146] docs(design): address review remarks on the automatic flow State that a re-armed account is reported as watching again, that the main account receives the sweepable amount minus the fee share, correct the worst-case cost arithmetic and state its success assumption, and distinguish a failed upgrade from a reinstallation. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 8a51a6406..26a296196 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -93,7 +93,7 @@ Compared to the manual flow, the automatic flow differs in who triggers the bala The call does not contact the SOL RPC canister and requires no cycles. It is rejected when the account already has a deposit in flight (`AlreadyDepositing`, carrying the deposit id, since the sweep flow already owns the address), when the latest deposit of the account is quarantined (`Quarantined`, as for `deposit_sol`), when the watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. Registering an account that is already watched is idempotent: the call returns the stored registration and does not extend its window. A registration is not recorded in the event log, see [Section 3.5](#35-events). -The query `watch_deposit_address_status` reports, for a given account, whether it is being watched, with the end of the window, the number of scans so far and the times of the last and next scans, or not, together with the id of the latest deposit queued for the account, if any, so that a client coming back hours after the detection can follow the deposit with `deposit_sol_status`. `NotWatching` covers accounts that were never registered as well as expired registrations and accounts whose deposit was detected, minted, dropped or quarantined. +The query `watch_deposit_address_status` reports, for a given account, whether it is being watched, with the end of the window, the number of scans so far and the times of the last and next scans, or not, together with the id of the latest deposit queued for the account, if any, so that a client coming back hours after the detection can follow the deposit with `deposit_sol_status`. `NotWatching` covers accounts that were never registered as well as expired registrations and accounts whose deposit was detected, minted or quarantined. 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, and as `NotWatching` only once its window has passed. The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. @@ -152,9 +152,9 @@ An address whose balance is at or above the minimum deposit amount is queued exa **Concurrency.** The scan and `deposit_sol` may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` checks the in-flight deposit again after `getBalance` and, if a scan queued the account in the meantime, returns the id of that deposit and charges only the RPC call. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. -**Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the whole sweepable amount reaches the main account as for any sweep. The main account therefore holds the fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. +**Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. -**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 6.5T cycles (about 9 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight HTTPS outcalls to those of the other timers. +**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This bound assumes that the calls succeed: while the SOL RPC canister fails persistently, the entries keep their progress and every tick may retry up to `MAX_CONCURRENT_RPC_CALLS` calls, i.e. up to 14,400 attempts per day. That situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight HTTPS outcalls to those of the other timers. The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events of the automatic flow are the `QueuedDeposit` of a 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. @@ -620,7 +620,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, and it is skipped when the watchlist is empty. 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` or a reinstallation loses the watchlist, which users rebuild by registering again for free; deposits already detected are unaffected, since `QueuedDeposit` is recorded at detection. +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, and it is skipped when the watchlist is empty. 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. ### 3.6. API From d6f97017d8c922d054ca6b640022741b0d7fb6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 09:49:58 +0000 Subject: [PATCH 003/146] docs(design): show the automatic flow diagram first and fix its rendering Move the sequence diagram of the automatic flow ahead of its explanation, remove the semicolon that broke the mermaid parser on GitHub, and define the deposit id where it is first used in the section. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 26a296196..841971657 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -89,14 +89,6 @@ The automatic flow lets a user obtain ckSOL without calling the ckSOL minter aft Compared to the manual flow, the automatic flow differs in who triggers the balance read (a timer instead of the caller), in what the caller pays (nothing at call time, and a fixed fee in SOL deducted from the minted amount instead of cycles) and in the detection latency (a schedule of scans spread over about a day instead of an immediate read). -**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the minimum deposit amount, the fee that will be deducted, the end of the window and the time of the next scan. The fee is recorded with the registration, so a fee change by upgrade while the address is watched does not alter what the user was told. - -The call does not contact the SOL RPC canister and requires no cycles. It is rejected when the account already has a deposit in flight (`AlreadyDepositing`, carrying the deposit id, since the sweep flow already owns the address), when the latest deposit of the account is quarantined (`Quarantined`, as for `deposit_sol`), when the watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. Registering an account that is already watched is idempotent: the call returns the stored registration and does not extend its window. A registration is not recorded in the event log, see [Section 3.5](#35-events). - -The query `watch_deposit_address_status` reports, for a given account, whether it is being watched, with the end of the window, the number of scans so far and the times of the last and next scans, or not, together with the id of the latest deposit queued for the account, if any, so that a client coming back hours after the detection can follow the deposit with `deposit_sol_status`. `NotWatching` covers accounts that were never registered as well as expired registrations and accounts whose deposit was detected, minted or quarantined. 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, and as `NotWatching` only once its window has passed. - -The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. - The flow is depicted in the following figure. ```mermaid @@ -117,7 +109,7 @@ sequenceDiagram RPC->>+Solana: getMultipleAccounts([deposit_address, ...]) Solana-->>-RPC: [balance, ...] RPC-->>-Minter: [balance, ...] - Note over Minter: balance < minimum deposit amount: advance the schedule + Note over Minter: balance below the minimum deposit amount, advance the schedule deactivate Minter User->>+Solana: transfer(deposit_address, amount) @@ -129,10 +121,10 @@ sequenceDiagram RPC->>+Solana: getMultipleAccounts([deposit_address, ...]) Solana-->>-RPC: [balance, ...] RPC-->>-Minter: [balance, ...] - Note over Minter: balance ≥ minimum deposit amount: queue the deposit
with sweepable := balance - rent exemption threshold
and remove the account from the watchlist + Note over Minter: balance at or above the minimum deposit amount, queue the deposit
with sweepable := balance - rent exemption threshold
and remove the account from the watchlist deactivate Minter - Note over Solana,Ledger: Sweep, finalization and mint as in Section 3.1.2;
the mint is sweepable - fee share - automatic deposit fee + Note over Solana,Ledger: Sweep, finalization and mint as in Section 3.1.2
the mint is sweepable - fee share - automatic deposit fee User->>+Minter: watch_deposit_address_status(account) Minter-->>-User: NotWatching { latest_deposit_id } @@ -140,6 +132,14 @@ sequenceDiagram Minter-->>-User: Minted { block index, amount } ``` +**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the minimum deposit amount, the fee that will be deducted, the end of the window and the time of the next scan. The fee is recorded with the registration, so a fee change by upgrade while the address is watched does not alter what the user was told. + +The call does not contact the SOL RPC canister and requires no cycles. It is rejected when the account already has a deposit in flight (`AlreadyDepositing`, carrying the *deposit id* that identifies a sweep of the account for its whole life, as defined in [Section 3.1.2](#312-manual-flow), since the sweep flow already owns the address), when the latest deposit of the account is quarantined (`Quarantined`, as for `deposit_sol`), when the watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. Registering an account that is already watched is idempotent: the call returns the stored registration and does not extend its window. A registration is not recorded in the event log, see [Section 3.5](#35-events). + +The query `watch_deposit_address_status` reports, for a given account, whether it is being watched, with the end of the window, the number of scans so far and the times of the last and next scans, or not, together with the id of the latest deposit queued for the account, if any, so that a client coming back hours after the detection can follow the deposit with `deposit_sol_status`. `NotWatching` covers accounts that were never registered as well as expired registrations and accounts whose deposit was detected, minted or quarantined. 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, and as `NotWatching` only once its window has passed. + +The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. + **Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends there: a transfer finalized after the last scan is not noticed, which the status reports by having no next scan. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. From 3203fc08f529c3ff8555edf3a83900c6bdb48199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 09:51:13 +0000 Subject: [PATCH 004/146] docs(design): non-overlapping scan ticks and the exact fee share in the constraint State that scan ticks are guarded like the other timers so that they never overlap, and base the automatic deposit fee constraint on the single-signature fee share that a pending mint actually bears. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 841971657..b71166b02 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -140,7 +140,7 @@ The query `watch_deposit_address_status` reports, for a given account, whether i The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends there: a transfer finalized after the last scan is not noticed, which the status reports by having no next scan. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends there: a transfer finalized after the last scan is not noticed, which the status reports by having no next scan. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. @@ -563,7 +563,7 @@ The following constraints regarding the parameters introduced in this section mu 1. **minimum deposit amount ≥ rent exemption threshold + 10 × Solana transfer fee**: The minimum deposit amount applies to the balance of the deposit address, which includes the rent exemption threshold left on it. The sweepable amount of a minimum deposit must still cover the fee of a full sweep transaction of 10 signatures, since the largest deposit of a batch pays the whole fee. 2. **minimum deposit amount ≥ 2 × rent exemption threshold + Solana transfer fee**: The very first sweep must leave the main account rent exempt, otherwise Solana rejects the transfer into it. A sweep of a single minimum deposit moves the balance minus the rent exemption threshold left on the deposit address and minus the fee of one signature, so that remainder must itself reach the threshold. This constraint subsumes the previous one at the current parameter values and is stated separately because the two express different requirements. -3. **minimum deposit amount − rent exemption threshold − 10 × Solana transfer fee > automatic deposit fee**: An automatic deposit of exactly the minimum deposit amount must still mint a positive amount after its share of the fee of a full sweep transaction and the automatic deposit fee have been deducted. With the current values, the left-hand side is 19,059,120 lamports, leaving ample room for the fee of 2,000,000 lamports. +3. **minimum deposit amount − rent exemption threshold − Solana transfer fee > automatic deposit fee**: An automatic deposit of exactly the minimum deposit amount must still mint a positive amount after its share of the sweep transaction fee and the automatic deposit fee have been deducted. The share is `ceil(5000 * k / k)`, i.e. the fee of one signature whatever the batch size, while the ability of the fee payer to fund the fee of a full batch is already guaranteed by the first constraint. With the current values, the left-hand side is 19,104,120 lamports, leaving ample room for the fee of 2,000,000 lamports. 4. **deposit_sol required cycles ≥ cycles attached to the `getBalance` call (`GET_BALANCE_CYCLES`, 10B) + deposit_sol fee**: `deposit_sol` charges, after the RPC call, the attached RPC cycles minus what was refunded plus, on success, the deposit_sol fee. A caller attaching exactly the required amount must always be able to pay this, otherwise the charge traps in the reply callback. The trap undoes the call, so the caller pays nothing and the ckSOL minter pays for the `getBalance` call out of its own balance, and since the trap happens before the deposit is recorded, no deposit can ever be queued while the parameters are wrong. Since the constraint is also checked when the event log is replayed on upgrade, raising `GET_BALANCE_CYCLES` requires first confirming that the init and every upgrade event of each deployed minter still satisfy it. ### 3.4. OFAC Checks From 6c93f3a941a025d6e240c195cceb692c92e8a05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 09:56:03 +0000 Subject: [PATCH 005/146] docs(design): report the watch status from watch_deposit_address itself Drop the separate status query: calling the registration endpoint again returns the stored registration or the id of the deposit in flight, as deposit_eth does in the ckETH minter. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index b71166b02..b80e41947 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -101,7 +101,7 @@ sequenceDiagram User->>+Minter: watch_deposit_address(subaccount) Note over Minter: add the account to the watchlist (no event) - Minter-->>-User: Ok(deposit_address, fee, valid_until, next_scan_at) + Minter-->>-User: Ok(deposit_address, fee, Watching { valid_until, next_scan_at }) Note over Minter: ⏱️ Scan timer activate Minter @@ -126,17 +126,17 @@ sequenceDiagram Note over Solana,Ledger: Sweep, finalization and mint as in Section 3.1.2
the mint is sweepable - fee share - automatic deposit fee - User->>+Minter: watch_deposit_address_status(account) - Minter-->>-User: NotWatching { latest_deposit_id } + User->>+Minter: watch_deposit_address(subaccount) + Minter-->>-User: Ok(deposit_address, fee, Detected { deposit id }) User->>+Minter: deposit_sol_status(deposit id) Minter-->>-User: Minted { block index, amount } ``` -**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the minimum deposit amount, the fee that will be deducted, the end of the window and the time of the next scan. The fee is recorded with the registration, so a fee change by upgrade while the address is watched does not alter what the user was told. +**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the minimum deposit amount, the fee that will be deducted, the *status* of the account and the id of the latest deposit queued for the account, if any. The fee is recorded with the registration, so a fee change by upgrade while the address is watched does not alter what the user was told. -The call does not contact the SOL RPC canister and requires no cycles. It is rejected when the account already has a deposit in flight (`AlreadyDepositing`, carrying the *deposit id* that identifies a sweep of the account for its whole life, as defined in [Section 3.1.2](#312-manual-flow), since the sweep flow already owns the address), when the latest deposit of the account is quarantined (`Quarantined`, as for `deposit_sol`), when the watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. Registering an account that is already watched is idempotent: the call returns the stored registration and does not extend its window. A registration is not recorded in the event log, see [Section 3.5](#35-events). +The status is `Watching`, with the end of the window, the number of scans so far and the times of the last and next scans, 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 query `watch_deposit_address_status` reports, for a given account, whether it is being watched, with the end of the window, the number of scans so far and the times of the last and next scans, or not, together with the id of the latest deposit queued for the account, if any, so that a client coming back hours after the detection can follow the deposit with `deposit_sol_status`. `NotWatching` covers accounts that were never registered as well as expired registrations and accounts whose deposit was detected, minted or quarantined. 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, and as `NotWatching` only once its window has passed. +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 watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. A registration is not recorded in the event log, see [Section 3.5](#35-events). The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. @@ -158,7 +158,7 @@ An address whose balance is at or above the minimum deposit amount is queued exa The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events of the automatic flow are the `QueuedDeposit` of a 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. -**Compatibility.** The automatic flow is added to a ckSOL minter that is deployed on mainnet, so it only extends the interface and the state: two new endpoints; a new optional `automatic_deposit_fee` field in the init and upgrade arguments and in `get_minter_info`, together with the collected fees; an optional part of the `QueuedDeposit` event carrying the fee and window of an automatic deposit, absent for manual deposits; 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; an optional part of the `QueuedDeposit` event carrying the fee and window of an automatic deposit, absent for manual deposits; 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 @@ -631,11 +631,10 @@ The ckSOL minter exposes the following endpoints: 1. `get_deposit_address(opt principal, opt subaccount)`: Returns the Solana address derived from the provided principal ID and subaccount. If no principal ID is provided, the principal ID of the caller is used. 2. `deposit_sol(opt principal, opt subaccount)`: Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, or of the sweep already in flight for the account. Otherwise, an error is returned. 3. `deposit_sol_status(deposit_id)`: Returns the status (`Queued`, `Swept`, `Finalized`, `Minted`, `Dropped`, `Quarantined`) of the deposit with the given id, or `NotFound` if the id is unknown. An explicit variant rather than an optional result lets a client that predates a later variant distinguish an unknown id from a status it cannot decode, as `withdraw_sol_status` does. -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. Returns the deposit address, the minimum deposit amount, the fee that will be deducted, the end of the window and the time of the next scan. Otherwise, an error is returned: `AlreadyDepositing` with the id of the deposit in flight, `Quarantined` with the id of the quarantined deposit, `TooManyWatchedAddresses`, or `TemporarilyUnavailable`. The owner is always the caller. -5. `watch_deposit_address_status(opt principal, opt subaccount)`: Returns `Watching`, with the end of the window, the number of scans and the times of the last and next scans, if the account is on the watchlist, and `NotWatching` otherwise, together with the id of the latest deposit queued for the account, if any. -6. `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. -7. `withdraw_sol_status(block_index)`: Returns the withdrawal status (`NotFound`, `Pending`, `TxSent`, `TxFinalized`) for the withdrawal identified by the given block index. -8. `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. -9. `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. +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 minimum deposit amount, the fee that will be deducted, the status of the account (`Watching` with the end of the window, the number of scans and the times of the last and next scans, 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`, 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. +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). From c05a01511e2e87dbbef72d058d8881f2496f01bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 09:57:03 +0000 Subject: [PATCH 006/146] docs(design): clarify retries, single-round scans and drop the unused signature lookup Spell out that scan ticks never overlap, that the fee derivation is a single-attempt estimate with re-arm costs absorbed by the minter, and that only manual deposits are retried by the caller. Remove getSignaturesForAddress from the RPC inventory and cost tables, since the minter no longer uses it. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index b80e41947..485189cdb 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -69,7 +69,6 @@ The ckSOL minter interacts with the Solana blockchain via the [SOL RPC canister] - [getBalance](https://solana.com/docs/rpc/http/getbalance): Returns the balance of the given address. This function is used by `deposit_sol` to read the balance of a deposit address at the `finalized` commitment level and determine the sweepable amount. - [getBlock](https://solana.com/docs/rpc/http/getblock): Returns the block for the given slot. This function is used to get a recent block hash, which is contained in the response. Note that `transactionDetails` is set to `null` in the request. As a result, signatures and transactions are not returned. - [getMultipleAccounts](https://solana.com/docs/rpc/http/getmultipleaccounts): Returns the account information, in particular the balance, of up to 100 addresses in a single call. This function is used by the automatic deposit flow of [Section 3.1.1](#311-automatic-flow) to read the balances of the watched deposit addresses at the `finalized` commitment level. It is not yet offered as a typed method by the SOL RPC canister; adding it, with the response context stripped so that the providers' responses can reach consensus, is a prerequisite of that flow. -- [getSignaturesForAddress](https://solana.com/docs/rpc/http/getsignaturesforaddress): Returns the signatures for a given address. This function is used to learn about new transactions (in Solana, signatures are used to identify transactions, as the first signature in a transaction is considered the transaction ID). - [getSignatureStatuses](https://solana.com/docs/rpc/http/getsignaturestatuses): Returns the status of each transaction specified through its identifier, i.e., the first signature in the transaction. This function is used to learn whether a sweep transaction has been finalized or has expired, as described in [Section 3.2.5](#325-finalization-and-re-broadcast). Withdrawal transactions are not checked with it. - [getSlot](https://solana.com/docs/rpc/http/getslot): Returns the current slot. Since the slot number changes rapidly, the SOL RPC canister merely obtains a rounded and therefore slightly outdated slot number. The slot number is required to obtain a recent block hash using `getBlock`, whose block height is persisted with the transaction and later compared against the current block height to check if an unconfirmed transaction has expired. - [getTransaction](https://solana.com/docs/rpc/http/gettransaction): Returns the whole transaction for the given signature. This function is used to read the outcome of a finalized sweep transaction and of a landed withdrawal transaction. @@ -150,7 +149,7 @@ Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` c An address whose balance is at or above the minimum deposit amount is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the fee and the window of the registration. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. -**Concurrency.** The scan and `deposit_sol` may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` checks the in-flight deposit again after `getBalance` and, if a scan queued the account in the meantime, returns the id of that deposit and charges only the RPC call. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. +**Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` checks the in-flight deposit again after `getBalance` and, if a scan queued the account in the meantime, returns the id of that deposit and charges only the RPC call. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. @@ -230,12 +229,12 @@ Pending mints are processed on a timer: each one is a single `icrc1_transfer` ca A user can follow the progress with `deposit_sol_status`, which takes a deposit id and reports its status: `Queued` with the sweepable amount, `Swept` and `Finalized` with the sweep signature, `Minted` with the ledger block index and the minted amount, and `Dropped` and `Quarantined` with the sweep signature when there is one. Since every sweep has its own id, a `Minted` status stays observable after the account has been swept again; the mint itself is also visible on the ckSOL ledger, whose memo links it to the sweep signature and the deposit id. -Two failure cases exist before the transaction is finalized, and neither is retried by the ckSOL minter: +Two failure cases exist before the transaction is finalized, and in neither is the transaction resubmitted by the ckSOL minter: 1. The transaction is finalized with an error. The funds are still on the deposit addresses, minus the fee paid by the fee payer. This should never happen with the invariants above, so it is reported as an error in the logs and in the metrics. 2. The transaction expires, i.e., its blockhash is no longer valid and it has no on-chain status. The sweep is dropped and *not* resubmitted with a new blockhash. Expiry is determined against the last valid block height persisted with the transaction, as described in [Section 3.2.5](#325-finalization-and-re-broadcast), never by counting slots; otherwise a transaction declared expired could still land, and the funds would reach the main account without being credited. Expiry only establishes that the transaction can no longer land, not that it never did, so the status check searches the available transaction history rather than the recent status cache, which holds about two minutes. Otherwise a sweep that landed while the checks had a longer gap, caused by the timer interval, by rounds skipped on RPC errors, or by an upgrade, would be reported as having no status and dropped after it had moved the funds. -In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for the remainder of its window, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, the retry is triggered and paid for by the caller. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. +In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for the remainder of its window, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, for a manual deposit the retry is triggered and paid for by the caller, while for an automatic deposit the re-arm leads to a new balance read and, if the funds are still there, a new sweep without another call, at the ckSOL minter's expense. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. **Example.** The following example uses the parameters of [Section 3.3](#33-fees--minimum-swap-amounts), a price of 1 SOL = 100 USD, and 1T cycles = 1 XDR = 1.44 USD. Alice deposits 1 SOL and Bob deposits 0.05 SOL, each to their own deposit address, which was empty before, and both deposits are swept in the same transaction. @@ -511,7 +510,6 @@ The response size affects the cost of HTTPS outcalls. The relevant RPC endpoints The maximum response sizes for each endpoint are listed here: -- `getSignaturesForAddress`: 11,793 bytes (with a limit of 50 signatures) - `getTransaction`: 31,985 bytes - `getSignatureStatuses`: 275 (1 sig.), 1784 (10 sig.), 14868 (100 sig.) bytes - `getBlock`: 232 bytes @@ -525,7 +523,6 @@ The `getTransaction` default is smaller than the 31,985 bytes measured above, wh Using the [cost estimation endpoints](https://dashboard.internetcomputer.org/canister/tghme-zyaaa-aaaar-qarca-cai) of the SOL RPC canister, the following estimates are computed for the cycles cost of each required endpoint, using the response sizes above on mainnet for commitment level `finalized` and requiring 3 out of 4 responses to agree. The ckSOL minter attaches more than these amounts, leaving a margin for provider or price changes and for the SOL RPC canister doubling its response size estimate after an oversized response; the unused part is refunded: - `getAccountInfo`: 2.34B cycles -- `getSignaturesForAddress`: 4.3B cycles - `getTransaction`: 3.2B cycles - `sendTransaction`: 2.2B cycles - `getSlot`: 2.29B cycles @@ -534,7 +531,7 @@ Using the [cost estimation endpoints](https://dashboard.internetcomputer.org/can - `getSignatureStatuses`: 2.3B (1 sig.), 10.7B (256 sig., the largest batch) cycles - `getMultipleAccounts`: about 2.3B (1 address) to 4.5B (100 addresses) cycles, assuming about 150 bytes of response per address and the same base cost as `getBalance`; to be measured once the SOL RPC canister offers the method -The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The earlier proposal of 0.01 SOL for a flow fetching every transaction of the address individually no longer applies: reading balances in batches is an order of magnitude cheaper. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. +The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt: a sweep that fails or expires and is re-armed, as described in [Section 3.1.1](#311-automatic-flow), costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs that cost like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The earlier proposal of 0.01 SOL for a flow fetching every transaction of the address individually no longer applies: reading balances in batches is an order of magnitude cheaper. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. The **manual deposit fee** is not a parameter but the depositor's share of the shortfall between what the sweep transaction moved and what arrived on the main account, derived from the finalized transaction as described in [Section 3.1.2](#312-manual-flow). Under the current fee schedule that shortfall is the transaction fee of `5000 * k` lamports for `k` signatures, so the share is `ceil(5000 * k / k) = 5000` lamports. In addition, the depositor leaves the rent exemption threshold of 890,880 lamports on the deposit address the first time it is swept. The cycles consumed by the manual flow are charged to the caller of `deposit_sol`: the cost of the `getBalance` call, roughly 2.31B cycles, plus the **deposit_sol fee**. The latter must cover the threshold signature of 26.2B cycles and, for a sweep containing a single deposit, all the RPC calls of the sweep and finalization timers, i.e., `getSlot`, `getBlock`, `sendTransaction`, `getSignatureStatuses`, and `getTransaction`, for about 12.3B cycles when each of them succeeds on its first attempt. A deposit_sol fee of **45B cycles** covers that case. From 257b3592e965952ec818d1725cc80dd47cbf5231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 10:00:23 +0000 Subject: [PATCH 007/146] docs(design): define the scan schedule of a re-armed deposit address A re-armed entry restarts its schedule from the first gap, bounded by the remaining window, and a re-arm only ever follows a detection. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 485189cdb..92850abe4 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -143,7 +143,7 @@ The manual flow remains available while an address is watched: a `deposit_sol` c The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. -Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window and fee, provided the window is still open, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. +Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window and fee, provided the window is still open, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-armed entry restarts the schedule from its first gap, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. **Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. From 4efb77defa619de81ec22a763ffa16390648b6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 10:04:44 +0000 Subject: [PATCH 008/146] docs(design): always snapshot the watchlist and name both queueing paths Record the watchlist snapshot on every upgrade so that an older one cannot be replayed after the watchlist emptied, and let the transaction submission section account for deposits queued by the balance scan. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 92850abe4..36a5124ff 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -288,7 +288,7 @@ Sweep transactions are created on a timer. Since a sweep must contain a recent b The block hash of a sweep is read at the `confirmed` commitment level, and the preflight simulation that the SOL RPC canister runs before broadcasting uses the same level. The 150-block validity window of a block hash lasts roughly 90 seconds on mainnet and about 36 seconds on Devnet, and a block hash read at `finalized` has already spent part of it by the time it is obtained, since finalization lags the chain tip. A sweep built on such a hash can therefore expire before it reaches the chain: on Devnet this dropped 18 of 50 deposits in a staging stress test, two sweeps being rejected by every provider with `BlockhashNotFound`. Reading the block hash at `confirmed` gives the sweep close to the full window. The expiry check of [Section 3.2.5](#325-finalization-and-re-broadcast) keeps reading the current block height at `finalized`, so a sweep is only ever declared expired against a height that can no longer be rolled back. -The sweep timer runs every minute, at the same frequency as withdrawal processing. A single SOL transfer requires roughly 70-90 bytes in a transaction. Since the maximum transaction size is 1232 bytes, up to approximately **10 transfers** per transaction are possible. Whenever the timer executes and there is *any* queued deposit, i.e., a deposit queued by `deposit_sol` as described in [Section 3.1.2](#312-manual-flow), the queued deposits are taken in batches of at most 10 and one transaction is submitted per batch. A round submits at most `MAX_CONCURRENT_RPC_CALLS` such transactions, i.e., at most 100 deposits, and reschedules itself immediately when deposits are left over, so that all queued deposits are swept across consecutive rounds. Each transaction is sent in its own HTTPS outcall; batching several transactions into one outcall is a deviation from this design that has not been implemented. A deposit address that has not been queued by `deposit_sol` is never swept, since no record would exist to credit its owner. +The sweep timer runs every minute, at the same frequency as withdrawal processing. A single SOL transfer requires roughly 70-90 bytes in a transaction. Since the maximum transaction size is 1232 bytes, up to approximately **10 transfers** per transaction are possible. Whenever the timer executes and there is *any* queued deposit, i.e., a deposit queued by `deposit_sol` as described in [Section 3.1.2](#312-manual-flow) or by a balance scan of the automatic flow as described in [Section 3.1.1](#311-automatic-flow), the queued deposits are taken in batches of at most 10 and one transaction is submitted per batch. A round submits at most `MAX_CONCURRENT_RPC_CALLS` such transactions, i.e., at most 100 deposits, and reschedules itself immediately when deposits are left over, so that all queued deposits are swept across consecutive rounds. Each transaction is sent in its own HTTPS outcall; batching several transactions into one outcall is a deviation from this design that has not been implemented. A deposit address that has not been queued by one of these two paths is never swept, since no record would exist to credit its owner. A concrete mainnet example of a transaction that makes two transfers to the same destination address can be viewed [here](https://solscan.io/tx/5CzNKyQsSfAtCQAxnj6acuhQZEh5J4B8aZzV1ArvvM8vodUr4vcQu7Co8wzbHrSYMW4h8ikg67bqCZSU4AHiL1D9). @@ -617,7 +617,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, and it is skipped when the watchlist is empty. 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`, `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. ### 3.6. API From f8a8a291086a66f9443e45e82a56f277b9aeaf9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 10:07:32 +0000 Subject: [PATCH 009/146] docs(design): say what happens after the last scan of a watched address The window ends with the last scan, and the next registration call starts a new window rather than reporting a registration without a next scan. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 36a5124ff..ee47f815e 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -139,7 +139,7 @@ The call does not contact the SOL RPC canister and requires no cycles. It is rej The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends there: a transfer finalized after the last scan is not noticed, which the status reports by having no next scan. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends with that scan: a transfer finalized after it is not noticed until the account is registered again, which the next `watch_deposit_address` call does, starting a new window and reporting its first scan time. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. From 9cf5264979b0b47849c615bfb98e031272183a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 11:50:10 +0000 Subject: [PATCH 010/146] docs(design): apply current parameters at detection and compare deposit ids Follow the ckETH minter: a registration freezes neither the minimum deposit amount nor the automatic deposit fee, both are quoted and the values in force at detection apply, the fee being recorded with the queued deposit. deposit_sol compares the latest deposit id of the account before and after its balance read, and a registration is allowed while a deposit_sol call is in progress. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index ee47f815e..071c563b8 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -131,25 +131,25 @@ sequenceDiagram Minter-->>-User: Minted { block index, amount } ``` -**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the minimum deposit amount, the fee that will be deducted, the *status* of the account and the id of the latest deposit queued for the account, if any. The fee is recorded with the registration, so a fee change by upgrade while the address is watched does not alter what the user was told. +**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the current minimum deposit amount and automatic deposit fee, the *status* of the account and the id of the latest deposit queued for the account, if any. As in the ckETH minter, nothing is frozen at registration: the two amounts are quotes of the parameters in force at the time of the call, whatever the status, and the parameters in force when a scan detects the deposit are the ones that apply, as described under detection below. An upgrade changing them during the window therefore affects every registration that has not been detected yet, and none that has. The status is `Watching`, with the end of the window, the number of scans so far and the times of the last and next scans, 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 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 watchlist is full of live entries (`TooManyWatchedAddresses`), and with `TemporarilyUnavailable` when the minter's public key is not yet known, when the automatic deposit fee has not been configured, or when a `deposit_sol` call for the same account is in progress. A registration is not recorded in the event log, see [Section 3.5](#35-events). +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 watchlist is full of live entries (`TooManyWatchedAddresses`), 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). The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the fee recorded at registration, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends with that scan: a transfer finalized after it is not noticed until the account is registered again, which the next `watch_deposit_address` call does, starting a new window and reporting its first scan time. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends with that scan: a transfer finalized after it is not noticed until the account is registered again, which the next `watch_deposit_address` call does, starting a new window and reporting its first scan time. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. -Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window and fee, provided the window is still open, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-armed entry restarts the schedule from its first gap, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. +Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window, provided the window is still open, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-armed entry restarts the schedule from its first gap, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. **Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. -An address whose balance is at or above the minimum deposit amount is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the fee and the window of the registration. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. +An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment and the end of the registration's window. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it, and replaying the event log recomputes the same mint; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The end of the window is carried for the re-arm described above. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. -**Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` checks the in-flight deposit again after `getBalance` and, if a scan queued the account in the meantime, returns the id of that deposit and charges only the RPC call. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. +**Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` records the id of the latest deposit of the account before its `getBalance` call and compares it after: if it changed, a scan queued a deposit in the meantime, so the call returns that id, whatever state the deposit has reached since, and charges only the RPC call. Comparing the id rather than checking whether a deposit is in flight also covers the theoretical case of the scan's deposit being minted or dropped before the callback runs, which would otherwise free the account for a second deposit from a stale balance. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. @@ -211,7 +211,7 @@ sequenceDiagram Minter-->>-User: Minted { block index, amount } ``` -**Request.** The flow is triggered by calling `deposit_sol` with the user's account (principal ID and subaccount) as parameters. The principal may differ from the caller's, so that a frontend or another canister can pay for a user's deposit, but it must be neither the anonymous principal nor the ckSOL minter's own principal, whose deposit address is the main address. The endpoint requires cycles to be attached; the required amount is exposed as `deposit_sol_required_cycles` in `get_minter_info` and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path. The reservation does not cover the scan of the automatic flow, which may read the same address concurrently; after the balance read, the call therefore checks again whether a deposit is in flight for the account and, if a scan queued one in the meantime, returns its id and charges only the RPC call, as described in [Section 3.1.1](#311-automatic-flow). A scan queues a deposit through the same steps as this endpoint, from the balance check onwards. +**Request.** The flow is triggered by calling `deposit_sol` with the user's account (principal ID and subaccount) as parameters. The principal may differ from the caller's, so that a frontend or another canister can pay for a user's deposit, but it must be neither the anonymous principal nor the ckSOL minter's own principal, whose deposit address is the main address. The endpoint requires cycles to be attached; the required amount is exposed as `deposit_sol_required_cycles` in `get_minter_info` and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path. The reservation does not cover the scan of the automatic flow, which may read the same address concurrently; the call therefore records the id of the latest deposit of the account before the balance read and compares it afterwards: if a scan queued a deposit in the meantime, the call returns its id and charges only the RPC call, as described in [Section 3.1.1](#311-automatic-flow). A scan queues a deposit through the same steps as this endpoint, from the balance check onwards. **Balance check.** The ckSOL minter calls the `getBalance` endpoint of the SOL RPC canister for the deposit address at the `finalized` commitment level. No `minContextSlot` is set: the SOL RPC canister returns no context slot, so the slot at which a previous sweep of the address was finalized cannot be learned, and the 3-out-of-4 provider consensus is part of the trust model. A balance that a lagging provider reports too high cannot cause an over-mint either, since ckSOL is minted only once the sweep transaction has been finalized and the amount credited is read from that transaction, and a sweep built on an inflated balance is not a valid transaction. The *sweepable amount* is the balance minus the **rent exemption threshold** (890,880 lamports for an account without data), or zero if the balance does not exceed the threshold. This threshold is deliberately left on the deposit address: Solana rejects any transaction that would leave an account with a nonzero balance below the threshold, so sweeping the whole balance would fail as soon as a small transfer arrived between the balance check and the execution of the sweep. Keeping the threshold on the address makes the sweep independent of concurrent transfers. The threshold is paid once per deposit address, since later sweeps find it already in place. @@ -628,7 +628,7 @@ The ckSOL minter exposes the following endpoints: 1. `get_deposit_address(opt principal, opt subaccount)`: Returns the Solana address derived from the provided principal ID and subaccount. If no principal ID is provided, the principal ID of the caller is used. 2. `deposit_sol(opt principal, opt subaccount)`: Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, or of the sweep already in flight for the account. Otherwise, an error is returned. 3. `deposit_sol_status(deposit_id)`: Returns the status (`Queued`, `Swept`, `Finalized`, `Minted`, `Dropped`, `Quarantined`) of the deposit with the given id, or `NotFound` if the id is unknown. An explicit variant rather than an optional result lets a client that predates a later variant distinguish an unknown id from a status it cannot decode, as `withdraw_sol_status` does. -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 minimum deposit amount, the fee that will be deducted, the status of the account (`Watching` with the end of the window, the number of scans and the times of the last and next scans, 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`, or `TemporarilyUnavailable`. The owner is always the caller. +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 and the times of the last and next scans, 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`, 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. From 46f47a2a8151334d8885dc881e414b8271e3d745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 11:54:17 +0000 Subject: [PATCH 011/146] docs(design): fix the watch window at 24 hours over the 23.5 hour schedule Measure gaps from the previous scan as before, but end the window at a fixed 24 hours after registration, as the ckETH minter does, so that the accumulated tick and call delays have slack and clients and the re-arm share one unambiguous boundary. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 071c563b8..cddd3aee7 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -139,7 +139,7 @@ The call does not contact the SOL RPC canister and requires no cycles. It is rej The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. An entry is therefore scanned at most 33 times, the last time 23.5 hours after registration, and its window ends with that scan: a transfer finalized after it is not noticed until the account is registered again, which the next `watch_deposit_address` call does, starting a new window and reporting its first scan time. Each scan lands on the next tick, so every scan may be up to a minute late, which is accepted. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. The gaps add up to 23.5 hours, while the window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter. The half hour of slack absorbs the delays that accumulate along the schedule, since each gap is measured from the previous actual scan, each scan lands on the next tick and a failed call defers a scan to the following tick. An entry is therefore scanned at most 33 times, normally the last time about 23.5 hours after registration; a scan that would fall after the end of the window is not run, and the status then reports no next scan. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first scan time. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. From e513dfd1ff0ccea0fc8eff46ad98ff153847b0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 11:59:12 +0000 Subject: [PATCH 012/146] docs(design): mention the automatic flow in the withdrawal delay sentence Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index cddd3aee7..c58432413 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -372,7 +372,7 @@ Since Solana has a high block rate, the timer should execute more frequently com There is a **minimum withdrawal amount**, which is defined in [Section 3.3.3](#333-minimum-swap-amounts). -The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see [Section 3.1.2](#312-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delay a user can experience is between a `deposit_sol` call and the mint of their own deposit. +The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see [Section 3.1.2](#312-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delay a user can experience is between the queueing of their own deposit, by a `deposit_sol` call or by a balance scan of the automatic flow, and its mint. Contrary to sweeps, a withdrawal transaction does not follow the transaction submission flow of [Section 3.1.3](#313-transaction-submission), since it must not reference a recent block hash. Instead, the ckSOL minter picks a free durable nonce account from the pool of [Section 3.2.1](#321-durable-nonce-accounts) and reads its current nonce value with `getAccountInfo` at the `finalized` commitment level; a response showing a nonce value already bound to an earlier transaction of that account is stale, and the batch waits for the next round. The transaction consists of an `AdvanceNonceAccount` instruction first, followed by one transfer per withdrawal request, and carries the nonce value in place of the recent block hash. The main address is the fee payer, the source of all transfers, and the nonce authority, so the transaction has a single signature. A single timer round processes withdrawals at a time, and it assigns distinct free nonce accounts to its batches synchronously, before the first await point, so that concurrently processed batches can never pick the same account. Before the threshold signature is requested, a `CreatedWithdrawalTransaction` event binds the nonce account and its nonce value to the burn indices of the withdrawals the transaction serves. Since each burn index identifies a withdrawal request, i.e., a destination and an amount, the binding fully determines the message to be signed. Threshold signing is treated as fallible: should it fail or be interrupted, the binding survives, and the ckSOL minter rebuilds the identical message from the binding, without reading the nonce account again, and signs it again, so that no two different messages are ever signed for the same nonce value. The signed transaction is recorded with a `SubmittedTransaction` event *before* it is sent, so that the identical transaction can later be re-broadcast. When processing this event, the ckSOL minter checks that the transaction advances a bound nonce account, serves the bound withdrawal requests, and carries exactly the message it rebuilds from the binding, signed by the main address only. From ea186e22bbbe66a32084c4e58e69b523d6b6ab16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 12:42:31 +0000 Subject: [PATCH 013/146] docs(design): add the state diagram of an account in the automatic flow Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index c58432413..ec188e52a 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -133,6 +133,30 @@ sequenceDiagram **Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the current minimum deposit amount and automatic deposit fee, the *status* of the account and the id of the latest deposit queued for the account, if any. As in the ckETH minter, nothing is frozen at registration: the two amounts are quotes of the parameters in force at the time of the call, whatever the status, and the parameters in force when a scan detects the deposit are the ones that apply, as described under detection below. An upgrade changing them during the window therefore affects every registration that has not been detected yet, and none that has. +The states of an account in the automatic flow, and the transitions between them, are shown in the following figure. `Detected` covers every stage of a deposit in flight, from queued to pending mint, which [Section 3.1.2](#312-manual-flow) details. + +```mermaid +stateDiagram-v2 + [*] --> NotWatching + NotWatching --> Watching: watch_deposit_address + Watching --> Watching: watch_deposit_address, idempotent + Watching --> Detected: scan finds the balance at or above the minimum, automatic deposit + Watching --> Detected: deposit_sol queues a deposit, manual deposit + NotWatching --> Detected: deposit_sol queues a deposit, manual deposit + Watching --> NotWatching: window expired + Detected --> Detected: watch_deposit_address returns the deposit id + Detected --> NotWatching: deposit minted + Detected --> Watching: automatic deposit dropped while the window is open, re-arm + Detected --> NotWatching: manual deposit dropped, or automatic deposit dropped after the window + Detected --> Quarantined: sweep or mint quarantined + Quarantined --> Quarantined: watch_deposit_address and deposit_sol rejected + + NotWatching: NotWatching, no registration and no deposit in flight + Watching: Watching, on the watchlist and scanned on the schedule + Detected: Detected, a deposit is queued, swept, finalized or pending mint + 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 and the times of the last and next scans, 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 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 watchlist is full of live entries (`TooManyWatchedAddresses`), 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). From 4edf1df0803e49baf7bff2b73c56b41b2288d22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 12:47:55 +0000 Subject: [PATCH 014/146] docs(design): state replay of the recorded fee precisely and fix the owner rationale The fee recorded at detection matters for deposits an upgrade finds not yet credited; a credited deposit has its mint amount in CreditedSweep. The minter's principal is excluded as a deposit owner because its default account is the ledger's minting account, not because of an address collision. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index ec188e52a..409e9a76a 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -171,7 +171,7 @@ Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` c **Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. -An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment and the end of the registration's window. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it, and replaying the event log recomputes the same mint; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The end of the window is carried for the re-arm described above. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. +An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment and the end of the registration's window. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The end of the window is carried for the re-arm described above. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. **Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` records the id of the latest deposit of the account before its `getBalance` call and compares it after: if it changed, a scan queued a deposit in the meantime, so the call returns that id, whatever state the deposit has reached since, and charges only the RPC call. Comparing the id rather than checking whether a deposit is in flight also covers the theoretical case of the scan's deposit being minted or dropped before the callback runs, which would otherwise free the account for a second deposit from a stale balance. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. @@ -235,7 +235,7 @@ sequenceDiagram Minter-->>-User: Minted { block index, amount } ``` -**Request.** The flow is triggered by calling `deposit_sol` with the user's account (principal ID and subaccount) as parameters. The principal may differ from the caller's, so that a frontend or another canister can pay for a user's deposit, but it must be neither the anonymous principal nor the ckSOL minter's own principal, whose deposit address is the main address. The endpoint requires cycles to be attached; the required amount is exposed as `deposit_sol_required_cycles` in `get_minter_info` and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path. The reservation does not cover the scan of the automatic flow, which may read the same address concurrently; the call therefore records the id of the latest deposit of the account before the balance read and compares it afterwards: if a scan queued a deposit in the meantime, the call returns its id and charges only the RPC call, as described in [Section 3.1.1](#311-automatic-flow). A scan queues a deposit through the same steps as this endpoint, from the balance check onwards. +**Request.** The flow is triggered by calling `deposit_sol` with the user's account (principal ID and subaccount) as parameters. The principal may differ from the caller's, so that a frontend or another canister can pay for a user's deposit, but it must be neither the anonymous principal nor the ckSOL minter's own principal, whose default account is the minting account of the ckSOL ledger, to which nothing can be minted, and which has no reason to deposit to itself. The endpoint requires cycles to be attached; the required amount is exposed as `deposit_sol_required_cycles` in `get_minter_info` and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path. The reservation does not cover the scan of the automatic flow, which may read the same address concurrently; the call therefore records the id of the latest deposit of the account before the balance read and compares it afterwards: if a scan queued a deposit in the meantime, the call returns its id and charges only the RPC call, as described in [Section 3.1.1](#311-automatic-flow). A scan queues a deposit through the same steps as this endpoint, from the balance check onwards. **Balance check.** The ckSOL minter calls the `getBalance` endpoint of the SOL RPC canister for the deposit address at the `finalized` commitment level. No `minContextSlot` is set: the SOL RPC canister returns no context slot, so the slot at which a previous sweep of the address was finalized cannot be learned, and the 3-out-of-4 provider consensus is part of the trust model. A balance that a lagging provider reports too high cannot cause an over-mint either, since ckSOL is minted only once the sweep transaction has been finalized and the amount credited is read from that transaction, and a sweep built on an inflated balance is not a valid transaction. The *sweepable amount* is the balance minus the **rent exemption threshold** (890,880 lamports for an account without data), or zero if the balance does not exceed the threshold. This threshold is deliberately left on the deposit address: Solana rejects any transaction that would leave an account with a nonzero balance below the threshold, so sweeping the whole balance would fail as soon as a small transfer arrived between the balance check and the execution of the sweep. Keeping the threshold on the address makes the sweep independent of concurrent transfers. The threshold is paid once per deposit address, since later sweeps find it already in place. From 1163210f7e63f688ed98136533438353921d416b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 12:53:25 +0000 Subject: [PATCH 015/146] docs(design): let the scan discard a read whose window closed meanwhile Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 409e9a76a..e9a81a37c 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -173,7 +173,7 @@ Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` c An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment and the end of the registration's window. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The end of the window is carried for the re-arm described above. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. -**Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration or if the account has a deposit in flight. Symmetrically, `deposit_sol` records the id of the latest deposit of the account before its `getBalance` call and compares it after: if it changed, a scan queued a deposit in the meantime, so the call returns that id, whatever state the deposit has reached since, and charges only the RPC call. Comparing the id rather than checking whether a deposit is in flight also covers the theoretical case of the scan's deposit being minted or dropped before the callback runs, which would otherwise free the account for a second deposit from a stale balance. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. +**Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration, if its window has passed in the meantime, or if the account has a deposit in flight; the window end thus holds even for a read that started just before it, at the price of discarding that one read. Symmetrically, `deposit_sol` records the id of the latest deposit of the account before its `getBalance` call and compares it after: if it changed, a scan queued a deposit in the meantime, so the call returns that id, whatever state the deposit has reached since, and charges only the RPC call. Comparing the id rather than checking whether a deposit is in flight also covers the theoretical case of the scan's deposit being minted or dropped before the callback runs, which would otherwise free the account for a second deposit from a stale balance. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. From 0519a297fd187eefa345c2351dbc3f8aa78ab66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 13 Oct 2026 12:57:41 +0000 Subject: [PATCH 016/146] docs(design): make the re-arm subject to the watchlist capacity Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index e9a81a37c..fc56b8ad1 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -167,7 +167,7 @@ The manual flow remains available while an address is watched: a `deposit_sol` c The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. -Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window, provided the window is still open, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-armed entry restarts the schedule from its first gap, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. +Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window, provided the window is still open and the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The re-armed entry restarts the schedule from its first gap, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. **Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. @@ -258,7 +258,7 @@ Two failure cases exist before the transaction is finalized, and in neither is t 1. The transaction is finalized with an error. The funds are still on the deposit addresses, minus the fee paid by the fee payer. This should never happen with the invariants above, so it is reported as an error in the logs and in the metrics. 2. The transaction expires, i.e., its blockhash is no longer valid and it has no on-chain status. The sweep is dropped and *not* resubmitted with a new blockhash. Expiry is determined against the last valid block height persisted with the transaction, as described in [Section 3.2.5](#325-finalization-and-re-broadcast), never by counting slots; otherwise a transaction declared expired could still land, and the funds would reach the main account without being credited. Expiry only establishes that the transaction can no longer land, not that it never did, so the status check searches the available transaction history rather than the recent status cache, which holds about two minutes. Otherwise a sweep that landed while the checks had a longer gap, caused by the timer interval, by rounds skipped on RPC errors, or by an upgrade, would be reported as having no status and dropped after it had moved the funds. -In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for the remainder of its window, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, for a manual deposit the retry is triggered and paid for by the caller, while for an automatic deposit the re-arm leads to a new balance read and, if the funds are still there, a new sweep without another call, at the ckSOL minter's expense. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. +In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for the remainder of its window, when the watchlist has room, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, for a manual deposit the retry is triggered and paid for by the caller, while for an automatic deposit the re-arm leads to a new balance read and, if the funds are still there, a new sweep without another call, at the ckSOL minter's expense. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. **Example.** The following example uses the parameters of [Section 3.3](#33-fees--minimum-swap-amounts), a price of 1 SOL = 100 USD, and 1T cycles = 1 XDR = 1.44 USD. Alice deposits 1 SOL and Bob deposits 0.05 SOL, each to their own deposit address, which was empty before, and both deposits are swept in the same transaction. From c658da2695fa6b78be4c5c0e32ee1180d858af77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 07:25:29 +0000 Subject: [PATCH 017/146] docs(design): align the diagram, the delay sentence and the minimum example with the automatic flow Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index fc56b8ad1..b9e288adc 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -100,7 +100,7 @@ sequenceDiagram User->>+Minter: watch_deposit_address(subaccount) Note over Minter: add the account to the watchlist (no event) - Minter-->>-User: Ok(deposit_address, fee, Watching { valid_until, next_scan_at }) + Minter-->>-User: Ok(deposit_address, minimum, fee, Watching { valid_until, next_scan_at }, latest_deposit_id) Note over Minter: ⏱️ Scan timer activate Minter @@ -126,7 +126,7 @@ sequenceDiagram Note over Solana,Ledger: Sweep, finalization and mint as in Section 3.1.2
the mint is sweepable - fee share - automatic deposit fee User->>+Minter: watch_deposit_address(subaccount) - Minter-->>-User: Ok(deposit_address, fee, Detected { deposit id }) + Minter-->>-User: Ok(deposit_address, minimum, fee, Detected { deposit id }, latest_deposit_id) User->>+Minter: deposit_sol_status(deposit id) Minter-->>-User: Minted { block index, amount } ``` @@ -396,7 +396,7 @@ Since Solana has a high block rate, the timer should execute more frequently com There is a **minimum withdrawal amount**, which is defined in [Section 3.3.3](#333-minimum-swap-amounts). -The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see [Section 3.1.2](#312-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delay a user can experience is between the queueing of their own deposit, by a `deposit_sol` call or by a balance scan of the automatic flow, and its mint. +The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see [Section 3.1.2](#312-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delays a user can experience concern their own deposit: in the automatic flow, between the transfer and the balance scan that detects it, which the schedule of [Section 3.1.1](#311-automatic-flow) bounds by the watch window, and in both flows, between the queueing of the deposit and its mint. Contrary to sweeps, a withdrawal transaction does not follow the transaction submission flow of [Section 3.1.3](#313-transaction-submission), since it must not reference a recent block hash. Instead, the ckSOL minter picks a free durable nonce account from the pool of [Section 3.2.1](#321-durable-nonce-accounts) and reads its current nonce value with `getAccountInfo` at the `finalized` commitment level; a response showing a nonce value already bound to an earlier transaction of that account is stale, and the batch waits for the next round. The transaction consists of an `AdvanceNonceAccount` instruction first, followed by one transfer per withdrawal request, and carries the nonce value in place of the recent block hash. The main address is the fee payer, the source of all transfers, and the nonce authority, so the transaction has a single signature. A single timer round processes withdrawals at a time, and it assigns distinct free nonce accounts to its batches synchronously, before the first await point, so that concurrently processed batches can never pick the same account. Before the threshold signature is requested, a `CreatedWithdrawalTransaction` event binds the nonce account and its nonce value to the burn indices of the withdrawals the transaction serves. Since each burn index identifies a withdrawal request, i.e., a destination and an amount, the binding fully determines the message to be signed. Threshold signing is treated as fallible: should it fail or be interrupted, the binding survives, and the ckSOL minter rebuilds the identical message from the binding, without reading the nonce account again, and signs it again, so that no two different messages are ever signed for the same nonce value. The signed transaction is recorded with a `SubmittedTransaction` event *before* it is sent, so that the identical transaction can later be re-broadcast. When processing this event, the ckSOL minter checks that the transaction advances a bound nonce account, serves the bound withdrawal requests, and carries exactly the message it rebuilds from the binding, signed by the main address only. @@ -574,7 +574,7 @@ When the user withdraws x SOL, the user receives x SOL minus the withdrawal fee - Minimum deposit amount: 0.02 SOL - Minimum withdrawal amount: 0.002 SOL -The **minimum deposit amount** applies to the balance of a deposit address, so that a user who deposits exactly the minimum is served. Following the constraints of [Section 3.3.4](#334-parameter-constraints), it must cover the rent exemption threshold of 890,880 lamports twice, once left on the deposit address and once reaching the main account on the very first sweep, plus the fee of one signature, for a total of 1,786,760 lamports. The minimum deposit amount is set to **0.02 SOL**, more than 11 times that lower bound, so that a first deposit of exactly the minimum is credited 19,104,120 lamports, i.e., 95.5% of the amount deposited, and the cycles attached to `deposit_sol` remain small compared to the deposit. +The **minimum deposit amount** applies to the balance of a deposit address, so that a user who deposits exactly the minimum is served. Following the constraints of [Section 3.3.4](#334-parameter-constraints), it must cover the rent exemption threshold of 890,880 lamports twice, once left on the deposit address and once reaching the main account on the very first sweep, plus the fee of one signature, for a total of 1,786,760 lamports. The minimum deposit amount is set to **0.02 SOL**, more than 11 times that lower bound, so that a first deposit of exactly the minimum is credited 19,104,120 lamports in the manual flow, i.e., 95.5% of the amount deposited, and 17,104,120 lamports in the automatic flow, i.e., 85.5%, after the automatic deposit fee of 2,000,000 lamports, which the constraint of [Section 3.3.4](#334-parameter-constraints) keeps well below the minimum, and the cycles attached to `deposit_sol` remain small compared to the deposit. A similar principle can be applied for the **minimum withdrawal amount**. Given the withdrawal fee of 0.001 SOL, the minimum withdrawal amount can be set to 0.002 SOL. The user will thus receive at least 0.001 SOL, which is strictly above the rent exemption threshold of 0.00089088 SOL (890,880 lamports). From 790ad8ed5ab6750930d57295d7a755013f26d410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 07:31:55 +0000 Subject: [PATCH 018/146] docs(design): state the hard cost bound and the deposit_sol reply after a concurrent scan Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index b9e288adc..ed17f7d86 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -177,7 +177,7 @@ An address whose balance is at or above the minimum deposit amount in force at t **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. -**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This bound assumes that the calls succeed: while the SOL RPC canister fails persistently, the entries keep their progress and every tick may retry up to `MAX_CONCURRENT_RPC_CALLS` calls, i.e. up to 14,400 attempts per day. That situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight HTTPS outcalls to those of the other timers. +**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans within the remaining window only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight HTTPS outcalls to those of the other timers. The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events of the automatic flow are the `QueuedDeposit` of a 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. @@ -650,7 +650,7 @@ The ckSOL ledger exposes all endpoints of a standard ICRC ledger, complying with The ckSOL minter exposes the following endpoints: 1. `get_deposit_address(opt principal, opt subaccount)`: Returns the Solana address derived from the provided principal ID and subaccount. If no principal ID is provided, the principal ID of the caller is used. -2. `deposit_sol(opt principal, opt subaccount)`: Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, or of the sweep already in flight for the account. Otherwise, an error is returned. +2. `deposit_sol(opt principal, opt subaccount)`: Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, of the sweep already in flight for the account, or of the deposit that a balance scan of the automatic flow queued during the balance read, whatever state that deposit has reached since. Otherwise, an error is returned. 3. `deposit_sol_status(deposit_id)`: Returns the status (`Queued`, `Swept`, `Finalized`, `Minted`, `Dropped`, `Quarantined`) of the deposit with the given id, or `NotFound` if the id is unknown. An explicit variant rather than an optional result lets a client that predates a later variant distinguish an unknown id from a status it cannot decode, as `withdraw_sol_status` does. 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 and the times of the last and next scans, 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`, 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. From b52f2d79b87c877acde27a3610f1dec80e6d75d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 07:36:51 +0000 Subject: [PATCH 019/146] docs(design): exclude the minter principal from automatic registrations Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index ed17f7d86..f70b8fb27 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -131,7 +131,7 @@ sequenceDiagram Minter-->>-User: Minted { block index, amount } ``` -**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must not be the anonymous principal. The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the current minimum deposit amount and automatic deposit fee, the *status* of the account and the id of the latest deposit queued for the account, if any. As in the ckETH minter, nothing is frozen at registration: the two amounts are quotes of the parameters in force at the time of the call, whatever the status, and the parameters in force when a scan detects the deposit are the ones that apply, as described under detection below. An upgrade changing them during the window therefore affects every registration that has not been detected yet, and none that has. +**Registration.** The flow is triggered by calling `watch_deposit_address` with an optional subaccount. The account to credit is the caller's principal with that subaccount. Unlike `deposit_sol`, the endpoint does not accept another owner: the call is free and the owner pays the fee, so letting a caller name another owner would allow anyone to force sweeps of, and fees on, a victim's deposit address. The caller must be neither the anonymous principal nor the ckSOL minter itself, for the reason given in [Section 3.1.2](#312-manual-flow). The call derives the deposit address, adds the account to the *watchlist* for a window of about 24 hours (see the schedule below), and returns the deposit address, the current minimum deposit amount and automatic deposit fee, the *status* of the account and the id of the latest deposit queued for the account, if any. As in the ckETH minter, nothing is frozen at registration: the two amounts are quotes of the parameters in force at the time of the call, whatever the status, and the parameters in force when a scan detects the deposit are the ones that apply, as described under detection below. An upgrade changing them during the window therefore affects every registration that has not been detected yet, and none that has. The states of an account in the automatic flow, and the transitions between them, are shown in the following figure. `Detected` covers every stage of a deposit in flight, from queued to pending mint, which [Section 3.1.2](#312-manual-flow) details. From 4c0715b121f2d1bb7ceb5cdc02a935bdf8e4be39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 07:43:33 +0000 Subject: [PATCH 020/146] docs(design): qualify the re-arm, the concurrency bound, the detection delay and the fee worst case Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index f70b8fb27..7c664292e 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -146,8 +146,8 @@ stateDiagram-v2 Watching --> NotWatching: window expired Detected --> Detected: watch_deposit_address returns the deposit id Detected --> NotWatching: deposit minted - Detected --> Watching: automatic deposit dropped while the window is open, re-arm - Detected --> NotWatching: manual deposit dropped, or automatic deposit dropped after the window + Detected --> Watching: automatic deposit dropped while the window is open and the watchlist has room, re-arm + Detected --> NotWatching: manual deposit dropped, or automatic deposit dropped after the window or with the watchlist full Detected --> Quarantined: sweep or mint quarantined Quarantined --> Quarantined: watch_deposit_address and deposit_sol rejected @@ -177,7 +177,7 @@ An address whose balance is at or above the minimum deposit amount in force at t **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. -**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans within the remaining window only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight HTTPS outcalls to those of the other timers. +**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans within the remaining window only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events of the automatic flow are the `QueuedDeposit` of a 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. @@ -396,7 +396,7 @@ Since Solana has a high block rate, the timer should execute more frequently com There is a **minimum withdrawal amount**, which is defined in [Section 3.3.3](#333-minimum-swap-amounts). -The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see [Section 3.1.2](#312-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delays a user can experience concern their own deposit: in the automatic flow, between the transfer and the balance scan that detects it, which the schedule of [Section 3.1.1](#311-automatic-flow) bounds by the watch window, and in both flows, between the queueing of the deposit and its mint. +The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see [Section 3.1.2](#312-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delays a user can experience concern their own deposit: in the automatic flow, between the transfer and the balance scan that detects it, which the schedule of [Section 3.1.1](#311-automatic-flow) bounds by the watch window for a transfer finalized before the last scan, whereas a later transfer waits for a new registration, and in both flows, between the queueing of the deposit and its mint. Contrary to sweeps, a withdrawal transaction does not follow the transaction submission flow of [Section 3.1.3](#313-transaction-submission), since it must not reference a recent block hash. Instead, the ckSOL minter picks a free durable nonce account from the pool of [Section 3.2.1](#321-durable-nonce-accounts) and reads its current nonce value with `getAccountInfo` at the `finalized` commitment level; a response showing a nonce value already bound to an earlier transaction of that account is stale, and the batch waits for the next round. The transaction consists of an `AdvanceNonceAccount` instruction first, followed by one transfer per withdrawal request, and carries the nonce value in place of the recent block hash. The main address is the fee payer, the source of all transfers, and the nonce authority, so the transaction has a single signature. A single timer round processes withdrawals at a time, and it assigns distinct free nonce accounts to its batches synchronously, before the first await point, so that concurrently processed batches can never pick the same account. Before the threshold signature is requested, a `CreatedWithdrawalTransaction` event binds the nonce account and its nonce value to the burn indices of the withdrawals the transaction serves. Since each burn index identifies a withdrawal request, i.e., a destination and an amount, the binding fully determines the message to be signed. Threshold signing is treated as fallible: should it fail or be interrupted, the binding survives, and the ckSOL minter rebuilds the identical message from the binding, without reading the nonce account again, and signs it again, so that no two different messages are ever signed for the same nonce value. The signed transaction is recorded with a `SubmittedTransaction` event *before* it is sent, so that the identical transaction can later be re-broadcast. When processing this event, the ckSOL minter checks that the transaction advances a bound nonce account, serves the bound withdrawal requests, and carries exactly the message it rebuilds from the binding, signed by the main address only. @@ -555,7 +555,7 @@ Using the [cost estimation endpoints](https://dashboard.internetcomputer.org/can - `getSignatureStatuses`: 2.3B (1 sig.), 10.7B (256 sig., the largest batch) cycles - `getMultipleAccounts`: about 2.3B (1 address) to 4.5B (100 addresses) cycles, assuming about 150 bytes of response per address and the same base cost as `getBalance`; to be measured once the SOL RPC canister offers the method -The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt: a sweep that fails or expires and is re-armed, as described in [Section 3.1.1](#311-automatic-flow), costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs that cost like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The earlier proposal of 0.01 SOL for a flow fetching every transaction of the address individually no longer applies: reading balances in batches is an order of magnitude cheaper. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. +The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case with every call succeeding, the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt without failed calls: a scan that fails is retried at the next tick, up to the hard bound of [Section 3.1.1](#311-automatic-flow), and a sweep that fails or expires and is re-armed, as described there, costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs these costs like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt without failed calls with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The earlier proposal of 0.01 SOL for a flow fetching every transaction of the address individually no longer applies: reading balances in batches is an order of magnitude cheaper. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. The **manual deposit fee** is not a parameter but the depositor's share of the shortfall between what the sweep transaction moved and what arrived on the main account, derived from the finalized transaction as described in [Section 3.1.2](#312-manual-flow). Under the current fee schedule that shortfall is the transaction fee of `5000 * k` lamports for `k` signatures, so the share is `ceil(5000 * k / k) = 5000` lamports. In addition, the depositor leaves the rent exemption threshold of 890,880 lamports on the deposit address the first time it is swept. The cycles consumed by the manual flow are charged to the caller of `deposit_sol`: the cost of the `getBalance` call, roughly 2.31B cycles, plus the **deposit_sol fee**. The latter must cover the threshold signature of 26.2B cycles and, for a sweep containing a single deposit, all the RPC calls of the sweep and finalization timers, i.e., `getSlot`, `getBlock`, `sendTransaction`, `getSignatureStatuses`, and `getTransaction`, for about 12.3B cycles when each of them succeeds on its first attempt. A deposit_sol fee of **45B cycles** covers that case. From cb528cb84108eb45980d648e5dd8e4b37a7733a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:08:37 +0000 Subject: [PATCH 021/146] docs(design): show the full Watching reply and scope the capacity error to new registrations Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 7c664292e..d7a355af9 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -100,7 +100,7 @@ sequenceDiagram User->>+Minter: watch_deposit_address(subaccount) Note over Minter: add the account to the watchlist (no event) - Minter-->>-User: Ok(deposit_address, minimum, fee, Watching { valid_until, next_scan_at }, latest_deposit_id) + Minter-->>-User: Ok(deposit_address, minimum, fee, Watching { valid_until, scan_count: 0, last_scanned_at: none, next_scan_at }, latest_deposit_id) Note over Minter: ⏱️ Scan timer activate Minter @@ -159,7 +159,7 @@ stateDiagram-v2 The status is `Watching`, with the end of the window, the number of scans so far and the times of the last and next scans, 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 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 watchlist is full of live entries (`TooManyWatchedAddresses`), 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). +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`; 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). The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. From 3ab9a97e79ccf162232d59fd3f42b95b59cf23bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:45:50 +0000 Subject: [PATCH 022/146] docs(design): define the next scan time as a due time and scope the events the automatic flow adds Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index d7a355af9..b91924ba1 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 and the times of the last and next scans, 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 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 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`; 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). @@ -179,7 +179,7 @@ An address whose balance is at or above the minimum deposit amount in force at t **Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans within the remaining window only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. -The event log is not a vector: a registration records nothing, so an adversary cannot grow the log for free, and the only events of the automatic flow are the `QueuedDeposit` of a 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. +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. **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; an optional part of the `QueuedDeposit` event carrying the fee and window of an automatic deposit, absent for manual deposits; 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. @@ -652,7 +652,7 @@ The ckSOL minter exposes the following endpoints: 1. `get_deposit_address(opt principal, opt subaccount)`: Returns the Solana address derived from the provided principal ID and subaccount. If no principal ID is provided, the principal ID of the caller is used. 2. `deposit_sol(opt principal, opt subaccount)`: Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, of the sweep already in flight for the account, or of the deposit that a balance scan of the automatic flow queued during the balance read, whatever state that deposit has reached since. Otherwise, an error is returned. 3. `deposit_sol_status(deposit_id)`: Returns the status (`Queued`, `Swept`, `Finalized`, `Minted`, `Dropped`, `Quarantined`) of the deposit with the given id, or `NotFound` if the id is unknown. An explicit variant rather than an optional result lets a client that predates a later variant distinguish an unknown id from a status it cannot decode, as `withdraw_sol_status` does. -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 and the times of the last and next scans, 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`, or `TemporarilyUnavailable`. The owner is always the caller. +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`, 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. From 656b946259527a30b129aba7470552bc412b012e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:42:24 +0000 Subject: [PATCH 023/146] docs(design): drop the superseded proposals and name the watchlist metric to alert on Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index b91924ba1..75e4c7008 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -84,7 +84,7 @@ Concretely, the deposit address is derived from the ckSOL minter's master public #### 3.1.1. Automatic Flow -The automatic flow lets a user obtain ckSOL without calling the ckSOL minter after the transfer, which is the situation of a deposit from a centralized exchange: the user withdraws SOL from the exchange to the deposit address, and nothing else happens on the user's side. It is a post-launch addition built on top of the manual flow of [Section 3.1.2](#312-manual-flow) rather than a separate crediting path: the ckSOL minter reads the balances of registered deposit addresses on a timer and, when a balance reaches the minimum deposit amount, queues exactly the sweep that `deposit_sol` would have queued. From that point on, the deposit is swept, finalized, settled and minted as described in [Section 3.1.2](#312-manual-flow), so a deposit address is only ever credited by one mechanism, the sweep, whichever way its balance was noticed. This replaces the earlier proposal of tracking the transaction signatures of each address with `getSignaturesForAddress` and `getTransaction`, which the sweep flow made obsolete. +The automatic flow lets a user obtain ckSOL without calling the ckSOL minter after the transfer, which is the situation of a deposit from a centralized exchange: the user withdraws SOL from the exchange to the deposit address, and nothing else happens on the user's side. It is a post-launch addition built on top of the manual flow of [Section 3.1.2](#312-manual-flow) rather than a separate crediting path: the ckSOL minter reads the balances of registered deposit addresses on a timer and, when a balance reaches the minimum deposit amount, queues exactly the sweep that `deposit_sol` would have queued. From that point on, the deposit is swept, finalized, settled and minted as described in [Section 3.1.2](#312-manual-flow), so a deposit address is only ever credited by one mechanism, the sweep, whichever way its balance was noticed. Compared to the manual flow, the automatic flow differs in who triggers the balance read (a timer instead of the caller), in what the caller pays (nothing at call time, and a fixed fee in SOL deducted from the minted amount instead of cycles) and in the detection latency (a schedule of scans spread over about a day instead of an immediate read). @@ -179,7 +179,7 @@ An address whose balance is at or above the minimum deposit amount in force at t **Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans within the remaining window only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. -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. +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; an optional part of the `QueuedDeposit` event carrying the fee and window of an automatic deposit, absent for manual deposits; 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. @@ -555,7 +555,7 @@ Using the [cost estimation endpoints](https://dashboard.internetcomputer.org/can - `getSignatureStatuses`: 2.3B (1 sig.), 10.7B (256 sig., the largest batch) cycles - `getMultipleAccounts`: about 2.3B (1 address) to 4.5B (100 addresses) cycles, assuming about 150 bytes of response per address and the same base cost as `getBalance`; to be measured once the SOL RPC canister offers the method -The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case with every call succeeding, the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt without failed calls: a scan that fails is retried at the next tick, up to the hard bound of [Section 3.1.1](#311-automatic-flow), and a sweep that fails or expires and is re-armed, as described there, costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs these costs like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt without failed calls with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The earlier proposal of 0.01 SOL for a flow fetching every transaction of the address individually no longer applies: reading balances in batches is an order of magnitude cheaper. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. +The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case with every call succeeding, the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt without failed calls: a scan that fails is retried at the next tick, up to the hard bound of [Section 3.1.1](#311-automatic-flow), and a sweep that fails or expires and is re-armed, as described there, costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs these costs like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt without failed calls with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. The **manual deposit fee** is not a parameter but the depositor's share of the shortfall between what the sweep transaction moved and what arrived on the main account, derived from the finalized transaction as described in [Section 3.1.2](#312-manual-flow). Under the current fee schedule that shortfall is the transaction fee of `5000 * k` lamports for `k` signatures, so the share is `ceil(5000 * k / k) = 5000` lamports. In addition, the depositor leaves the rent exemption threshold of 890,880 lamports on the deposit address the first time it is swept. The cycles consumed by the manual flow are charged to the caller of `deposit_sol`: the cost of the `getBalance` call, roughly 2.31B cycles, plus the **deposit_sol fee**. The latter must cover the threshold signature of 26.2B cycles and, for a sweep containing a single deposit, all the RPC calls of the sweep and finalization timers, i.e., `getSlot`, `getBlock`, `sendTransaction`, `getSignatureStatuses`, and `getTransaction`, for about 12.3B cycles when each of them succeeds on its first attempt. A deposit_sol fee of **45B cycles** covers that case. From 5bd1619e7fc3bfe097739794ef7593b4a6e5e978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:52:20 +0000 Subject: [PATCH 024/146] docs(design): fix the scan slots at the start of the schedule instead of chaining gaps Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 75e4c7008..484b1affc 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -163,11 +163,11 @@ The call does not contact the SOL RPC canister and requires no cycles. It is rej The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*: an entry is due for its first scan at the first tick after registration, and afterwards once the gap following its previous scan has elapsed, where the gaps follow a schedule adapted from the ckETH minter: 60, 60, 120, 120 and 240 seconds, then four times 300 seconds, i.e. ten scans in the first 30 minutes, followed by 23 hourly scans. The gaps add up to 23.5 hours, while the window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter. The half hour of slack absorbs the delays that accumulate along the schedule, since each gap is measured from the previous actual scan, each scan lands on the next tick and a failed call defers a scan to the following tick. An entry is therefore scanned at most 33 times, normally the last time about 23.5 hours after registration; a scan that would fall after the end of the window is not run, and the status then reports no next scan. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first scan time. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window, the start of its schedule and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*. The schedule of an entry is a fixed list of *slots*, measured from the start of its schedule and adapted from the ckETH minter: at 0, 60, 120, 240, 360 and 600 seconds, then every 300 seconds up to 1,800 seconds, i.e. ten slots in the first 30 minutes, followed by hourly slots from 1.5 hours up to 23.5 hours, 33 slots in all. An entry is due at its first slot if it has not been scanned yet, and otherwise once the first slot after its last scan has passed; a scan lands on the next tick at or after that slot. Since the slots are fixed, a scan that comes late, because a tick is skipped, a call fails or the ckSOL minter is stopped for an upgrade, does not shift the slots that follow: the entry is scanned once it is due, and its next slot is the first one after that scan, so slots that passed in the meantime are not made up and the number of scans may fall short of the number of slots. Nothing is lost by that, since a balance scan is idempotent: one read after an outage tells as much as the reads it replaces. The window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter; the last slot lies half an hour before its end, and a slot after the end of the window is never scanned, so the status then reports no next scan. The due time reported by the status is the next slot, so it does not move between two reads. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first slot. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. -Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window, provided the window is still open and the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The re-armed entry restarts the schedule from its first gap, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. +Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window, provided the window is still open and the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The re-armed entry starts a new schedule at the time of the re-arm, within its original window, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. **Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. From e0d1e67ecb1add97ce7460e3e1e7a47b068cb843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:49:59 +0000 Subject: [PATCH 025/146] docs(design): re-arm a dropped automatic deposit for a fresh window The re-arm registers the account again instead of restoring the original window, so the queued deposit carries only the automatic deposit fee. Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 484b1affc..364a8865e 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -146,8 +146,8 @@ stateDiagram-v2 Watching --> NotWatching: window expired Detected --> Detected: watch_deposit_address returns the deposit id Detected --> NotWatching: deposit minted - Detected --> Watching: automatic deposit dropped while the window is open and the watchlist has room, re-arm - Detected --> NotWatching: manual deposit dropped, or automatic deposit dropped after the window or with the watchlist full + Detected --> Watching: automatic deposit dropped and the watchlist has room, re-arm + Detected --> NotWatching: manual deposit dropped, or automatic deposit dropped with the watchlist full Detected --> Quarantined: sweep or mint quarantined Quarantined --> Quarantined: watch_deposit_address and deposit_sol rejected @@ -167,21 +167,21 @@ The manual flow remains available while an address is watched: a `deposit_sol` c The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. -Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is put back on the watchlist with its original window, provided the window is still open and the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The re-armed entry starts a new schedule at the time of the re-arm, within its original window, since its scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans are bounded by the remaining window and only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is bounded by the original window, so a sweep that keeps failing cannot keep the ckSOL minter busy beyond it; after the window, the funds stay on the address until the user registers again or calls `deposit_sol`. An account on the watchlist never has a deposit in flight. +Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is registered again, for a fresh window and schedule starting at the drop, provided the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The fresh schedule starts with the burst, since the scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is not bounded by the original window, so a sweep that keeps failing is detected and re-armed again until it lands, at the ckSOL minter's expense; this is accepted because a dropped sweep is an infrastructure failure that is alerted on and that no user can bring about, and because a bound at the original window would only have capped such a chain at a day while costing every queued deposit its window end. An account on the watchlist never has a deposit in flight. **Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. -An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment and the end of the registration's window. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The end of the window is carried for the re-arm described above. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. +An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. **Concurrency.** Scan ticks never overlap: the scan runs under the same single-round guard as the sweep and finalization timers, so a tick that fires while the previous one is still waiting for its calls returns immediately, and the bound of `MAX_CONCURRENT_RPC_CALLS` in-flight calls holds. The scan and `deposit_sol`, however, may read the balance of the same address at the same time, since both span an inter-canister call, and neither path may assert on the other. Before its call, the scan skips the accounts for which a `deposit_sol` call is in progress; after the call, it checks each entry again and skips it silently if it is no longer watched under the same registration, if its window has passed in the meantime, or if the account has a deposit in flight; the window end thus holds even for a read that started just before it, at the price of discarding that one read. Symmetrically, `deposit_sol` records the id of the latest deposit of the account before its `getBalance` call and compares it after: if it changed, a scan queued a deposit in the meantime, so the call returns that id, whatever state the deposit has reached since, and charges only the RPC call. Comparing the id rather than checking whether a deposit is in flight also covers the theoretical case of the scan's deposit being minted or dropped before the callback runs, which would otherwise free the account for a second deposit from a stale balance. An account registered while a tick is running is scanned at the next tick. Since the watchlist is heap state, an upgrade in the middle of a tick loses nothing but that tick: the snapshot taken before the upgrade carries the watchlist, and the scan is redone afterwards. **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. -**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans within the remaining window only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. +**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. 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; an optional part of the `QueuedDeposit` event carrying the fee and window of an automatic deposit, absent for manual deposits; 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; an optional field of the `QueuedDeposit` event carrying the fee of an automatic deposit, absent for manual deposits; 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 @@ -258,7 +258,7 @@ Two failure cases exist before the transaction is finalized, and in neither is t 1. The transaction is finalized with an error. The funds are still on the deposit addresses, minus the fee paid by the fee payer. This should never happen with the invariants above, so it is reported as an error in the logs and in the metrics. 2. The transaction expires, i.e., its blockhash is no longer valid and it has no on-chain status. The sweep is dropped and *not* resubmitted with a new blockhash. Expiry is determined against the last valid block height persisted with the transaction, as described in [Section 3.2.5](#325-finalization-and-re-broadcast), never by counting slots; otherwise a transaction declared expired could still land, and the funds would reach the main account without being credited. Expiry only establishes that the transaction can no longer land, not that it never did, so the status check searches the available transaction history rather than the recent status cache, which holds about two minutes. Otherwise a sweep that landed while the checks had a longer gap, caused by the timer interval, by rounds skipped on RPC errors, or by an upgrade, would be reported as having no status and dropped after it had moved the funds. -In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for the remainder of its window, when the watchlist has room, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, for a manual deposit the retry is triggered and paid for by the caller, while for an automatic deposit the re-arm leads to a new balance read and, if the funds are still there, a new sweep without another call, at the ckSOL minter's expense. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. +In both cases the queued deposits are marked as *dropped*, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls `deposit_sol` again to queue a new sweep of the balance that is still on the deposit address; a deposit that was queued by the automatic flow is instead put back on the watchlist for a fresh window, when the watchlist has room, as described in [Section 3.1.1](#311-automatic-flow). For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, for a manual deposit the retry is triggered and paid for by the caller, while for an automatic deposit the re-arm leads to a new balance read and, if the funds are still there, a new sweep without another call, at the ckSOL minter's expense. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to `5000 * k` lamports and its deposit address can fall just below the minimum deposit amount, in which case the next `deposit_sol` call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it. **Example.** The following example uses the parameters of [Section 3.3](#33-fees--minimum-swap-amounts), a price of 1 SOL = 100 USD, and 1T cycles = 1 XDR = 1.44 USD. Alice deposits 1 SOL and Bob deposits 0.05 SOL, each to their own deposit address, which was empty before, and both deposits are swept in the same transaction. From 322851866cc73da84511ff6d46c1cde18c973c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:11:20 +0000 Subject: [PATCH 026/146] refactor(minter): extract the queueing of a deposit and the in-flight classification from deposit_sol Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/sweep/mod.rs | 71 ++++++++++++++++++-------------- minter/src/state/audit.rs | 12 +++++- minter/src/state/deposits/mod.rs | 31 ++++++++++++++ minter/src/state/mod.rs | 28 +++++-------- minter/src/state/tests.rs | 5 +-- 5 files changed, 93 insertions(+), 54 deletions(-) diff --git a/minter/src/deposit/sweep/mod.rs b/minter/src/deposit/sweep/mod.rs index de66e9d23..b6f2221e0 100644 --- a/minter/src/deposit/sweep/mod.rs +++ b/minter/src/deposit/sweep/mod.rs @@ -5,7 +5,10 @@ use crate::{ guard::deposit_sol_guard, rpc::get_balance, runtime::CanisterRuntime, - state::{DepositBalance, audit::process_event, event::EventType, mutate_state, read_state}, + state::{ + DepositBalance, InFlightDeposit, QueuedDeposit, State, audit::process_event, + event::EventType, mutate_state, read_state, + }, utils::assert_valid_deposit_owner, }; use canlog::log; @@ -41,25 +44,12 @@ pub async fn deposit_sol( }); check_caller_available_cycles(runtime, required_cycles)?; - if let Some((deposit_id, status)) = read_state(|state| { - state - .deposits() - .in_flight_id(&account) - .map(|deposit_id| (deposit_id, state.deposits().status(deposit_id))) - }) { - return match status { - DepositSolStatus::Queued { .. } - | DepositSolStatus::Swept { .. } - | DepositSolStatus::Finalized { .. } => Ok(deposit_id), - DepositSolStatus::Quarantined { .. } => { - Err(DepositSolError::Quarantined { deposit_id }) - } - DepositSolStatus::Dropped { .. } - | DepositSolStatus::Minted { .. } - | DepositSolStatus::NotFound => panic!( - "BUG: in-flight deposit {deposit_id} of account {account:?} has status {status:?}" - ), - }; + match read_state(|state| state.deposits().in_flight(&account)) { + Some(InFlightDeposit::Processing(deposit_id)) => return Ok(deposit_id), + Some(InFlightDeposit::Quarantined(deposit_id)) => { + return Err(DepositSolError::Quarantined { deposit_id }); + } + None => {} } let master_key = @@ -81,25 +71,46 @@ pub async fn deposit_sol( .expect("BUG: the minimum deposit amount covers the rent exemption threshold"); let deposit_id = mutate_state(|state| { - let deposit_id = state.deposits().next_id(); - process_event( + queue_deposit( + runtime, state, - EventType::QueuedDeposit { - deposit_id, + QueuedDeposit { account, address: deposit_address, balance, }, - runtime, - ); - deposit_id + ) }); + Ok(deposit_id) +} + +/// Queues the deposit under the next deposit id, which both `deposit_sol` and a balance +/// scan of the automatic flow do once a deposit address holds at least the minimum +/// deposit amount, and returns that id. +pub(crate) fn queue_deposit( + runtime: &R, + state: &mut State, + deposit: QueuedDeposit, +) -> DepositSolId { + let deposit_id = state.deposits().next_id(); + process_event( + state, + EventType::QueuedDeposit { + deposit_id, + account: deposit.account, + address: deposit.address, + balance: deposit.balance, + }, + runtime, + ); log!( Priority::Info, - "Queued deposit {deposit_id} for account {account:?}: {} lamports sweepable from {deposit_address}", - balance.sweepable_amount() + "Queued deposit {deposit_id} for account {:?}: {} lamports sweepable from {}", + deposit.account, + deposit.sweepable_amount(), + deposit.address ); - Ok(deposit_id) + deposit_id } pub fn deposit_status(deposit_id: DepositSolId) -> DepositSolStatus { diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index d93b62ed0..922a957e6 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -1,7 +1,7 @@ use crate::{ runtime::CanisterRuntime, state::{ - SchnorrPublicKey, State, + QueuedDeposit, SchnorrPublicKey, State, event::{Event, EventType}, }, storage, @@ -50,7 +50,15 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 address, balance, } => { - state.process_queued_deposit(*deposit_id, account, address, *balance, timestamp); + state.process_queued_deposit( + *deposit_id, + QueuedDeposit { + account: *account, + address: *address, + balance: *balance, + }, + timestamp, + ); } EventType::CreditedSweep { signature, diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 7e30a49d9..f886901af 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -103,6 +103,28 @@ impl Deposits { self.in_flight_ids.get(account).copied() } + /// The deposit of the account that is in flight, with whether it is still processed + /// or quarantined. + /// + /// # Panics + /// + /// If the in-flight deposit is minted, dropped or unknown, since such a deposit must + /// have released its account. + pub fn in_flight(&self, account: &Account) -> Option { + let deposit_id = self.in_flight_id(account)?; + Some(match self.status(deposit_id) { + DepositSolStatus::Queued { .. } + | DepositSolStatus::Swept { .. } + | DepositSolStatus::Finalized { .. } => InFlightDeposit::Processing(deposit_id), + DepositSolStatus::Quarantined { .. } => InFlightDeposit::Quarantined(deposit_id), + status @ (DepositSolStatus::Dropped { .. } + | DepositSolStatus::Minted { .. } + | DepositSolStatus::NotFound) => panic!( + "BUG: in-flight deposit {deposit_id} of account {account:?} has status {status:?}" + ), + }) + } + /// Returns the timestamp (in nanoseconds) at which the oldest in-flight deposit was queued. /// A deposit is in flight from the moment it is queued until it is minted, dropped or /// quarantined, whatever sweep stage it has reached in between. @@ -355,6 +377,15 @@ impl Deposits { } } +/// A deposit of an account that has not been minted, dropped or quarantined yet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InFlightDeposit { + /// The deposit is queued, swept or finalized. + Processing(DepositSolId), + /// The deposit is quarantined and keeps its account rejected until a minter upgrade. + Quarantined(DepositSolId), +} + /// A deposit address queued for a sweep to the minter's main account. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct QueuedDeposit { diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 5c1bca70c..a4987e305 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -42,9 +42,9 @@ pub mod event; mod nonce_pool; pub use deposits::{ - DepositBalance, Deposits, MintedSweep, PendingMint, QuarantineCause, QuarantinedDeposit, - QueuedDeposit, SettledSweep, Sweep, SweepMismatch, SweepRecoveryError, SweepSettlementError, - Sweeps, SweptDeposit, Transfer, UnreadableOutcome, + DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, QuarantineCause, + QuarantinedDeposit, QueuedDeposit, SettledSweep, Sweep, SweepMismatch, SweepRecoveryError, + SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, }; pub use nonce_pool::{DurableNoncePool, NoncePoolError, NonceRead}; @@ -403,30 +403,22 @@ impl State { fn process_queued_deposit( &mut self, deposit_id: DepositSolId, - account: &Account, - address: &Address, - balance: DepositBalance, + deposit: QueuedDeposit, queued_at: u64, ) { debug_assert_eq!( - *address, + deposit.address, account_address( self.minter_public_key .as_ref() .expect("BUG: a deposit was queued before the minter public key was recorded"), - account, + &deposit.account, ), - "Attempted to queue deposit {deposit_id} with address {address} not derived from account {account:?}", - ); - self.deposits.queue( - deposit_id, - QueuedDeposit { - account: *account, - address: *address, - balance, - }, - queued_at, + "Attempted to queue deposit {deposit_id} with address {} not derived from account {:?}", + deposit.address, + deposit.account, ); + self.deposits.queue(deposit_id, deposit, queued_at); } fn process_credited_sweep( diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 8c12ee944..65ac4f4fa 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -123,12 +123,9 @@ mod queued_deposits { let mut expected = State::try_from(valid_init_args()).unwrap(); expected.cache_minter_public_key(crate::test_fixtures::schnorr_master_key()); for deposit_id in 0..2 { - let deposit = queued_deposit(deposit_id); expected.process_queued_deposit( deposit_id, - &deposit.account, - &deposit.address, - deposit.balance, + queued_deposit(deposit_id), queued_at(deposit_id), ); } From 171686fcaa60761a611f64a4f528f85fc43e1c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:55:39 +0000 Subject: [PATCH 027/146] docs(minter): describe InFlightDeposit by the account it reserves Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index f886901af..ca86f6a1f 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -377,7 +377,9 @@ impl Deposits { } } -/// A deposit of an account that has not been minted, dropped or quarantined yet. +/// The deposit that reserves its account: no other deposit can be queued for the account +/// until it is minted or dropped, and a quarantined one keeps the account reserved until +/// a minter upgrade resolves it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum InFlightDeposit { /// The deposit is queued, swept or finalized. From 5fc9c9c02b763aeb098810286678a10db647378b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:55:39 +0000 Subject: [PATCH 028/146] docs(minter): state the in-flight precondition of queue_deposit Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/sweep/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/minter/src/deposit/sweep/mod.rs b/minter/src/deposit/sweep/mod.rs index b6f2221e0..ff5e41fe5 100644 --- a/minter/src/deposit/sweep/mod.rs +++ b/minter/src/deposit/sweep/mod.rs @@ -87,6 +87,11 @@ pub async fn deposit_sol( /// Queues the deposit under the next deposit id, which both `deposit_sol` and a balance /// scan of the automatic flow do once a deposit address holds at least the minimum /// deposit amount, and returns that id. +/// +/// # Panics +/// +/// If the account of the deposit already has a deposit in flight, which callers must +/// rule out after their balance read. pub(crate) fn queue_deposit( runtime: &R, state: &mut State, From 42a2ab6afc2c6a4f8c320feeeaff7e3ea08e4e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:55:39 +0000 Subject: [PATCH 029/146] test(minter): pin the in-flight variant at every deposit stage Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/tests.rs | 95 ++++++++++++++++++------------ 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/minter/src/state/deposits/tests.rs b/minter/src/state/deposits/tests.rs index f204eab2c..01a6e6307 100644 --- a/minter/src/state/deposits/tests.rs +++ b/minter/src/state/deposits/tests.rs @@ -1,6 +1,6 @@ use super::{ - DepositBalance, Deposits, MintedSweep, PendingMint, QuarantineCause, QuarantinedDeposit, - QueuedDeposit, SweptDeposit, + DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, QuarantineCause, + QuarantinedDeposit, QueuedDeposit, SweptDeposit, }; use crate::{ constants::{FEE_PER_SIGNATURE, RENT_EXEMPTION_THRESHOLD}, @@ -48,7 +48,9 @@ mod deposit_balance { } mod queue { - use super::{BTreeMap, DepositSolStatus, Deposits, queued_deposit, queued_deposit_of}; + use super::{ + BTreeMap, DepositSolStatus, Deposits, InFlightDeposit, queued_deposit, queued_deposit_of, + }; #[test] fn should_assign_sequential_ids_and_keep_accounts_in_flight() { @@ -61,9 +63,15 @@ mod queue { deposits.queued(), &BTreeMap::from([(0, queued_deposit(0)), (1, queued_deposit(1))]) ); - assert_eq!(deposits.in_flight_id(&queued_deposit(0).account), Some(0)); - assert_eq!(deposits.in_flight_id(&queued_deposit(1).account), Some(1)); - assert_eq!(deposits.in_flight_id(&queued_deposit(2).account), None); + assert_eq!( + deposits.in_flight(&queued_deposit(0).account), + Some(InFlightDeposit::Processing(0)) + ); + assert_eq!( + deposits.in_flight(&queued_deposit(1).account), + Some(InFlightDeposit::Processing(1)) + ); + assert_eq!(deposits.in_flight(&queued_deposit(2).account), None); } #[test] @@ -98,7 +106,7 @@ mod queue { mod sweep { use super::{ - BTreeMap, DepositSolStatus, Deposits, FEE_PER_SIGNATURE, MINTER_ADDRESS, + BTreeMap, DepositSolStatus, Deposits, FEE_PER_SIGNATURE, InFlightDeposit, MINTER_ADDRESS, SWEEP_SIGNATURE_INDEX, planned_sweep, queued_deposit, signature, sweep_message, }; @@ -133,8 +141,8 @@ mod sweep { assert_eq!(deposits.queued(), &BTreeMap::from([(1, queued_deposit(1))])); for deposit_id in 0..3 { assert_eq!( - deposits.in_flight_id(&queued_deposit(deposit_id).account), - Some(deposit_id) + deposits.in_flight(&queued_deposit(deposit_id).account), + Some(InFlightDeposit::Processing(deposit_id)) ); } assert_eq!( @@ -233,8 +241,8 @@ mod sweep { mod drop_swept { use super::{ - BTreeMap, DepositSolStatus, Deposits, MINTER_ADDRESS, SWEEP_SIGNATURE_INDEX, SweptDeposit, - queue_deposits, signature, sweep_message, + BTreeMap, DepositSolStatus, Deposits, InFlightDeposit, MINTER_ADDRESS, + SWEEP_SIGNATURE_INDEX, SweptDeposit, queue_deposits, signature, sweep_message, }; #[test] @@ -261,7 +269,7 @@ mod drop_swept { &BTreeMap::from([(0, dropped(first)), (2, dropped(third))]) ); for (deposit_id, deposit) in [(0, first), (2, third)] { - assert_eq!(deposits.in_flight_id(&deposit.account), None); + assert_eq!(deposits.in_flight(&deposit.account), None); assert_eq!( deposits.status(deposit_id), DepositSolStatus::Dropped { @@ -269,7 +277,10 @@ mod drop_swept { } ); } - assert_eq!(deposits.in_flight_id(&second.account), Some(1)); + assert_eq!( + deposits.in_flight(&second.account), + Some(InFlightDeposit::Processing(1)) + ); } #[test] @@ -281,8 +292,8 @@ mod drop_swept { mod finalize_swept { use super::{ - BTreeMap, DepositSolStatus, Deposits, MINTER_ADDRESS, SWEEP_SIGNATURE_INDEX, planned_sweep, - queued_deposit, signature, sweep_message, + BTreeMap, DepositSolStatus, Deposits, InFlightDeposit, MINTER_ADDRESS, + SWEEP_SIGNATURE_INDEX, planned_sweep, queued_deposit, signature, sweep_message, }; #[test] @@ -312,8 +323,8 @@ mod finalize_swept { assert_eq!(deposits.queued(), &BTreeMap::from([(1, queued_deposit(1))])); for deposit_id in 0..3 { assert_eq!( - deposits.in_flight_id(&queued_deposit(deposit_id).account), - Some(deposit_id) + deposits.in_flight(&queued_deposit(deposit_id).account), + Some(InFlightDeposit::Processing(deposit_id)) ); } for deposit_id in [0, 2] { @@ -341,9 +352,9 @@ mod finalize_swept { mod credit_sweep { use super::{ - BTreeMap, CREDIT_TIMESTAMP, DepositSolStatus, Deposits, MINTER_ADDRESS, PendingMint, - SWEEP_SIGNATURE_INDEX, SweptDeposit, finalized_sweep, mint, queued_deposit, signature, - sweep_message, + BTreeMap, CREDIT_TIMESTAMP, DepositSolStatus, Deposits, InFlightDeposit, MINTER_ADDRESS, + PendingMint, SWEEP_SIGNATURE_INDEX, SweptDeposit, finalized_sweep, mint, queued_deposit, + signature, sweep_message, }; #[test] @@ -371,8 +382,8 @@ mod credit_sweep { ); for deposit_id in 0..3 { assert_eq!( - deposits.in_flight_id(&queued_deposit(deposit_id).account), - Some(deposit_id) + deposits.in_flight(&queued_deposit(deposit_id).account), + Some(InFlightDeposit::Processing(deposit_id)) ); } assert_eq!( @@ -446,8 +457,8 @@ mod credit_sweep { mod mint { use super::{ - BTreeMap, DepositSolStatus, Deposits, LedgerMintIndex, MintedSweep, SweptDeposit, - credited_sweep, mint, queued_deposit, + BTreeMap, DepositSolStatus, Deposits, InFlightDeposit, LedgerMintIndex, MintedSweep, + SweptDeposit, credited_sweep, mint, queued_deposit, }; #[test] @@ -474,8 +485,11 @@ mod mint { } )]) ); - assert_eq!(deposits.in_flight_id(&queued_deposit(0).account), None); - assert_eq!(deposits.in_flight_id(&queued_deposit(1).account), Some(1)); + assert_eq!(deposits.in_flight(&queued_deposit(0).account), None); + assert_eq!( + deposits.in_flight(&queued_deposit(1).account), + Some(InFlightDeposit::Processing(1)) + ); assert_eq!( deposits.status(0), DepositSolStatus::Minted { @@ -500,7 +514,7 @@ mod mint { mod quarantine_pending_mint { use super::{ - BTreeMap, CREDIT_TIMESTAMP, DepositSolStatus, Deposits, QuarantineCause, + BTreeMap, CREDIT_TIMESTAMP, DepositSolStatus, Deposits, InFlightDeposit, QuarantineCause, QuarantinedDeposit, SweptDeposit, credited_sweep, mint, queued_deposit, }; @@ -531,12 +545,14 @@ mod quarantine_pending_mint { }, )]) ); - for deposit_id in 0..2 { - assert_eq!( - deposits.in_flight_id(&queued_deposit(deposit_id).account), - Some(deposit_id) - ); - } + assert_eq!( + deposits.in_flight(&queued_deposit(0).account), + Some(InFlightDeposit::Quarantined(0)) + ); + assert_eq!( + deposits.in_flight(&queued_deposit(1).account), + Some(InFlightDeposit::Processing(1)) + ); assert_eq!( deposits.status(0), DepositSolStatus::Quarantined { @@ -554,8 +570,9 @@ mod quarantine_pending_mint { mod quarantine_sweep { use super::{ - BTreeMap, DepositSolStatus, Deposits, MINTER_ADDRESS, QuarantineCause, QuarantinedDeposit, - SWEEP_SIGNATURE_INDEX, SweptDeposit, queue_deposits, signature, sweep_message, + BTreeMap, DepositSolStatus, Deposits, InFlightDeposit, MINTER_ADDRESS, QuarantineCause, + QuarantinedDeposit, SWEEP_SIGNATURE_INDEX, SweptDeposit, queue_deposits, signature, + sweep_message, }; #[test] @@ -586,8 +603,12 @@ mod quarantine_sweep { deposits.quarantined(), &BTreeMap::from([(0, quarantined(first)), (2, quarantined(third))]) ); - for (deposit_id, deposit) in [(0, first), (1, second), (2, third)] { - assert_eq!(deposits.in_flight_id(&deposit.account), Some(deposit_id)); + for (deposit, in_flight) in [ + (first, InFlightDeposit::Quarantined(0)), + (second, InFlightDeposit::Processing(1)), + (third, InFlightDeposit::Quarantined(2)), + ] { + assert_eq!(deposits.in_flight(&deposit.account), Some(in_flight)); } for deposit_id in [0, 2] { assert_eq!( From ba1c2c56b3d3e83c362c430efe69490dcad98664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:15:40 +0000 Subject: [PATCH 030/146] feat(minter): add the automatic deposit fee parameter Co-Authored-By: Claude Fable 5.1 --- integration_tests/src/lib.rs | 30 ++++++-- integration_tests/tests/tests.rs | 4 ++ libs/types-internal/src/lib.rs | 8 +++ libs/types/src/lib.rs | 3 + minter/cksol_minter.did | 7 ++ minter/src/canbench.rs | 1 + minter/src/main.rs | 1 + minter/src/state/mod.rs | 33 +++++++++ minter/src/state/tests.rs | 117 ++++++++++++++++++++++++++++--- minter/src/test_fixtures/mod.rs | 14 +++- 10 files changed, 201 insertions(+), 17 deletions(-) diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index f1c1f7626..0b21c5f4a 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -51,6 +51,7 @@ pub struct SetupBuilder { initial_ledger_balances: Option>, proxy_canister: bool, nonce_accounts: Vec, + automatic_deposit_fee: Option, } impl Default for SetupBuilder { @@ -61,6 +62,7 @@ impl Default for SetupBuilder { initial_ledger_balances: None, proxy_canister: false, nonce_accounts: vec![Setup::DEFAULT_NONCE_ACCOUNT.to_string()], + automatic_deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), } } } @@ -98,18 +100,34 @@ impl SetupBuilder { self } + /// Installs the minter without an automatic deposit fee, which keeps the automatic + /// deposit flow unavailable until an upgrade configures the fee. + pub fn without_automatic_deposit_fee(mut self) -> Self { + self.automatic_deposit_fee = None; + self + } + pub async fn build(self) -> Setup { Setup::new( self.make_live.unwrap_or_default(), self.sol_rpc_install_args.unwrap_or_default(), self.initial_ledger_balances, self.proxy_canister, - self.nonce_accounts, + MinterInitArgs { + nonce_accounts: self.nonce_accounts, + automatic_deposit_fee: self.automatic_deposit_fee, + }, ) .await } } +/// The installation arguments of the minter a test chooses. +pub struct MinterInitArgs { + pub nonce_accounts: Vec, + pub automatic_deposit_fee: Option, +} + pub struct Setup { env: Option, /// Whether the instance produces blocks on its own, which decides how @@ -128,6 +146,7 @@ impl Setup { pub const DEFAULT_MINIMUM_DEPOSIT_AMOUNT: Lamport = 20_000_000; // 0.02 SOL pub const DEFAULT_DEPOSIT_SOL_REQUIRED_CYCLES: u128 = 1_000_000_000_000; pub const DEFAULT_MINIMUM_WITHDRAWAL_AMOUNT: Lamport = 2_000_000; // 0.002 SOL + pub const DEFAULT_AUTOMATIC_DEPOSIT_FEE: Lamport = 2_000_000; // 0.002 SOL pub const DEFAULT_CALLER: Principal = Principal::from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x0, 0x3, 0x1, 0x1]); pub const DEFAULT_NONCE_ACCOUNT: solana_address::Address = @@ -138,7 +157,7 @@ impl Setup { sol_rpc_install_args: sol_rpc_types::InstallArgs, initial_ledger_balances: Option>, with_proxy_canister: bool, - nonce_accounts: Vec, + minter_init_args: MinterInitArgs, ) -> Self { let env = PocketIcBuilder::new() .with_nns_subnet() //make_live requires NNS subnet. @@ -182,7 +201,7 @@ impl Setup { Encode!(&cksol_minter_init_args( sol_rpc_canister_id, ledger_canister_id, - nonce_accounts, + minter_init_args, )) .unwrap(), Some(Self::DEFAULT_CONTROLLER), @@ -844,7 +863,7 @@ fn cksol_minter_wasm() -> Vec { fn cksol_minter_init_args( sol_rpc_canister_id: Principal, ledger_canister_id: Principal, - nonce_accounts: Vec, + minter_init_args: MinterInitArgs, ) -> MinterArg { use cksol_types_internal::{Ed25519KeyName, InitArgs, MinterArg, SolanaNetwork}; MinterArg::Init(InitArgs { @@ -857,7 +876,8 @@ fn cksol_minter_init_args( deposit_sol_required_cycles: Setup::DEFAULT_DEPOSIT_SOL_REQUIRED_CYCLES as u64, solana_network: SolanaNetwork::Mainnet, deposit_sol_fee: Setup::DEFAULT_DEPOSIT_SOL_FEE as u64, - nonce_accounts, + nonce_accounts: minter_init_args.nonce_accounts, + automatic_deposit_fee: minter_init_args.automatic_deposit_fee, }) } diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 8c247fc91..8251f30cb 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -171,6 +171,7 @@ mod lifecycle { const NEW_WITHDRAWAL_FEE: Lamport = 100_000; const NEW_MINIMUM_WITHDRAWAL_AMOUNT: Lamport = 1_000_000; const NEW_DEPOSIT_SOL_REQUIRED_CYCLES: u128 = 500_000_000_000; + const NEW_AUTOMATIC_DEPOSIT_FEE: Lamport = 100_000; let setup = SetupBuilder::new().build().await; wait_for_initial_minter_address(&setup).await; @@ -187,6 +188,7 @@ mod lifecycle { balance: 0, minter_address: Some(MINTER_ADDRESS.to_string()), nonce_accounts: vec![Setup::DEFAULT_NONCE_ACCOUNT.to_string()], + automatic_deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), } ); @@ -211,6 +213,7 @@ mod lifecycle { deposit_sol_required_cycles: Some(NEW_DEPOSIT_SOL_REQUIRED_CYCLES as u64), deposit_sol_fee: None, nonce_accounts_to_add: None, + automatic_deposit_fee: Some(NEW_AUTOMATIC_DEPOSIT_FEE), }) .await .expect("upgrade failed"); @@ -227,6 +230,7 @@ mod lifecycle { balance: 0, minter_address: Some(MINTER_ADDRESS.to_string()), nonce_accounts: vec![Setup::DEFAULT_NONCE_ACCOUNT.to_string()], + automatic_deposit_fee: Some(NEW_AUTOMATIC_DEPOSIT_FEE), } ); diff --git a/libs/types-internal/src/lib.rs b/libs/types-internal/src/lib.rs index c381e6eb9..c585fb0da 100644 --- a/libs/types-internal/src/lib.rs +++ b/libs/types-internal/src/lib.rs @@ -60,6 +60,10 @@ pub struct InitArgs { /// The base58 addresses of the durable nonce accounts reserved for withdrawal transactions. #[cfg_attr(feature = "event", n(9))] pub nonce_accounts: Vec, + /// The fee in lamports deducted from the minted amount of a deposit detected by the + /// automatic flow. The automatic flow stays unavailable until it is set. + #[cfg_attr(feature = "event", n(10))] + pub automatic_deposit_fee: Option, } /// The upgrade args for the ckSOL minter canister. @@ -87,6 +91,10 @@ pub struct UpgradeArgs { /// The base58 addresses of durable nonce accounts to add to the withdrawal pool. #[cfg_attr(feature = "event", n(6))] pub nonce_accounts_to_add: Option>, + /// The new fee in lamports deducted from the minted amount of a deposit detected by the + /// automatic flow. + #[cfg_attr(feature = "event", n(7))] + pub automatic_deposit_fee: Option, } /// The Solana network to connect to via the SOL RPC canister. diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 9b7ab6d4a..93c31e924 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -270,4 +270,7 @@ pub struct MinterInfo { pub minter_address: Option, /// The base58 addresses of the durable nonce accounts reserved for withdrawal transactions. pub nonce_accounts: Vec, + /// The fee in lamports deducted from the minted amount of a deposit detected by the + /// automatic flow, once configured. + pub automatic_deposit_fee: Option, } diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 0da8b876f..45e1f5716 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -180,6 +180,9 @@ type InitArgs = record { deposit_sol_required_cycles : nat64; solana_network : SolanaNetwork; nonce_accounts : vec text; + // The fee in lamports deducted from the minted amount of a deposit detected + // by the automatic flow. The automatic flow stays unavailable until it is set. + automatic_deposit_fee : opt Lamport; }; // Information about the ckSOL minter canister. @@ -192,6 +195,9 @@ type MinterInfo = record { balance : Lamport; minter_address : opt text; nonce_accounts : vec text; + // The fee in lamports deducted from the minted amount of a deposit detected + // by the automatic flow, once configured. + automatic_deposit_fee : opt Lamport; }; // The ckSOL minter service arguments. @@ -215,6 +221,7 @@ type UpgradeArgs = record { withdrawal_fee : opt Lamport; deposit_sol_required_cycles : opt nat64; nonce_accounts_to_add : opt vec text; + automatic_deposit_fee : opt Lamport; }; type WithdrawSolArgs = record { diff --git a/minter/src/canbench.rs b/minter/src/canbench.rs index 0567e8ea4..a308cd164 100644 --- a/minter/src/canbench.rs +++ b/minter/src/canbench.rs @@ -41,6 +41,7 @@ fn init_args() -> InitArgs { solana_network: SolanaNetwork::Mainnet, deposit_sol_fee: 10_000_000_000, nonce_accounts: vec![nonce_account().to_string()], + automatic_deposit_fee: Some(2_000_000), } } diff --git a/minter/src/main.rs b/minter/src/main.rs index d3db2f92a..71c352fe2 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -130,6 +130,7 @@ fn get_minter_info() -> MinterInfo { .iter() .map(|address| address.to_string()) .collect(), + automatic_deposit_fee: s.automatic_deposit_fee(), }) } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index a4987e305..4b5e1bf62 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -109,6 +109,7 @@ pub struct State { minimum_deposit_amount: Lamport, deposit_sol_required_cycles: u128, deposit_sol_fee: u128, + automatic_deposit_fee: Option, pending_deposit_sol_request_guards: BTreeSet, pending_withdrawal_request_guards: BTreeSet, deposits: Deposits, @@ -184,6 +185,10 @@ impl State { self.deposit_sol_required_cycles } + pub fn automatic_deposit_fee(&self) -> Option { + self.automatic_deposit_fee + } + pub fn deposits(&self) -> &Deposits { &self.deposits } @@ -329,9 +334,25 @@ impl State { deposit_sol_fee: self.deposit_sol_fee, }); } + if let Some(automatic_deposit_fee) = self.automatic_deposit_fee + && self.minimum_automatic_mint_before_fee() <= automatic_deposit_fee + { + return Err(InvalidStateError::AutomaticDepositFeeTooHigh { + automatic_deposit_fee, + minimum_deposit_amount: self.minimum_deposit_amount, + rent_exemption_threshold: RENT_EXEMPTION_THRESHOLD, + fee_per_signature: FEE_PER_SIGNATURE, + }); + } Ok(()) } + fn minimum_automatic_mint_before_fee(&self) -> Lamport { + self.minimum_deposit_amount + .saturating_sub(RENT_EXEMPTION_THRESHOLD) + .saturating_sub(FEE_PER_SIGNATURE) + } + fn upgrade( &mut self, UpgradeArgs { @@ -342,6 +363,7 @@ impl State { deposit_sol_required_cycles, deposit_sol_fee, nonce_accounts_to_add, + automatic_deposit_fee, }: UpgradeArgs, ) -> Result<(), InvalidStateError> { if let Some(sol_rpc_canister_id) = sol_rpc_canister_id { @@ -362,6 +384,9 @@ impl State { if let Some(deposit_sol_fee) = deposit_sol_fee { self.deposit_sol_fee = deposit_sol_fee as u128; } + if let Some(automatic_deposit_fee) = automatic_deposit_fee { + self.automatic_deposit_fee = Some(automatic_deposit_fee); + } if let Some(nonce_accounts) = nonce_accounts_to_add { let nonce_accounts = parse_nonce_accounts(nonce_accounts)?; self.ensure_no_incomplete_withdrawal_to(&nonce_accounts)?; @@ -808,6 +833,12 @@ pub enum InvalidStateError { get_balance_cycles: u128, deposit_sol_fee: u128, }, + AutomaticDepositFeeTooHigh { + automatic_deposit_fee: Lamport, + minimum_deposit_amount: Lamport, + rent_exemption_threshold: Lamport, + fee_per_signature: Lamport, + }, InvalidNonceAccount(String), DuplicateNonceAccount(Address), NonceAccountIsWithdrawalDestination(Address), @@ -849,6 +880,7 @@ impl TryFrom for State { solana_network, deposit_sol_fee, nonce_accounts, + automatic_deposit_fee, }: InitArgs, ) -> Result { let nonce_pool = DurableNoncePool::new(parse_nonce_accounts(nonce_accounts)?)?; @@ -863,6 +895,7 @@ impl TryFrom for State { minimum_deposit_amount, deposit_sol_required_cycles: deposit_sol_required_cycles as u128, deposit_sol_fee: deposit_sol_fee as u128, + automatic_deposit_fee, pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 65ac4f4fa..94b5013e6 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -5,8 +5,8 @@ use crate::{ sol_transfer::MAX_SIGNATURES, state::{audit::process_event, read_state}, test_fixtures::{ - DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, MINIMUM_DEPOSIT_AMOUNT, - MINIMUM_WITHDRAWAL_AMOUNT, WITHDRAWAL_FEE, account, address, + AUTOMATIC_DEPOSIT_FEE, DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, + MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, WITHDRAWAL_FEE, account, address, arb::arb_event, deposit_id, events::{ @@ -605,22 +605,73 @@ mod state_validation { } }, ); + let maximum_automatic_deposit_fee = + MINIMUM_DEPOSIT_AMOUNT - RENT_EXEMPTION_THRESHOLD - FEE_PER_SIGNATURE - 1; + assert_fails_both( + InitArgs { + automatic_deposit_fee: Some(maximum_automatic_deposit_fee + 1), + ..valid_init_args() + }, + UpgradeArgs { + automatic_deposit_fee: Some(maximum_automatic_deposit_fee + 1), + ..Default::default() + }, + |e| { + e == &InvalidStateError::AutomaticDepositFeeTooHigh { + automatic_deposit_fee: maximum_automatic_deposit_fee + 1, + minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, + rent_exemption_threshold: RENT_EXEMPTION_THRESHOLD, + fee_per_signature: FEE_PER_SIGNATURE, + } + }, + ); + let minimum_deposit_amount_for_fee = + AUTOMATIC_DEPOSIT_FEE + RENT_EXEMPTION_THRESHOLD + FEE_PER_SIGNATURE; + assert_fails_both( + InitArgs { + minimum_deposit_amount: minimum_deposit_amount_for_fee, + ..valid_init_args() + }, + UpgradeArgs { + minimum_deposit_amount: Some(minimum_deposit_amount_for_fee), + ..Default::default() + }, + |e| matches!(e, InvalidStateError::AutomaticDepositFeeTooHigh { .. }), + ); + } + + #[test] + fn should_not_constrain_an_unconfigured_automatic_deposit_fee() { + let minimum_deposit_amount = 2 * RENT_EXEMPTION_THRESHOLD + FEE_PER_SIGNATURE; + + State::try_from(InitArgs { + minimum_deposit_amount, + automatic_deposit_fee: None, + ..valid_init_args() + }) + .unwrap(); } #[test] fn should_succeed_at_boundary_conditions() { // minimum_deposit_amount can equal twice the rent exemption threshold + one signature fee let minimum_required = 2 * RENT_EXEMPTION_THRESHOLD + FEE_PER_SIGNATURE; - assert_succeeds_both( - InitArgs { - minimum_deposit_amount: minimum_required, - ..valid_init_args() - }, - UpgradeArgs { + let init_args_without_automatic_deposit_fee = InitArgs { + automatic_deposit_fee: None, + ..valid_init_args() + }; + State::try_from(InitArgs { + minimum_deposit_amount: minimum_required, + ..init_args_without_automatic_deposit_fee.clone() + }) + .unwrap(); + State::try_from(init_args_without_automatic_deposit_fee) + .unwrap() + .upgrade(UpgradeArgs { minimum_deposit_amount: Some(minimum_required), ..Default::default() - }, - ); + }) + .unwrap(); // minimum_withdrawal_amount can equal withdrawal_fee + rent exemption threshold let minimum_required = WITHDRAWAL_FEE + RENT_EXEMPTION_THRESHOLD; assert_succeeds_both( @@ -655,6 +706,18 @@ mod state_validation { ..Default::default() }, ); + let maximum_automatic_deposit_fee = + MINIMUM_DEPOSIT_AMOUNT - RENT_EXEMPTION_THRESHOLD - FEE_PER_SIGNATURE - 1; + assert_succeeds_both( + InitArgs { + automatic_deposit_fee: Some(maximum_automatic_deposit_fee), + ..valid_init_args() + }, + UpgradeArgs { + automatic_deposit_fee: Some(maximum_automatic_deposit_fee), + ..Default::default() + }, + ); } fn assert_fails_both( @@ -696,6 +759,7 @@ mod state_from_init_args { minimum_withdrawal_amount: MINIMUM_WITHDRAWAL_AMOUNT, minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, deposit_sol_required_cycles: DEPOSIT_SOL_REQUIRED_CYCLES, + automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), @@ -763,6 +827,7 @@ mod state_upgrade { let new_minimum_withdrawal_amount = MINIMUM_WITHDRAWAL_AMOUNT * 2; let new_withdrawal_fee = WITHDRAWAL_FEE / 2; let new_deposit_sol_required_cycles = (DEPOSIT_SOL_REQUIRED_CYCLES * 2) as u64; + let new_automatic_deposit_fee = AUTOMATIC_DEPOSIT_FEE * 2; let mut state = initial_state(); state @@ -821,6 +886,38 @@ mod state_upgrade { state.deposit_sol_required_cycles(), new_deposit_sol_required_cycles as u128 ); + + let mut state = initial_state(); + state + .upgrade(UpgradeArgs { + automatic_deposit_fee: Some(new_automatic_deposit_fee), + ..Default::default() + }) + .unwrap(); + assert_eq!( + state.automatic_deposit_fee(), + Some(new_automatic_deposit_fee) + ); + } + + #[test] + fn should_keep_the_automatic_deposit_fee_when_the_upgrade_omits_it() { + let mut state = State::try_from(InitArgs { + automatic_deposit_fee: None, + ..valid_init_args() + }) + .unwrap(); + assert_eq!(state.automatic_deposit_fee(), None); + + state + .upgrade(UpgradeArgs { + automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), + ..Default::default() + }) + .unwrap(); + state.upgrade(UpgradeArgs::default()).unwrap(); + + assert_eq!(state.automatic_deposit_fee(), Some(AUTOMATIC_DEPOSIT_FEE)); } #[test] diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 1c3272442..2eff4dc82 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -54,6 +54,7 @@ pub const MINTER_ADDRESS: Address = address!("Fkt68XQXBDDBGBNNjFh8GM27ffpZGmncUd pub const NONCE_ACCOUNT: Address = address!("US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx"); pub const MINIMUM_DEPOSIT_AMOUNT: Lamport = 20_000_000; // 0.02 SOL pub const DEPOSIT_SOL_REQUIRED_CYCLES: u128 = 1_000_000_000_000; +pub const AUTOMATIC_DEPOSIT_FEE: Lamport = 2_000_000; // 0.002 SOL pub fn sol_rpc_canister_id() -> Principal { Principal::from_slice(&[1_u8; 20]) @@ -75,6 +76,7 @@ pub fn valid_init_args() -> InitArgs { solana_network: SolanaNetwork::Mainnet, deposit_sol_fee: DEPOSIT_SOL_FEE as u64, nonce_accounts: vec![], + automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), } } @@ -1408,7 +1410,11 @@ pub mod arb { any::(), any::(), arb_solana_network(), - (any::(), arb_nonce_accounts()), + ( + any::(), + arb_nonce_accounts(), + prop::option::of(any::()), + ), ) .prop_map( |( @@ -1420,7 +1426,7 @@ pub mod arb { withdrawal_fee, deposit_sol_required_cycles, solana_network, - (deposit_sol_fee, nonce_accounts), + (deposit_sol_fee, nonce_accounts, automatic_deposit_fee), )| { InitArgs { sol_rpc_canister_id, @@ -1433,6 +1439,7 @@ pub mod arb { solana_network, deposit_sol_fee, nonce_accounts, + automatic_deposit_fee, } }, ) @@ -1451,6 +1458,7 @@ pub mod arb { prop::option::of(any::()), prop::option::of(any::()), prop::option::of(arb_nonce_accounts()), + prop::option::of(any::()), ) .prop_map( |( @@ -1461,6 +1469,7 @@ pub mod arb { deposit_sol_required_cycles, deposit_sol_fee, nonce_accounts_to_add, + automatic_deposit_fee, )| UpgradeArgs { sol_rpc_canister_id, minimum_withdrawal_amount, @@ -1469,6 +1478,7 @@ pub mod arb { deposit_sol_required_cycles, deposit_sol_fee, nonce_accounts_to_add, + automatic_deposit_fee, }, ) } From 8c5723d02742117b3340aed2dc7fb3a521f8d75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:17:47 +0000 Subject: [PATCH 031/146] refactor(minter): report the maximum automatic deposit fee in the validation error Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 16 ++++++++-------- minter/src/state/tests.rs | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 4b5e1bf62..c2287f698 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -335,22 +335,24 @@ impl State { }); } if let Some(automatic_deposit_fee) = self.automatic_deposit_fee - && self.minimum_automatic_mint_before_fee() <= automatic_deposit_fee + && automatic_deposit_fee > self.max_automatic_deposit_fee() { return Err(InvalidStateError::AutomaticDepositFeeTooHigh { automatic_deposit_fee, - minimum_deposit_amount: self.minimum_deposit_amount, - rent_exemption_threshold: RENT_EXEMPTION_THRESHOLD, - fee_per_signature: FEE_PER_SIGNATURE, + max_automatic_deposit_fee: self.max_automatic_deposit_fee(), }); } Ok(()) } - fn minimum_automatic_mint_before_fee(&self) -> Lamport { + /// The largest automatic deposit fee that leaves a positive mint for a deposit of + /// exactly the minimum deposit amount, after the rent exemption threshold and the + /// deposit's share of the sweep fee. + fn max_automatic_deposit_fee(&self) -> Lamport { self.minimum_deposit_amount .saturating_sub(RENT_EXEMPTION_THRESHOLD) .saturating_sub(FEE_PER_SIGNATURE) + .saturating_sub(1) } fn upgrade( @@ -835,9 +837,7 @@ pub enum InvalidStateError { }, AutomaticDepositFeeTooHigh { automatic_deposit_fee: Lamport, - minimum_deposit_amount: Lamport, - rent_exemption_threshold: Lamport, - fee_per_signature: Lamport, + max_automatic_deposit_fee: Lamport, }, InvalidNonceAccount(String), DuplicateNonceAccount(Address), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 94b5013e6..fc9470251 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -619,9 +619,7 @@ mod state_validation { |e| { e == &InvalidStateError::AutomaticDepositFeeTooHigh { automatic_deposit_fee: maximum_automatic_deposit_fee + 1, - minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, - rent_exemption_threshold: RENT_EXEMPTION_THRESHOLD, - fee_per_signature: FEE_PER_SIGNATURE, + max_automatic_deposit_fee: maximum_automatic_deposit_fee, } }, ); From 0a4ab2101c98b91e494a21525646c2dc6a3fe543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:19:54 +0000 Subject: [PATCH 032/146] refactor(minter): drop the comment on the private fee bound helper Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index c2287f698..31310941d 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -345,9 +345,6 @@ impl State { Ok(()) } - /// The largest automatic deposit fee that leaves a positive mint for a deposit of - /// exactly the minimum deposit amount, after the rent exemption threshold and the - /// deposit's share of the sweep fee. fn max_automatic_deposit_fee(&self) -> Lamport { self.minimum_deposit_amount .saturating_sub(RENT_EXEMPTION_THRESHOLD) From a7bcf3baa2fa77305af4260670684b1057dce58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:17:10 +0000 Subject: [PATCH 033/146] feat(minter): add a bounded map of expiring entries Co-Authored-By: Claude Fable 5.1 --- minter/src/utils/mod.rs | 1 + minter/src/utils/timed_sized_map/mod.rs | 244 +++++++++++++ minter/src/utils/timed_sized_map/tests.rs | 419 ++++++++++++++++++++++ 3 files changed, 664 insertions(+) create mode 100644 minter/src/utils/timed_sized_map/mod.rs create mode 100644 minter/src/utils/timed_sized_map/tests.rs diff --git a/minter/src/utils/mod.rs b/minter/src/utils/mod.rs index e0a20f45f..9ccd95755 100644 --- a/minter/src/utils/mod.rs +++ b/minter/src/utils/mod.rs @@ -2,6 +2,7 @@ use candid::Principal; use icrc_ledger_types::icrc1::account::Account; pub mod insertion_ordered_map; +pub mod timed_sized_map; #[cfg(test)] mod tests; diff --git a/minter/src/utils/timed_sized_map/mod.rs b/minter/src/utils/timed_sized_map/mod.rs new file mode 100644 index 000000000..0e33500d5 --- /dev/null +++ b/minter/src/utils/timed_sized_map/mod.rs @@ -0,0 +1,244 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + num::NonZeroUsize, + time::Duration, +}; + +#[cfg(test)] +mod tests; + +/// A value of a [`TimedSizedMap`] together with the time, in nanoseconds since the epoch, +/// after which it is expired. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct Entry { + pub value: V, + pub expires_at: u64, +} + +impl Entry { + /// Whether the entry is live at `now`, which it is through its expiry instant. + pub fn is_live(&self, now: u64) -> bool { + !is_expired(self.expires_at, now) + } +} + +/// A map of at most `capacity` entries, each living for at most `ttl`. +/// +/// Re-inserting a key that still has a live entry is rejected, leaving the map untouched. +/// Admitting a new key first evicts the entries that have outlived their `ttl`; if the map +/// is still at capacity with live entries only, the insertion is rejected rather than +/// evicting a live entry. Time is supplied by the caller on every operation, so the map +/// holds no clock and is deterministic. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct TimedSizedMap { + ttl: Duration, + capacity: NonZeroUsize, + entries: BTreeMap>, + by_expiry: BTreeMap>, +} + +/// Why [`TimedSizedMap::insert`] or [`TimedSizedMap::insert_entry`] did not admit a new +/// entry. The rejected key and value are handed back to the caller. +#[derive(Clone, Eq, PartialEq, Debug)] +pub enum InsertError { + AlreadyPresent { + key: K, + value: V, + }, + AtCapacity { + key: K, + value: V, + }, + /// The entry has already expired, which only [`TimedSizedMap::insert_entry`] reports. + Expired { + key: K, + value: V, + }, +} + +impl TimedSizedMap { + pub fn new(ttl: Duration, capacity: NonZeroUsize) -> Self { + Self { + ttl, + capacity, + entries: BTreeMap::new(), + by_expiry: BTreeMap::new(), + } + } + + /// Rebuilds a map verbatim from its `ttl`, `capacity` and the entries previously produced + /// by [`Self::iter_by_expiry`]: no expiry clamping, no eviction of expired entries and no + /// capacity check, so that the result equals the map that produced the entries. + /// + /// # Panics + /// + /// If a key appears twice, since the entries are not a snapshot of a map then. + pub fn from_ordered_entries( + ttl: Duration, + capacity: NonZeroUsize, + entries: impl IntoIterator)>, + ) -> Self { + let mut map = Self::new(ttl, capacity); + for (key, entry) in entries { + map.index(&key, entry.expires_at); + assert!( + map.entries.insert(key, entry).is_none(), + "BUG: from_ordered_entries received a duplicate key" + ); + } + map + } + + /// Inserts `value` under `key` with a lifetime of `ttl` from `now` and returns the expired + /// entries evicted to make room. + pub fn insert(&mut self, now: u64, key: K, value: V) -> Result, InsertError> { + let entry = Entry { + value, + expires_at: self.expiry(now), + }; + self.insert_entry(now, key, entry) + } + + /// Inserts an entry with its own expiry, clamped to `ttl` from `now`, and returns the + /// expired entries evicted to make room. An already expired entry is rejected. + pub fn insert_entry( + &mut self, + now: u64, + key: K, + entry: Entry, + ) -> Result, InsertError> { + if !entry.is_live(now) { + return Err(InsertError::Expired { + key, + value: entry.value, + }); + } + if self.get(now, &key).is_some() { + return Err(InsertError::AlreadyPresent { + key, + value: entry.value, + }); + } + let evicted = self.evict_expired(now); + if self.entries.len() >= self.capacity.get() { + return Err(InsertError::AtCapacity { + key, + value: entry.value, + }); + } + let entry = Entry { + value: entry.value, + expires_at: entry.expires_at.min(self.expiry(now)), + }; + self.index(&key, entry.expires_at); + self.entries.insert(key, entry); + Ok(evicted) + } + + /// The live value under `key`, if any. + pub fn get(&self, now: u64, key: &K) -> Option<&V> { + self.get_entry(now, key).map(|entry| &entry.value) + } + + /// The live entry under `key`, if any. + pub fn get_entry(&self, now: u64, key: &K) -> Option<&Entry> { + self.entries.get(key).filter(|entry| entry.is_live(now)) + } + + /// The live value under `key` for modification, if any. The expiry stays out of reach, so + /// that the expiry index cannot go out of sync. + pub fn get_value_mut(&mut self, now: u64, key: &K) -> Option<&mut V> { + self.entries + .get_mut(key) + .filter(|entry| entry.is_live(now)) + .map(|entry| &mut entry.value) + } + + /// Removes and returns every entry that has outlived its `ttl`. + pub fn evict_expired(&mut self, now: u64) -> Vec<(K, V)> { + let mut evicted = Vec::new(); + while let Some(expires_at) = self.earliest_expiry() + && is_expired(expires_at, now) + { + let bucket = self + .by_expiry + .remove(&expires_at) + .expect("BUG: the earliest expiry has a bucket"); + for key in bucket { + let entry = self + .entries + .remove(&key) + .expect("BUG: every indexed key has an entry"); + evicted.push((key, entry.value)); + } + } + evicted + } + + /// Removes and returns the entry under `key`, live or expired. + pub fn remove(&mut self, key: &K) -> Option> { + let entry = self.entries.remove(key)?; + let bucket = self + .by_expiry + .get_mut(&entry.expires_at) + .expect("BUG: every entry is indexed under its expiry"); + bucket.retain(|indexed| indexed != key); + if bucket.is_empty() { + self.by_expiry.remove(&entry.expires_at); + } + Some(entry) + } + + /// Every entry, live or expired, in key order. + pub fn iter(&self) -> impl Iterator)> { + self.entries.iter() + } + + /// Every entry, live or expired, by ascending expiry and insertion order within an expiry, + /// which is the order [`Self::from_ordered_entries`] rebuilds an identical map from. + pub fn iter_by_expiry(&self) -> impl Iterator)> { + self.by_expiry.values().flatten().map(|key| { + let entry = self + .entries + .get(key) + .expect("BUG: every indexed key has an entry"); + (key, entry) + }) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn capacity(&self) -> NonZeroUsize { + self.capacity + } + + pub fn ttl(&self) -> Duration { + self.ttl + } + + fn expiry(&self, now: u64) -> u64 { + let ttl_nanos = u64::try_from(self.ttl.as_nanos()).unwrap_or(u64::MAX); + now.saturating_add(ttl_nanos) + } + + fn earliest_expiry(&self) -> Option { + self.by_expiry.keys().next().copied() + } + + fn index(&mut self, key: &K, expires_at: u64) { + self.by_expiry + .entry(expires_at) + .or_default() + .push_back(key.clone()); + } +} + +fn is_expired(expires_at: u64, now: u64) -> bool { + now > expires_at +} diff --git a/minter/src/utils/timed_sized_map/tests.rs b/minter/src/utils/timed_sized_map/tests.rs new file mode 100644 index 000000000..ca9b17b90 --- /dev/null +++ b/minter/src/utils/timed_sized_map/tests.rs @@ -0,0 +1,419 @@ +use crate::utils::timed_sized_map::{Entry, InsertError, TimedSizedMap}; +use std::{num::NonZeroUsize, time::Duration}; + +#[test] +fn should_be_empty_on_creation() { + let map: TimedSizedMap<&str, u32> = TimedSizedMap::new(Duration::from_nanos(10), capacity(4)); + + assert!(map.is_empty()); + assert_eq!(map.len(), 0); + assert_eq!(map.capacity(), capacity(4)); + assert_eq!(map.ttl(), Duration::from_nanos(10)); + assert_eq!(map.get(0, &"absent"), None); +} + +#[test] +fn should_reject_a_new_key_when_full_of_live_entries() { + let mut map = TimedSizedMap::new(Duration::from_nanos(1000), capacity(3)); + map.insert(1, "a", 1).unwrap(); + map.insert(2, "b", 2).unwrap(); + map.insert(3, "c", 3).unwrap(); + let before = map.clone(); + + let rejected = map.insert(4, "d", 4); + + assert_eq!( + rejected, + Err(InsertError::AtCapacity { key: "d", value: 4 }) + ); + assert_eq!(map, before); + assert_consistent(&map); +} + +#[test] +fn should_reject_a_refresh_of_a_live_key() { + let mut map = TimedSizedMap::new(Duration::from_nanos(1000), capacity(5)); + map.insert(1, "a", 1).unwrap(); + let before = map.clone(); + + let rejected = map.insert(2, "a", 2); + + assert_eq!( + rejected, + Err(InsertError::AlreadyPresent { key: "a", value: 2 }) + ); + assert_eq!(map, before); + assert_eq!(map.get(2, &"a"), Some(&1)); + assert_consistent(&map); +} + +#[test] +fn should_not_evict_expired_entries_when_rejecting_a_live_refresh() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + map.insert(0, "dormant", 1).unwrap(); + map.insert(5, "live", 2).unwrap(); + let before = map.clone(); + + let rejected = map.insert(12, "live", 3); + + assert_eq!( + rejected, + Err(InsertError::AlreadyPresent { + key: "live", + value: 3 + }) + ); + assert_eq!(map, before); + assert_eq!(map.len(), 2); + assert_consistent(&map); +} + +#[test] +fn should_admit_a_new_key_once_expired_entries_free_room() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(2)); + map.insert(0, "a", 1).unwrap(); + map.insert(1, "b", 2).unwrap(); + + let mut evicted = map.insert(12, "c", 3).unwrap(); + evicted.sort(); + + assert_eq!(evicted, vec![("a", 1), ("b", 2)]); + assert_eq!(map.len(), 1); + assert_eq!(map.get(12, &"c"), Some(&3)); + assert_consistent(&map); +} + +#[test] +fn should_readmit_an_expired_key_as_a_fresh_entry() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + map.insert(0, "a", 1).unwrap(); + + let evicted = map.insert(20, "a", 2).unwrap(); + + assert_eq!(evicted, vec![("a", 1)]); + assert_eq!(map.get(20, &"a"), Some(&2)); + assert_eq!( + map.get_entry(20, &"a").map(|entry| entry.expires_at), + Some(30) + ); + assert_eq!(map.len(), 1); + assert_consistent(&map); +} + +#[test] +fn should_keep_an_entry_live_through_its_expiry_instant() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(10)); + map.insert(0, "a", 1).unwrap(); + + assert_eq!(map.get(10, &"a"), Some(&1)); + assert_eq!(map.get(11, &"a"), None); + assert_eq!(map.get_value_mut(10, &"a"), Some(&mut 1)); + assert_eq!(map.get_value_mut(11, &"a"), None); + assert_eq!(map.len(), 1); + + let evicted = map.evict_expired(11); + + assert_eq!(evicted, vec![("a", 1)]); + assert!(map.is_empty()); + assert_consistent(&map); +} + +#[test] +fn should_evict_nothing_before_the_earliest_expiry() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(10)); + map.insert(0, "a", 1).unwrap(); + map.insert(5, "b", 2).unwrap(); + + assert_eq!(map.evict_expired(10), vec![]); + assert_eq!(map.evict_expired(11), vec![("a", 1)]); + assert_eq!(map.evict_expired(15), vec![]); + assert_eq!(map.evict_expired(16), vec![("b", 2)]); + assert_consistent(&map); +} + +#[test] +fn should_modify_a_live_value_in_place() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(10)); + map.insert(0, "a", 1).unwrap(); + + *map.get_value_mut(5, &"a").unwrap() = 7; + + assert_eq!(map.get(5, &"a"), Some(&7)); + assert_consistent(&map); +} + +#[test] +fn should_clamp_the_expiry_of_an_inserted_entry_to_the_ttl() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + + map.insert_entry( + 0, + "a", + Entry { + value: 1, + expires_at: 100, + }, + ) + .unwrap(); + + assert_eq!(map.get(10, &"a"), Some(&1)); + assert_eq!(map.get(11, &"a"), None); + assert_consistent(&map); +} + +#[test] +fn should_keep_the_expiry_of_an_inserted_entry_shorter_than_the_ttl() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + + map.insert_entry( + 0, + "a", + Entry { + value: 1, + expires_at: 5, + }, + ) + .unwrap(); + + assert_eq!(map.get(5, &"a"), Some(&1)); + assert_eq!(map.get(6, &"a"), None); + assert_consistent(&map); +} + +#[test] +fn should_reject_an_already_expired_entry() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + + let rejected = map.insert_entry( + 20, + "a", + Entry { + value: 1, + expires_at: 19, + }, + ); + + assert_eq!(rejected, Err(InsertError::Expired { key: "a", value: 1 })); + assert!(map.is_empty()); + assert_consistent(&map); +} + +#[test] +fn should_reject_an_inserted_entry_when_full_of_live_entries() { + let mut map = TimedSizedMap::new(Duration::from_nanos(1000), capacity(1)); + map.insert(1, "a", 1).unwrap(); + let before = map.clone(); + + let rejected = map.insert_entry( + 2, + "b", + Entry { + value: 2, + expires_at: 500, + }, + ); + + assert_eq!( + rejected, + Err(InsertError::AtCapacity { key: "b", value: 2 }) + ); + assert_eq!(map, before); + assert_consistent(&map); +} + +#[test] +fn should_remove_an_entry_from_both_indices() { + let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(5)); + map.insert(0, "a", 1).unwrap(); + map.insert(0, "b", 2).unwrap(); + map.insert(10, "c", 3).unwrap(); + + let removed = map.remove(&"a"); + + assert_eq!( + removed, + Some(Entry { + value: 1, + expires_at: 100, + }) + ); + assert_eq!(map.len(), 2); + assert_eq!(map.get(0, &"a"), None); + assert!(map.iter().all(|(key, _)| *key != "a")); + assert!(map.iter_by_expiry().all(|(key, _)| *key != "a")); + assert_consistent(&map); + + map.remove(&"b"); + + assert_eq!( + map.iter_by_expiry() + .map(|(key, _)| *key) + .collect::>(), + vec!["c"] + ); + assert_consistent(&map); +} + +#[test] +fn should_remove_an_expired_entry() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + map.insert(0, "a", 1).unwrap(); + + let removed = map.remove(&"a"); + + assert_eq!(removed.map(|entry| entry.value), Some(1)); + assert!(map.is_empty()); + assert_consistent(&map); +} + +#[test] +fn should_return_none_when_removing_an_absent_key() { + let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(5)); + map.insert(0, "a", 1).unwrap(); + let before = map.clone(); + + assert_eq!(map.remove(&"absent"), None); + assert_eq!(map, before); + assert_consistent(&map); +} + +#[test] +fn should_keep_the_indices_consistent_through_churn() { + let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(4)); + for (index, key) in ["a", "b", "c", "d"].into_iter().enumerate() { + map.insert(index as u64 + 1, key, index).unwrap(); + } + assert_eq!(map.len(), 4); + assert_consistent(&map); + + assert!(matches!( + map.insert(5, "e", 4), + Err(InsertError::AtCapacity { .. }) + )); + assert!(matches!( + map.insert(6, "a", 99), + Err(InsertError::AlreadyPresent { .. }) + )); + assert_eq!(map.len(), 4); + assert_consistent(&map); + + let evicted = map.evict_expired(1000); + + assert_eq!(evicted.len(), 4); + assert!(map.is_empty()); + assert_consistent(&map); +} + +#[test] +fn should_saturate_the_expiry_of_a_ttl_beyond_the_epoch_range() { + let mut map = TimedSizedMap::new(Duration::MAX, capacity(5)); + + map.insert(u64::MAX - 1, "a", 1).unwrap(); + + assert_eq!( + map.get_entry(u64::MAX, &"a").map(|entry| entry.expires_at), + Some(u64::MAX) + ); +} + +#[test] +fn should_round_trip_through_from_ordered_entries() { + let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(5)); + map.insert(0, "b", 2).unwrap(); + map.insert(0, "a", 1).unwrap(); + map.insert(10, "c", 3).unwrap(); + + let entries: Vec<_> = map + .iter_by_expiry() + .map(|(key, entry)| (*key, entry.clone())) + .collect(); + let restored = TimedSizedMap::from_ordered_entries(map.ttl(), map.capacity(), entries); + + assert_eq!(restored, map); + assert_consistent(&restored); +} + +#[test] +fn should_preserve_expired_entries_when_rebuilding() { + let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); + map.insert(0, "a", 1).unwrap(); + let entries: Vec<_> = map + .iter_by_expiry() + .map(|(key, entry)| (*key, entry.clone())) + .collect(); + + let restored = TimedSizedMap::from_ordered_entries(map.ttl(), map.capacity(), entries); + + assert_eq!(restored, map); + assert_eq!(restored.len(), 1); + assert_eq!(restored.get(20, &"a"), None); + assert_consistent(&restored); +} + +#[test] +fn should_rebuild_beyond_the_capacity_without_eviction() { + let entries = (0..3).map(|index| { + ( + index, + Entry { + value: index, + expires_at: 10, + }, + ) + }); + + let restored = + TimedSizedMap::from_ordered_entries(Duration::from_nanos(1), capacity(1), entries); + + assert_eq!(restored.len(), 3); + assert_eq!( + restored + .iter_by_expiry() + .map(|(key, _)| *key) + .collect::>(), + vec![0, 1, 2] + ); + assert_consistent(&restored); +} + +#[test] +#[should_panic(expected = "duplicate key")] +fn should_panic_on_a_duplicate_key_in_from_ordered_entries() { + let entry = Entry { + value: 1, + expires_at: 10, + }; + + let _ = TimedSizedMap::from_ordered_entries( + Duration::from_nanos(10), + capacity(5), + vec![("a", entry.clone()), ("a", entry)], + ); +} + +fn capacity(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).unwrap() +} + +fn assert_consistent( + map: &TimedSizedMap, +) { + let indexed: Vec<&K> = map.iter_by_expiry().map(|(key, _)| key).collect(); + assert_eq!(indexed.len(), map.len(), "index and entry counts disagree"); + for (key, entry) in map.iter() { + let indexed_entry = map + .iter_by_expiry() + .find(|(indexed_key, _)| *indexed_key == key) + .map(|(_, entry)| entry) + .unwrap_or_else(|| panic!("entry {key:?} missing from the expiry index")); + assert_eq!(indexed_entry, entry); + } + let expiries: Vec = map + .iter_by_expiry() + .map(|(_, entry)| entry.expires_at) + .collect(); + assert!( + expiries.is_sorted(), + "expiry index out of order: {expiries:?}" + ); +} From 3441685e61c56fbbddfc8f4b103bf03591ec79cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:33:10 +0000 Subject: [PATCH 034/146] refactor(minter): wrap the timestamps of the timed sized map in a newtype Co-Authored-By: Claude Fable 5.1 --- minter/src/utils/timed_sized_map/mod.rs | 50 +++++--- minter/src/utils/timed_sized_map/tests.rs | 147 +++++++++++----------- 2 files changed, 110 insertions(+), 87 deletions(-) diff --git a/minter/src/utils/timed_sized_map/mod.rs b/minter/src/utils/timed_sized_map/mod.rs index 0e33500d5..1ebeb8eb1 100644 --- a/minter/src/utils/timed_sized_map/mod.rs +++ b/minter/src/utils/timed_sized_map/mod.rs @@ -7,17 +7,30 @@ use std::{ #[cfg(test)] mod tests; -/// A value of a [`TimedSizedMap`] together with the time, in nanoseconds since the epoch, -/// after which it is expired. +/// Nanoseconds since the Unix epoch, as returned by `ic_cdk::api::time()`. +#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub struct Timestamp(u64); + +impl Timestamp { + pub const fn from_nanos(nanos: u64) -> Self { + Self(nanos) + } + + pub const fn as_nanos(self) -> u64 { + self.0 + } +} + +/// A value of a [`TimedSizedMap`] together with the time after which it is expired. #[derive(Clone, Eq, PartialEq, Debug)] pub struct Entry { pub value: V, - pub expires_at: u64, + pub expires_at: Timestamp, } impl Entry { /// Whether the entry is live at `now`, which it is through its expiry instant. - pub fn is_live(&self, now: u64) -> bool { + pub fn is_live(&self, now: Timestamp) -> bool { !is_expired(self.expires_at, now) } } @@ -34,7 +47,7 @@ pub struct TimedSizedMap { ttl: Duration, capacity: NonZeroUsize, entries: BTreeMap>, - by_expiry: BTreeMap>, + by_expiry: BTreeMap>, } /// Why [`TimedSizedMap::insert`] or [`TimedSizedMap::insert_entry`] did not admit a new @@ -91,7 +104,12 @@ impl TimedSizedMap { /// Inserts `value` under `key` with a lifetime of `ttl` from `now` and returns the expired /// entries evicted to make room. - pub fn insert(&mut self, now: u64, key: K, value: V) -> Result, InsertError> { + pub fn insert( + &mut self, + now: Timestamp, + key: K, + value: V, + ) -> Result, InsertError> { let entry = Entry { value, expires_at: self.expiry(now), @@ -103,7 +121,7 @@ impl TimedSizedMap { /// expired entries evicted to make room. An already expired entry is rejected. pub fn insert_entry( &mut self, - now: u64, + now: Timestamp, key: K, entry: Entry, ) -> Result, InsertError> { @@ -136,18 +154,18 @@ impl TimedSizedMap { } /// The live value under `key`, if any. - pub fn get(&self, now: u64, key: &K) -> Option<&V> { + pub fn get(&self, now: Timestamp, key: &K) -> Option<&V> { self.get_entry(now, key).map(|entry| &entry.value) } /// The live entry under `key`, if any. - pub fn get_entry(&self, now: u64, key: &K) -> Option<&Entry> { + pub fn get_entry(&self, now: Timestamp, key: &K) -> Option<&Entry> { self.entries.get(key).filter(|entry| entry.is_live(now)) } /// The live value under `key` for modification, if any. The expiry stays out of reach, so /// that the expiry index cannot go out of sync. - pub fn get_value_mut(&mut self, now: u64, key: &K) -> Option<&mut V> { + pub fn get_value_mut(&mut self, now: Timestamp, key: &K) -> Option<&mut V> { self.entries .get_mut(key) .filter(|entry| entry.is_live(now)) @@ -155,7 +173,7 @@ impl TimedSizedMap { } /// Removes and returns every entry that has outlived its `ttl`. - pub fn evict_expired(&mut self, now: u64) -> Vec<(K, V)> { + pub fn evict_expired(&mut self, now: Timestamp) -> Vec<(K, V)> { let mut evicted = Vec::new(); while let Some(expires_at) = self.earliest_expiry() && is_expired(expires_at, now) @@ -222,16 +240,16 @@ impl TimedSizedMap { self.ttl } - fn expiry(&self, now: u64) -> u64 { + fn expiry(&self, now: Timestamp) -> Timestamp { let ttl_nanos = u64::try_from(self.ttl.as_nanos()).unwrap_or(u64::MAX); - now.saturating_add(ttl_nanos) + Timestamp::from_nanos(now.as_nanos().saturating_add(ttl_nanos)) } - fn earliest_expiry(&self) -> Option { + fn earliest_expiry(&self) -> Option { self.by_expiry.keys().next().copied() } - fn index(&mut self, key: &K, expires_at: u64) { + fn index(&mut self, key: &K, expires_at: Timestamp) { self.by_expiry .entry(expires_at) .or_default() @@ -239,6 +257,6 @@ impl TimedSizedMap { } } -fn is_expired(expires_at: u64, now: u64) -> bool { +fn is_expired(expires_at: Timestamp, now: Timestamp) -> bool { now > expires_at } diff --git a/minter/src/utils/timed_sized_map/tests.rs b/minter/src/utils/timed_sized_map/tests.rs index ca9b17b90..0ffd431f6 100644 --- a/minter/src/utils/timed_sized_map/tests.rs +++ b/minter/src/utils/timed_sized_map/tests.rs @@ -1,4 +1,4 @@ -use crate::utils::timed_sized_map::{Entry, InsertError, TimedSizedMap}; +use crate::utils::timed_sized_map::{Entry, InsertError, TimedSizedMap, Timestamp}; use std::{num::NonZeroUsize, time::Duration}; #[test] @@ -9,18 +9,18 @@ fn should_be_empty_on_creation() { assert_eq!(map.len(), 0); assert_eq!(map.capacity(), capacity(4)); assert_eq!(map.ttl(), Duration::from_nanos(10)); - assert_eq!(map.get(0, &"absent"), None); + assert_eq!(map.get(at(0), &"absent"), None); } #[test] fn should_reject_a_new_key_when_full_of_live_entries() { let mut map = TimedSizedMap::new(Duration::from_nanos(1000), capacity(3)); - map.insert(1, "a", 1).unwrap(); - map.insert(2, "b", 2).unwrap(); - map.insert(3, "c", 3).unwrap(); + map.insert(at(1), "a", 1).unwrap(); + map.insert(at(2), "b", 2).unwrap(); + map.insert(at(3), "c", 3).unwrap(); let before = map.clone(); - let rejected = map.insert(4, "d", 4); + let rejected = map.insert(at(4), "d", 4); assert_eq!( rejected, @@ -33,28 +33,28 @@ fn should_reject_a_new_key_when_full_of_live_entries() { #[test] fn should_reject_a_refresh_of_a_live_key() { let mut map = TimedSizedMap::new(Duration::from_nanos(1000), capacity(5)); - map.insert(1, "a", 1).unwrap(); + map.insert(at(1), "a", 1).unwrap(); let before = map.clone(); - let rejected = map.insert(2, "a", 2); + let rejected = map.insert(at(2), "a", 2); assert_eq!( rejected, Err(InsertError::AlreadyPresent { key: "a", value: 2 }) ); assert_eq!(map, before); - assert_eq!(map.get(2, &"a"), Some(&1)); + assert_eq!(map.get(at(2), &"a"), Some(&1)); assert_consistent(&map); } #[test] fn should_not_evict_expired_entries_when_rejecting_a_live_refresh() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); - map.insert(0, "dormant", 1).unwrap(); - map.insert(5, "live", 2).unwrap(); + map.insert(at(0), "dormant", 1).unwrap(); + map.insert(at(5), "live", 2).unwrap(); let before = map.clone(); - let rejected = map.insert(12, "live", 3); + let rejected = map.insert(at(12), "live", 3); assert_eq!( rejected, @@ -71,30 +71,30 @@ fn should_not_evict_expired_entries_when_rejecting_a_live_refresh() { #[test] fn should_admit_a_new_key_once_expired_entries_free_room() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(2)); - map.insert(0, "a", 1).unwrap(); - map.insert(1, "b", 2).unwrap(); + map.insert(at(0), "a", 1).unwrap(); + map.insert(at(1), "b", 2).unwrap(); - let mut evicted = map.insert(12, "c", 3).unwrap(); + let mut evicted = map.insert(at(12), "c", 3).unwrap(); evicted.sort(); assert_eq!(evicted, vec![("a", 1), ("b", 2)]); assert_eq!(map.len(), 1); - assert_eq!(map.get(12, &"c"), Some(&3)); + assert_eq!(map.get(at(12), &"c"), Some(&3)); assert_consistent(&map); } #[test] fn should_readmit_an_expired_key_as_a_fresh_entry() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); - map.insert(0, "a", 1).unwrap(); + map.insert(at(0), "a", 1).unwrap(); - let evicted = map.insert(20, "a", 2).unwrap(); + let evicted = map.insert(at(20), "a", 2).unwrap(); assert_eq!(evicted, vec![("a", 1)]); - assert_eq!(map.get(20, &"a"), Some(&2)); + assert_eq!(map.get(at(20), &"a"), Some(&2)); assert_eq!( - map.get_entry(20, &"a").map(|entry| entry.expires_at), - Some(30) + map.get_entry(at(20), &"a").map(|entry| entry.expires_at), + Some(at(30)) ); assert_eq!(map.len(), 1); assert_consistent(&map); @@ -103,15 +103,15 @@ fn should_readmit_an_expired_key_as_a_fresh_entry() { #[test] fn should_keep_an_entry_live_through_its_expiry_instant() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(10)); - map.insert(0, "a", 1).unwrap(); + map.insert(at(0), "a", 1).unwrap(); - assert_eq!(map.get(10, &"a"), Some(&1)); - assert_eq!(map.get(11, &"a"), None); - assert_eq!(map.get_value_mut(10, &"a"), Some(&mut 1)); - assert_eq!(map.get_value_mut(11, &"a"), None); + assert_eq!(map.get(at(10), &"a"), Some(&1)); + assert_eq!(map.get(at(11), &"a"), None); + assert_eq!(map.get_value_mut(at(10), &"a"), Some(&mut 1)); + assert_eq!(map.get_value_mut(at(11), &"a"), None); assert_eq!(map.len(), 1); - let evicted = map.evict_expired(11); + let evicted = map.evict_expired(at(11)); assert_eq!(evicted, vec![("a", 1)]); assert!(map.is_empty()); @@ -121,24 +121,24 @@ fn should_keep_an_entry_live_through_its_expiry_instant() { #[test] fn should_evict_nothing_before_the_earliest_expiry() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(10)); - map.insert(0, "a", 1).unwrap(); - map.insert(5, "b", 2).unwrap(); + map.insert(at(0), "a", 1).unwrap(); + map.insert(at(5), "b", 2).unwrap(); - assert_eq!(map.evict_expired(10), vec![]); - assert_eq!(map.evict_expired(11), vec![("a", 1)]); - assert_eq!(map.evict_expired(15), vec![]); - assert_eq!(map.evict_expired(16), vec![("b", 2)]); + assert_eq!(map.evict_expired(at(10)), vec![]); + assert_eq!(map.evict_expired(at(11)), vec![("a", 1)]); + assert_eq!(map.evict_expired(at(15)), vec![]); + assert_eq!(map.evict_expired(at(16)), vec![("b", 2)]); assert_consistent(&map); } #[test] fn should_modify_a_live_value_in_place() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(10)); - map.insert(0, "a", 1).unwrap(); + map.insert(at(0), "a", 1).unwrap(); - *map.get_value_mut(5, &"a").unwrap() = 7; + *map.get_value_mut(at(5), &"a").unwrap() = 7; - assert_eq!(map.get(5, &"a"), Some(&7)); + assert_eq!(map.get(at(5), &"a"), Some(&7)); assert_consistent(&map); } @@ -147,17 +147,17 @@ fn should_clamp_the_expiry_of_an_inserted_entry_to_the_ttl() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); map.insert_entry( - 0, + at(0), "a", Entry { value: 1, - expires_at: 100, + expires_at: at(100), }, ) .unwrap(); - assert_eq!(map.get(10, &"a"), Some(&1)); - assert_eq!(map.get(11, &"a"), None); + assert_eq!(map.get(at(10), &"a"), Some(&1)); + assert_eq!(map.get(at(11), &"a"), None); assert_consistent(&map); } @@ -166,17 +166,17 @@ fn should_keep_the_expiry_of_an_inserted_entry_shorter_than_the_ttl() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); map.insert_entry( - 0, + at(0), "a", Entry { value: 1, - expires_at: 5, + expires_at: at(5), }, ) .unwrap(); - assert_eq!(map.get(5, &"a"), Some(&1)); - assert_eq!(map.get(6, &"a"), None); + assert_eq!(map.get(at(5), &"a"), Some(&1)); + assert_eq!(map.get(at(6), &"a"), None); assert_consistent(&map); } @@ -185,11 +185,11 @@ fn should_reject_an_already_expired_entry() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); let rejected = map.insert_entry( - 20, + at(20), "a", Entry { value: 1, - expires_at: 19, + expires_at: at(19), }, ); @@ -201,15 +201,15 @@ fn should_reject_an_already_expired_entry() { #[test] fn should_reject_an_inserted_entry_when_full_of_live_entries() { let mut map = TimedSizedMap::new(Duration::from_nanos(1000), capacity(1)); - map.insert(1, "a", 1).unwrap(); + map.insert(at(1), "a", 1).unwrap(); let before = map.clone(); let rejected = map.insert_entry( - 2, + at(2), "b", Entry { value: 2, - expires_at: 500, + expires_at: at(500), }, ); @@ -224,9 +224,9 @@ fn should_reject_an_inserted_entry_when_full_of_live_entries() { #[test] fn should_remove_an_entry_from_both_indices() { let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(5)); - map.insert(0, "a", 1).unwrap(); - map.insert(0, "b", 2).unwrap(); - map.insert(10, "c", 3).unwrap(); + map.insert(at(0), "a", 1).unwrap(); + map.insert(at(0), "b", 2).unwrap(); + map.insert(at(10), "c", 3).unwrap(); let removed = map.remove(&"a"); @@ -234,11 +234,11 @@ fn should_remove_an_entry_from_both_indices() { removed, Some(Entry { value: 1, - expires_at: 100, + expires_at: at(100), }) ); assert_eq!(map.len(), 2); - assert_eq!(map.get(0, &"a"), None); + assert_eq!(map.get(at(0), &"a"), None); assert!(map.iter().all(|(key, _)| *key != "a")); assert!(map.iter_by_expiry().all(|(key, _)| *key != "a")); assert_consistent(&map); @@ -257,7 +257,7 @@ fn should_remove_an_entry_from_both_indices() { #[test] fn should_remove_an_expired_entry() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); - map.insert(0, "a", 1).unwrap(); + map.insert(at(0), "a", 1).unwrap(); let removed = map.remove(&"a"); @@ -269,7 +269,7 @@ fn should_remove_an_expired_entry() { #[test] fn should_return_none_when_removing_an_absent_key() { let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(5)); - map.insert(0, "a", 1).unwrap(); + map.insert(at(0), "a", 1).unwrap(); let before = map.clone(); assert_eq!(map.remove(&"absent"), None); @@ -281,23 +281,23 @@ fn should_return_none_when_removing_an_absent_key() { fn should_keep_the_indices_consistent_through_churn() { let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(4)); for (index, key) in ["a", "b", "c", "d"].into_iter().enumerate() { - map.insert(index as u64 + 1, key, index).unwrap(); + map.insert(at(index as u64 + 1), key, index).unwrap(); } assert_eq!(map.len(), 4); assert_consistent(&map); assert!(matches!( - map.insert(5, "e", 4), + map.insert(at(5), "e", 4), Err(InsertError::AtCapacity { .. }) )); assert!(matches!( - map.insert(6, "a", 99), + map.insert(at(6), "a", 99), Err(InsertError::AlreadyPresent { .. }) )); assert_eq!(map.len(), 4); assert_consistent(&map); - let evicted = map.evict_expired(1000); + let evicted = map.evict_expired(at(1000)); assert_eq!(evicted.len(), 4); assert!(map.is_empty()); @@ -308,20 +308,21 @@ fn should_keep_the_indices_consistent_through_churn() { fn should_saturate_the_expiry_of_a_ttl_beyond_the_epoch_range() { let mut map = TimedSizedMap::new(Duration::MAX, capacity(5)); - map.insert(u64::MAX - 1, "a", 1).unwrap(); + map.insert(at(u64::MAX - 1), "a", 1).unwrap(); assert_eq!( - map.get_entry(u64::MAX, &"a").map(|entry| entry.expires_at), - Some(u64::MAX) + map.get_entry(at(u64::MAX), &"a") + .map(|entry| entry.expires_at), + Some(at(u64::MAX)) ); } #[test] fn should_round_trip_through_from_ordered_entries() { let mut map = TimedSizedMap::new(Duration::from_nanos(100), capacity(5)); - map.insert(0, "b", 2).unwrap(); - map.insert(0, "a", 1).unwrap(); - map.insert(10, "c", 3).unwrap(); + map.insert(at(0), "b", 2).unwrap(); + map.insert(at(0), "a", 1).unwrap(); + map.insert(at(10), "c", 3).unwrap(); let entries: Vec<_> = map .iter_by_expiry() @@ -336,7 +337,7 @@ fn should_round_trip_through_from_ordered_entries() { #[test] fn should_preserve_expired_entries_when_rebuilding() { let mut map = TimedSizedMap::new(Duration::from_nanos(10), capacity(5)); - map.insert(0, "a", 1).unwrap(); + map.insert(at(0), "a", 1).unwrap(); let entries: Vec<_> = map .iter_by_expiry() .map(|(key, entry)| (*key, entry.clone())) @@ -346,7 +347,7 @@ fn should_preserve_expired_entries_when_rebuilding() { assert_eq!(restored, map); assert_eq!(restored.len(), 1); - assert_eq!(restored.get(20, &"a"), None); + assert_eq!(restored.get(at(20), &"a"), None); assert_consistent(&restored); } @@ -357,7 +358,7 @@ fn should_rebuild_beyond_the_capacity_without_eviction() { index, Entry { value: index, - expires_at: 10, + expires_at: at(10), }, ) }); @@ -381,7 +382,7 @@ fn should_rebuild_beyond_the_capacity_without_eviction() { fn should_panic_on_a_duplicate_key_in_from_ordered_entries() { let entry = Entry { value: 1, - expires_at: 10, + expires_at: at(10), }; let _ = TimedSizedMap::from_ordered_entries( @@ -391,6 +392,10 @@ fn should_panic_on_a_duplicate_key_in_from_ordered_entries() { ); } +fn at(nanos: u64) -> Timestamp { + Timestamp::from_nanos(nanos) +} + fn capacity(n: usize) -> NonZeroUsize { NonZeroUsize::new(n).unwrap() } @@ -408,7 +413,7 @@ fn assert_consistent = map + let expiries: Vec = map .iter_by_expiry() .map(|(_, entry)| entry.expires_at) .collect(); From fa252d024012e53f35ab1d87977804ad5d61b690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 11:19:00 +0000 Subject: [PATCH 035/146] refactor(minter): name the expiry index insertion of the timed sized map for what it does Co-Authored-By: Claude Fable 5.1 --- minter/src/utils/timed_sized_map/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minter/src/utils/timed_sized_map/mod.rs b/minter/src/utils/timed_sized_map/mod.rs index 1ebeb8eb1..3673edbfa 100644 --- a/minter/src/utils/timed_sized_map/mod.rs +++ b/minter/src/utils/timed_sized_map/mod.rs @@ -93,7 +93,7 @@ impl TimedSizedMap { ) -> Self { let mut map = Self::new(ttl, capacity); for (key, entry) in entries { - map.index(&key, entry.expires_at); + map.enqueue_by_expiry(&key, entry.expires_at); assert!( map.entries.insert(key, entry).is_none(), "BUG: from_ordered_entries received a duplicate key" @@ -148,7 +148,7 @@ impl TimedSizedMap { value: entry.value, expires_at: entry.expires_at.min(self.expiry(now)), }; - self.index(&key, entry.expires_at); + self.enqueue_by_expiry(&key, entry.expires_at); self.entries.insert(key, entry); Ok(evicted) } @@ -249,7 +249,7 @@ impl TimedSizedMap { self.by_expiry.keys().next().copied() } - fn index(&mut self, key: &K, expires_at: Timestamp) { + fn enqueue_by_expiry(&mut self, key: &K, expires_at: Timestamp) { self.by_expiry .entry(expires_at) .or_default() From 570cb8152337212559a2433aeaa5d9f6c95ce717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:18:46 +0000 Subject: [PATCH 036/146] feat(minter): add the watchlist of deposit addresses with its scan schedule Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 15 ++ minter/src/state/tests.rs | 1 + minter/src/state/watchlist/mod.rs | 205 +++++++++++++++++++++ minter/src/state/watchlist/tests.rs | 264 ++++++++++++++++++++++++++++ minter/src/test_fixtures/mod.rs | 21 ++- 5 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 minter/src/state/watchlist/mod.rs create mode 100644 minter/src/state/watchlist/tests.rs diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 31310941d..b7910082a 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -40,6 +40,7 @@ pub mod audit; mod deposits; pub mod event; mod nonce_pool; +pub(crate) mod watchlist; pub use deposits::{ DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, QuarantineCause, @@ -47,6 +48,7 @@ pub use deposits::{ SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, }; pub use nonce_pool::{DurableNoncePool, NoncePoolError, NonceRead}; +pub use watchlist::{WatchedAddress, Watchlist, WatchlistFull}; thread_local! { static STATE: RefCell> = RefCell::default(); @@ -113,6 +115,7 @@ pub struct State { pending_deposit_sol_request_guards: BTreeSet, pending_withdrawal_request_guards: BTreeSet, deposits: Deposits, + watchlist: Watchlist, pending_withdrawal_requests: BTreeMap, created_withdrawal_requests: BTreeMap, sent_withdrawal_requests: BTreeMap, @@ -193,6 +196,16 @@ impl State { &self.deposits } + pub fn watchlist(&self) -> &Watchlist { + &self.watchlist + } + + /// The watchlist is transient state: registrations are not recorded as events, so it is + /// mutated directly and carried across upgrades by a snapshot taken before the upgrade. + pub fn watchlist_mut(&mut self) -> &mut Watchlist { + &mut self.watchlist + } + pub fn sent_withdrawal_requests(&self) -> &BTreeMap { &self.sent_withdrawal_requests } @@ -442,6 +455,7 @@ impl State { deposit.address, deposit.account, ); + self.watchlist.remove(&deposit.account); self.deposits.queue(deposit_id, deposit, queued_at); } @@ -896,6 +910,7 @@ impl TryFrom for State { pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), + watchlist: Watchlist::default(), pending_withdrawal_requests: BTreeMap::new(), created_withdrawal_requests: BTreeMap::new(), sent_withdrawal_requests: BTreeMap::new(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index fc9470251..6547dd45e 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -761,6 +761,7 @@ mod state_from_init_args { pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), + watchlist: Watchlist::default(), pending_withdrawal_requests: BTreeMap::new(), created_withdrawal_requests: BTreeMap::new(), sent_withdrawal_requests: BTreeMap::new(), diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs new file mode 100644 index 000000000..132895d9f --- /dev/null +++ b/minter/src/state/watchlist/mod.rs @@ -0,0 +1,205 @@ +use crate::utils::timed_sized_map::{Entry, InsertError, TimedSizedMap}; +use icrc_ledger_types::icrc1::account::Account; +use solana_address::Address; +use std::{num::NonZeroUsize, time::Duration}; + +#[cfg(test)] +mod tests; + +/// How long a registration keeps a deposit address watched. +pub const WATCH_WINDOW: Duration = Duration::from_hours(24); + +/// The number of live registrations the watchlist holds at most. +pub const MAX_WATCHED_ADDRESSES: NonZeroUsize = + NonZeroUsize::new(1_000).expect("BUG: the watchlist capacity must be non-zero"); + +/// The gap, in seconds, between a scan and the next one, indexed by the number of scans done +/// so far minus one: a burst of ten scans within the first half hour, then hourly scans until +/// the window closes. +const SCAN_GAPS_SECS: [u64; 32] = [ + 60, 60, 120, 120, 240, 300, 300, 300, 300, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, + 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, +]; + +const NANOS_PER_SECOND: u64 = 1_000_000_000; + +/// The deposit addresses registered for the automatic deposit flow, each watched for +/// [`WATCH_WINDOW`] and scanned on the schedule of [`SCAN_GAPS_SECS`]. +#[derive(Debug, PartialEq, Eq)] +pub struct Watchlist { + entries: TimedSizedMap, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ScanProgress { + address: Address, + scan_count: u32, + last_scanned_at: Option, +} + +impl ScanProgress { + fn unscanned(address: Address) -> Self { + Self { + address, + scan_count: 0, + last_scanned_at: None, + } + } + + fn next_scan_at(&self, registered_at: u64, valid_until: u64) -> Option { + let due_at = match self.last_scanned_at { + None => registered_at, + Some(last_scanned_at) => { + let gap_index = usize::try_from(self.scan_count).ok()?.checked_sub(1)?; + let gap_secs = SCAN_GAPS_SECS.get(gap_index)?; + last_scanned_at.saturating_add(gap_secs.saturating_mul(NANOS_PER_SECOND)) + } + }; + (due_at <= valid_until).then_some(due_at) + } +} + +/// A registration of the watchlist with its scan progress, as of the time it was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WatchedAddress { + pub account: Account, + pub address: Address, + pub valid_until: u64, + pub scan_count: u32, + pub last_scanned_at: Option, + /// When the next scan is due, or `None` once the schedule leaves no scan before the + /// window closes. + pub next_scan_at: Option, +} + +/// The watchlist is full of live registrations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WatchlistFull; + +impl Default for Watchlist { + fn default() -> Self { + Self { + entries: TimedSizedMap::new(WATCH_WINDOW, MAX_WATCHED_ADDRESSES), + } + } +} + +impl Watchlist { + /// Registers the deposit address of the account for a window starting now, or returns the + /// live registration of the account as it is, without extending it. + pub fn register( + &mut self, + now: u64, + account: Account, + address: Address, + ) -> Result { + let inserted = self + .entries + .insert(now, account, ScanProgress::unscanned(address)); + self.admitted(now, &account, inserted) + } + + fn admitted( + &self, + now: u64, + account: &Account, + inserted: Result, InsertError>, + ) -> Result { + match inserted { + Ok(_) | Err(InsertError::AlreadyPresent { .. }) => Ok(self + .get(now, account) + .expect("BUG: a registration is live right after its insertion")), + Err(InsertError::AtCapacity { .. }) => Err(WatchlistFull), + Err(InsertError::Expired { .. }) => { + unreachable!( + "BUG: a registration for the window starting now never expires on insertion" + ) + } + } + } + + /// The live registration of the account, if any. + pub fn get(&self, now: u64, account: &Account) -> Option { + let entry = self.entries.get_entry(now, account)?; + Some(self.watched(account, entry)) + } + + pub fn remove(&mut self, account: &Account) { + self.entries.remove(account); + } + + /// The live registrations whose next scan is due. + pub fn due(&self, now: u64) -> Vec { + self.live(now) + .filter(|watched| watched.next_scan_at.is_some_and(|due_at| due_at <= now)) + .collect() + } + + /// Records a scan of the account's deposit address at `now`, which advances the account + /// along the schedule, if the account is still watched. + pub fn record_scan(&mut self, now: u64, account: &Account) { + if let Some(progress) = self.entries.get_value_mut(now, account) { + progress.scan_count = progress.scan_count.saturating_add(1); + progress.last_scanned_at = Some(now); + } + } + + /// Removes the registrations whose window has closed and returns how many there were. + pub fn evict_expired(&mut self, now: u64) -> usize { + self.entries.evict_expired(now).len() + } + + /// Every registration, live or expired but not yet evicted, by ascending window end. + pub fn iter(&self) -> impl Iterator { + self.entries + .iter_by_expiry() + .map(|(account, entry)| self.watched(account, entry)) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn live_len(&self, now: u64) -> usize { + self.live(now).count() + } + + /// When the oldest live registration was made, if any. + pub fn oldest_registered_at(&self, now: u64) -> Option { + self.live(now) + .map(|watched| self.registered_at(watched.valid_until)) + .min() + } + + fn live(&self, now: u64) -> impl Iterator { + self.entries + .iter_by_expiry() + .filter(move |(_, entry)| entry.is_live(now)) + .map(|(account, entry)| self.watched(account, entry)) + } + + fn watched(&self, account: &Account, entry: &Entry) -> WatchedAddress { + let progress = &entry.value; + let valid_until = entry.expires_at; + WatchedAddress { + account: *account, + address: progress.address, + valid_until, + scan_count: progress.scan_count, + last_scanned_at: progress.last_scanned_at, + next_scan_at: progress.next_scan_at(self.registered_at(valid_until), valid_until), + } + } + + fn registered_at(&self, valid_until: u64) -> u64 { + valid_until.saturating_sub(self.window_nanos()) + } + + fn window_nanos(&self) -> u64 { + u64::try_from(self.entries.ttl().as_nanos()).unwrap_or(u64::MAX) + } +} diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs new file mode 100644 index 000000000..887b01c05 --- /dev/null +++ b/minter/src/state/watchlist/tests.rs @@ -0,0 +1,264 @@ +use crate::{ + state::watchlist::{ + MAX_WATCHED_ADDRESSES, SCAN_GAPS_SECS, WatchedAddress, Watchlist, WatchlistFull, + }, + test_fixtures::{ + HOUR_NANOS, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, deposit_address, + register, + }, +}; +use icrc_ledger_types::icrc1::account::Account; +use std::time::Duration; + +const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; + +#[test] +fn should_register_an_account_for_the_window() { + let mut watchlist = Watchlist::default(); + + let watched = register(&mut watchlist, REGISTERED_AT, account(1)); + + assert_eq!(watched, unscanned(account(1))); + assert_eq!(watchlist.get(REGISTERED_AT, &account(1)), Some(watched)); + assert_eq!(watchlist.get(VALID_UNTIL, &account(1)), Some(watched)); + assert_eq!(watchlist.get(VALID_UNTIL + 1, &account(1)), None); + assert_eq!(watchlist.len(), 1); +} + +#[test] +fn should_keep_the_stored_registration_when_registering_again() { + let mut watchlist = Watchlist::default(); + let first = register(&mut watchlist, REGISTERED_AT, account(1)); + watchlist.record_scan(REGISTERED_AT + MINUTE_NANOS, &account(1)); + let scanned = watchlist + .get(REGISTERED_AT + MINUTE_NANOS, &account(1)) + .unwrap(); + + let second = register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); + + assert_eq!(second, scanned); + assert_eq!(second.valid_until, first.valid_until); + assert_eq!(watchlist.len(), 1); +} + +#[test] +fn should_register_anew_once_the_window_has_passed() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + watchlist.record_scan(REGISTERED_AT + MINUTE_NANOS, &account(1)); + + let renewed = register(&mut watchlist, VALID_UNTIL + 1, account(1)); + + assert_eq!( + renewed, + WatchedAddress { + valid_until: VALID_UNTIL + 1 + WATCH_WINDOW_NANOS, + next_scan_at: Some(VALID_UNTIL + 1), + ..unscanned(account(1)) + } + ); + assert_eq!(watchlist.len(), 1); +} + +#[test] +fn should_reject_a_registration_when_full_of_live_entries() { + let mut watchlist = full_watchlist(); + let extra = account(MAX_WATCHED_ADDRESSES.get()); + + let result = watchlist.register(VALID_UNTIL, extra, deposit_address(extra)); + + assert_eq!(result, Err(WatchlistFull)); + assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); +} + +#[test] +fn should_keep_an_existing_registration_while_full() { + let mut watchlist = full_watchlist(); + + let result = watchlist.register( + REGISTERED_AT + HOUR_NANOS, + account(0), + deposit_address(account(0)), + ); + + assert_eq!(result, Ok(unscanned(account(0)))); + assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); +} + +#[test] +fn should_admit_a_registration_once_a_window_of_a_full_watchlist_has_passed() { + let mut watchlist = full_watchlist(); + let extra = account(MAX_WATCHED_ADDRESSES.get()); + + let result = watchlist.register(VALID_UNTIL + 1, extra, deposit_address(extra)); + + assert!(result.is_ok()); + assert_eq!(watchlist.len(), 1); +} + +#[test] +fn should_be_due_for_the_first_scan_right_after_registration() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + + assert_eq!(watchlist.due(REGISTERED_AT), vec![unscanned(account(1))]); + assert_eq!(watchlist.due(REGISTERED_AT - 1), vec![]); +} + +#[test] +fn should_follow_the_scan_schedule_and_stop_before_the_window_closes() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + let mut scanned_at = REGISTERED_AT; + + for (scan_count, gap_secs) in SCAN_GAPS_SECS.into_iter().enumerate() { + watchlist.record_scan(scanned_at, &account(1)); + let watched = watchlist.get(scanned_at, &account(1)).unwrap(); + let next_scan_at = scanned_at + gap_secs * 1_000_000_000; + + assert_eq!(watched.scan_count, scan_count as u32 + 1); + assert_eq!(watched.last_scanned_at, Some(scanned_at)); + assert_eq!(watched.next_scan_at, Some(next_scan_at)); + assert_eq!(watchlist.due(next_scan_at - 1), vec![]); + assert_eq!(watchlist.due(next_scan_at), vec![watched]); + scanned_at = next_scan_at; + } + assert_eq!( + scanned_at, + REGISTERED_AT + Duration::from_mins(30 + 23 * 60).as_nanos() as u64 + ); + + watchlist.record_scan(scanned_at, &account(1)); + let watched = watchlist.get(scanned_at, &account(1)).unwrap(); + + assert_eq!(watched.scan_count, 33); + assert_eq!(watched.next_scan_at, None); + assert_eq!(watchlist.due(VALID_UNTIL), vec![]); +} + +#[test] +fn should_report_no_next_scan_when_the_gap_ends_after_the_window() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + for scan in 0..9 { + watchlist.record_scan(REGISTERED_AT + scan * MINUTE_NANOS, &account(1)); + } + + watchlist.record_scan(VALID_UNTIL - HOUR_NANOS, &account(1)); + assert_eq!( + watchlist + .get(VALID_UNTIL, &account(1)) + .unwrap() + .next_scan_at, + Some(VALID_UNTIL) + ); + + watchlist.record_scan(VALID_UNTIL - HOUR_NANOS + 1, &account(1)); + assert_eq!( + watchlist + .get(VALID_UNTIL, &account(1)) + .unwrap() + .next_scan_at, + None + ); +} + +#[test] +fn should_ignore_a_scan_of_an_account_that_is_not_watched() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + + watchlist.record_scan(REGISTERED_AT, &account(2)); + watchlist.record_scan(VALID_UNTIL + 1, &account(1)); + + assert_eq!( + watchlist.iter().collect::>(), + vec![unscanned(account(1))] + ); +} + +#[test] +fn should_remove_a_registration() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + register(&mut watchlist, REGISTERED_AT, account(2)); + + watchlist.remove(&account(1)); + watchlist.remove(&account(3)); + + assert_eq!(watchlist.get(REGISTERED_AT, &account(1)), None); + assert_eq!(watchlist.len(), 1); + assert_eq!(watchlist.due(REGISTERED_AT), vec![unscanned(account(2))]); +} + +#[test] +fn should_count_the_live_registrations_and_report_the_oldest() { + let watchlist = watchlist_of_two_registrations_an_hour_apart(); + + assert_eq!(watchlist.live_len(VALID_UNTIL), 2); + assert_eq!( + watchlist.oldest_registered_at(VALID_UNTIL), + Some(REGISTERED_AT) + ); + assert_eq!(watchlist.live_len(VALID_UNTIL + 1), 1); + assert_eq!( + watchlist.oldest_registered_at(VALID_UNTIL + 1), + Some(REGISTERED_AT + HOUR_NANOS) + ); + assert_eq!(watchlist.live_len(VALID_UNTIL + HOUR_NANOS + 1), 0); + assert_eq!( + watchlist.oldest_registered_at(VALID_UNTIL + HOUR_NANOS + 1), + None + ); + assert_eq!(watchlist.len(), 2); +} + +#[test] +fn should_evict_the_registrations_whose_window_has_closed() { + let mut watchlist = watchlist_of_two_registrations_an_hour_apart(); + + assert_eq!(watchlist.evict_expired(VALID_UNTIL), 0); + assert_eq!(watchlist.len(), 2); + assert_eq!(watchlist.evict_expired(VALID_UNTIL + 1), 1); + assert_eq!(watchlist.len(), 1); + assert_eq!(watchlist.evict_expired(VALID_UNTIL + HOUR_NANOS + 1), 1); + assert!(watchlist.is_empty()); +} + +#[test] +fn should_iterate_by_ascending_window_end() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); + register(&mut watchlist, REGISTERED_AT, account(3)); + register(&mut watchlist, REGISTERED_AT, account(2)); + + let accounts: Vec = watchlist.iter().map(|watched| watched.account).collect(); + + assert_eq!(accounts, vec![account(3), account(2), account(1)]); +} + +fn full_watchlist() -> Watchlist { + let mut watchlist = Watchlist::default(); + for index in 0..MAX_WATCHED_ADDRESSES.get() { + register(&mut watchlist, REGISTERED_AT, account(index)); + } + watchlist +} + +fn watchlist_of_two_registrations_an_hour_apart() -> Watchlist { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT, account(1)); + register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(2)); + watchlist +} + +fn unscanned(account: Account) -> WatchedAddress { + WatchedAddress { + account, + address: deposit_address(account), + valid_until: VALID_UNTIL, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(REGISTERED_AT), + } +} diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 2eff4dc82..24a722c08 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -4,9 +4,10 @@ use crate::{ rpc::{BlockHeight, FetchedTransaction}, sol_transfer::build_batch_withdrawal_message, state::{ - DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, + DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, WatchedAddress, Watchlist, event::{Event, EventType, VersionedMessage}, init_once_state, mutate_state, + watchlist::WATCH_WINDOW, }, storage::with_event_iter, }; @@ -28,7 +29,7 @@ use solana_transaction_status_client_types::{ EncodedTransactionWithStatusMeta, TransactionBinaryEncoding, UiLoadedAddresses, UiTransactionStatusMeta, option_serializer::OptionSerializer, }; -use std::{collections::VecDeque, str::FromStr}; +use std::{collections::VecDeque, str::FromStr, time::Duration}; pub mod flow; pub mod runtime; @@ -55,6 +56,10 @@ pub const NONCE_ACCOUNT: Address = address!("US517G5965aydkZ46HS38QLi7UQiSojurfb pub const MINIMUM_DEPOSIT_AMOUNT: Lamport = 20_000_000; // 0.02 SOL pub const DEPOSIT_SOL_REQUIRED_CYCLES: u128 = 1_000_000_000_000; pub const AUTOMATIC_DEPOSIT_FEE: Lamport = 2_000_000; // 0.002 SOL +pub const REGISTERED_AT: u64 = 1_700_000_000_000_000_000; +pub const WATCH_WINDOW_NANOS: u64 = WATCH_WINDOW.as_nanos() as u64; +pub const MINUTE_NANOS: u64 = Duration::from_mins(1).as_nanos() as u64; +pub const HOUR_NANOS: u64 = Duration::from_hours(1).as_nanos() as u64; pub fn sol_rpc_canister_id() -> Principal { Principal::from_slice(&[1_u8; 20]) @@ -154,6 +159,18 @@ pub fn minter_public_key_fetched_event() -> EventType { } } +/// Registers the deposit address of the account on the watchlist of the state at `now`. +pub fn watch(now: u64, account: Account) -> WatchedAddress { + mutate_state(|state| register(state.watchlist_mut(), now, account)) +} + +/// Registers the deposit address of the account on the watchlist at `now`. +pub fn register(watchlist: &mut Watchlist, now: u64, account: Account) -> WatchedAddress { + watchlist + .register(now, account, deposit_address(account)) + .expect("BUG: the watchlist has room for the registration") +} + /// Returns a [`Signature`] unique for any `usize` index, derived from `i as u64` via le_bytes. pub fn signature(i: usize) -> solana_signature::Signature { let mut bytes = [0u8; 64]; From f9aefd0cab0dba77688270659d0044a0105e3351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:33:35 +0000 Subject: [PATCH 037/146] refactor(minter): convert the watchlist times at the boundary of the timed sized map Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 132895d9f..546bdca6c 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -1,4 +1,4 @@ -use crate::utils::timed_sized_map::{Entry, InsertError, TimedSizedMap}; +use crate::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}; @@ -93,9 +93,11 @@ impl Watchlist { account: Account, address: Address, ) -> Result { - let inserted = self - .entries - .insert(now, account, ScanProgress::unscanned(address)); + let inserted = self.entries.insert( + Timestamp::from_nanos(now), + account, + ScanProgress::unscanned(address), + ); self.admitted(now, &account, inserted) } @@ -120,7 +122,9 @@ impl Watchlist { /// The live registration of the account, if any. pub fn get(&self, now: u64, account: &Account) -> Option { - let entry = self.entries.get_entry(now, account)?; + let entry = self + .entries + .get_entry(Timestamp::from_nanos(now), account)?; Some(self.watched(account, entry)) } @@ -138,7 +142,10 @@ impl Watchlist { /// Records a scan of the account's deposit address at `now`, which advances the account /// along the schedule, if the account is still watched. pub fn record_scan(&mut self, now: u64, account: &Account) { - if let Some(progress) = self.entries.get_value_mut(now, account) { + if let Some(progress) = self + .entries + .get_value_mut(Timestamp::from_nanos(now), account) + { progress.scan_count = progress.scan_count.saturating_add(1); progress.last_scanned_at = Some(now); } @@ -146,7 +153,7 @@ impl Watchlist { /// Removes the registrations whose window has closed and returns how many there were. pub fn evict_expired(&mut self, now: u64) -> usize { - self.entries.evict_expired(now).len() + self.entries.evict_expired(Timestamp::from_nanos(now)).len() } /// Every registration, live or expired but not yet evicted, by ascending window end. @@ -176,6 +183,7 @@ impl Watchlist { } fn live(&self, now: u64) -> impl Iterator { + let now = Timestamp::from_nanos(now); self.entries .iter_by_expiry() .filter(move |(_, entry)| entry.is_live(now)) @@ -184,7 +192,7 @@ impl Watchlist { fn watched(&self, account: &Account, entry: &Entry) -> WatchedAddress { let progress = &entry.value; - let valid_until = entry.expires_at; + let valid_until = entry.expires_at.as_nanos(); WatchedAddress { account: *account, address: progress.address, From 98229938b65932ab3c64481e103b7ea63ee0347f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:49:47 +0000 Subject: [PATCH 038/146] refactor(minter): take and report timestamps as a newtype in the watchlist API Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 60 ++++++------- minter/src/state/watchlist/tests.rs | 132 +++++++++++++++------------- minter/src/test_fixtures/mod.rs | 10 ++- 3 files changed, 105 insertions(+), 97 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 546bdca6c..686e69a8c 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -34,7 +34,7 @@ pub struct Watchlist { struct ScanProgress { address: Address, scan_count: u32, - last_scanned_at: Option, + last_scanned_at: Option, } impl ScanProgress { @@ -46,13 +46,17 @@ impl ScanProgress { } } - fn next_scan_at(&self, registered_at: u64, valid_until: u64) -> Option { + fn next_scan_at(&self, registered_at: Timestamp, valid_until: Timestamp) -> Option { let due_at = match self.last_scanned_at { None => registered_at, Some(last_scanned_at) => { let gap_index = usize::try_from(self.scan_count).ok()?.checked_sub(1)?; let gap_secs = SCAN_GAPS_SECS.get(gap_index)?; - last_scanned_at.saturating_add(gap_secs.saturating_mul(NANOS_PER_SECOND)) + Timestamp::from_nanos( + last_scanned_at + .as_nanos() + .saturating_add(gap_secs.saturating_mul(NANOS_PER_SECOND)), + ) } }; (due_at <= valid_until).then_some(due_at) @@ -64,12 +68,12 @@ impl ScanProgress { pub struct WatchedAddress { pub account: Account, pub address: Address, - pub valid_until: u64, + pub valid_until: Timestamp, pub scan_count: u32, - pub last_scanned_at: Option, + pub last_scanned_at: Option, /// When the next scan is due, or `None` once the schedule leaves no scan before the /// window closes. - pub next_scan_at: Option, + pub next_scan_at: Option, } /// The watchlist is full of live registrations. @@ -89,21 +93,19 @@ impl Watchlist { /// live registration of the account as it is, without extending it. pub fn register( &mut self, - now: u64, + now: Timestamp, account: Account, address: Address, ) -> Result { - let inserted = self.entries.insert( - Timestamp::from_nanos(now), - account, - ScanProgress::unscanned(address), - ); + let inserted = self + .entries + .insert(now, account, ScanProgress::unscanned(address)); self.admitted(now, &account, inserted) } fn admitted( &self, - now: u64, + now: Timestamp, account: &Account, inserted: Result, InsertError>, ) -> Result { @@ -121,10 +123,8 @@ impl Watchlist { } /// The live registration of the account, if any. - pub fn get(&self, now: u64, account: &Account) -> Option { - let entry = self - .entries - .get_entry(Timestamp::from_nanos(now), account)?; + pub fn get(&self, now: Timestamp, account: &Account) -> Option { + let entry = self.entries.get_entry(now, account)?; Some(self.watched(account, entry)) } @@ -133,7 +133,7 @@ impl Watchlist { } /// The live registrations whose next scan is due. - pub fn due(&self, now: u64) -> Vec { + pub fn due(&self, now: Timestamp) -> Vec { self.live(now) .filter(|watched| watched.next_scan_at.is_some_and(|due_at| due_at <= now)) .collect() @@ -141,19 +141,16 @@ impl Watchlist { /// Records a scan of the account's deposit address at `now`, which advances the account /// along the schedule, if the account is still watched. - pub fn record_scan(&mut self, now: u64, account: &Account) { - if let Some(progress) = self - .entries - .get_value_mut(Timestamp::from_nanos(now), account) - { + pub fn record_scan(&mut self, now: Timestamp, account: &Account) { + if let Some(progress) = self.entries.get_value_mut(now, account) { progress.scan_count = progress.scan_count.saturating_add(1); progress.last_scanned_at = Some(now); } } /// Removes the registrations whose window has closed and returns how many there were. - pub fn evict_expired(&mut self, now: u64) -> usize { - self.entries.evict_expired(Timestamp::from_nanos(now)).len() + pub fn evict_expired(&mut self, now: Timestamp) -> usize { + self.entries.evict_expired(now).len() } /// Every registration, live or expired but not yet evicted, by ascending window end. @@ -171,19 +168,18 @@ impl Watchlist { self.entries.is_empty() } - pub fn live_len(&self, now: u64) -> usize { + pub fn live_len(&self, now: Timestamp) -> usize { self.live(now).count() } /// When the oldest live registration was made, if any. - pub fn oldest_registered_at(&self, now: u64) -> Option { + pub fn oldest_registered_at(&self, now: Timestamp) -> Option { self.live(now) .map(|watched| self.registered_at(watched.valid_until)) .min() } - fn live(&self, now: u64) -> impl Iterator { - let now = Timestamp::from_nanos(now); + fn live(&self, now: Timestamp) -> impl Iterator { self.entries .iter_by_expiry() .filter(move |(_, entry)| entry.is_live(now)) @@ -192,7 +188,7 @@ impl Watchlist { fn watched(&self, account: &Account, entry: &Entry) -> WatchedAddress { let progress = &entry.value; - let valid_until = entry.expires_at.as_nanos(); + let valid_until = entry.expires_at; WatchedAddress { account: *account, address: progress.address, @@ -203,8 +199,8 @@ impl Watchlist { } } - fn registered_at(&self, valid_until: u64) -> u64 { - valid_until.saturating_sub(self.window_nanos()) + fn registered_at(&self, valid_until: Timestamp) -> Timestamp { + Timestamp::from_nanos(valid_until.as_nanos().saturating_sub(self.window_nanos())) } fn window_nanos(&self) -> u64 { diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 887b01c05..6bc792c6e 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -3,7 +3,7 @@ use crate::{ MAX_WATCHED_ADDRESSES, SCAN_GAPS_SECS, WatchedAddress, Watchlist, WatchlistFull, }, test_fixtures::{ - HOUR_NANOS, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, deposit_address, + HOUR_NANOS, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, register, }, }; @@ -16,25 +16,25 @@ const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; fn should_register_an_account_for_the_window() { let mut watchlist = Watchlist::default(); - let watched = register(&mut watchlist, REGISTERED_AT, account(1)); + let watched = register(&mut watchlist, at(REGISTERED_AT), account(1)); assert_eq!(watched, unscanned(account(1))); - assert_eq!(watchlist.get(REGISTERED_AT, &account(1)), Some(watched)); - assert_eq!(watchlist.get(VALID_UNTIL, &account(1)), Some(watched)); - assert_eq!(watchlist.get(VALID_UNTIL + 1, &account(1)), None); + assert_eq!(watchlist.get(at(REGISTERED_AT), &account(1)), Some(watched)); + assert_eq!(watchlist.get(at(VALID_UNTIL), &account(1)), Some(watched)); + assert_eq!(watchlist.get(at(VALID_UNTIL + 1), &account(1)), None); assert_eq!(watchlist.len(), 1); } #[test] fn should_keep_the_stored_registration_when_registering_again() { let mut watchlist = Watchlist::default(); - let first = register(&mut watchlist, REGISTERED_AT, account(1)); - watchlist.record_scan(REGISTERED_AT + MINUTE_NANOS, &account(1)); + let first = register(&mut watchlist, at(REGISTERED_AT), account(1)); + watchlist.record_scan(at(REGISTERED_AT + MINUTE_NANOS), &account(1)); let scanned = watchlist - .get(REGISTERED_AT + MINUTE_NANOS, &account(1)) + .get(at(REGISTERED_AT + MINUTE_NANOS), &account(1)) .unwrap(); - let second = register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); + let second = register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); assert_eq!(second, scanned); assert_eq!(second.valid_until, first.valid_until); @@ -44,16 +44,16 @@ fn should_keep_the_stored_registration_when_registering_again() { #[test] fn should_register_anew_once_the_window_has_passed() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); - watchlist.record_scan(REGISTERED_AT + MINUTE_NANOS, &account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + watchlist.record_scan(at(REGISTERED_AT + MINUTE_NANOS), &account(1)); - let renewed = register(&mut watchlist, VALID_UNTIL + 1, account(1)); + let renewed = register(&mut watchlist, at(VALID_UNTIL + 1), account(1)); assert_eq!( renewed, WatchedAddress { - valid_until: VALID_UNTIL + 1 + WATCH_WINDOW_NANOS, - next_scan_at: Some(VALID_UNTIL + 1), + valid_until: at(VALID_UNTIL + 1 + WATCH_WINDOW_NANOS), + next_scan_at: Some(at(VALID_UNTIL + 1)), ..unscanned(account(1)) } ); @@ -65,7 +65,7 @@ fn should_reject_a_registration_when_full_of_live_entries() { let mut watchlist = full_watchlist(); let extra = account(MAX_WATCHED_ADDRESSES.get()); - let result = watchlist.register(VALID_UNTIL, extra, deposit_address(extra)); + let result = watchlist.register(at(VALID_UNTIL), extra, deposit_address(extra)); assert_eq!(result, Err(WatchlistFull)); assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); @@ -76,7 +76,7 @@ fn should_keep_an_existing_registration_while_full() { let mut watchlist = full_watchlist(); let result = watchlist.register( - REGISTERED_AT + HOUR_NANOS, + at(REGISTERED_AT + HOUR_NANOS), account(0), deposit_address(account(0)), ); @@ -90,7 +90,7 @@ fn should_admit_a_registration_once_a_window_of_a_full_watchlist_has_passed() { let mut watchlist = full_watchlist(); let extra = account(MAX_WATCHED_ADDRESSES.get()); - let result = watchlist.register(VALID_UNTIL + 1, extra, deposit_address(extra)); + let result = watchlist.register(at(VALID_UNTIL + 1), extra, deposit_address(extra)); assert!(result.is_ok()); assert_eq!(watchlist.len(), 1); @@ -99,28 +99,31 @@ fn should_admit_a_registration_once_a_window_of_a_full_watchlist_has_passed() { #[test] fn should_be_due_for_the_first_scan_right_after_registration() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); - assert_eq!(watchlist.due(REGISTERED_AT), vec![unscanned(account(1))]); - assert_eq!(watchlist.due(REGISTERED_AT - 1), vec![]); + assert_eq!( + watchlist.due(at(REGISTERED_AT)), + vec![unscanned(account(1))] + ); + assert_eq!(watchlist.due(at(REGISTERED_AT - 1)), vec![]); } #[test] fn should_follow_the_scan_schedule_and_stop_before_the_window_closes() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); let mut scanned_at = REGISTERED_AT; for (scan_count, gap_secs) in SCAN_GAPS_SECS.into_iter().enumerate() { - watchlist.record_scan(scanned_at, &account(1)); - let watched = watchlist.get(scanned_at, &account(1)).unwrap(); + watchlist.record_scan(at(scanned_at), &account(1)); + let watched = watchlist.get(at(scanned_at), &account(1)).unwrap(); let next_scan_at = scanned_at + gap_secs * 1_000_000_000; assert_eq!(watched.scan_count, scan_count as u32 + 1); - assert_eq!(watched.last_scanned_at, Some(scanned_at)); - assert_eq!(watched.next_scan_at, Some(next_scan_at)); - assert_eq!(watchlist.due(next_scan_at - 1), vec![]); - assert_eq!(watchlist.due(next_scan_at), vec![watched]); + assert_eq!(watched.last_scanned_at, Some(at(scanned_at))); + assert_eq!(watched.next_scan_at, Some(at(next_scan_at))); + assert_eq!(watchlist.due(at(next_scan_at - 1)), vec![]); + assert_eq!(watchlist.due(at(next_scan_at)), vec![watched]); scanned_at = next_scan_at; } assert_eq!( @@ -128,35 +131,35 @@ fn should_follow_the_scan_schedule_and_stop_before_the_window_closes() { REGISTERED_AT + Duration::from_mins(30 + 23 * 60).as_nanos() as u64 ); - watchlist.record_scan(scanned_at, &account(1)); - let watched = watchlist.get(scanned_at, &account(1)).unwrap(); + watchlist.record_scan(at(scanned_at), &account(1)); + let watched = watchlist.get(at(scanned_at), &account(1)).unwrap(); assert_eq!(watched.scan_count, 33); assert_eq!(watched.next_scan_at, None); - assert_eq!(watchlist.due(VALID_UNTIL), vec![]); + assert_eq!(watchlist.due(at(VALID_UNTIL)), vec![]); } #[test] fn should_report_no_next_scan_when_the_gap_ends_after_the_window() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); for scan in 0..9 { - watchlist.record_scan(REGISTERED_AT + scan * MINUTE_NANOS, &account(1)); + watchlist.record_scan(at(REGISTERED_AT + scan * MINUTE_NANOS), &account(1)); } - watchlist.record_scan(VALID_UNTIL - HOUR_NANOS, &account(1)); + watchlist.record_scan(at(VALID_UNTIL - HOUR_NANOS), &account(1)); assert_eq!( watchlist - .get(VALID_UNTIL, &account(1)) + .get(at(VALID_UNTIL), &account(1)) .unwrap() .next_scan_at, - Some(VALID_UNTIL) + Some(at(VALID_UNTIL)) ); - watchlist.record_scan(VALID_UNTIL - HOUR_NANOS + 1, &account(1)); + watchlist.record_scan(at(VALID_UNTIL - HOUR_NANOS + 1), &account(1)); assert_eq!( watchlist - .get(VALID_UNTIL, &account(1)) + .get(at(VALID_UNTIL), &account(1)) .unwrap() .next_scan_at, None @@ -166,10 +169,10 @@ fn should_report_no_next_scan_when_the_gap_ends_after_the_window() { #[test] fn should_ignore_a_scan_of_an_account_that_is_not_watched() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); - watchlist.record_scan(REGISTERED_AT, &account(2)); - watchlist.record_scan(VALID_UNTIL + 1, &account(1)); + watchlist.record_scan(at(REGISTERED_AT), &account(2)); + watchlist.record_scan(at(VALID_UNTIL + 1), &account(1)); assert_eq!( watchlist.iter().collect::>(), @@ -180,34 +183,37 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { #[test] fn should_remove_a_registration() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); - register(&mut watchlist, REGISTERED_AT, account(2)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(2)); watchlist.remove(&account(1)); watchlist.remove(&account(3)); - assert_eq!(watchlist.get(REGISTERED_AT, &account(1)), None); + assert_eq!(watchlist.get(at(REGISTERED_AT), &account(1)), None); assert_eq!(watchlist.len(), 1); - assert_eq!(watchlist.due(REGISTERED_AT), vec![unscanned(account(2))]); + assert_eq!( + watchlist.due(at(REGISTERED_AT)), + vec![unscanned(account(2))] + ); } #[test] fn should_count_the_live_registrations_and_report_the_oldest() { let watchlist = watchlist_of_two_registrations_an_hour_apart(); - assert_eq!(watchlist.live_len(VALID_UNTIL), 2); + assert_eq!(watchlist.live_len(at(VALID_UNTIL)), 2); assert_eq!( - watchlist.oldest_registered_at(VALID_UNTIL), - Some(REGISTERED_AT) + watchlist.oldest_registered_at(at(VALID_UNTIL)), + Some(at(REGISTERED_AT)) ); - assert_eq!(watchlist.live_len(VALID_UNTIL + 1), 1); + assert_eq!(watchlist.live_len(at(VALID_UNTIL + 1)), 1); assert_eq!( - watchlist.oldest_registered_at(VALID_UNTIL + 1), - Some(REGISTERED_AT + HOUR_NANOS) + watchlist.oldest_registered_at(at(VALID_UNTIL + 1)), + Some(at(REGISTERED_AT + HOUR_NANOS)) ); - assert_eq!(watchlist.live_len(VALID_UNTIL + HOUR_NANOS + 1), 0); + assert_eq!(watchlist.live_len(at(VALID_UNTIL + HOUR_NANOS + 1)), 0); assert_eq!( - watchlist.oldest_registered_at(VALID_UNTIL + HOUR_NANOS + 1), + watchlist.oldest_registered_at(at(VALID_UNTIL + HOUR_NANOS + 1)), None ); assert_eq!(watchlist.len(), 2); @@ -217,20 +223,20 @@ fn should_count_the_live_registrations_and_report_the_oldest() { fn should_evict_the_registrations_whose_window_has_closed() { let mut watchlist = watchlist_of_two_registrations_an_hour_apart(); - assert_eq!(watchlist.evict_expired(VALID_UNTIL), 0); + assert_eq!(watchlist.evict_expired(at(VALID_UNTIL)), 0); assert_eq!(watchlist.len(), 2); - assert_eq!(watchlist.evict_expired(VALID_UNTIL + 1), 1); + assert_eq!(watchlist.evict_expired(at(VALID_UNTIL + 1)), 1); assert_eq!(watchlist.len(), 1); - assert_eq!(watchlist.evict_expired(VALID_UNTIL + HOUR_NANOS + 1), 1); + assert_eq!(watchlist.evict_expired(at(VALID_UNTIL + HOUR_NANOS + 1)), 1); assert!(watchlist.is_empty()); } #[test] fn should_iterate_by_ascending_window_end() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); - register(&mut watchlist, REGISTERED_AT, account(3)); - register(&mut watchlist, REGISTERED_AT, account(2)); + register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(3)); + register(&mut watchlist, at(REGISTERED_AT), account(2)); let accounts: Vec = watchlist.iter().map(|watched| watched.account).collect(); @@ -240,15 +246,15 @@ fn should_iterate_by_ascending_window_end() { fn full_watchlist() -> Watchlist { let mut watchlist = Watchlist::default(); for index in 0..MAX_WATCHED_ADDRESSES.get() { - register(&mut watchlist, REGISTERED_AT, account(index)); + register(&mut watchlist, at(REGISTERED_AT), account(index)); } watchlist } fn watchlist_of_two_registrations_an_hour_apart() -> Watchlist { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT, account(1)); - register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(2)); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(2)); watchlist } @@ -256,9 +262,9 @@ fn unscanned(account: Account) -> WatchedAddress { WatchedAddress { account, address: deposit_address(account), - valid_until: VALID_UNTIL, + valid_until: at(VALID_UNTIL), scan_count: 0, last_scanned_at: None, - next_scan_at: Some(REGISTERED_AT), + next_scan_at: Some(at(REGISTERED_AT)), } } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 24a722c08..158bb30dc 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -10,6 +10,7 @@ use crate::{ watchlist::WATCH_WINDOW, }, storage::with_event_iter, + utils::timed_sized_map::Timestamp, }; use base64::{Engine, engine::general_purpose::STANDARD}; use candid::Principal; @@ -159,13 +160,18 @@ pub fn minter_public_key_fetched_event() -> EventType { } } +/// The timestamp of `nanos` nanoseconds since the epoch. +pub fn at(nanos: u64) -> Timestamp { + Timestamp::from_nanos(nanos) +} + /// Registers the deposit address of the account on the watchlist of the state at `now`. -pub fn watch(now: u64, account: Account) -> WatchedAddress { +pub fn watch(now: Timestamp, account: Account) -> WatchedAddress { mutate_state(|state| register(state.watchlist_mut(), now, account)) } /// Registers the deposit address of the account on the watchlist at `now`. -pub fn register(watchlist: &mut Watchlist, now: u64, account: Account) -> WatchedAddress { +pub fn register(watchlist: &mut Watchlist, now: Timestamp, account: Account) -> WatchedAddress { watchlist .register(now, account, deposit_address(account)) .expect("BUG: the watchlist has room for the registration") From 5b8b3899a75a72701aa91e7734dd171e67072557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:38:33 +0000 Subject: [PATCH 039/146] refactor(minter): add saturating duration arithmetic to the timestamps Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 14 ++------------ minter/src/utils/timed_sized_map/mod.rs | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 686e69a8c..44db2139d 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -21,8 +21,6 @@ const SCAN_GAPS_SECS: [u64; 32] = [ 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, ]; -const NANOS_PER_SECOND: u64 = 1_000_000_000; - /// The deposit addresses registered for the automatic deposit flow, each watched for /// [`WATCH_WINDOW`] and scanned on the schedule of [`SCAN_GAPS_SECS`]. #[derive(Debug, PartialEq, Eq)] @@ -52,11 +50,7 @@ impl ScanProgress { Some(last_scanned_at) => { let gap_index = usize::try_from(self.scan_count).ok()?.checked_sub(1)?; let gap_secs = SCAN_GAPS_SECS.get(gap_index)?; - Timestamp::from_nanos( - last_scanned_at - .as_nanos() - .saturating_add(gap_secs.saturating_mul(NANOS_PER_SECOND)), - ) + last_scanned_at.saturating_add(Duration::from_secs(*gap_secs)) } }; (due_at <= valid_until).then_some(due_at) @@ -200,10 +194,6 @@ impl Watchlist { } fn registered_at(&self, valid_until: Timestamp) -> Timestamp { - Timestamp::from_nanos(valid_until.as_nanos().saturating_sub(self.window_nanos())) - } - - fn window_nanos(&self) -> u64 { - u64::try_from(self.entries.ttl().as_nanos()).unwrap_or(u64::MAX) + valid_until.saturating_sub(self.entries.ttl()) } } diff --git a/minter/src/utils/timed_sized_map/mod.rs b/minter/src/utils/timed_sized_map/mod.rs index 3673edbfa..2bee60828 100644 --- a/minter/src/utils/timed_sized_map/mod.rs +++ b/minter/src/utils/timed_sized_map/mod.rs @@ -19,6 +19,18 @@ impl Timestamp { pub const fn as_nanos(self) -> u64 { self.0 } + + pub fn saturating_add(self, duration: Duration) -> Self { + Self(self.0.saturating_add(nanos(duration))) + } + + pub fn saturating_sub(self, duration: Duration) -> Self { + Self(self.0.saturating_sub(nanos(duration))) + } +} + +fn nanos(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) } /// A value of a [`TimedSizedMap`] together with the time after which it is expired. @@ -241,8 +253,7 @@ impl TimedSizedMap { } fn expiry(&self, now: Timestamp) -> Timestamp { - let ttl_nanos = u64::try_from(self.ttl.as_nanos()).unwrap_or(u64::MAX); - Timestamp::from_nanos(now.as_nanos().saturating_add(ttl_nanos)) + now.saturating_add(self.ttl) } fn earliest_expiry(&self) -> Option { From e0a39df00e38902c72c6ed3bf93482cc6396c5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:38:43 +0000 Subject: [PATCH 040/146] refactor(minter): schedule the scans from a burst of gaps and a steady gap Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 30 ++++++++++++++++++++--------- minter/src/state/watchlist/tests.rs | 10 +++++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 44db2139d..4622f4f89 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -13,16 +13,25 @@ pub const WATCH_WINDOW: Duration = Duration::from_hours(24); pub const MAX_WATCHED_ADDRESSES: NonZeroUsize = NonZeroUsize::new(1_000).expect("BUG: the watchlist capacity must be non-zero"); -/// The gap, in seconds, between a scan and the next one, indexed by the number of scans done -/// so far minus one: a burst of ten scans within the first half hour, then hourly scans until -/// the window closes. -const SCAN_GAPS_SECS: [u64; 32] = [ - 60, 60, 120, 120, 240, 300, 300, 300, 300, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, - 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, 3600, +/// The gaps between a scan and the next one for the first scans, indexed by the number of +/// scans done so far minus one: a burst of ten scans within the first half hour. +const BURST_SCAN_GAPS: [Duration; 9] = [ + Duration::from_secs(60), + Duration::from_secs(60), + Duration::from_secs(120), + Duration::from_secs(120), + Duration::from_secs(240), + Duration::from_secs(300), + Duration::from_secs(300), + Duration::from_secs(300), + Duration::from_secs(300), ]; +/// The gap between a scan and the next one after the burst, until the window closes. +const STEADY_SCAN_GAP: Duration = Duration::from_hours(1); + /// The deposit addresses registered for the automatic deposit flow, each watched for -/// [`WATCH_WINDOW`] and scanned on the schedule of [`SCAN_GAPS_SECS`]. +/// [`WATCH_WINDOW`] and scanned on the schedule of [`BURST_SCAN_GAPS`] and [`STEADY_SCAN_GAP`]. #[derive(Debug, PartialEq, Eq)] pub struct Watchlist { entries: TimedSizedMap, @@ -49,8 +58,11 @@ impl ScanProgress { None => registered_at, Some(last_scanned_at) => { let gap_index = usize::try_from(self.scan_count).ok()?.checked_sub(1)?; - let gap_secs = SCAN_GAPS_SECS.get(gap_index)?; - last_scanned_at.saturating_add(Duration::from_secs(*gap_secs)) + let gap = BURST_SCAN_GAPS + .get(gap_index) + .copied() + .unwrap_or(STEADY_SCAN_GAP); + last_scanned_at.saturating_add(gap) } }; (due_at <= valid_until).then_some(due_at) diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 6bc792c6e..65646f39d 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -1,6 +1,7 @@ use crate::{ state::watchlist::{ - MAX_WATCHED_ADDRESSES, SCAN_GAPS_SECS, WatchedAddress, Watchlist, WatchlistFull, + BURST_SCAN_GAPS, MAX_WATCHED_ADDRESSES, STEADY_SCAN_GAP, WatchedAddress, Watchlist, + WatchlistFull, }, test_fixtures::{ HOUR_NANOS, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, @@ -114,10 +115,13 @@ fn should_follow_the_scan_schedule_and_stop_before_the_window_closes() { register(&mut watchlist, at(REGISTERED_AT), account(1)); let mut scanned_at = REGISTERED_AT; - for (scan_count, gap_secs) in SCAN_GAPS_SECS.into_iter().enumerate() { + let gaps = BURST_SCAN_GAPS + .into_iter() + .chain(std::iter::repeat_n(STEADY_SCAN_GAP, 23)); + for (scan_count, gap) in gaps.enumerate() { watchlist.record_scan(at(scanned_at), &account(1)); let watched = watchlist.get(at(scanned_at), &account(1)).unwrap(); - let next_scan_at = scanned_at + gap_secs * 1_000_000_000; + let next_scan_at = scanned_at + gap.as_nanos() as u64; assert_eq!(watched.scan_count, scan_count as u32 + 1); assert_eq!(watched.last_scanned_at, Some(at(scanned_at))); From 0b7b3bb7c5a5601effb7ae6f58ab088eb91790d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:40:25 +0000 Subject: [PATCH 041/146] refactor(minter): make an unscanned address due at the time of the read Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 29 ++++++++++++++----------- minter/src/state/watchlist/tests.rs | 33 +++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 4622f4f89..9b00a1732 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -53,9 +53,9 @@ impl ScanProgress { } } - fn next_scan_at(&self, registered_at: Timestamp, valid_until: Timestamp) -> Option { + fn next_scan_at(&self, now: Timestamp, valid_until: Timestamp) -> Option { let due_at = match self.last_scanned_at { - None => registered_at, + None => now, Some(last_scanned_at) => { let gap_index = usize::try_from(self.scan_count).ok()?.checked_sub(1)?; let gap = BURST_SCAN_GAPS @@ -77,8 +77,9 @@ pub struct WatchedAddress { pub valid_until: Timestamp, pub scan_count: u32, pub last_scanned_at: Option, - /// When the next scan is due, or `None` once the schedule leaves no scan before the - /// window closes. + /// When the next scan is due: at once for an address not scanned yet, at the end of the + /// gap after its last scan otherwise, or `None` once the schedule leaves no scan before + /// the window closes. pub next_scan_at: Option, } @@ -131,7 +132,7 @@ impl Watchlist { /// The live registration of the account, if any. pub fn get(&self, now: Timestamp, account: &Account) -> Option { let entry = self.entries.get_entry(now, account)?; - Some(self.watched(account, entry)) + Some(self.watched(now, account, entry)) } pub fn remove(&mut self, account: &Account) { @@ -160,10 +161,10 @@ impl Watchlist { } /// Every registration, live or expired but not yet evicted, by ascending window end. - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self, now: Timestamp) -> impl Iterator { self.entries .iter_by_expiry() - .map(|(account, entry)| self.watched(account, entry)) + .map(move |(account, entry)| self.watched(now, account, entry)) } pub fn len(&self) -> usize { @@ -189,19 +190,23 @@ impl Watchlist { self.entries .iter_by_expiry() .filter(move |(_, entry)| entry.is_live(now)) - .map(|(account, entry)| self.watched(account, entry)) + .map(move |(account, entry)| self.watched(now, account, entry)) } - fn watched(&self, account: &Account, entry: &Entry) -> WatchedAddress { + fn watched( + &self, + now: Timestamp, + account: &Account, + entry: &Entry, + ) -> WatchedAddress { let progress = &entry.value; - let valid_until = entry.expires_at; WatchedAddress { account: *account, address: progress.address, - valid_until, + valid_until: entry.expires_at, scan_count: progress.scan_count, last_scanned_at: progress.last_scanned_at, - next_scan_at: progress.next_scan_at(self.registered_at(valid_until), valid_until), + next_scan_at: progress.next_scan_at(now, entry.expires_at), } } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 65646f39d..145824618 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -21,7 +21,13 @@ fn should_register_an_account_for_the_window() { assert_eq!(watched, unscanned(account(1))); assert_eq!(watchlist.get(at(REGISTERED_AT), &account(1)), Some(watched)); - assert_eq!(watchlist.get(at(VALID_UNTIL), &account(1)), Some(watched)); + assert_eq!( + watchlist.get(at(VALID_UNTIL), &account(1)), + Some(WatchedAddress { + next_scan_at: Some(at(VALID_UNTIL)), + ..watched + }) + ); assert_eq!(watchlist.get(at(VALID_UNTIL + 1), &account(1)), None); assert_eq!(watchlist.len(), 1); } @@ -82,7 +88,13 @@ fn should_keep_an_existing_registration_while_full() { deposit_address(account(0)), ); - assert_eq!(result, Ok(unscanned(account(0)))); + assert_eq!( + result, + Ok(WatchedAddress { + next_scan_at: Some(at(REGISTERED_AT + HOUR_NANOS)), + ..unscanned(account(0)) + }) + ); assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); } @@ -98,7 +110,7 @@ fn should_admit_a_registration_once_a_window_of_a_full_watchlist_has_passed() { } #[test] -fn should_be_due_for_the_first_scan_right_after_registration() { +fn should_be_due_for_the_first_scan_until_scanned() { let mut watchlist = Watchlist::default(); register(&mut watchlist, at(REGISTERED_AT), account(1)); @@ -106,7 +118,13 @@ fn should_be_due_for_the_first_scan_right_after_registration() { watchlist.due(at(REGISTERED_AT)), vec![unscanned(account(1))] ); - assert_eq!(watchlist.due(at(REGISTERED_AT - 1)), vec![]); + assert_eq!( + watchlist.due(at(REGISTERED_AT + HOUR_NANOS)), + vec![WatchedAddress { + next_scan_at: Some(at(REGISTERED_AT + HOUR_NANOS)), + ..unscanned(account(1)) + }] + ); } #[test] @@ -179,7 +197,7 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { watchlist.record_scan(at(VALID_UNTIL + 1), &account(1)); assert_eq!( - watchlist.iter().collect::>(), + watchlist.iter(at(REGISTERED_AT)).collect::>(), vec![unscanned(account(1))] ); } @@ -242,7 +260,10 @@ fn should_iterate_by_ascending_window_end() { register(&mut watchlist, at(REGISTERED_AT), account(3)); register(&mut watchlist, at(REGISTERED_AT), account(2)); - let accounts: Vec = watchlist.iter().map(|watched| watched.account).collect(); + let accounts: Vec = watchlist + .iter(at(REGISTERED_AT)) + .map(|watched| watched.account) + .collect(); assert_eq!(accounts, vec![account(3), account(2), account(1)]); } From 6412cec5ded44c10146513e3ce231f19c5fac2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:40:33 +0000 Subject: [PATCH 042/146] refactor(minter): inline the admission of a registration into register Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 9b00a1732..6c7b0c992 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -104,21 +104,12 @@ impl Watchlist { account: Account, address: Address, ) -> Result { - let inserted = self + match self .entries - .insert(now, account, ScanProgress::unscanned(address)); - self.admitted(now, &account, inserted) - } - - fn admitted( - &self, - now: Timestamp, - account: &Account, - inserted: Result, InsertError>, - ) -> Result { - match inserted { + .insert(now, account, ScanProgress::unscanned(address)) + { Ok(_) | Err(InsertError::AlreadyPresent { .. }) => Ok(self - .get(now, account) + .get(now, &account) .expect("BUG: a registration is live right after its insertion")), Err(InsertError::AtCapacity { .. }) => Err(WatchlistFull), Err(InsertError::Expired { .. }) => { From a2e16fadb341f000af2a4acf919bf5d1b5809f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:40:37 +0000 Subject: [PATCH 043/146] refactor(minter): keep the watchlist module private behind re-exports Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 6 ++++-- minter/src/test_fixtures/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index b7910082a..d58783649 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -40,7 +40,7 @@ pub mod audit; mod deposits; pub mod event; mod nonce_pool; -pub(crate) mod watchlist; +mod watchlist; pub use deposits::{ DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, QuarantineCause, @@ -48,7 +48,9 @@ pub use deposits::{ SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, }; pub use nonce_pool::{DurableNoncePool, NoncePoolError, NonceRead}; -pub use watchlist::{WatchedAddress, Watchlist, WatchlistFull}; +pub use watchlist::{ + MAX_WATCHED_ADDRESSES, WATCH_WINDOW, WatchedAddress, Watchlist, WatchlistFull, +}; thread_local! { static STATE: RefCell> = RefCell::default(); diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 158bb30dc..654e803af 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -4,10 +4,10 @@ use crate::{ rpc::{BlockHeight, FetchedTransaction}, sol_transfer::build_batch_withdrawal_message, state::{ - DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, WatchedAddress, Watchlist, + DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, WATCH_WINDOW, + WatchedAddress, Watchlist, event::{Event, EventType, VersionedMessage}, init_once_state, mutate_state, - watchlist::WATCH_WINDOW, }, storage::with_event_iter, utils::timed_sized_map::Timestamp, From bcb3ed3eccfe18aba3c672edc6dd87e0a224564e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:40:51 +0000 Subject: [PATCH 044/146] refactor(minter): hold the watchlist in the deposits Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/mod.rs | 16 +++++++++++++++- minter/src/state/mod.rs | 9 ++------- minter/src/state/tests.rs | 1 - minter/src/test_fixtures/mod.rs | 5 ----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index ca86f6a1f..08025d32a 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -1,7 +1,10 @@ use crate::{ constants::RENT_EXEMPTION_THRESHOLD, numeric::LedgerMintIndex, - state::event::{CreditedDeposit, VersionedMessage}, + state::{ + Watchlist, + event::{CreditedDeposit, VersionedMessage}, + }, }; use cksol_types::{DepositSolId, DepositSolStatus}; use icrc_ledger_types::icrc1::account::Account; @@ -64,6 +67,7 @@ pub struct Deposits { quarantined: BTreeMap, in_flight_ids: BTreeMap, queued_at_by_id: BTreeMap, + watchlist: Watchlist, } impl Deposits { @@ -99,6 +103,16 @@ impl Deposits { &self.quarantined } + pub fn watchlist(&self) -> &Watchlist { + &self.watchlist + } + + /// The watchlist is transient state: registrations are not recorded as events, so it is + /// mutated directly and carried across upgrades by a snapshot taken before the upgrade. + pub fn watchlist_mut(&mut self) -> &mut Watchlist { + &mut self.watchlist + } + pub fn in_flight_id(&self, account: &Account) -> Option { self.in_flight_ids.get(account).copied() } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index d58783649..62d3c2fe1 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -117,7 +117,6 @@ pub struct State { pending_deposit_sol_request_guards: BTreeSet, pending_withdrawal_request_guards: BTreeSet, deposits: Deposits, - watchlist: Watchlist, pending_withdrawal_requests: BTreeMap, created_withdrawal_requests: BTreeMap, sent_withdrawal_requests: BTreeMap, @@ -199,13 +198,11 @@ impl State { } pub fn watchlist(&self) -> &Watchlist { - &self.watchlist + self.deposits.watchlist() } - /// The watchlist is transient state: registrations are not recorded as events, so it is - /// mutated directly and carried across upgrades by a snapshot taken before the upgrade. pub fn watchlist_mut(&mut self) -> &mut Watchlist { - &mut self.watchlist + self.deposits.watchlist_mut() } pub fn sent_withdrawal_requests(&self) -> &BTreeMap { @@ -457,7 +454,6 @@ impl State { deposit.address, deposit.account, ); - self.watchlist.remove(&deposit.account); self.deposits.queue(deposit_id, deposit, queued_at); } @@ -912,7 +908,6 @@ impl TryFrom for State { pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), - watchlist: Watchlist::default(), pending_withdrawal_requests: BTreeMap::new(), created_withdrawal_requests: BTreeMap::new(), sent_withdrawal_requests: BTreeMap::new(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 6547dd45e..fc9470251 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -761,7 +761,6 @@ mod state_from_init_args { pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), - watchlist: Watchlist::default(), pending_withdrawal_requests: BTreeMap::new(), created_withdrawal_requests: BTreeMap::new(), sent_withdrawal_requests: BTreeMap::new(), diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 654e803af..c281ef18b 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -165,11 +165,6 @@ pub fn at(nanos: u64) -> Timestamp { Timestamp::from_nanos(nanos) } -/// Registers the deposit address of the account on the watchlist of the state at `now`. -pub fn watch(now: Timestamp, account: Account) -> WatchedAddress { - mutate_state(|state| register(state.watchlist_mut(), now, account)) -} - /// Registers the deposit address of the account on the watchlist at `now`. pub fn register(watchlist: &mut Watchlist, now: Timestamp, account: Account) -> WatchedAddress { watchlist From de4c736f62b541537fa40bf7d777bce4faedce74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:04:16 +0000 Subject: [PATCH 045/146] refactor(minter): index the burst gaps with a plain cast of the scan count Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 6c7b0c992..95c070f24 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -57,9 +57,8 @@ impl ScanProgress { let due_at = match self.last_scanned_at { None => now, Some(last_scanned_at) => { - let gap_index = usize::try_from(self.scan_count).ok()?.checked_sub(1)?; let gap = BURST_SCAN_GAPS - .get(gap_index) + .get(self.scan_count as usize - 1) .copied() .unwrap_or(STEADY_SCAN_GAP); last_scanned_at.saturating_add(gap) From caeb73cebda778f56c8cf747018cb33a27e651af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:06:37 +0000 Subject: [PATCH 046/146] test(minter): drop the watchlist tests that only re-check the timed sized map Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/tests.rs | 94 ----------------------------- 1 file changed, 94 deletions(-) diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 145824618..633c1e892 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -48,25 +48,6 @@ fn should_keep_the_stored_registration_when_registering_again() { assert_eq!(watchlist.len(), 1); } -#[test] -fn should_register_anew_once_the_window_has_passed() { - let mut watchlist = Watchlist::default(); - register(&mut watchlist, at(REGISTERED_AT), account(1)); - watchlist.record_scan(at(REGISTERED_AT + MINUTE_NANOS), &account(1)); - - let renewed = register(&mut watchlist, at(VALID_UNTIL + 1), account(1)); - - assert_eq!( - renewed, - WatchedAddress { - valid_until: at(VALID_UNTIL + 1 + WATCH_WINDOW_NANOS), - next_scan_at: Some(at(VALID_UNTIL + 1)), - ..unscanned(account(1)) - } - ); - assert_eq!(watchlist.len(), 1); -} - #[test] fn should_reject_a_registration_when_full_of_live_entries() { let mut watchlist = full_watchlist(); @@ -78,37 +59,6 @@ fn should_reject_a_registration_when_full_of_live_entries() { assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); } -#[test] -fn should_keep_an_existing_registration_while_full() { - let mut watchlist = full_watchlist(); - - let result = watchlist.register( - at(REGISTERED_AT + HOUR_NANOS), - account(0), - deposit_address(account(0)), - ); - - assert_eq!( - result, - Ok(WatchedAddress { - next_scan_at: Some(at(REGISTERED_AT + HOUR_NANOS)), - ..unscanned(account(0)) - }) - ); - assert_eq!(watchlist.len(), MAX_WATCHED_ADDRESSES.get()); -} - -#[test] -fn should_admit_a_registration_once_a_window_of_a_full_watchlist_has_passed() { - let mut watchlist = full_watchlist(); - let extra = account(MAX_WATCHED_ADDRESSES.get()); - - let result = watchlist.register(at(VALID_UNTIL + 1), extra, deposit_address(extra)); - - assert!(result.is_ok()); - assert_eq!(watchlist.len(), 1); -} - #[test] fn should_be_due_for_the_first_scan_until_scanned() { let mut watchlist = Watchlist::default(); @@ -202,23 +152,6 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { ); } -#[test] -fn should_remove_a_registration() { - let mut watchlist = Watchlist::default(); - register(&mut watchlist, at(REGISTERED_AT), account(1)); - register(&mut watchlist, at(REGISTERED_AT), account(2)); - - watchlist.remove(&account(1)); - watchlist.remove(&account(3)); - - assert_eq!(watchlist.get(at(REGISTERED_AT), &account(1)), None); - assert_eq!(watchlist.len(), 1); - assert_eq!( - watchlist.due(at(REGISTERED_AT)), - vec![unscanned(account(2))] - ); -} - #[test] fn should_count_the_live_registrations_and_report_the_oldest() { let watchlist = watchlist_of_two_registrations_an_hour_apart(); @@ -241,33 +174,6 @@ fn should_count_the_live_registrations_and_report_the_oldest() { assert_eq!(watchlist.len(), 2); } -#[test] -fn should_evict_the_registrations_whose_window_has_closed() { - let mut watchlist = watchlist_of_two_registrations_an_hour_apart(); - - assert_eq!(watchlist.evict_expired(at(VALID_UNTIL)), 0); - assert_eq!(watchlist.len(), 2); - assert_eq!(watchlist.evict_expired(at(VALID_UNTIL + 1)), 1); - assert_eq!(watchlist.len(), 1); - assert_eq!(watchlist.evict_expired(at(VALID_UNTIL + HOUR_NANOS + 1)), 1); - assert!(watchlist.is_empty()); -} - -#[test] -fn should_iterate_by_ascending_window_end() { - let mut watchlist = Watchlist::default(); - register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); - register(&mut watchlist, at(REGISTERED_AT), account(3)); - register(&mut watchlist, at(REGISTERED_AT), account(2)); - - let accounts: Vec = watchlist - .iter(at(REGISTERED_AT)) - .map(|watched| watched.account) - .collect(); - - assert_eq!(accounts, vec![account(3), account(2), account(1)]); -} - fn full_watchlist() -> Watchlist { let mut watchlist = Watchlist::default(); for index in 0..MAX_WATCHED_ADDRESSES.get() { From 826b50d368b37941912be7d4fbff637a23aeed0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 13:13:29 +0000 Subject: [PATCH 047/146] test(minter): inline the two watchlist builders into their only callers Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/tests.rs | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 633c1e892..88b152eeb 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -50,7 +50,10 @@ fn should_keep_the_stored_registration_when_registering_again() { #[test] fn should_reject_a_registration_when_full_of_live_entries() { - let mut watchlist = full_watchlist(); + let mut watchlist = Watchlist::default(); + for index in 0..MAX_WATCHED_ADDRESSES.get() { + register(&mut watchlist, at(REGISTERED_AT), account(index)); + } let extra = account(MAX_WATCHED_ADDRESSES.get()); let result = watchlist.register(at(VALID_UNTIL), extra, deposit_address(extra)); @@ -154,7 +157,9 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { #[test] fn should_count_the_live_registrations_and_report_the_oldest() { - let watchlist = watchlist_of_two_registrations_an_hour_apart(); + let mut watchlist = Watchlist::default(); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(2)); assert_eq!(watchlist.live_len(at(VALID_UNTIL)), 2); assert_eq!( @@ -174,21 +179,6 @@ fn should_count_the_live_registrations_and_report_the_oldest() { assert_eq!(watchlist.len(), 2); } -fn full_watchlist() -> Watchlist { - let mut watchlist = Watchlist::default(); - for index in 0..MAX_WATCHED_ADDRESSES.get() { - register(&mut watchlist, at(REGISTERED_AT), account(index)); - } - watchlist -} - -fn watchlist_of_two_registrations_an_hour_apart() -> Watchlist { - let mut watchlist = Watchlist::default(); - register(&mut watchlist, at(REGISTERED_AT), account(1)); - register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(2)); - watchlist -} - fn unscanned(account: Account) -> WatchedAddress { WatchedAddress { account, From 134aed623d840947766a083381d3c7ca84860b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:05:13 +0000 Subject: [PATCH 048/146] refactor(minter): fix the scan slots at the start of the schedule instead of chaining gaps Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 92 +++++++++++++++------------ minter/src/state/watchlist/tests.rs | 99 +++++++++++++++-------------- 2 files changed, 103 insertions(+), 88 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 95c070f24..63e6b3377 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -13,25 +13,27 @@ pub const WATCH_WINDOW: Duration = Duration::from_hours(24); pub const MAX_WATCHED_ADDRESSES: NonZeroUsize = NonZeroUsize::new(1_000).expect("BUG: the watchlist capacity must be non-zero"); -/// The gaps between a scan and the next one for the first scans, indexed by the number of -/// scans done so far minus one: a burst of ten scans within the first half hour. -const BURST_SCAN_GAPS: [Duration; 9] = [ +/// The slots of the first scans, measured from the start of the schedule: a burst of ten +/// scans within the first half hour. +const BURST_SCAN_SLOTS: [Duration; 10] = [ + Duration::ZERO, Duration::from_secs(60), - Duration::from_secs(60), - Duration::from_secs(120), Duration::from_secs(120), Duration::from_secs(240), - Duration::from_secs(300), - Duration::from_secs(300), - Duration::from_secs(300), - Duration::from_secs(300), + Duration::from_secs(360), + Duration::from_secs(600), + Duration::from_secs(900), + Duration::from_secs(1_200), + Duration::from_secs(1_500), + Duration::from_secs(1_800), ]; -/// The gap between a scan and the next one after the burst, until the window closes. +/// The gap between two slots after the burst, until the window closes. const STEADY_SCAN_GAP: Duration = Duration::from_hours(1); /// The deposit addresses registered for the automatic deposit flow, each watched for -/// [`WATCH_WINDOW`] and scanned on the schedule of [`BURST_SCAN_GAPS`] and [`STEADY_SCAN_GAP`]. +/// [`WATCH_WINDOW`] and scanned at the slots of [`BURST_SCAN_SLOTS`], then every +/// [`STEADY_SCAN_GAP`]. #[derive(Debug, PartialEq, Eq)] pub struct Watchlist { entries: TimedSizedMap, @@ -40,34 +42,45 @@ pub struct Watchlist { #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ScanProgress { address: Address, + scheduled_from: Timestamp, scan_count: u32, last_scanned_at: Option, } impl ScanProgress { - fn unscanned(address: Address) -> Self { + fn unscanned(address: Address, scheduled_from: Timestamp) -> Self { Self { address, + scheduled_from, scan_count: 0, last_scanned_at: None, } } - fn next_scan_at(&self, now: Timestamp, valid_until: Timestamp) -> Option { - let due_at = match self.last_scanned_at { - None => now, - Some(last_scanned_at) => { - let gap = BURST_SCAN_GAPS - .get(self.scan_count as usize - 1) - .copied() - .unwrap_or(STEADY_SCAN_GAP); - last_scanned_at.saturating_add(gap) - } - }; - (due_at <= valid_until).then_some(due_at) + /// The first slot of the schedule before any scan and the first slot after the last scan + /// otherwise, unless no slot is left before the end of the window: the slots are fixed, so + /// a late scan does not shift the ones that follow and the slots it skipped are not made up. + fn next_scan_at(&self, valid_until: Timestamp) -> Option { + let mut slots = scan_slots() + .map(|slot| self.scheduled_from.saturating_add(slot)) + .take_while(|slot_at| *slot_at <= valid_until); + match self.last_scanned_at { + None => slots.next(), + Some(last_scanned_at) => slots.find(|slot_at| *slot_at > last_scanned_at), + } } } +/// The slots of the schedule in order, as offsets from its start, up to the length of a window. +fn scan_slots() -> impl Iterator { + let last_burst_slot = BURST_SCAN_SLOTS[BURST_SCAN_SLOTS.len() - 1]; + let steady_slots = (1..).map(move |gaps| last_burst_slot + STEADY_SCAN_GAP * gaps); + BURST_SCAN_SLOTS + .into_iter() + .chain(steady_slots) + .take_while(|slot| *slot <= WATCH_WINDOW) +} + /// A registration of the watchlist with its scan progress, as of the time it was read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct WatchedAddress { @@ -76,9 +89,9 @@ pub struct WatchedAddress { pub valid_until: Timestamp, pub scan_count: u32, pub last_scanned_at: Option, - /// When the next scan is due: at once for an address not scanned yet, at the end of the - /// gap after its last scan otherwise, or `None` once the schedule leaves no scan before - /// the window closes. + /// When the next scan is due: the first slot of the schedule for an address not scanned + /// yet, the first slot after its last scan otherwise, or `None` once no slot is left + /// before the window closes. pub next_scan_at: Option, } @@ -95,8 +108,8 @@ impl Default for Watchlist { } impl Watchlist { - /// Registers the deposit address of the account for a window starting now, or returns the - /// live registration of the account as it is, without extending it. + /// Registers the deposit address of the account for a window and a schedule starting now, + /// or returns the live registration of the account as it is, without extending it. pub fn register( &mut self, now: Timestamp, @@ -105,7 +118,7 @@ impl Watchlist { ) -> Result { match self .entries - .insert(now, account, ScanProgress::unscanned(address)) + .insert(now, account, ScanProgress::unscanned(address, now)) { Ok(_) | Err(InsertError::AlreadyPresent { .. }) => Ok(self .get(now, &account) @@ -122,7 +135,7 @@ impl Watchlist { /// The live registration of the account, if any. pub fn get(&self, now: Timestamp, account: &Account) -> Option { let entry = self.entries.get_entry(now, account)?; - Some(self.watched(now, account, entry)) + Some(Self::watched(account, entry)) } pub fn remove(&mut self, account: &Account) { @@ -137,7 +150,7 @@ impl Watchlist { } /// Records a scan of the account's deposit address at `now`, which advances the account - /// along the schedule, if the account is still watched. + /// to the first slot of its schedule after `now`, if the account is still watched. pub fn record_scan(&mut self, now: Timestamp, account: &Account) { if let Some(progress) = self.entries.get_value_mut(now, account) { progress.scan_count = progress.scan_count.saturating_add(1); @@ -151,10 +164,10 @@ impl Watchlist { } /// Every registration, live or expired but not yet evicted, by ascending window end. - pub fn iter(&self, now: Timestamp) -> impl Iterator { + pub fn iter(&self) -> impl Iterator { self.entries .iter_by_expiry() - .map(move |(account, entry)| self.watched(now, account, entry)) + .map(|(account, entry)| Self::watched(account, entry)) } pub fn len(&self) -> usize { @@ -180,15 +193,10 @@ impl Watchlist { self.entries .iter_by_expiry() .filter(move |(_, entry)| entry.is_live(now)) - .map(move |(account, entry)| self.watched(now, account, entry)) + .map(|(account, entry)| Self::watched(account, entry)) } - fn watched( - &self, - now: Timestamp, - account: &Account, - entry: &Entry, - ) -> WatchedAddress { + fn watched(account: &Account, entry: &Entry) -> WatchedAddress { let progress = &entry.value; WatchedAddress { account: *account, @@ -196,7 +204,7 @@ impl Watchlist { valid_until: entry.expires_at, scan_count: progress.scan_count, last_scanned_at: progress.last_scanned_at, - next_scan_at: progress.next_scan_at(now, entry.expires_at), + next_scan_at: progress.next_scan_at(entry.expires_at), } } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 88b152eeb..033d379a5 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -1,7 +1,6 @@ use crate::{ state::watchlist::{ - BURST_SCAN_GAPS, MAX_WATCHED_ADDRESSES, STEADY_SCAN_GAP, WatchedAddress, Watchlist, - WatchlistFull, + MAX_WATCHED_ADDRESSES, WatchedAddress, Watchlist, WatchlistFull, scan_slots, }, test_fixtures::{ HOUR_NANOS, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, @@ -9,9 +8,9 @@ use crate::{ }, }; use icrc_ledger_types::icrc1::account::Account; -use std::time::Duration; const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; +const LAST_SLOT_AT: u64 = VALID_UNTIL - 30 * MINUTE_NANOS; #[test] fn should_register_an_account_for_the_window() { @@ -21,13 +20,7 @@ fn should_register_an_account_for_the_window() { assert_eq!(watched, unscanned(account(1))); assert_eq!(watchlist.get(at(REGISTERED_AT), &account(1)), Some(watched)); - assert_eq!( - watchlist.get(at(VALID_UNTIL), &account(1)), - Some(WatchedAddress { - next_scan_at: Some(at(VALID_UNTIL)), - ..watched - }) - ); + assert_eq!(watchlist.get(at(VALID_UNTIL), &account(1)), Some(watched)); assert_eq!(watchlist.get(at(VALID_UNTIL + 1), &account(1)), None); assert_eq!(watchlist.len(), 1); } @@ -73,65 +66,79 @@ fn should_be_due_for_the_first_scan_until_scanned() { ); assert_eq!( watchlist.due(at(REGISTERED_AT + HOUR_NANOS)), - vec![WatchedAddress { - next_scan_at: Some(at(REGISTERED_AT + HOUR_NANOS)), - ..unscanned(account(1)) - }] + vec![unscanned(account(1))] ); } #[test] -fn should_follow_the_scan_schedule_and_stop_before_the_window_closes() { +fn should_follow_the_scan_slots_and_stop_after_the_last_one() { let mut watchlist = Watchlist::default(); register(&mut watchlist, at(REGISTERED_AT), account(1)); - let mut scanned_at = REGISTERED_AT; - - let gaps = BURST_SCAN_GAPS - .into_iter() - .chain(std::iter::repeat_n(STEADY_SCAN_GAP, 23)); - for (scan_count, gap) in gaps.enumerate() { - watchlist.record_scan(at(scanned_at), &account(1)); - let watched = watchlist.get(at(scanned_at), &account(1)).unwrap(); - let next_scan_at = scanned_at + gap.as_nanos() as u64; - - assert_eq!(watched.scan_count, scan_count as u32 + 1); - assert_eq!(watched.last_scanned_at, Some(at(scanned_at))); - assert_eq!(watched.next_scan_at, Some(at(next_scan_at))); - assert_eq!(watchlist.due(at(next_scan_at - 1)), vec![]); - assert_eq!(watchlist.due(at(next_scan_at)), vec![watched]); - scanned_at = next_scan_at; + let slots_at: Vec = scan_slots() + .map(|slot| REGISTERED_AT + slot.as_nanos() as u64) + .collect(); + assert_eq!(slots_at.len(), 33); + assert_eq!(slots_at[9], REGISTERED_AT + 30 * MINUTE_NANOS); + assert_eq!(slots_at[32], LAST_SLOT_AT); + + for (index, &slot_at) in slots_at.iter().enumerate() { + let watched = watchlist.get(at(slot_at), &account(1)).unwrap(); + assert_eq!(watched.scan_count, index as u32); + assert_eq!(watched.next_scan_at, Some(at(slot_at))); + assert_eq!(watchlist.due(at(slot_at - 1)), vec![]); + assert_eq!(watchlist.due(at(slot_at)), vec![watched]); + + watchlist.record_scan(at(slot_at), &account(1)); } - assert_eq!( - scanned_at, - REGISTERED_AT + Duration::from_mins(30 + 23 * 60).as_nanos() as u64 - ); - - watchlist.record_scan(at(scanned_at), &account(1)); - let watched = watchlist.get(at(scanned_at), &account(1)).unwrap(); + let watched = watchlist.get(at(LAST_SLOT_AT), &account(1)).unwrap(); assert_eq!(watched.scan_count, 33); + assert_eq!(watched.last_scanned_at, Some(at(LAST_SLOT_AT))); assert_eq!(watched.next_scan_at, None); assert_eq!(watchlist.due(at(VALID_UNTIL)), vec![]); } #[test] -fn should_report_no_next_scan_when_the_gap_ends_after_the_window() { +fn should_skip_the_slots_passed_before_a_late_scan() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, at(REGISTERED_AT), account(1)); + + watchlist.record_scan(at(REGISTERED_AT + 10 * MINUTE_NANOS), &account(1)); + let watched = watchlist + .get(at(REGISTERED_AT + 10 * MINUTE_NANOS), &account(1)) + .unwrap(); + assert_eq!(watched.scan_count, 1); + assert_eq!( + watched.next_scan_at, + Some(at(REGISTERED_AT + 15 * MINUTE_NANOS)) + ); + + watchlist.record_scan(at(REGISTERED_AT + 15 * MINUTE_NANOS + 1), &account(1)); + let watched = watchlist + .get(at(REGISTERED_AT + 15 * MINUTE_NANOS + 1), &account(1)) + .unwrap(); + assert_eq!(watched.scan_count, 2); + assert_eq!( + watched.next_scan_at, + Some(at(REGISTERED_AT + 20 * MINUTE_NANOS)) + ); +} + +#[test] +fn should_report_no_next_scan_after_the_last_slot() { let mut watchlist = Watchlist::default(); register(&mut watchlist, at(REGISTERED_AT), account(1)); - for scan in 0..9 { - watchlist.record_scan(at(REGISTERED_AT + scan * MINUTE_NANOS), &account(1)); - } - watchlist.record_scan(at(VALID_UNTIL - HOUR_NANOS), &account(1)); + watchlist.record_scan(at(LAST_SLOT_AT - 1), &account(1)); assert_eq!( watchlist .get(at(VALID_UNTIL), &account(1)) .unwrap() .next_scan_at, - Some(at(VALID_UNTIL)) + Some(at(LAST_SLOT_AT)) ); - watchlist.record_scan(at(VALID_UNTIL - HOUR_NANOS + 1), &account(1)); + watchlist.record_scan(at(LAST_SLOT_AT), &account(1)); assert_eq!( watchlist .get(at(VALID_UNTIL), &account(1)) @@ -150,7 +157,7 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { watchlist.record_scan(at(VALID_UNTIL + 1), &account(1)); assert_eq!( - watchlist.iter(at(REGISTERED_AT)).collect::>(), + watchlist.iter().collect::>(), vec![unscanned(account(1))] ); } From 4824497c3967791619eaebe01497e147cbec76e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:24:08 +0000 Subject: [PATCH 049/146] feat(minter): add the watch_deposit_address endpoint Co-Authored-By: Claude Fable 5.1 --- integration_tests/src/fixtures.rs | 3 +- integration_tests/src/lib.rs | 28 ++- integration_tests/tests/tests.rs | 94 ++++++++- libs/types/src/lib.rs | 62 ++++++ minter/cksol_minter.did | 73 +++++++ minter/src/deposit/automatic/mod.rs | 70 +++++++ minter/src/deposit/automatic/tests.rs | 284 ++++++++++++++++++++++++++ minter/src/deposit/mod.rs | 1 + minter/src/main.rs | 13 +- minter/src/state/deposits/mod.rs | 7 + 10 files changed, 627 insertions(+), 8 deletions(-) create mode 100644 minter/src/deposit/automatic/mod.rs create mode 100644 minter/src/deposit/automatic/tests.rs diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index ff0d93e16..86f20ae19 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -20,7 +20,7 @@ use solana_nonce::{ versions::Versions, }; use solana_transaction::Transaction; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use tokio::sync::Mutex; pub const DEFAULT_CALLER_ACCOUNT: Account = Account { @@ -36,6 +36,7 @@ pub const MINTER_ADDRESS: Address = address!("2YKPWqP51gUFEYo1FD2K3LoRE6Cd2f99E1 pub const DEPOSIT_AMOUNT: Lamport = 500_000_000; /// Minimum balance left on a deposit address to keep it rent-exempt. pub const RENT_EXEMPTION_THRESHOLD: Lamport = 890_880; +pub const WATCH_WINDOW: Duration = Duration::from_hours(24); /// The slot the mocked `getSlot` reports. A test says which block height the mocked /// `getBlock` then reports, since that is what the minter records and what it judges diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 0b21c5f4a..0866685e3 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -3,8 +3,9 @@ use candid::{CandidType, Decode, Encode, Nat, Principal, utils::ArgumentEncoder} use canlog::{Log, LogEntry}; use cksol_types::{ Address, DepositSolArgs, DepositSolError, DepositSolId, DepositSolStatus, - GetDepositAddressArgs, MinterInfo, WithdrawSolArgs, WithdrawSolError, WithdrawSolOk, - WithdrawSolStatus, WithdrawSolStatusArgs, + GetDepositAddressArgs, MinterInfo, WatchDepositAddressArgs, WatchDepositAddressError, + WatchDepositAddressOk, WithdrawSolArgs, WithdrawSolError, WithdrawSolOk, WithdrawSolStatus, + WithdrawSolStatusArgs, }; use cksol_types_internal::{ MinterArg, @@ -16,7 +17,7 @@ use ic_http_types::{HttpRequest, HttpResponse}; use ic_management_canister_types::{CanisterId, CanisterSettings}; use ic_pocket_canister_runtime::{ExecuteHttpOutcallMocks, PocketIcRuntime}; use icrc_ledger_types::{ - icrc1::account::Account, + icrc1::account::{Account, Subaccount}, icrc2::approve::{ApproveArgs, ApproveError}, icrc3::blocks::{GetBlocksRequest, GetBlocksResult, ICRC3GenericBlock}, }; @@ -576,6 +577,27 @@ impl<'a> CkSolMinter<'a> { self.query_call("deposit_sol_status", (deposit_id,)).await } + pub async fn watch_deposit_address( + &self, + subaccount: Option, + ) -> Result { + self.try_watch_deposit_address(subaccount) + .await + .expect("watch_deposit_address failed") + } + + pub async fn try_watch_deposit_address( + &self, + subaccount: Option, + ) -> Result, String> { + self.try_update_call( + "watch_deposit_address", + (WatchDepositAddressArgs { subaccount },), + 0, + ) + .await + } + pub async fn withdraw_sol( &self, args: WithdrawSolArgs, diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 8251f30cb..2e0ac7e14 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -5,13 +5,14 @@ use cksol_int_tests::{ CkSolMinter, Setup, SetupBuilder, fixtures::{ DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, MINTER_ADDRESS, - MockBuilder, RENT_EXEMPTION_THRESHOLD, SharedMockHttpOutcalls, + MockBuilder, RENT_EXEMPTION_THRESHOLD, SharedMockHttpOutcalls, WATCH_WINDOW, }, validator::FEE_PER_SIGNATURE, }; use cksol_types::{ DepositSolArgs, DepositSolError, DepositSolStatus, GetDepositAddressArgs, Lamport, MinterInfo, - TxFinalizedStatus, WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus, + TxFinalizedStatus, WatchDepositAddressError, WatchDepositAddressStatus, WithdrawSolArgs, + WithdrawSolError, WithdrawSolStatus, }; use cksol_types_internal::{ UpgradeArgs, @@ -1221,6 +1222,92 @@ mod deposit_sol_tests { } } +mod watch_deposit_address_tests { + use super::*; + + #[tokio::test] + async fn should_register_the_caller() { + let setup = SetupBuilder::new().build().await; + let subaccount = Some([7; 32]); + + let registration = setup + .minter() + .watch_deposit_address(subaccount) + .await + .expect("watch_deposit_address should register the caller"); + + let deposit_address = setup + .minter() + .get_deposit_address(GetDepositAddressArgs { + owner: None, + subaccount, + }) + .await; + assert_eq!(registration.address, deposit_address); + assert_eq!( + registration.minimum_deposit_amount, + Setup::DEFAULT_MINIMUM_DEPOSIT_AMOUNT + ); + assert_eq!( + registration.automatic_deposit_fee, + Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE + ); + assert_eq!(registration.latest_deposit_id, None); + let (valid_until, next_scan_at) = assert_matches!( + registration.status, + WatchDepositAddressStatus::Watching { + valid_until, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(next_scan_at), + } => (valid_until, next_scan_at) + ); + assert_eq!(valid_until - next_scan_at, WATCH_WINDOW.as_nanos() as u64); + + setup.drop().await; + } + + #[tokio::test] + async fn should_be_unavailable_until_the_fee_is_configured_by_an_upgrade() { + let setup = SetupBuilder::new() + .without_automatic_deposit_fee() + .build() + .await; + assert_eq!( + setup.minter().get_minter_info().await.automatic_deposit_fee, + None + ); + + let result = setup.minter().watch_deposit_address(None).await; + + assert_matches!( + result, + Err(WatchDepositAddressError::TemporarilyUnavailable(e)) if e.contains("automatic deposit fee") + ); + + setup + .minter() + .upgrade(UpgradeArgs { + automatic_deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), + ..UpgradeArgs::default() + }) + .await + .expect("upgrade should configure the fee"); + + let registration = setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should register the caller"); + assert_matches!( + registration.status, + WatchDepositAddressStatus::Watching { .. } + ); + + setup.drop().await; + } +} + mod anonymous_caller_tests { use super::*; @@ -1265,6 +1352,9 @@ mod anonymous_caller_tests { .await; assert_matches!(result, Err(s) if s.contains("the owner must be non-anonymous")); + let result = minter.try_watch_deposit_address(None).await; + assert_matches!(result, Err(s) if s.contains("the owner must be non-anonymous")); + setup.drop().await; } } diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 93c31e924..efd2aa483 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -153,6 +153,68 @@ pub enum DepositSolError { }, } +/// Arguments for a request to the `watch_deposit_address` ckSOL minter endpoint. +#[derive(Clone, Eq, PartialEq, Debug, Default, CandidType, Deserialize, Serialize)] +pub struct WatchDepositAddressArgs { + /// The subaccount of the caller to credit with the deposits detected on its deposit address. + pub subaccount: Option, +} + +/// The successful result of a `watch_deposit_address` call. +#[derive(Clone, Eq, PartialEq, Debug, CandidType, Deserialize, Serialize)] +pub struct WatchDepositAddressOk { + /// The deposit address of the caller's account. + pub address: Address, + /// The minimum deposit amount in force at the time of the call. + pub minimum_deposit_amount: Lamport, + /// The automatic deposit fee in force at the time of the call. + pub automatic_deposit_fee: Lamport, + /// Whether the account is watched or has a deposit in flight. + pub status: WatchDepositAddressStatus, + /// The id of the latest deposit queued for the account, if any, whatever stage it reached. + pub latest_deposit_id: Option, +} + +/// The status of an account registered with `watch_deposit_address`. +#[derive(Clone, Eq, PartialEq, Debug, CandidType, Deserialize, Serialize)] +pub enum WatchDepositAddressStatus { + /// The deposit address is on the watchlist and scanned on the schedule. + Watching { + /// The end of the registration window, in nanoseconds since the epoch. + valid_until: u64, + /// The number of scans of the deposit address so far. + scan_count: u32, + /// The time of the last scan, in nanoseconds since the epoch, if any. + last_scanned_at: Option, + /// The time the next scan is due, in nanoseconds since the epoch, or `None` once the + /// schedule leaves no scan before the window closes. + next_scan_at: Option, + }, + /// A deposit of the account is in flight: queued, swept, finalized or pending its mint. + Detected { + /// The identifier of the deposit in flight. + deposit_id: DepositSolId, + }, +} + +/// An error from the `watch_deposit_address` ckSOL minter endpoint. +#[derive(Debug, Clone, PartialEq, CandidType, Deserialize, Error)] +pub enum WatchDepositAddressError { + /// The latest deposit of the account is quarantined, see + /// [`DepositSolError::Quarantined`]. + #[error("The latest deposit {deposit_id} of this account is quarantined")] + Quarantined { + /// The identifier of the quarantined deposit. + deposit_id: DepositSolId, + }, + /// The watchlist is full of live registrations, try again later or use `deposit_sol`. + #[error("The watchlist is full, try again later or use `deposit_sol`")] + TooManyWatchedAddresses, + /// The minter experiences temporary issues, try the call again later. + #[error("Transient error, try the call again later: {0}")] + TemporarilyUnavailable(String), +} + /// Insufficient cycles attached by the caller to complete the call. #[derive(Debug, Clone, PartialEq, CandidType, Deserialize, Error)] #[error("Insufficient cycles attached, expected {expected} but got {received}")] diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 45e1f5716..5e7d84a0f 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -158,6 +158,70 @@ type DepositSolError = variant { // Represents the result of a call to the `deposit_sol` endpoint. type DepositSolResult = variant { Ok : DepositSolId; Err : DepositSolError }; +// Arguments for a request to the `watch_deposit_address` endpoint. +type WatchDepositAddressArgs = record { + // The subaccount of the caller to credit with the deposits detected on + // its deposit address. + subaccount: opt blob; +}; + +// The status of an account registered with `watch_deposit_address`. +type WatchDepositAddressStatus = variant { + // The deposit address is on the watchlist and scanned on the schedule. + Watching : record { + // The end of the registration window, in nanoseconds since the epoch. + valid_until: nat64; + // The number of scans of the deposit address so far. + scan_count: nat32; + // The time of the last scan, in nanoseconds since the epoch, if any. + last_scanned_at: opt nat64; + // The time the next scan is due, in nanoseconds since the epoch, or + // absent once the schedule leaves no scan before the window closes. + next_scan_at: opt nat64; + }; + // A deposit of the account is in flight: queued, swept, finalized or + // pending its mint. + Detected : record { + // The identifier of the deposit in flight. + deposit_id: DepositSolId; + }; +}; + +// The successful result of a `watch_deposit_address` call. +type WatchDepositAddressOk = record { + // The deposit address of the caller's account. + address: Address; + // The minimum deposit amount in force at the time of the call. + minimum_deposit_amount: Lamport; + // The automatic deposit fee in force at the time of the call. + automatic_deposit_fee: Lamport; + // Whether the account is watched or has a deposit in flight. + status: WatchDepositAddressStatus; + // The id of the latest deposit queued for the account, if any, whatever + // stage it reached. + latest_deposit_id: opt DepositSolId; +}; + +// An error from the `watch_deposit_address` endpoint. +type WatchDepositAddressError = variant { + // The latest deposit of the account is quarantined, see `DepositSolError`. + Quarantined : record { + // The identifier of the quarantined deposit. + deposit_id: DepositSolId; + }; + // The watchlist is full of live registrations, try again later or use + // `deposit_sol`. + TooManyWatchedAddresses; + // The minter experiences temporary issues, try the call again later. + TemporarilyUnavailable : text; +}; + +// Represents the result of a call to the `watch_deposit_address` endpoint. +type WatchDepositAddressResult = variant { + Ok : WatchDepositAddressOk; + Err : WatchDepositAddressError; +}; + // The Solana network to connect to. type SolanaNetwork = variant { Mainnet; @@ -538,6 +602,15 @@ service : (MinterArg) -> { // or `NotFound` if no deposit with this identifier was queued. deposit_sol_status: (DepositSolId) -> (DepositSolStatus) query; + // Registers the deposit address of the caller's account for the + // automatic deposit flow, for free, or reports on its registration: + // the minter reads the balance of the address on a schedule for about + // a day and queues a sweep once it reaches the minimum deposit amount, + // deducting the automatic deposit fee from the minted amount. + // + // The owner is always the caller, who must be a non-anonymous principal. + watch_deposit_address: (WatchDepositAddressArgs) -> (WatchDepositAddressResult); + // Submits a request to convert ckSOL to Solana. withdraw_sol : (WithdrawSolArgs) -> (WithdrawSolResult); diff --git a/minter/src/deposit/automatic/mod.rs b/minter/src/deposit/automatic/mod.rs new file mode 100644 index 000000000..357bc1108 --- /dev/null +++ b/minter/src/deposit/automatic/mod.rs @@ -0,0 +1,70 @@ +use crate::{ + address::{account_address, minter_public_key}, + runtime::CanisterRuntime, + state::{InFlightDeposit, State, WatchedAddress, WatchlistFull, mutate_state}, + utils::assert_valid_deposit_owner, +}; +use cksol_types::{WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus}; +use icrc_ledger_types::icrc1::account::Account; +use solana_address::Address; + +#[cfg(test)] +mod tests; + +pub fn watch_deposit_address( + runtime: &R, + account: Account, +) -> Result { + assert_valid_deposit_owner(&account, runtime.canister_self()); + let master_key = minter_public_key() + .map_err(|e| WatchDepositAddressError::TemporarilyUnavailable(e.to_string()))?; + let address = account_address(&master_key, &account); + let now = runtime.time(); + + mutate_state(|state| { + let automatic_deposit_fee = state.automatic_deposit_fee().ok_or_else(|| { + WatchDepositAddressError::TemporarilyUnavailable( + "the automatic deposit fee is not configured".to_string(), + ) + })?; + let status = status_after_registration(state, now, account, address)?; + Ok(WatchDepositAddressOk { + address: address.into(), + minimum_deposit_amount: state.minimum_deposit_amount(), + automatic_deposit_fee, + status, + latest_deposit_id: state.deposits().latest_id(&account), + }) + }) +} + +fn status_after_registration( + state: &mut State, + now: u64, + account: Account, + address: Address, +) -> Result { + match state.deposits().in_flight(&account) { + Some(InFlightDeposit::Processing(deposit_id)) => { + return Ok(WatchDepositAddressStatus::Detected { deposit_id }); + } + Some(InFlightDeposit::Quarantined(deposit_id)) => { + return Err(WatchDepositAddressError::Quarantined { deposit_id }); + } + None => {} + } + state + .watchlist_mut() + .register(now, account, address) + .map(watching) + .map_err(|WatchlistFull| WatchDepositAddressError::TooManyWatchedAddresses) +} + +fn watching(watched: WatchedAddress) -> WatchDepositAddressStatus { + WatchDepositAddressStatus::Watching { + valid_until: watched.valid_until, + scan_count: watched.scan_count, + last_scanned_at: watched.last_scanned_at, + next_scan_at: watched.next_scan_at, + } +} diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs new file mode 100644 index 000000000..e75d08f0a --- /dev/null +++ b/minter/src/deposit/automatic/tests.rs @@ -0,0 +1,284 @@ +use crate::{ + deposit::automatic::watch_deposit_address, + guard::deposit_sol_guard, + state::{mutate_state, read_state, watchlist::MAX_WATCHED_ADDRESSES}, + test_fixtures::{ + AUTOMATIC_DEPOSIT_FEE, EventsAssert, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, + MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, deposit_address, + events::{ + fail_transaction, quarantine_sweep, queue_deposit, submit_sweep, succeed_transaction, + }, + flow::deposit::DepositFlow, + init_schnorr_master_key, init_state, init_state_with_args, + runtime::TestCanisterRuntime, + signature, valid_init_args, + }, +}; +use assert_matches::assert_matches; +use candid::Principal; +use cksol_types::{WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus}; +use cksol_types_internal::InitArgs; +use icrc_ledger_types::icrc1::account::Account; + +#[test] +fn should_fail_while_the_automatic_deposit_fee_is_not_configured() { + init_state_with_args(InitArgs { + automatic_deposit_fee: None, + ..valid_init_args() + }); + init_schnorr_master_key(); + + let result = watch_deposit_address(&runtime(), account(1)); + + assert_matches!( + result, + Err(WatchDepositAddressError::TemporarilyUnavailable(e)) => assert!(e.contains("automatic deposit fee")) + ); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[test] +fn should_fail_while_the_minter_public_key_is_unavailable() { + init_state(); + + let result = watch_deposit_address(&runtime(), account(1)); + + assert_matches!( + result, + Err(WatchDepositAddressError::TemporarilyUnavailable(e)) => assert!(e.contains("minter public key")) + ); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[test] +fn should_register_the_account_without_recording_an_event() { + setup(); + + let result = watch_deposit_address(&runtime(), account(1)); + + assert_eq!( + result, + Ok(WatchDepositAddressOk { + address: deposit_address(account(1)).into(), + minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, + automatic_deposit_fee: AUTOMATIC_DEPOSIT_FEE, + status: WatchDepositAddressStatus::Watching { + valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(REGISTERED_AT), + }, + latest_deposit_id: None, + }) + ); + EventsAssert::assert_no_events_recorded(); + assert_eq!(read_state(|s| s.watchlist().len()), 1); +} + +#[test] +fn should_report_the_stored_registration_without_extending_it() { + setup(); + let first = watch_deposit_address(&runtime(), account(1)).unwrap(); + mutate_state(|s| { + s.watchlist_mut() + .record_scan(REGISTERED_AT + 1, &account(1)) + }); + + let second = + watch_deposit_address(&runtime_at(REGISTERED_AT + HOUR_NANOS), account(1)).unwrap(); + + assert_eq!( + second.status, + WatchDepositAddressStatus::Watching { + valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + scan_count: 1, + last_scanned_at: Some(REGISTERED_AT + 1), + next_scan_at: Some(REGISTERED_AT + 1 + MINUTE_NANOS), + } + ); + assert_eq!(second.address, first.address); + assert_eq!(read_state(|s| s.watchlist().len()), 1); +} + +#[test] +fn should_register_anew_once_the_window_has_passed() { + setup(); + watch_deposit_address(&runtime(), account(1)).unwrap(); + let after_window = REGISTERED_AT + WATCH_WINDOW_NANOS + 1; + + let renewed = watch_deposit_address(&runtime_at(after_window), account(1)).unwrap(); + + assert_eq!( + renewed.status, + WatchDepositAddressStatus::Watching { + valid_until: after_window + WATCH_WINDOW_NANOS, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(after_window), + } + ); +} + +#[test] +fn should_report_a_queued_deposit_as_detected_without_registering() { + setup(); + let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); + + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + + assert_eq!( + result.status, + WatchDepositAddressStatus::Detected { + deposit_id: deposit.deposit_id + } + ); + assert_eq!(result.latest_deposit_id, Some(deposit.deposit_id)); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[test] +fn should_report_a_swept_deposit_as_detected_without_registering() { + setup(); + let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); + deposit.sweep(signature(0xAA)); + + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + + assert_eq!( + result.status, + WatchDepositAddressStatus::Detected { + deposit_id: deposit.deposit_id + } + ); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[test] +fn should_report_a_credited_deposit_as_detected_without_registering() { + setup(); + let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); + deposit.sweep(signature(0xAA)).succeed().credit(); + + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + + assert_eq!( + result.status, + WatchDepositAddressStatus::Detected { + deposit_id: deposit.deposit_id + } + ); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[test] +fn should_register_anew_once_the_deposit_is_minted() { + setup(); + let minted = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT) + .sweep(signature(0xAA)) + .succeed() + .credit() + .single_pending_mint() + .mint(7); + + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + + assert_matches!( + result.status, + WatchDepositAddressStatus::Watching { scan_count: 0, .. } + ); + assert_eq!(result.latest_deposit_id, Some(minted.deposit_id)); +} + +#[test] +fn should_register_anew_once_the_deposit_is_dropped() { + setup(); + queue_deposit(0, account(1), MINIMUM_DEPOSIT_AMOUNT); + submit_sweep(signature(0xAA), vec![0]); + fail_transaction(signature(0xAA)); + + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + + assert_matches!( + result.status, + WatchDepositAddressStatus::Watching { scan_count: 0, .. } + ); + assert_eq!(result.latest_deposit_id, Some(0)); +} + +#[test] +fn should_reject_an_account_whose_latest_deposit_is_quarantined() { + setup(); + queue_deposit(0, account(1), MINIMUM_DEPOSIT_AMOUNT); + submit_sweep(signature(0xAA), vec![0]); + succeed_transaction(signature(0xAA)); + quarantine_sweep(signature(0xAA)); + + let result = watch_deposit_address(&runtime(), account(1)); + + assert_eq!( + result, + Err(WatchDepositAddressError::Quarantined { deposit_id: 0 }) + ); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[test] +fn should_reject_a_registration_when_the_watchlist_is_full() { + setup(); + for index in 0..MAX_WATCHED_ADDRESSES.get() { + watch_deposit_address(&runtime(), account(index)).unwrap(); + } + + let result = watch_deposit_address(&runtime(), account(MAX_WATCHED_ADDRESSES.get())); + + assert_eq!( + result, + Err(WatchDepositAddressError::TooManyWatchedAddresses) + ); + assert_matches!( + watch_deposit_address(&runtime(), account(0)) + .unwrap() + .status, + WatchDepositAddressStatus::Watching { .. } + ); +} + +#[test] +fn should_register_while_a_deposit_sol_call_is_in_progress() { + setup(); + let _guard = deposit_sol_guard(account(1)).unwrap(); + + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + + assert_matches!(result.status, WatchDepositAddressStatus::Watching { .. }); +} + +#[test] +#[should_panic(expected = "the owner must be non-anonymous")] +fn should_reject_an_anonymous_owner() { + let anonymous_account = Account { + owner: Principal::anonymous(), + subaccount: None, + }; + + let _ = watch_deposit_address(&runtime(), anonymous_account); +} + +#[test] +#[should_panic(expected = "is not a valid deposit owner")] +fn should_reject_the_minter_as_owner() { + let _ = watch_deposit_address(&runtime(), MINTER_ACCOUNT); +} + +fn setup() { + init_state(); + init_schnorr_master_key(); +} + +fn runtime() -> TestCanisterRuntime { + runtime_at(REGISTERED_AT) +} + +fn runtime_at(now: u64) -> TestCanisterRuntime { + TestCanisterRuntime::new().with_increasing_time_from(now) +} diff --git a/minter/src/deposit/mod.rs b/minter/src/deposit/mod.rs index 0af6abbc7..4370f57dc 100644 --- a/minter/src/deposit/mod.rs +++ b/minter/src/deposit/mod.rs @@ -1 +1,2 @@ +pub mod automatic; pub mod sweep; diff --git a/minter/src/main.rs b/minter/src/main.rs index 71c352fe2..2c61af51b 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -13,8 +13,9 @@ use cksol_minter::{ }; use cksol_types::{ Address, DepositSolArgs, DepositSolError, DepositSolId, DepositSolStatus, - GetDepositAddressArgs, MinterInfo, WithdrawSolArgs, WithdrawSolError, WithdrawSolOk, - WithdrawSolStatus, WithdrawSolStatusArgs, + GetDepositAddressArgs, MinterInfo, WatchDepositAddressArgs, WatchDepositAddressError, + WatchDepositAddressOk, WithdrawSolArgs, WithdrawSolError, WithdrawSolOk, WithdrawSolStatus, + WithdrawSolStatusArgs, }; use cksol_types_internal::{MinterArg, log::Priority}; use ic_http_types::{HttpRequest, HttpResponse, HttpResponseBuilder}; @@ -75,6 +76,14 @@ fn deposit_sol_status(deposit_id: DepositSolId) -> DepositSolStatus { cksol_minter::deposit::sweep::deposit_status(deposit_id) } +#[ic_cdk::update] +fn watch_deposit_address( + args: WatchDepositAddressArgs, +) -> Result { + let account = resolve_account(None, args.subaccount); + cksol_minter::deposit::automatic::watch_deposit_address(&IcCanisterRuntime::new(), account) +} + #[ic_cdk::update] async fn withdraw_sol(args: WithdrawSolArgs) -> Result { let account = assert_non_anonymous_account(None, args.from_subaccount); diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 08025d32a..61db99fd8 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -66,6 +66,7 @@ pub struct Deposits { dropped: BTreeMap, quarantined: BTreeMap, in_flight_ids: BTreeMap, + latest_ids: BTreeMap, queued_at_by_id: BTreeMap, watchlist: Watchlist, } @@ -139,6 +140,11 @@ impl Deposits { }) } + /// The id of the latest deposit queued for the account, whatever stage it reached since. + pub fn latest_id(&self, account: &Account) -> Option { + self.latest_ids.get(account).copied() + } + /// Returns the timestamp (in nanoseconds) at which the oldest in-flight deposit was queued. /// A deposit is in flight from the moment it is queued until it is minted, dropped or /// quarantined, whatever sweep stage it has reached in between. @@ -213,6 +219,7 @@ impl Deposits { "Attempted to queue a deposit for account {:?} that already has one in flight", deposit.account ); + self.latest_ids.insert(deposit.account, deposit_id); self.queued.insert(deposit_id, deposit); self.queued_at_by_id.insert(deposit_id, queued_at); self.next_id += 1; From ee22e154dbbb7cf5c3f8a01b3b823076eec2e5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:51:39 +0000 Subject: [PATCH 050/146] refactor(minter): convert the registration times of watch_deposit_address at the candid boundary Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/mod.rs | 12 ++++++------ minter/src/deposit/automatic/tests.rs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/minter/src/deposit/automatic/mod.rs b/minter/src/deposit/automatic/mod.rs index 357bc1108..78685e036 100644 --- a/minter/src/deposit/automatic/mod.rs +++ b/minter/src/deposit/automatic/mod.rs @@ -2,7 +2,7 @@ use crate::{ address::{account_address, minter_public_key}, runtime::CanisterRuntime, state::{InFlightDeposit, State, WatchedAddress, WatchlistFull, mutate_state}, - utils::assert_valid_deposit_owner, + utils::{assert_valid_deposit_owner, timed_sized_map::Timestamp}, }; use cksol_types::{WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus}; use icrc_ledger_types::icrc1::account::Account; @@ -19,7 +19,7 @@ pub fn watch_deposit_address( let master_key = minter_public_key() .map_err(|e| WatchDepositAddressError::TemporarilyUnavailable(e.to_string()))?; let address = account_address(&master_key, &account); - let now = runtime.time(); + let now = Timestamp::from_nanos(runtime.time()); mutate_state(|state| { let automatic_deposit_fee = state.automatic_deposit_fee().ok_or_else(|| { @@ -40,7 +40,7 @@ pub fn watch_deposit_address( fn status_after_registration( state: &mut State, - now: u64, + now: Timestamp, account: Account, address: Address, ) -> Result { @@ -62,9 +62,9 @@ fn status_after_registration( fn watching(watched: WatchedAddress) -> WatchDepositAddressStatus { WatchDepositAddressStatus::Watching { - valid_until: watched.valid_until, + valid_until: watched.valid_until.as_nanos(), scan_count: watched.scan_count, - last_scanned_at: watched.last_scanned_at, - next_scan_at: watched.next_scan_at, + last_scanned_at: watched.last_scanned_at.map(Timestamp::as_nanos), + next_scan_at: watched.next_scan_at.map(Timestamp::as_nanos), } } diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index e75d08f0a..a0ce132f0 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -4,7 +4,7 @@ use crate::{ state::{mutate_state, read_state, watchlist::MAX_WATCHED_ADDRESSES}, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, EventsAssert, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, - MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, deposit_address, + MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, events::{ fail_transaction, quarantine_sweep, queue_deposit, submit_sweep, succeed_transaction, }, @@ -81,7 +81,7 @@ fn should_report_the_stored_registration_without_extending_it() { let first = watch_deposit_address(&runtime(), account(1)).unwrap(); mutate_state(|s| { s.watchlist_mut() - .record_scan(REGISTERED_AT + 1, &account(1)) + .record_scan(at(REGISTERED_AT + 1), &account(1)) }); let second = From 82c4e0fb0e497126ca54b8467419243e3af540b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:42:24 +0000 Subject: [PATCH 051/146] feat(minter): take a queued account off the watchlist Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/tests.rs | 2 +- minter/src/state/deposits/mod.rs | 1 + minter/src/state/tests.rs | 22 ++++++++++++++++++++++ minter/src/test_fixtures/mod.rs | 5 +++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index a0ce132f0..7d38ba522 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -1,7 +1,7 @@ use crate::{ deposit::automatic::watch_deposit_address, guard::deposit_sol_guard, - state::{mutate_state, read_state, watchlist::MAX_WATCHED_ADDRESSES}, + state::{MAX_WATCHED_ADDRESSES, mutate_state, read_state}, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, EventsAssert, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 61db99fd8..3c6a1e99c 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -220,6 +220,7 @@ impl Deposits { deposit.account ); self.latest_ids.insert(deposit.account, deposit_id); + self.watchlist.remove(&deposit.account); self.queued.insert(deposit_id, deposit); self.queued_at_by_id.insert(deposit_id, queued_at); self.next_id += 1; diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index fc9470251..9b9d20141 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -140,6 +140,28 @@ mod queued_deposits { } } +mod watched_deposit_addresses { + use super::*; + use crate::test_fixtures::{ + MINIMUM_DEPOSIT_AMOUNT, REGISTERED_AT, at, flow::deposit::DepositFlow, watch, + }; + + #[test] + fn should_leave_the_watchlist_when_a_deposit_is_queued() { + init_state(); + init_schnorr_master_key(); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT), account(2)); + + DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); + + read_state(|s| { + assert_eq!(s.watchlist().get(at(REGISTERED_AT), &account(1)), None); + assert!(s.watchlist().get(at(REGISTERED_AT), &account(2)).is_some()); + }); + } +} + mod swept_deposits { use super::*; use crate::{ diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index c281ef18b..654e803af 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -165,6 +165,11 @@ pub fn at(nanos: u64) -> Timestamp { Timestamp::from_nanos(nanos) } +/// Registers the deposit address of the account on the watchlist of the state at `now`. +pub fn watch(now: Timestamp, account: Account) -> WatchedAddress { + mutate_state(|state| register(state.watchlist_mut(), now, account)) +} + /// Registers the deposit address of the account on the watchlist at `now`. pub fn register(watchlist: &mut Watchlist, now: Timestamp, account: Account) -> WatchedAddress { watchlist From aa7755835d8d6221fddde6bf873d59138700b457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:09:25 +0000 Subject: [PATCH 052/146] test(minter): expect the next slot of the schedule after a scan at its start Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index 7d38ba522..cff2dbeb8 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -93,7 +93,7 @@ fn should_report_the_stored_registration_without_extending_it() { valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, scan_count: 1, last_scanned_at: Some(REGISTERED_AT + 1), - next_scan_at: Some(REGISTERED_AT + 1 + MINUTE_NANOS), + next_scan_at: Some(REGISTERED_AT + MINUTE_NANOS), } ); assert_eq!(second.address, first.address); From 2e75a326bc540f4b4d39d3c99c7e2c740a9474d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:20:51 +0000 Subject: [PATCH 053/146] test(minter): report the same registration on a repeated call before the first scan Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index cff2dbeb8..0d16afe9a 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -100,6 +100,17 @@ fn should_report_the_stored_registration_without_extending_it() { assert_eq!(read_state(|s| s.watchlist().len()), 1); } +#[test] +fn should_report_the_same_registration_before_the_first_scan() { + setup(); + let first = watch_deposit_address(&runtime(), account(1)).unwrap(); + + let second = + watch_deposit_address(&runtime_at(REGISTERED_AT + HOUR_NANOS), account(1)).unwrap(); + + assert_eq!(second, first); +} + #[test] fn should_register_anew_once_the_window_has_passed() { setup(); From ed2a039f961f10c68ea851ca30a160b651eed2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:55:54 +0000 Subject: [PATCH 054/146] refactor(minter): register inside the status match and convert the registration with From Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/mod.rs | 51 ++++++++++++----------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/minter/src/deposit/automatic/mod.rs b/minter/src/deposit/automatic/mod.rs index 78685e036..64a639b48 100644 --- a/minter/src/deposit/automatic/mod.rs +++ b/minter/src/deposit/automatic/mod.rs @@ -1,12 +1,11 @@ use crate::{ address::{account_address, minter_public_key}, runtime::CanisterRuntime, - state::{InFlightDeposit, State, WatchedAddress, WatchlistFull, mutate_state}, + state::{InFlightDeposit, WatchedAddress, WatchlistFull, mutate_state}, utils::{assert_valid_deposit_owner, timed_sized_map::Timestamp}, }; use cksol_types::{WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus}; use icrc_ledger_types::icrc1::account::Account; -use solana_address::Address; #[cfg(test)] mod tests; @@ -27,7 +26,19 @@ pub fn watch_deposit_address( "the automatic deposit fee is not configured".to_string(), ) })?; - let status = status_after_registration(state, now, account, address)?; + let status = match state.deposits().in_flight(&account) { + Some(InFlightDeposit::Processing(deposit_id)) => { + WatchDepositAddressStatus::Detected { deposit_id } + } + Some(InFlightDeposit::Quarantined(deposit_id)) => { + return Err(WatchDepositAddressError::Quarantined { deposit_id }); + } + None => state + .watchlist_mut() + .register(now, account, address) + .map_err(|WatchlistFull| WatchDepositAddressError::TooManyWatchedAddresses)? + .into(), + }; Ok(WatchDepositAddressOk { address: address.into(), minimum_deposit_amount: state.minimum_deposit_amount(), @@ -38,33 +49,13 @@ pub fn watch_deposit_address( }) } -fn status_after_registration( - state: &mut State, - now: Timestamp, - account: Account, - address: Address, -) -> Result { - match state.deposits().in_flight(&account) { - Some(InFlightDeposit::Processing(deposit_id)) => { - return Ok(WatchDepositAddressStatus::Detected { deposit_id }); - } - Some(InFlightDeposit::Quarantined(deposit_id)) => { - return Err(WatchDepositAddressError::Quarantined { deposit_id }); +impl From for WatchDepositAddressStatus { + fn from(watched: WatchedAddress) -> Self { + WatchDepositAddressStatus::Watching { + valid_until: watched.valid_until.as_nanos(), + scan_count: watched.scan_count, + last_scanned_at: watched.last_scanned_at.map(Timestamp::as_nanos), + next_scan_at: watched.next_scan_at.map(Timestamp::as_nanos), } - None => {} - } - state - .watchlist_mut() - .register(now, account, address) - .map(watching) - .map_err(|WatchlistFull| WatchDepositAddressError::TooManyWatchedAddresses) -} - -fn watching(watched: WatchedAddress) -> WatchDepositAddressStatus { - WatchDepositAddressStatus::Watching { - valid_until: watched.valid_until.as_nanos(), - scan_count: watched.scan_count, - last_scanned_at: watched.last_scanned_at.map(Timestamp::as_nanos), - next_scan_at: watched.next_scan_at.map(Timestamp::as_nanos), } } From 8d12662ee44859cd2874f158f02abc0dd7225ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:55:54 +0000 Subject: [PATCH 055/146] docs(minter): point the latest deposit id at deposit_sol_status Co-Authored-By: Claude Fable 5.1 --- libs/types/src/lib.rs | 4 +++- minter/cksol_minter.did | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index efd2aa483..dcb2fac50 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -171,7 +171,9 @@ pub struct WatchDepositAddressOk { pub automatic_deposit_fee: Lamport, /// Whether the account is watched or has a deposit in flight. pub status: WatchDepositAddressStatus, - /// The id of the latest deposit queued for the account, if any, whatever stage it reached. + /// The id of the latest deposit queued for the account, if any, whatever stage it reached, + /// to follow with `deposit_sol_status`. For a `Detected` status it is the id of the deposit + /// in flight. pub latest_deposit_id: Option, } diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 5e7d84a0f..d045b1305 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -198,7 +198,8 @@ type WatchDepositAddressOk = record { // Whether the account is watched or has a deposit in flight. status: WatchDepositAddressStatus; // The id of the latest deposit queued for the account, if any, whatever - // stage it reached. + // stage it reached, to follow with `deposit_sol_status`. For a `Detected` + // status it is the id of the deposit in flight. latest_deposit_id: opt DepositSolId; }; From 110cc47918a523dc164148e252cda923d884dac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:55:58 +0000 Subject: [PATCH 056/146] refactor(minter): reject the anonymous caller of watch_deposit_address at the endpoint Co-Authored-By: Claude Fable 5.1 --- minter/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minter/src/main.rs b/minter/src/main.rs index 2c61af51b..f3cc2e2f6 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -80,7 +80,7 @@ fn deposit_sol_status(deposit_id: DepositSolId) -> DepositSolStatus { fn watch_deposit_address( args: WatchDepositAddressArgs, ) -> Result { - let account = resolve_account(None, args.subaccount); + let account = assert_non_anonymous_account(None, args.subaccount); cksol_minter::deposit::automatic::watch_deposit_address(&IcCanisterRuntime::new(), account) } From 9672797769dc2c6d1e9c34704b871ee93fd90bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:55:58 +0000 Subject: [PATCH 057/146] test(minter): compare the registration quotes with get_minter_info Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 2e0ac7e14..c65a0ecc4 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1244,13 +1244,14 @@ mod watch_deposit_address_tests { }) .await; assert_eq!(registration.address, deposit_address); + let minter_info = setup.minter().get_minter_info().await; assert_eq!( registration.minimum_deposit_amount, - Setup::DEFAULT_MINIMUM_DEPOSIT_AMOUNT + minter_info.minimum_deposit_amount ); assert_eq!( - registration.automatic_deposit_fee, - Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE + Some(registration.automatic_deposit_fee), + minter_info.automatic_deposit_fee ); assert_eq!(registration.latest_deposit_id, None); let (valid_until, next_scan_at) = assert_matches!( From 4a33b69ba97ab715f724962d820508d1bc29ab92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:55:58 +0000 Subject: [PATCH 058/146] test(minter): name the integration tests of the endpoint after the automatic deposit flow Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index c65a0ecc4..1b55943d6 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1222,7 +1222,7 @@ mod deposit_sol_tests { } } -mod watch_deposit_address_tests { +mod automatic_deposit_sol_tests { use super::*; #[tokio::test] From dde75c0c53c23846c986637fd3b2c4bfcbfbbc54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:56:00 +0000 Subject: [PATCH 059/146] test(minter): share the watch window in nanoseconds through the integration fixtures Co-Authored-By: Claude Fable 5.1 --- integration_tests/src/fixtures.rs | 1 + integration_tests/tests/tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index 86f20ae19..55293976e 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -37,6 +37,7 @@ pub const DEPOSIT_AMOUNT: Lamport = 500_000_000; /// Minimum balance left on a deposit address to keep it rent-exempt. pub const RENT_EXEMPTION_THRESHOLD: Lamport = 890_880; pub const WATCH_WINDOW: Duration = Duration::from_hours(24); +pub const WATCH_WINDOW_NANOS: u64 = WATCH_WINDOW.as_nanos() as u64; /// The slot the mocked `getSlot` reports. A test says which block height the mocked /// `getBlock` then reports, since that is what the minter records and what it judges diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 1b55943d6..2a7e995d5 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -5,7 +5,7 @@ use cksol_int_tests::{ CkSolMinter, Setup, SetupBuilder, fixtures::{ DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, MINTER_ADDRESS, - MockBuilder, RENT_EXEMPTION_THRESHOLD, SharedMockHttpOutcalls, WATCH_WINDOW, + MockBuilder, RENT_EXEMPTION_THRESHOLD, SharedMockHttpOutcalls, WATCH_WINDOW_NANOS, }, validator::FEE_PER_SIGNATURE, }; @@ -1263,7 +1263,7 @@ mod automatic_deposit_sol_tests { next_scan_at: Some(next_scan_at), } => (valid_until, next_scan_at) ); - assert_eq!(valid_until - next_scan_at, WATCH_WINDOW.as_nanos() as u64); + assert_eq!(valid_until - next_scan_at, WATCH_WINDOW_NANOS); setup.drop().await; } From 62309927e32ffe991288589e8e2dde724e7846e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 08:57:30 +0000 Subject: [PATCH 060/146] refactor(minter): derive Clone for the state and its collections Lets tests snapshot the whole state and assert that a rejected call left it untouched. Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/mod.rs | 2 +- minter/src/state/deposits/sweeps/mod.rs | 2 +- minter/src/state/mod.rs | 2 +- minter/src/state/nonce_pool/mod.rs | 4 ++-- minter/src/state/watchlist/mod.rs | 2 +- minter/src/utils/insertion_ordered_map/mod.rs | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 3c6a1e99c..57c7c1a39 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -55,7 +55,7 @@ mod tests; /// /// Every account has at most one deposit in flight, so that `deposit_sol` can report the /// deposit it is already tracking instead of queueing the same balance twice. -#[derive(Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Deposits { next_id: DepositSolId, queued: BTreeMap, diff --git a/minter/src/state/deposits/sweeps/mod.rs b/minter/src/state/deposits/sweeps/mod.rs index 74ee87b57..581c01972 100644 --- a/minter/src/state/deposits/sweeps/mod.rs +++ b/minter/src/state/deposits/sweeps/mod.rs @@ -25,7 +25,7 @@ mod tests; /// A deposit belongs to exactly one sweep, so it can also be found by its id: /// [`Sweep::plan`] rejects a duplicated deposit id and [`Sweeps::insert`] rejects /// a sweep containing a deposit that another sweep already holds. -#[derive(Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Sweeps { by_signature: BTreeMap, } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 62d3c2fe1..7a753f82e 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -101,7 +101,7 @@ where /// to ensure that the state is only mutating through events. /// * Having public methods mutating the state may be acceptable for transient data (e.g. guards) /// that do not need to be preserved across canister upgrades. -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct State { minter_public_key: Option, master_key_name: Ed25519KeyName, diff --git a/minter/src/state/nonce_pool/mod.rs b/minter/src/state/nonce_pool/mod.rs index 2d5654b71..ba0e1cf34 100644 --- a/minter/src/state/nonce_pool/mod.rs +++ b/minter/src/state/nonce_pool/mod.rs @@ -11,7 +11,7 @@ mod tests; /// address as the nonce authority and enter the pool through the init and /// upgrade arguments; an account never leaves the pool. The pool may be empty, /// in which case no withdrawal transaction can be submitted. -#[derive(Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DurableNoncePool { accounts: BTreeMap, } @@ -135,7 +135,7 @@ impl DurableNoncePool { /// bound to transactions of this account, which classify every nonce read: /// a never-seen value is the account's new frontier, while a seen value is /// either the in-flight binding or a stale response. -#[derive(Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] struct NonceAccount { state: NonceAccountState, seen_nonce_values: BTreeSet, diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 63e6b3377..af03ebb5f 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -34,7 +34,7 @@ const STEADY_SCAN_GAP: Duration = Duration::from_hours(1); /// The deposit addresses registered for the automatic deposit flow, each watched for /// [`WATCH_WINDOW`] and scanned at the slots of [`BURST_SCAN_SLOTS`], then every /// [`STEADY_SCAN_GAP`]. -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Watchlist { entries: TimedSizedMap, } diff --git a/minter/src/utils/insertion_ordered_map/mod.rs b/minter/src/utils/insertion_ordered_map/mod.rs index 24720b7b0..47d2c0e7b 100644 --- a/minter/src/utils/insertion_ordered_map/mod.rs +++ b/minter/src/utils/insertion_ordered_map/mod.rs @@ -16,6 +16,7 @@ mod tests; /// [`iter`]: InsertionOrderedMap::iter /// [`keys`]: InsertionOrderedMap::keys /// [`values`]: InsertionOrderedMap::values +#[derive(Clone)] pub struct InsertionOrderedMap { entries: BTreeMap, order: BTreeMap, From b010146928bcbd99097f923baa8b1087043e98a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 08:59:27 +0000 Subject: [PATCH 061/146] test(minter): cover the latest deposit id of an account Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/tests.rs | 50 +++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/minter/src/state/deposits/tests.rs b/minter/src/state/deposits/tests.rs index 01a6e6307..1cb8d3dbe 100644 --- a/minter/src/state/deposits/tests.rs +++ b/minter/src/state/deposits/tests.rs @@ -74,6 +74,19 @@ mod queue { assert_eq!(deposits.in_flight(&queued_deposit(2).account), None); } + #[test] + fn should_remember_the_latest_deposit_id_of_each_account() { + let mut deposits = Deposits::default(); + assert_eq!(deposits.latest_id(&queued_deposit(0).account), None); + + deposits.queue(0, queued_deposit(0), 0); + deposits.queue(1, queued_deposit(1), 0); + + assert_eq!(deposits.latest_id(&queued_deposit(0).account), Some(0)); + assert_eq!(deposits.latest_id(&queued_deposit(1).account), Some(1)); + assert_eq!(deposits.latest_id(&queued_deposit(2).account), None); + } + #[test] fn should_report_the_status_of_queued_and_unknown_deposits() { let mut deposits = Deposits::default(); @@ -283,6 +296,24 @@ mod drop_swept { ); } + #[test] + fn should_keep_the_latest_id_of_a_dropped_deposit() { + let mut deposits = Deposits::default(); + let [first] = queue_deposits(&mut deposits); + let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); + deposits.sweep( + &[0], + MINTER_ADDRESS, + &sweep_message([(0, first)]), + &sweep_signature, + ); + + deposits.drop_swept(&sweep_signature); + + assert_eq!(deposits.in_flight(&first.account), None); + assert_eq!(deposits.latest_id(&first.account), Some(0)); + } + #[test] #[should_panic(expected = "Attempted to drop sweep")] fn should_panic_when_dropping_a_sweep_that_is_not_swept() { @@ -458,7 +489,7 @@ mod credit_sweep { mod mint { use super::{ BTreeMap, DepositSolStatus, Deposits, InFlightDeposit, LedgerMintIndex, MintedSweep, - SweptDeposit, credited_sweep, mint, queued_deposit, + SweptDeposit, credited_sweep, mint, queued_deposit, queued_deposit_of, }; #[test] @@ -505,6 +536,23 @@ mod mint { ); } + #[test] + fn should_keep_the_latest_id_of_a_minted_deposit_until_the_account_deposits_again() { + let (mut deposits, _) = credited_sweep(&[mint(0, 100)]); + let account = queued_deposit(0).account; + deposits.mint(0, LedgerMintIndex::from(42)); + assert_eq!(deposits.in_flight(&account), None); + assert_eq!(deposits.latest_id(&account), Some(0)); + + deposits.queue(1, queued_deposit_of(account, 200), 0); + + assert_eq!( + deposits.in_flight(&account), + Some(InFlightDeposit::Processing(1)) + ); + assert_eq!(deposits.latest_id(&account), Some(1)); + } + #[test] #[should_panic(expected = "Attempted to mint deposit 0 that has no pending mint")] fn should_panic_without_pending_mint() { From 40a7ffa3cfc30f8e1ba20fe9afff04318f586016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:00:27 +0000 Subject: [PATCH 062/146] test(minter): assert a rejected registration leaves the state untouched Table-drives the two unavailable prerequisites, each on a fresh state. Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/tests.rs | 83 ++++++++++++++++----------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index 0d16afe9a..15d88fb39 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -1,7 +1,7 @@ use crate::{ deposit::automatic::watch_deposit_address, guard::deposit_sol_guard, - state::{MAX_WATCHED_ADDRESSES, mutate_state, read_state}, + state::{MAX_WATCHED_ADDRESSES, State, mutate_state, read_state}, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, EventsAssert, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, @@ -21,33 +21,27 @@ use cksol_types_internal::InitArgs; use icrc_ledger_types::icrc1::account::Account; #[test] -fn should_fail_while_the_automatic_deposit_fee_is_not_configured() { - init_state_with_args(InitArgs { - automatic_deposit_fee: None, - ..valid_init_args() - }); - init_schnorr_master_key(); - - let result = watch_deposit_address(&runtime(), account(1)); - - assert_matches!( - result, - Err(WatchDepositAddressError::TemporarilyUnavailable(e)) => assert!(e.contains("automatic deposit fee")) - ); - assert!(read_state(|s| s.watchlist().is_empty())); -} - -#[test] -fn should_fail_while_the_minter_public_key_is_unavailable() { - init_state(); - - let result = watch_deposit_address(&runtime(), account(1)); - - assert_matches!( - result, - Err(WatchDepositAddressError::TemporarilyUnavailable(e)) => assert!(e.contains("minter public key")) - ); - assert!(read_state(|s| s.watchlist().is_empty())); +fn should_fail_without_registering_while_a_prerequisite_is_unavailable() { + for (setup_without_prerequisite, unavailable) in [ + ( + setup_without_automatic_deposit_fee as fn(), + "automatic deposit fee", + ), + (setup_without_minter_public_key, "minter public key"), + ] { + in_fresh_state(|| { + setup_without_prerequisite(); + let state_before = current_state(); + + let result = watch_deposit_address(&runtime(), account(1)); + + assert_matches!( + result, + Err(WatchDepositAddressError::TemporarilyUnavailable(e)) => assert!(e.contains(unavailable)) + ); + assert_eq!(current_state(), state_before); + }); + } } #[test] @@ -223,6 +217,7 @@ fn should_reject_an_account_whose_latest_deposit_is_quarantined() { submit_sweep(signature(0xAA), vec![0]); succeed_transaction(signature(0xAA)); quarantine_sweep(signature(0xAA)); + let state_before = current_state(); let result = watch_deposit_address(&runtime(), account(1)); @@ -230,7 +225,7 @@ fn should_reject_an_account_whose_latest_deposit_is_quarantined() { result, Err(WatchDepositAddressError::Quarantined { deposit_id: 0 }) ); - assert!(read_state(|s| s.watchlist().is_empty())); + assert_eq!(current_state(), state_before); } #[test] @@ -239,6 +234,7 @@ fn should_reject_a_registration_when_the_watchlist_is_full() { for index in 0..MAX_WATCHED_ADDRESSES.get() { watch_deposit_address(&runtime(), account(index)).unwrap(); } + let state_before = current_state(); let result = watch_deposit_address(&runtime(), account(MAX_WATCHED_ADDRESSES.get())); @@ -246,12 +242,7 @@ fn should_reject_a_registration_when_the_watchlist_is_full() { result, Err(WatchDepositAddressError::TooManyWatchedAddresses) ); - assert_matches!( - watch_deposit_address(&runtime(), account(0)) - .unwrap() - .status, - WatchDepositAddressStatus::Watching { .. } - ); + assert_eq!(current_state(), state_before); } #[test] @@ -286,6 +277,28 @@ fn setup() { init_schnorr_master_key(); } +fn setup_without_automatic_deposit_fee() { + init_state_with_args(InitArgs { + automatic_deposit_fee: None, + ..valid_init_args() + }); + init_schnorr_master_key(); +} + +fn setup_without_minter_public_key() { + init_state(); +} + +fn in_fresh_state(test: impl FnOnce() + Send) { + std::thread::scope(|scope| { + scope.spawn(test); + }); +} + +fn current_state() -> State { + read_state(|s| s.clone()) +} + fn runtime() -> TestCanisterRuntime { runtime_at(REGISTERED_AT) } From 505eeb01ef84d224726c1c56f151774f9ac0a4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:00:37 +0000 Subject: [PATCH 063/146] test(minter): table-drive the in-flight stages reported as detected Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/tests.rs | 83 ++++++++++++--------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index 15d88fb39..7626a848c 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -16,7 +16,9 @@ use crate::{ }; use assert_matches::assert_matches; use candid::Principal; -use cksol_types::{WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus}; +use cksol_types::{ + DepositSolId, WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus, +}; use cksol_types_internal::InitArgs; use icrc_ledger_types::icrc1::account::Account; @@ -125,54 +127,23 @@ fn should_register_anew_once_the_window_has_passed() { } #[test] -fn should_report_a_queued_deposit_as_detected_without_registering() { - setup(); - let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); - - let result = watch_deposit_address(&runtime(), account(1)).unwrap(); - - assert_eq!( - result.status, - WatchDepositAddressStatus::Detected { - deposit_id: deposit.deposit_id - } - ); - assert_eq!(result.latest_deposit_id, Some(deposit.deposit_id)); - assert!(read_state(|s| s.watchlist().is_empty())); -} - -#[test] -fn should_report_a_swept_deposit_as_detected_without_registering() { - setup(); - let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); - deposit.sweep(signature(0xAA)); - - let result = watch_deposit_address(&runtime(), account(1)).unwrap(); - - assert_eq!( - result.status, - WatchDepositAddressStatus::Detected { - deposit_id: deposit.deposit_id - } - ); - assert!(read_state(|s| s.watchlist().is_empty())); -} - -#[test] -fn should_report_a_credited_deposit_as_detected_without_registering() { - setup(); - let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); - deposit.sweep(signature(0xAA)).succeed().credit(); +fn should_report_an_in_flight_deposit_as_detected_without_registering() { + for reach_in_flight_stage in [queued as fn(Account) -> DepositSolId, swept, credited] { + in_fresh_state(|| { + setup(); + let deposit_id = reach_in_flight_stage(account(1)); + let state_before = current_state(); - let result = watch_deposit_address(&runtime(), account(1)).unwrap(); + let result = watch_deposit_address(&runtime(), account(1)).unwrap(); - assert_eq!( - result.status, - WatchDepositAddressStatus::Detected { - deposit_id: deposit.deposit_id - } - ); - assert!(read_state(|s| s.watchlist().is_empty())); + assert_eq!( + result.status, + WatchDepositAddressStatus::Detected { deposit_id } + ); + assert_eq!(result.latest_deposit_id, Some(deposit_id)); + assert_eq!(current_state(), state_before); + }); + } } #[test] @@ -289,6 +260,24 @@ fn setup_without_minter_public_key() { init_state(); } +fn queued(account: Account) -> DepositSolId { + DepositFlow::queue(account, MINIMUM_DEPOSIT_AMOUNT).deposit_id +} + +fn swept(account: Account) -> DepositSolId { + let deposit = DepositFlow::queue(account, MINIMUM_DEPOSIT_AMOUNT); + let deposit_id = deposit.deposit_id; + deposit.sweep(signature(0xAA)); + deposit_id +} + +fn credited(account: Account) -> DepositSolId { + let deposit = DepositFlow::queue(account, MINIMUM_DEPOSIT_AMOUNT); + let deposit_id = deposit.deposit_id; + deposit.sweep(signature(0xAA)).succeed().credit(); + deposit_id +} + fn in_fresh_state(test: impl FnOnce() + Send) { std::thread::scope(|scope| { scope.spawn(test); From 966c23dcf0fef5d9ccd396a1e8750e8f84aa814b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:00:46 +0000 Subject: [PATCH 064/146] test(minter): assert the whole stored registration against the first one Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/tests.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/minter/src/deposit/automatic/tests.rs b/minter/src/deposit/automatic/tests.rs index 7626a848c..db38ec04e 100644 --- a/minter/src/deposit/automatic/tests.rs +++ b/minter/src/deposit/automatic/tests.rs @@ -75,6 +75,10 @@ fn should_register_the_account_without_recording_an_event() { fn should_report_the_stored_registration_without_extending_it() { setup(); let first = watch_deposit_address(&runtime(), account(1)).unwrap(); + let first_valid_until = assert_matches!( + first.status, + WatchDepositAddressStatus::Watching { valid_until, .. } => valid_until + ); mutate_state(|s| { s.watchlist_mut() .record_scan(at(REGISTERED_AT + 1), &account(1)) @@ -84,15 +88,17 @@ fn should_report_the_stored_registration_without_extending_it() { watch_deposit_address(&runtime_at(REGISTERED_AT + HOUR_NANOS), account(1)).unwrap(); assert_eq!( - second.status, - WatchDepositAddressStatus::Watching { - valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, - scan_count: 1, - last_scanned_at: Some(REGISTERED_AT + 1), - next_scan_at: Some(REGISTERED_AT + MINUTE_NANOS), + second, + WatchDepositAddressOk { + status: WatchDepositAddressStatus::Watching { + valid_until: first_valid_until, + scan_count: 1, + last_scanned_at: Some(REGISTERED_AT + 1), + next_scan_at: Some(REGISTERED_AT + MINUTE_NANOS), + }, + ..first } ); - assert_eq!(second.address, first.address); assert_eq!(read_state(|s| s.watchlist().len()), 1); } From 7ad4b78e1264a8d652a599045313dc8809132a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:27:10 +0000 Subject: [PATCH 065/146] feat(minter): record the automatic part of a queued deposit and deduct its fee from the mint Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 4 + libs/types-internal/src/event.rs | 19 ++- libs/types/src/lib.rs | 5 +- minter/cksol_minter.did | 26 ++++- minter/src/canbench.rs | 2 + minter/src/deposit/sweep/mod.rs | 2 + minter/src/deposit/sweep/tests.rs | 1 + minter/src/main.rs | 1 + minter/src/state/audit.rs | 2 + minter/src/state/deposits/mod.rs | 40 ++++++- minter/src/state/deposits/sweeps/mod.rs | 45 +++++-- minter/src/state/deposits/sweeps/tests.rs | 136 ++++++++++++++++++++-- minter/src/state/event.rs | 12 +- minter/src/state/event/candid/mod.rs | 30 ++++- minter/src/state/event/candid/tests.rs | 1 + minter/src/state/mod.rs | 15 ++- minter/src/state/tests.rs | 86 +++++++++++++- minter/src/test_fixtures/flow/deposit.rs | 23 +++- minter/src/test_fixtures/mod.rs | 74 ++++++++++-- 19 files changed, 473 insertions(+), 51 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 2a7e995d5..af19f9bcd 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -190,6 +190,7 @@ mod lifecycle { minter_address: Some(MINTER_ADDRESS.to_string()), nonce_accounts: vec![Setup::DEFAULT_NONCE_ACCOUNT.to_string()], automatic_deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), + collected_automatic_deposit_fees: Some(0), } ); @@ -232,6 +233,7 @@ mod lifecycle { minter_address: Some(MINTER_ADDRESS.to_string()), nonce_accounts: vec![Setup::DEFAULT_NONCE_ACCOUNT.to_string()], automatic_deposit_fee: Some(NEW_AUTOMATIC_DEPOSIT_FEE), + collected_automatic_deposit_fees: Some(0), } ); @@ -880,6 +882,7 @@ mod deposit_sol_tests { }, address: deposit_address.clone(), balance: BALANCE_ABOVE_MINIMUM, + automatic: None, }] ); }); @@ -960,6 +963,7 @@ mod deposit_sol_tests { account: DEFAULT_CALLER_ACCOUNT, address: deposit_address.clone(), balance: BALANCE_ABOVE_MINIMUM, + automatic: None, }] ); }); diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index b2b6dc2b7..f49a01c8f 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -65,7 +65,8 @@ pub enum EventType { /// The signature of the expired Solana transaction. signature: Signature, }, - /// A user queued the deposit address of an account for a sweep via `deposit_sol`. + /// The deposit address of an account was queued for a sweep, by a user via `deposit_sol` + /// or by a balance scan of the automatic flow. QueuedDeposit { /// The identifier of the queued deposit. deposit_id: u64, @@ -75,6 +76,9 @@ pub enum EventType { address: Address, /// The balance of the deposit address when the deposit was queued. balance: Lamport, + /// What the automatic flow recorded when it detected the deposit, absent for a + /// deposit queued via `deposit_sol`. + automatic: Option, }, /// The minter read the amount that the finalized sweep transaction moved to its /// main account and enqueued a pending mint for each deposit of that sweep. @@ -146,10 +150,21 @@ pub enum EventType { pub struct CreditedDeposit { /// The identifier of the deposit. pub deposit_id: u64, - /// The sweepable amount minus the deposit's share of the transaction fee of the sweep. + /// The sweepable amount minus the deposit's share of the transaction fee of the sweep + /// and, for an automatic deposit, minus the automatic deposit fee. pub amount_to_mint: Lamport, } +/// The part of a `QueuedDeposit` event that the automatic flow recorded when it detected +/// the deposit. +#[derive(Clone, Copy, Debug, PartialEq, CandidType, Deserialize)] +pub struct AutomaticDeposit { + /// The automatic deposit fee in force at detection, deducted from the minted amount. + pub fee: Lamport, + /// The end of the registration window, in nanoseconds since the epoch. + pub valid_until: u64, +} + /// The key that produced one signature of a submitted Solana transaction. #[derive(Clone, Debug, PartialEq, CandidType, Deserialize)] pub enum Signer { diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index dcb2fac50..57a081c78 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -92,7 +92,8 @@ pub enum DepositSolStatus { /// The mint transaction index on the ckSOL ledger. block_index: LedgerMintIndex, /// The minted amount: the swept amount minus the deposit's share of the - /// transaction fee of the sweep. + /// transaction fee of the sweep and, for a deposit detected by the automatic + /// flow, minus the automatic deposit fee. minted_amount: Lamport, }, /// The sweep transaction failed, or expired without ever being seen on chain, so no @@ -337,4 +338,6 @@ pub struct MinterInfo { /// The fee in lamports deducted from the minted amount of a deposit detected by the /// automatic flow, once configured. pub automatic_deposit_fee: Option, + /// The sum in lamports of the automatic deposit fees collected so far. + pub collected_automatic_deposit_fees: Option, } diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index d045b1305..87a714184 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -99,7 +99,8 @@ type DepositSolStatus = variant { // The mint transaction index on the ckSOL ledger. block_index: LedgerMintIndex; // The minted amount: the swept amount minus the deposit's share of the - // transaction fee of the sweep. + // transaction fee of the sweep and, for a deposit detected by the + // automatic flow, minus the automatic deposit fee. minted_amount: Lamport; }; // The sweep transaction failed, or expired without ever being seen on chain, @@ -263,6 +264,8 @@ type MinterInfo = record { // The fee in lamports deducted from the minted amount of a deposit detected // by the automatic flow, once configured. automatic_deposit_fee : opt Lamport; + // The sum in lamports of the automatic deposit fees collected so far. + collected_automatic_deposit_fees : opt Lamport; }; // The ckSOL minter service arguments. @@ -371,10 +374,23 @@ type WithdrawSolStatus = variant { type CreditedDeposit = record { // The identifier of the deposit. deposit_id: DepositSolId; - // The sweepable amount minus the deposit's share of the transaction fee of the sweep. + // The sweepable amount minus the deposit's share of the transaction fee of + // the sweep and, for an automatic deposit, minus the automatic deposit fee. amount_to_mint: Lamport; }; +// The part of a `QueuedDeposit` event that the automatic flow recorded when +// it detected the deposit. +// +// *WARNING*: This type is used exclusively by the debug `get_events` endpoint. +// Backwards-compatibility is not guaranteed. +type AutomaticDeposit = record { + // The automatic deposit fee in force at detection, deducted from the minted amount. + fee: Lamport; + // The end of the registration window, in nanoseconds since the epoch. + valid_until: nat64; +}; + // Event payload for an accepted ckSOL-to-Solana withdrawal request. // // *WARNING*: This type is used exclusively by the debug `get_events` endpoint. @@ -480,7 +496,8 @@ type EventType = variant { // The signature of the expired Solana transaction. signature: Signature; }; - // A user queued the deposit address of an account for a sweep via `deposit_sol`. + // The deposit address of an account was queued for a sweep, by a user via + // `deposit_sol` or by a balance scan of the automatic flow. QueuedDeposit : record { // The identifier of the queued deposit. deposit_id: DepositSolId; @@ -490,6 +507,9 @@ type EventType = variant { address: Address; // The balance of the deposit address when the deposit was queued. balance: Lamport; + // What the automatic flow recorded when it detected the deposit, + // absent for a deposit queued via `deposit_sol`. + automatic: opt AutomaticDeposit; }; // The minter read the amount that the finalized sweep transaction moved to // its main account and enqueued a pending mint for each deposit of that sweep. diff --git a/minter/src/canbench.rs b/minter/src/canbench.rs index a308cd164..39d6dd9ef 100644 --- a/minter/src/canbench.rs +++ b/minter/src/canbench.rs @@ -109,12 +109,14 @@ fn queue_and_sweep(deposit_id: u64, account_index: usize, amount: u64, sig: Sign address: deposit_address(account_index), balance: DepositBalance::new(amount + RENT_EXEMPTION_THRESHOLD) .expect("BUG: the balance covers the rent exemption threshold"), + automatic: None, }; record(EventType::QueuedDeposit { deposit_id, account, address: deposit.address, balance: deposit.balance, + automatic: None, }); let sweep = Sweep::plan([(deposit_id, deposit)], minter_address(&master_key())); record(EventType::SubmittedTransaction { diff --git a/minter/src/deposit/sweep/mod.rs b/minter/src/deposit/sweep/mod.rs index ff5e41fe5..c69fef913 100644 --- a/minter/src/deposit/sweep/mod.rs +++ b/minter/src/deposit/sweep/mod.rs @@ -78,6 +78,7 @@ pub async fn deposit_sol( account, address: deposit_address, balance, + automatic: None, }, ) }); @@ -105,6 +106,7 @@ pub(crate) fn queue_deposit( account: deposit.account, address: deposit.address, balance: deposit.balance, + automatic: deposit.automatic, }, runtime, ); diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index 553610a6c..3548de15d 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -240,6 +240,7 @@ fn queued_deposit_event(deposit_id: u64, account: Account, sweepable_amount: Lam account, address: deposit.address, balance: deposit.balance, + automatic: None, } } diff --git a/minter/src/main.rs b/minter/src/main.rs index f3cc2e2f6..3ff461a46 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -140,6 +140,7 @@ fn get_minter_info() -> MinterInfo { .map(|address| address.to_string()) .collect(), automatic_deposit_fee: s.automatic_deposit_fee(), + collected_automatic_deposit_fees: Some(s.collected_automatic_deposit_fees()), }) } diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 922a957e6..1d00ec4bd 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -49,6 +49,7 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 account, address, balance, + automatic, } => { state.process_queued_deposit( *deposit_id, @@ -56,6 +57,7 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 account: *account, address: *address, balance: *balance, + automatic: *automatic, }, timestamp, ); diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 57c7c1a39..5f45afb40 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -8,6 +8,7 @@ use crate::{ }; use cksol_types::{DepositSolId, DepositSolStatus}; use icrc_ledger_types::icrc1::account::Account; +use minicbor::{Decode, Encode}; use sol_rpc_types::Lamport; use solana_address::Address; use solana_signature::Signature; @@ -68,6 +69,7 @@ pub struct Deposits { in_flight_ids: BTreeMap, latest_ids: BTreeMap, queued_at_by_id: BTreeMap, + automatic_deposit_count: u64, watchlist: Watchlist, } @@ -145,6 +147,11 @@ impl Deposits { self.latest_ids.get(account).copied() } + /// How many deposits the automatic flow has queued so far. + pub fn automatic_deposit_count(&self) -> u64 { + self.automatic_deposit_count + } + /// Returns the timestamp (in nanoseconds) at which the oldest in-flight deposit was queued. /// A deposit is in flight from the moment it is queued until it is minted, dropped or /// quarantined, whatever sweep stage it has reached in between. @@ -220,6 +227,9 @@ impl Deposits { deposit.account ); self.latest_ids.insert(deposit.account, deposit_id); + if deposit.automatic.is_some() { + self.automatic_deposit_count += 1; + } self.watchlist.remove(&deposit.account); self.queued.insert(deposit_id, deposit); self.queued_at_by_id.insert(deposit_id, queued_at); @@ -316,13 +326,13 @@ impl Deposits { } /// Moves every deposit of the given finalized sweep to the pending mints, each with - /// the amount its mint carries. + /// the amount its mint carries, and returns the automatic deposit fees the sweep collects. pub(super) fn credit_sweep( &mut self, signature: &Signature, mints: &[CreditedDeposit], timestamp: u64, - ) { + ) -> Lamport { let sweep = self.finalized.remove(signature).unwrap_or_else(|| { panic!("Attempted to credit sweep {signature} that is not finalized") }); @@ -363,6 +373,7 @@ impl Deposits { mint.deposit_id ); } + sweep.automatic_deposit_fees() } pub(super) fn mint(&mut self, deposit_id: DepositSolId, mint_block_index: LedgerMintIndex) { @@ -419,12 +430,31 @@ pub struct QueuedDeposit { pub address: Address, /// The balance of the deposit address when the deposit was queued. pub balance: DepositBalance, + /// What the automatic flow recorded when it detected the deposit, absent for a deposit + /// queued by `deposit_sol`. + pub automatic: Option, } impl QueuedDeposit { pub fn sweepable_amount(&self) -> Lamport { self.balance.sweepable_amount() } + + /// The automatic deposit fee deducted from the mint, zero for a manual deposit. + pub fn automatic_deposit_fee(&self) -> Lamport { + self.automatic.map_or(0, |automatic| automatic.fee) + } +} + +/// The part of a queued deposit that the automatic flow detected: the fee in force at +/// detection, which the mint is reduced by, and the end of the registration window, which +/// bounds the re-arm of the deposit should its sweep be dropped. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Decode, Encode)] +pub struct AutomaticDeposit { + #[n(0)] + pub fee: Lamport, + #[n(1)] + pub valid_until: u64, } impl From for Lamport { @@ -463,7 +493,8 @@ pub struct SweptDeposit { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PendingMint { pub deposit: SweptDeposit, - /// The sweepable amount minus the deposit's share of the transaction fee of the sweep. + /// The sweepable amount minus the deposit's share of the transaction fee of the sweep + /// and, for an automatic deposit, minus the automatic deposit fee. pub amount_to_mint: Lamport, /// The timestamp of the `CreditedSweep` event that enqueued this pending mint. /// @@ -527,7 +558,8 @@ pub enum QuarantineCause { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MintedSweep { pub deposit: SweptDeposit, - /// The sweepable amount minus the deposit's share of the transaction fee of the sweep. + /// The sweepable amount minus the deposit's share of the transaction fee of the sweep + /// and, for an automatic deposit, minus the automatic deposit fee. pub minted_amount: Lamport, pub mint_block_index: LedgerMintIndex, } diff --git a/minter/src/state/deposits/sweeps/mod.rs b/minter/src/state/deposits/sweeps/mod.rs index 581c01972..6d50df7d3 100644 --- a/minter/src/state/deposits/sweeps/mod.rs +++ b/minter/src/state/deposits/sweeps/mod.rs @@ -316,26 +316,48 @@ impl Sweep { Ok(SettledSweep { amount_received, - mints: self.mints(), + mints: self.mints()?, }) } /// The mints crediting the deposits of the sweep: each sweepable amount minus the /// deposit's share of the fee, rounded up so that the total never exceeds the amount - /// the minter address receives. - pub fn mints(&self) -> Vec { + /// the minter address receives, minus the automatic deposit fee of an automatic deposit. + /// + /// The parameter constraints keep the fees below the sweepable amount of a deposit + /// queued under them, so a deposit they do not fit is a mismatch that quarantines the + /// sweep rather than a reason to trap. + pub fn mints(&self) -> Result, SweepMismatch> { let fee_share = self.fee.div_ceil(self.deposit_count() as Lamport); self.deposits .iter() - .map(|(deposit_id, deposit)| CreditedDeposit { - deposit_id: *deposit_id, - amount_to_mint: deposit + .map(|(deposit_id, deposit)| { + let amount_to_mint = deposit .sweepable_amount() .checked_sub(fee_share) - .expect("BUG: the minimum deposit amount covers the fee share"), + .and_then(|amount| amount.checked_sub(deposit.automatic_deposit_fee())) + .ok_or(SweepMismatch::FeesExceedSweepableAmount { + deposit_id: *deposit_id, + sweepable_amount: deposit.sweepable_amount(), + fee_share, + automatic_deposit_fee: deposit.automatic_deposit_fee(), + })?; + Ok(CreditedDeposit { + deposit_id: *deposit_id, + amount_to_mint, + }) }) .collect() } + + /// The automatic deposit fees the sweep collects: the sum of the fees recorded with its + /// automatic deposits, which stay on the main account on top of the minted amounts. + pub fn automatic_deposit_fees(&self) -> Lamport { + self.deposits + .values() + .map(QueuedDeposit::automatic_deposit_fee) + .sum() + } } /// A sweep whose outcome matched its plan, with what the minter received and mints for it. @@ -398,6 +420,15 @@ pub enum SweepMismatch { MainAccountDebited { pre: Lamport, post: Lamport }, #[error("the main account received {actual} lamports instead of the planned {expected}")] UnexpectedAmountReceived { expected: Lamport, actual: Lamport }, + #[error( + "the fee share of {fee_share} lamports and the automatic deposit fee of {automatic_deposit_fee} lamports exceed the {sweepable_amount} lamports swept for deposit {deposit_id}" + )] + FeesExceedSweepableAmount { + deposit_id: DepositSolId, + sweepable_amount: Lamport, + fee_share: Lamport, + automatic_deposit_fee: Lamport, + }, } #[derive(Clone, Debug, PartialEq, Eq, Error)] diff --git a/minter/src/state/deposits/sweeps/tests.rs b/minter/src/state/deposits/sweeps/tests.rs index 9db1e81eb..b125149db 100644 --- a/minter/src/state/deposits/sweeps/tests.rs +++ b/minter/src/state/deposits/sweeps/tests.rs @@ -98,12 +98,15 @@ fn should_panic_when_inserting_a_sweep_with_a_known_signature() { mod plan { use super::{ - FEE_PER_SIGNATURE, Lamport, MINTER_ADDRESS, Sweep, Transfer, account, planned_sweep, - queued_deposit_of, + FEE_PER_SIGNATURE, Lamport, MINTER_ADDRESS, Sweep, SweepMismatch, Transfer, account, + planned_sweep, queued_deposit_of, }; use crate::{ - state::event::CreditedDeposit, - test_fixtures::arb::{arb_address, arb_sweep_deposits}, + state::{AutomaticDeposit, event::CreditedDeposit}, + test_fixtures::{ + arb::{arb_address, arb_sweep_deposits}, + automatic_deposit_of, + }, }; use proptest::{prop_assert, prop_assert_eq, proptest}; @@ -140,7 +143,7 @@ mod plan { ); assert_eq!(sweep.expected_received(), 6_000_000 - 3 * FEE_PER_SIGNATURE); assert_eq!( - sweep.mints(), + sweep.mints().unwrap(), vec![ CreditedDeposit { deposit_id: 0, @@ -176,6 +179,84 @@ mod plan { ); } + #[test] + fn should_deduct_the_automatic_deposit_fee_from_the_mint_of_an_automatic_deposit() { + let automatic = AutomaticDeposit { + fee: 2_000_000, + valid_until: 1_700_000_000_000_000_000, + }; + let manual = queued_deposit_of(account(1), 3_000_000); + let detected = automatic_deposit_of(account(2), 5_000_000, automatic); + + let sweep = planned_sweep([(0, manual), (1, detected)]); + + assert_eq!(sweep.fee_payer(), 1); + assert_eq!(sweep.expected_received(), 8_000_000 - 2 * FEE_PER_SIGNATURE); + assert_eq!( + sweep.transfers(), + vec![ + Transfer { + deposit_id: 1, + from: detected.address, + amount: 5_000_000 - 2 * FEE_PER_SIGNATURE, + }, + Transfer { + deposit_id: 0, + from: manual.address, + amount: 3_000_000, + }, + ] + ); + assert_eq!( + sweep.mints().unwrap(), + vec![ + CreditedDeposit { + deposit_id: 0, + amount_to_mint: 3_000_000 - FEE_PER_SIGNATURE, + }, + CreditedDeposit { + deposit_id: 1, + amount_to_mint: 5_000_000 - FEE_PER_SIGNATURE - automatic.fee, + }, + ] + ); + assert_eq!(sweep.automatic_deposit_fees(), automatic.fee); + } + + #[test] + fn should_collect_no_fee_from_manual_deposits() { + let sweep = planned_sweep([ + (0, queued_deposit_of(account(1), 1_000_000)), + (1, queued_deposit_of(account(2), 2_000_000)), + ]); + + assert_eq!(sweep.automatic_deposit_fees(), 0); + } + + #[test] + fn should_have_no_mints_when_the_fees_exceed_the_sweepable_amount() { + let automatic = AutomaticDeposit { + fee: 1_000_000, + valid_until: 0, + }; + let sweepable_amount = automatic.fee + FEE_PER_SIGNATURE - 1; + + let sweep = planned_sweep([( + 0, + automatic_deposit_of(account(1), sweepable_amount, automatic), + )]); + + assert_eq!( + sweep.mints(), + Err(SweepMismatch::FeesExceedSweepableAmount { + deposit_id: 0, + sweepable_amount, + fee_share: FEE_PER_SIGNATURE, + automatic_deposit_fee: automatic.fee, + }) + ); + } + #[test] fn should_charge_the_whole_fee_to_a_single_deposit() { let sweep = planned_sweep([(7, queued_deposit_of(account(1), 1_000_000))]); @@ -184,7 +265,7 @@ mod plan { assert_eq!(sweep.fee(), FEE_PER_SIGNATURE); assert_eq!(sweep.expected_received(), 1_000_000 - FEE_PER_SIGNATURE); assert_eq!( - sweep.mints(), + sweep.mints().unwrap(), vec![CreditedDeposit { deposit_id: 7, amount_to_mint: 1_000_000 - FEE_PER_SIGNATURE, @@ -200,7 +281,7 @@ mod plan { ) { let sweep = Sweep::plan(deposits.clone(), minter_address); - let mints = sweep.mints(); + let mints = sweep.mints().unwrap(); prop_assert_eq!( mints.iter().map(|mint| mint.deposit_id).collect::>(), @@ -296,11 +377,14 @@ mod message { mod settle { use super::{ - EncodedConfirmedTransactionWithStatusMeta, RENT_EXEMPTION_THRESHOLD, SweepMismatch, + EncodedConfirmedTransactionWithStatusMeta, RENT_EXEMPTION_THRESHOLD, Sweep, SweepMismatch, SweepSettlementError, TransactionError, UiTransactionError, UnreadableOutcome, account, devnet_sweep, planned_sweep, queued_deposit_of, }; - use crate::test_fixtures::fetched; + use crate::{ + state::{AutomaticDeposit, QueuedDeposit}, + test_fixtures::fetched, + }; type Deviation = fn(&mut EncodedConfirmedTransactionWithStatusMeta); @@ -349,6 +433,40 @@ mod settle { } } + #[test] + fn should_reject_a_matching_outcome_whose_fees_exceed_a_sweepable_amount() { + let automatic = AutomaticDeposit { + fee: SECOND_SWEEPABLE_AMOUNT, + valid_until: 0, + }; + let deposits = devnet_sweep::deposits().into_iter().map(|(id, deposit)| { + let automatic = (deposit.address == SECOND_DEPOSIT).then_some(automatic); + ( + id, + QueuedDeposit { + automatic, + ..deposit + }, + ) + }); + let fetched = fetched(devnet_sweep::outcome()); + + let result = Sweep::plan(deposits, devnet_sweep::MINTER_ADDRESS) + .settle(&fetched.transaction, fetched.meta.as_ref()); + + assert_eq!( + result, + Err(SweepSettlementError::Mismatch( + SweepMismatch::FeesExceedSweepableAmount { + deposit_id: 1, + sweepable_amount: SECOND_SWEEPABLE_AMOUNT, + fee_share: devnet_sweep::FEE / devnet_sweep::DEPOSITS.len() as u64, + automatic_deposit_fee: automatic.fee, + } + )) + ); + } + #[test] fn should_reject_an_outcome_that_cannot_be_read() { let cases: [(&str, Deviation, UnreadableOutcome); 2] = [ diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 2a4fff871..cfde12c05 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -3,7 +3,7 @@ use crate::{ constants::FEE_PER_SIGNATURE, numeric::{LedgerBurnIndex, LedgerMintIndex}, rpc::BlockHeight, - state::DepositBalance, + state::{AutomaticDeposit, DepositBalance}, }; use cksol_types::DepositSolId; use cksol_types_internal::{InitArgs, UpgradeArgs}; @@ -128,7 +128,8 @@ pub enum EventType { #[cbor(n(0), with = "cbor::signature")] signature: Signature, }, - /// A user queued the deposit address of an account for a sweep via `deposit_sol`. + /// The deposit address of an account was queued for a sweep, by a user via `deposit_sol` + /// or by a balance scan of the automatic flow. /// The deposit id is the next sequence number of the minter at the time of the event. #[n(8)] QueuedDeposit { @@ -140,6 +141,10 @@ pub enum EventType { address: Address, #[cbor(n(3), with = "cbor::deposit_balance")] balance: DepositBalance, + /// The fee and window the automatic flow recorded when it detected the deposit, + /// absent for a deposit queued via `deposit_sol`. + #[n(4)] + automatic: Option, }, /// The minter read the amount that the finalized sweep transaction moved to its /// main account and enqueued a pending mint for each deposit of that sweep. @@ -230,7 +235,8 @@ pub enum EventType { pub struct CreditedDeposit { #[n(0)] pub deposit_id: DepositSolId, - /// The sweepable amount minus the deposit's share of the transaction fee of the sweep. + /// The sweepable amount minus the deposit's share of the transaction fee of the sweep + /// and, for an automatic deposit, minus the automatic deposit fee. #[n(1)] pub amount_to_mint: Lamport, } diff --git a/minter/src/state/event/candid/mod.rs b/minter/src/state/event/candid/mod.rs index da8935e65..af578d634 100644 --- a/minter/src/state/event/candid/mod.rs +++ b/minter/src/state/event/candid/mod.rs @@ -5,7 +5,7 @@ use crate::{ numeric::{LedgerBurnIndex, LedgerMintIndex}, rpc::BlockHeight, state::{ - DepositBalance, + AutomaticDeposit, DepositBalance, event::{ CreditedDeposit, Event, EventType, Signer, TransactionPurpose, VersionedMessage, WithdrawalRequest, @@ -13,9 +13,9 @@ use crate::{ }, }; use cksol_types_internal::event::{ - CreditedDeposit as CandidCreditedDeposit, Event as CandidEvent, EventType as CandidEventType, - Signer as CandidSigner, TransactionPurpose as CandidTransactionPurpose, - VersionedTransactionMessage, + AutomaticDeposit as CandidAutomaticDeposit, CreditedDeposit as CandidCreditedDeposit, + Event as CandidEvent, EventType as CandidEventType, Signer as CandidSigner, + TransactionPurpose as CandidTransactionPurpose, VersionedTransactionMessage, }; use ic_ed25519::PublicKey; use sol_rpc_types::{ @@ -76,11 +76,13 @@ impl From for CandidEventType { account, address, balance, + automatic, } => CandidEventType::QueuedDeposit { deposit_id, account, address: CandidAddress::from(address), balance: Lamport::from(balance), + automatic: automatic.map(CandidAutomaticDeposit::from), }, EventType::CreditedSweep { signature, @@ -171,6 +173,15 @@ impl From for CandidCreditedDeposit { } } +impl From for CandidAutomaticDeposit { + fn from(automatic: AutomaticDeposit) -> Self { + CandidAutomaticDeposit { + fee: automatic.fee, + valid_until: automatic.valid_until, + } + } +} + impl TryFrom for Event { type Error = InvalidCandidEvent; @@ -229,6 +240,7 @@ impl TryFrom for EventType { account, address, balance, + automatic, } => EventType::QueuedDeposit { deposit_id, account, @@ -236,6 +248,7 @@ impl TryFrom for EventType { balance: DepositBalance::new(balance).ok_or( InvalidCandidEvent::DepositBalanceBelowRentExemption(balance), )?, + automatic: automatic.map(AutomaticDeposit::from), }, CandidEventType::CreditedSweep { signature, @@ -330,6 +343,15 @@ impl From for CreditedDeposit { } } +impl From for AutomaticDeposit { + fn from(automatic: CandidAutomaticDeposit) -> Self { + AutomaticDeposit { + fee: automatic.fee, + valid_until: automatic.valid_until, + } + } +} + /// A Candid event that cannot be converted back into a minter event. #[derive(Clone, Debug, PartialEq, Eq, Error)] pub enum InvalidCandidEvent { diff --git a/minter/src/state/event/candid/tests.rs b/minter/src/state/event/candid/tests.rs index a792b4da2..9f0adb015 100644 --- a/minter/src/state/event/candid/tests.rs +++ b/minter/src/state/event/candid/tests.rs @@ -44,6 +44,7 @@ proptest! { account, address: address.into(), balance, + automatic: None, }; prop_assert_eq!( EventType::try_from(payload).unwrap_err(), diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 7a753f82e..706e7f294 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -43,9 +43,9 @@ mod nonce_pool; mod watchlist; pub use deposits::{ - DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, QuarantineCause, - QuarantinedDeposit, QueuedDeposit, SettledSweep, Sweep, SweepMismatch, SweepRecoveryError, - SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, + AutomaticDeposit, DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, + QuarantineCause, QuarantinedDeposit, QueuedDeposit, SettledSweep, Sweep, SweepMismatch, + SweepRecoveryError, SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, }; pub use nonce_pool::{DurableNoncePool, NoncePoolError, NonceRead}; pub use watchlist::{ @@ -114,6 +114,7 @@ pub struct State { deposit_sol_required_cycles: u128, deposit_sol_fee: u128, automatic_deposit_fee: Option, + collected_automatic_deposit_fees: Lamport, pending_deposit_sol_request_guards: BTreeSet, pending_withdrawal_request_guards: BTreeSet, deposits: Deposits, @@ -193,6 +194,10 @@ impl State { self.automatic_deposit_fee } + pub fn collected_automatic_deposit_fees(&self) -> Lamport { + self.collected_automatic_deposit_fees + } + pub fn deposits(&self) -> &Deposits { &self.deposits } @@ -469,7 +474,8 @@ impl State { amount_to_mint <= amount_received, "Attempted to credit sweep {signature} with mints of {amount_to_mint} lamports exceeding the {amount_received} lamports received" ); - self.deposits.credit_sweep(signature, mints, timestamp); + let collected_fees = self.deposits.credit_sweep(signature, mints, timestamp); + self.collected_automatic_deposit_fees += collected_fees; self.balance += amount_received; } @@ -905,6 +911,7 @@ impl TryFrom for State { deposit_sol_required_cycles: deposit_sol_required_cycles as u128, deposit_sol_fee: deposit_sol_fee as u128, automatic_deposit_fee, + collected_automatic_deposit_fees: 0, pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 9b9d20141..4810ce9eb 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -36,6 +36,32 @@ proptest! { } } +/// The automatic part was added to the `QueuedDeposit` event after the minter was deployed, +/// so the events recorded before must still decode, as this encoding recorded by the previous +/// version checks. +#[test] +fn should_decode_a_queued_deposit_recorded_without_the_automatic_part() { + const RECORDED_BEFORE_THE_AUTOMATIC_FLOW: &str = "821a499602d28208840781581d08000000000000000000000000000000000000000000000000000000005820d8c8b6b0295ea41f91d2173a81503fcec3148f69e8e130813d228badbaaf28e41a0087aa00"; + let bytes = hex::decode(RECORDED_BEFORE_THE_AUTOMATIC_FLOW).unwrap(); + let deposit = queued_deposit(7); + + let decoded = Event::from_bytes(Cow::Borrowed(&bytes)); + + assert_eq!( + decoded, + Event { + timestamp: 1_234_567_890, + payload: EventType::QueuedDeposit { + deposit_id: 7, + account: deposit.account, + address: deposit.address, + balance: deposit.balance, + automatic: None, + }, + } + ); +} + mod cache_minter_public_key { use super::*; use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; @@ -110,6 +136,7 @@ mod queued_deposits { account: queued_deposit(deposit_id).account, address: queued_deposit(deposit_id).address, balance: queued_deposit(deposit_id).balance, + automatic: None, }, }; let init = Event { @@ -168,13 +195,15 @@ mod swept_deposits { state::{audit::replay_events, reset_state}, storage::{reset_events, with_event_iter}, test_fixtures::{ - DEFAULT_BLOCK_HEIGHT, + DEFAULT_BLOCK_HEIGHT, REGISTERED_AT, WATCH_WINDOW_NANOS, events::{credit_sweep, quarantine_sweep, queue_deposits, submit_sweep}, flow::deposit::{CreditedSweepFlow, DepositFlow, SweepFlow}, - sweep_message, + replayed_recorded_log, sweep_message, }, }; + const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; + const SWEEP_SIGNATURE_INDEX: usize = 0xAA; #[test] @@ -355,6 +384,57 @@ mod swept_deposits { read_state(|live_state| assert_eq!(&replayed, live_state)); } + #[test] + fn should_collect_the_automatic_deposit_fees_of_a_credited_sweep() { + init_state(); + init_schnorr_master_key(); + let manual = DepositFlow::queue(account(1), 10_000_000); + let first_automatic = DepositFlow::queue_automatic( + account(2), + 20_000_000, + AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: VALID_UNTIL, + }, + ); + let second_automatic = DepositFlow::queue_automatic( + account(3), + 30_000_000, + AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE / 2, + valid_until: VALID_UNTIL, + }, + ); + let finalized = SweepFlow::of([manual, first_automatic, second_automatic]) + .submit(signature(SWEEP_SIGNATURE_INDEX)) + .succeed(); + read_state(|s| assert_eq!(s.collected_automatic_deposit_fees(), 0)); + + let credited = finalized.credit(); + + let [manual_mint, first_mint, second_mint] = credited.pending_mints(); + assert_eq!(manual_mint.amount_to_mint, 10_000_000 - FEE_PER_SIGNATURE); + assert_eq!( + first_mint.amount_to_mint, + 20_000_000 - FEE_PER_SIGNATURE - AUTOMATIC_DEPOSIT_FEE + ); + assert_eq!( + second_mint.amount_to_mint, + 30_000_000 - FEE_PER_SIGNATURE - AUTOMATIC_DEPOSIT_FEE / 2 + ); + read_state(|s| { + assert_eq!( + s.collected_automatic_deposit_fees(), + AUTOMATIC_DEPOSIT_FEE + AUTOMATIC_DEPOSIT_FEE / 2 + ); + assert_eq!(s.balance(), credited.amount_received); + assert_eq!(s.deposits().automatic_deposit_count(), 2); + }); + + let replayed = replayed_recorded_log(); + read_state(|live_state| assert_eq!(&replayed, live_state)); + } + fn credit_sweep_of_two_deposits(credited_at: u64) -> CreditedSweepFlow { init_schnorr_master_key(); let deposits = [1, 2].map(|i| DepositFlow::queue(account(i), 1_000_000 * i as u64)); @@ -780,6 +860,7 @@ mod state_from_init_args { minimum_deposit_amount: MINIMUM_DEPOSIT_AMOUNT, deposit_sol_required_cycles: DEPOSIT_SOL_REQUIRED_CYCLES, automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), + collected_automatic_deposit_fees: 0, pending_deposit_sol_request_guards: BTreeSet::new(), pending_withdrawal_request_guards: BTreeSet::new(), deposits: Deposits::default(), @@ -1602,6 +1683,7 @@ mod withdrawal_transactions { account: funding_deposit.account, address: funding_deposit.address, balance: funding_deposit.balance, + automatic: None, }, EventType::SubmittedTransaction { signature: signature(9), diff --git a/minter/src/test_fixtures/flow/deposit.rs b/minter/src/test_fixtures/flow/deposit.rs index ed7764f4e..8a7001dd5 100644 --- a/minter/src/test_fixtures/flow/deposit.rs +++ b/minter/src/test_fixtures/flow/deposit.rs @@ -1,6 +1,7 @@ use crate::{ - state::read_state, + state::{AutomaticDeposit, QueuedDeposit, read_state}, test_fixtures::{ + automatic_deposit_of, events::{ credit_sweep_at, mint_swept_deposit, quarantine_pending_mint, queue, submit_sweep, succeed_transaction, @@ -22,14 +23,28 @@ impl DepositFlow { /// Queues a deposit of `account`, whose deposit address holds `sweepable_amount` on top /// of the rent exemption threshold, under the next deposit id of the state. pub fn queue(account: Account, sweepable_amount: Lamport) -> QueuedDepositFlow { + Self::queue_deposit(queued_deposit_of(account, sweepable_amount)) + } + + /// Queues a deposit of `account` detected by the automatic flow with the given fee and + /// window, whose deposit address holds `sweepable_amount` on top of the rent exemption + /// threshold, under the next deposit id of the state. + pub fn queue_automatic( + account: Account, + sweepable_amount: Lamport, + automatic: AutomaticDeposit, + ) -> QueuedDepositFlow { + Self::queue_deposit(automatic_deposit_of(account, sweepable_amount, automatic)) + } + + fn queue_deposit(deposit: QueuedDeposit) -> QueuedDepositFlow { let deposit_id = read_state(|state| state.deposits().next_id()); - let deposit = queued_deposit_of(account, sweepable_amount); queue(deposit_id, deposit); QueuedDepositFlow { deposit_id, - account, + account: deposit.account, address: deposit.address, - sweepable_amount, + sweepable_amount: deposit.sweepable_amount(), } } } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 654e803af..fb048ce6f 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -4,8 +4,9 @@ use crate::{ rpc::{BlockHeight, FetchedTransaction}, sol_transfer::build_batch_withdrawal_message, state::{ - DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, WATCH_WINDOW, - WatchedAddress, Watchlist, + AutomaticDeposit, DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, + WATCH_WINDOW, WatchedAddress, Watchlist, + audit::replay_events, event::{Event, EventType, VersionedMessage}, init_once_state, mutate_state, }, @@ -160,6 +161,30 @@ pub fn minter_public_key_fetched_event() -> EventType { } } +/// The state replayed from the events recorded so far, preceded by the init and key-fetched +/// events that [`init_state`] and [`init_schnorr_master_key`] stand for, so that it can be +/// compared with the live state. +pub fn replayed_recorded_log() -> State { + replayed_recorded_log_after([]) +} + +/// Like [`replayed_recorded_log`], with `events` replayed between the init and key-fetched +/// events and the recorded ones. +pub fn replayed_recorded_log_after(events: impl IntoIterator) -> State { + let recorded: Vec = with_event_iter(|events| events.collect()); + let prefix = [ + EventType::Init(init_args_with_nonce_account()), + minter_public_key_fetched_event(), + ] + .into_iter() + .chain(events) + .map(|payload| Event { + timestamp: 0, + payload, + }); + replay_events(prefix.chain(recorded)) +} + /// The timestamp of `nanos` nanoseconds since the epoch. pub fn at(nanos: u64) -> Timestamp { Timestamp::from_nanos(nanos) @@ -319,6 +344,20 @@ pub fn queued_deposit_of(account: Account, sweepable_amount: Lamport) -> QueuedD address: deposit_address(account), balance: DepositBalance::new(sweepable_amount + RENT_EXEMPTION_THRESHOLD) .expect("BUG: the balance covers the rent exemption threshold"), + automatic: None, + } +} + +/// The deposit of the given account detected by the automatic flow, whose address holds the +/// sweepable amount on top of the rent exemption threshold. +pub fn automatic_deposit_of( + account: Account, + sweepable_amount: Lamport, + automatic: AutomaticDeposit, +) -> QueuedDeposit { + QueuedDeposit { + automatic: Some(automatic), + ..queued_deposit_of(account, sweepable_amount) } } @@ -572,6 +611,7 @@ pub mod devnet_sweep { address: crate::address::account_address(&master_key(), &account), balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), + automatic: None, }, ) }) @@ -588,6 +628,7 @@ pub mod devnet_sweep { sweepable_amount + crate::constants::RENT_EXEMPTION_THRESHOLD, ) .expect("BUG: the balance covers the rent exemption threshold"), + automatic: None, } } @@ -693,6 +734,7 @@ pub mod devnet_sweep { address, balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), + automatic: None, }, ) }) @@ -907,6 +949,7 @@ pub mod events { account: deposit.account, address: deposit.address, balance: deposit.balance, + automatic: deposit.automatic, }, &runtime(), ) @@ -986,6 +1029,7 @@ pub mod events { .get(&signature) .expect("BUG: no finalized sweep with the given signature") .mints() + .expect("BUG: the fees fit the sweepable amounts of the finalized sweep") }); mutate_state(|state| { process_event( @@ -1286,7 +1330,7 @@ pub mod arb { rpc::BlockHeight, sol_transfer::MAX_SIGNATURES, state::{ - DepositBalance, QueuedDeposit, + AutomaticDeposit, DepositBalance, QueuedDeposit, event::{ CreditedDeposit, Event, EventType, Signer, TransactionPurpose, WithdrawalRequest, }, @@ -1347,14 +1391,26 @@ pub mod arb { pub fn arb_queued_deposit() -> impl Strategy { const MIN_BALANCE: u64 = RENT_EXEMPTION_THRESHOLD + FEE_PER_SIGNATURE * MAX_SIGNATURES; const MAX_BALANCE: u64 = u64::MAX / MAX_SIGNATURES; - (arb_account(), arb_address(), MIN_BALANCE..=MAX_BALANCE).prop_map( - |(account, address, balance)| QueuedDeposit { + ( + arb_account(), + arb_address(), + MIN_BALANCE..=MAX_BALANCE, + prop::option::of(arb_automatic_deposit()), + ) + .prop_map(|(account, address, balance, automatic)| QueuedDeposit { account, address, balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), - }, - ) + automatic, + }) + } + + /// The automatic part of a deposit whose fee fits in the sweepable amount of + /// [`arb_queued_deposit`] after the fee share of a full sweep. + pub fn arb_automatic_deposit() -> impl Strategy { + (0..FEE_PER_SIGNATURE * (MAX_SIGNATURES - 1), any::()) + .prop_map(|(fee, valid_until)| AutomaticDeposit { fee, valid_until }) } /// The deposits of one sweep: at least one, at most one per signature. @@ -1558,13 +1614,15 @@ pub mod arb { arb_account(), arb_address(), arb_deposit_balance(), + prop::option::of(arb_automatic_deposit()), ) - .prop_map(|(deposit_id, account, address, balance)| { + .prop_map(|(deposit_id, account, address, balance, automatic)| { EventType::QueuedDeposit { deposit_id, account, address, balance, + automatic, } },), ( From 4e9f34ddc199a1e80b207011f51395571b96678a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:02:35 +0000 Subject: [PATCH 066/146] test: expect the collected automatic deposit fees after upgrading the staging snapshot Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/snapshot.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/integration_tests/tests/snapshot.rs b/integration_tests/tests/snapshot.rs index a236bb7fc..07cf80d62 100644 --- a/integration_tests/tests/snapshot.rs +++ b/integration_tests/tests/snapshot.rs @@ -1,5 +1,6 @@ use assert_matches::assert_matches; use cksol_int_tests::CkSolMinter; +use cksol_types::MinterInfo; use cksol_types_internal::{ UpgradeArgs, event::{Event, EventType}, @@ -35,7 +36,13 @@ async fn should_load_staging_snapshot_and_upgrade_to_current_wasm() { minter.start().await; let state_after = minter.state().await; - assert_eq!(state_after.info, state_before.info); + assert_eq!( + state_after.info, + MinterInfo { + collected_automatic_deposit_fees: Some(0), + ..state_before.info.clone() + } + ); let (replayed_events, new_events) = state_after.events.split_at(state_before.events.len()); assert_eq!(replayed_events, state_before.events); assert_matches!( From 155c2829aab158d5b3774f2459c79b26d5bde07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:35:57 +0000 Subject: [PATCH 067/146] refactor(minter): record only the automatic deposit fee with a queued deposit The re-arm of a dropped automatic deposit opens a fresh watch window, so the end of the original window no longer needs to travel with the deposit. Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 4 +- libs/types-internal/src/event.rs | 16 +---- minter/cksol_minter.did | 19 ++---- minter/src/canbench.rs | 4 +- minter/src/deposit/sweep/mod.rs | 4 +- minter/src/deposit/sweep/tests.rs | 2 +- minter/src/state/audit.rs | 4 +- minter/src/state/deposits/mod.rs | 24 ++------ minter/src/state/deposits/sweeps/mod.rs | 6 +- minter/src/state/deposits/sweeps/tests.rs | 40 +++++------- minter/src/state/event.rs | 6 +- minter/src/state/event/candid/mod.rs | 34 +++-------- minter/src/state/event/candid/tests.rs | 2 +- minter/src/state/mod.rs | 6 +- minter/src/state/tests.rs | 30 +++------ minter/src/test_fixtures/flow/deposit.rs | 14 +++-- minter/src/test_fixtures/mod.rs | 74 ++++++++++++----------- 17 files changed, 110 insertions(+), 179 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index af19f9bcd..5d2c0404e 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -882,7 +882,7 @@ mod deposit_sol_tests { }, address: deposit_address.clone(), balance: BALANCE_ABOVE_MINIMUM, - automatic: None, + automatic_deposit_fee: None, }] ); }); @@ -963,7 +963,7 @@ mod deposit_sol_tests { account: DEFAULT_CALLER_ACCOUNT, address: deposit_address.clone(), balance: BALANCE_ABOVE_MINIMUM, - automatic: None, + automatic_deposit_fee: None, }] ); }); diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index f49a01c8f..93de0f290 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -76,9 +76,9 @@ pub enum EventType { address: Address, /// The balance of the deposit address when the deposit was queued. balance: Lamport, - /// What the automatic flow recorded when it detected the deposit, absent for a - /// deposit queued via `deposit_sol`. - automatic: Option, + /// The automatic deposit fee in force when the automatic flow detected the deposit, + /// deducted from the minted amount; absent for a deposit queued via `deposit_sol`. + automatic_deposit_fee: Option, }, /// The minter read the amount that the finalized sweep transaction moved to its /// main account and enqueued a pending mint for each deposit of that sweep. @@ -155,16 +155,6 @@ pub struct CreditedDeposit { pub amount_to_mint: Lamport, } -/// The part of a `QueuedDeposit` event that the automatic flow recorded when it detected -/// the deposit. -#[derive(Clone, Copy, Debug, PartialEq, CandidType, Deserialize)] -pub struct AutomaticDeposit { - /// The automatic deposit fee in force at detection, deducted from the minted amount. - pub fee: Lamport, - /// The end of the registration window, in nanoseconds since the epoch. - pub valid_until: u64, -} - /// The key that produced one signature of a submitted Solana transaction. #[derive(Clone, Debug, PartialEq, CandidType, Deserialize)] pub enum Signer { diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 87a714184..0f04b4bdc 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -379,18 +379,6 @@ type CreditedDeposit = record { amount_to_mint: Lamport; }; -// The part of a `QueuedDeposit` event that the automatic flow recorded when -// it detected the deposit. -// -// *WARNING*: This type is used exclusively by the debug `get_events` endpoint. -// Backwards-compatibility is not guaranteed. -type AutomaticDeposit = record { - // The automatic deposit fee in force at detection, deducted from the minted amount. - fee: Lamport; - // The end of the registration window, in nanoseconds since the epoch. - valid_until: nat64; -}; - // Event payload for an accepted ckSOL-to-Solana withdrawal request. // // *WARNING*: This type is used exclusively by the debug `get_events` endpoint. @@ -507,9 +495,10 @@ type EventType = variant { address: Address; // The balance of the deposit address when the deposit was queued. balance: Lamport; - // What the automatic flow recorded when it detected the deposit, - // absent for a deposit queued via `deposit_sol`. - automatic: opt AutomaticDeposit; + // The automatic deposit fee in force when the automatic flow detected the + // deposit, deducted from the minted amount; absent for a deposit queued via + // `deposit_sol`. + automatic_deposit_fee: opt Lamport; }; // The minter read the amount that the finalized sweep transaction moved to // its main account and enqueued a pending mint for each deposit of that sweep. diff --git a/minter/src/canbench.rs b/minter/src/canbench.rs index 39d6dd9ef..6db239877 100644 --- a/minter/src/canbench.rs +++ b/minter/src/canbench.rs @@ -109,14 +109,14 @@ fn queue_and_sweep(deposit_id: u64, account_index: usize, amount: u64, sig: Sign address: deposit_address(account_index), balance: DepositBalance::new(amount + RENT_EXEMPTION_THRESHOLD) .expect("BUG: the balance covers the rent exemption threshold"), - automatic: None, + automatic_deposit_fee: None, }; record(EventType::QueuedDeposit { deposit_id, account, address: deposit.address, balance: deposit.balance, - automatic: None, + automatic_deposit_fee: None, }); let sweep = Sweep::plan([(deposit_id, deposit)], minter_address(&master_key())); record(EventType::SubmittedTransaction { diff --git a/minter/src/deposit/sweep/mod.rs b/minter/src/deposit/sweep/mod.rs index c69fef913..94fee8c32 100644 --- a/minter/src/deposit/sweep/mod.rs +++ b/minter/src/deposit/sweep/mod.rs @@ -78,7 +78,7 @@ pub async fn deposit_sol( account, address: deposit_address, balance, - automatic: None, + automatic_deposit_fee: None, }, ) }); @@ -106,7 +106,7 @@ pub(crate) fn queue_deposit( account: deposit.account, address: deposit.address, balance: deposit.balance, - automatic: deposit.automatic, + automatic_deposit_fee: deposit.automatic_deposit_fee, }, runtime, ); diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index 3548de15d..4ac408235 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -240,7 +240,7 @@ fn queued_deposit_event(deposit_id: u64, account: Account, sweepable_amount: Lam account, address: deposit.address, balance: deposit.balance, - automatic: None, + automatic_deposit_fee: None, } } diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 1d00ec4bd..4985bdc83 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -49,7 +49,7 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 account, address, balance, - automatic, + automatic_deposit_fee, } => { state.process_queued_deposit( *deposit_id, @@ -57,7 +57,7 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 account: *account, address: *address, balance: *balance, - automatic: *automatic, + automatic_deposit_fee: *automatic_deposit_fee, }, timestamp, ); diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 5f45afb40..fa3569c64 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -8,7 +8,6 @@ use crate::{ }; use cksol_types::{DepositSolId, DepositSolStatus}; use icrc_ledger_types::icrc1::account::Account; -use minicbor::{Decode, Encode}; use sol_rpc_types::Lamport; use solana_address::Address; use solana_signature::Signature; @@ -227,7 +226,7 @@ impl Deposits { deposit.account ); self.latest_ids.insert(deposit.account, deposit_id); - if deposit.automatic.is_some() { + if deposit.automatic_deposit_fee.is_some() { self.automatic_deposit_count += 1; } self.watchlist.remove(&deposit.account); @@ -430,9 +429,9 @@ pub struct QueuedDeposit { pub address: Address, /// The balance of the deposit address when the deposit was queued. pub balance: DepositBalance, - /// What the automatic flow recorded when it detected the deposit, absent for a deposit - /// queued by `deposit_sol`. - pub automatic: Option, + /// The automatic deposit fee in force when the automatic flow detected the deposit, + /// absent for a deposit queued by `deposit_sol`. + pub automatic_deposit_fee: Option, } impl QueuedDeposit { @@ -441,22 +440,11 @@ impl QueuedDeposit { } /// The automatic deposit fee deducted from the mint, zero for a manual deposit. - pub fn automatic_deposit_fee(&self) -> Lamport { - self.automatic.map_or(0, |automatic| automatic.fee) + pub fn deducted_automatic_deposit_fee(&self) -> Lamport { + self.automatic_deposit_fee.unwrap_or(0) } } -/// The part of a queued deposit that the automatic flow detected: the fee in force at -/// detection, which the mint is reduced by, and the end of the registration window, which -/// bounds the re-arm of the deposit should its sweep be dropped. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Decode, Encode)] -pub struct AutomaticDeposit { - #[n(0)] - pub fee: Lamport, - #[n(1)] - pub valid_until: u64, -} - impl From for Lamport { fn from(balance: DepositBalance) -> Self { balance.0 diff --git a/minter/src/state/deposits/sweeps/mod.rs b/minter/src/state/deposits/sweeps/mod.rs index 6d50df7d3..1230f0b8b 100644 --- a/minter/src/state/deposits/sweeps/mod.rs +++ b/minter/src/state/deposits/sweeps/mod.rs @@ -335,12 +335,12 @@ impl Sweep { let amount_to_mint = deposit .sweepable_amount() .checked_sub(fee_share) - .and_then(|amount| amount.checked_sub(deposit.automatic_deposit_fee())) + .and_then(|amount| amount.checked_sub(deposit.deducted_automatic_deposit_fee())) .ok_or(SweepMismatch::FeesExceedSweepableAmount { deposit_id: *deposit_id, sweepable_amount: deposit.sweepable_amount(), fee_share, - automatic_deposit_fee: deposit.automatic_deposit_fee(), + automatic_deposit_fee: deposit.deducted_automatic_deposit_fee(), })?; Ok(CreditedDeposit { deposit_id: *deposit_id, @@ -355,7 +355,7 @@ impl Sweep { pub fn automatic_deposit_fees(&self) -> Lamport { self.deposits .values() - .map(QueuedDeposit::automatic_deposit_fee) + .map(QueuedDeposit::deducted_automatic_deposit_fee) .sum() } } diff --git a/minter/src/state/deposits/sweeps/tests.rs b/minter/src/state/deposits/sweeps/tests.rs index b125149db..643191467 100644 --- a/minter/src/state/deposits/sweeps/tests.rs +++ b/minter/src/state/deposits/sweeps/tests.rs @@ -102,7 +102,7 @@ mod plan { planned_sweep, queued_deposit_of, }; use crate::{ - state::{AutomaticDeposit, event::CreditedDeposit}, + state::event::CreditedDeposit, test_fixtures::{ arb::{arb_address, arb_sweep_deposits}, automatic_deposit_of, @@ -181,12 +181,9 @@ mod plan { #[test] fn should_deduct_the_automatic_deposit_fee_from_the_mint_of_an_automatic_deposit() { - let automatic = AutomaticDeposit { - fee: 2_000_000, - valid_until: 1_700_000_000_000_000_000, - }; + let automatic_deposit_fee = 2_000_000; let manual = queued_deposit_of(account(1), 3_000_000); - let detected = automatic_deposit_of(account(2), 5_000_000, automatic); + let detected = automatic_deposit_of(account(2), 5_000_000, automatic_deposit_fee); let sweep = planned_sweep([(0, manual), (1, detected)]); @@ -216,11 +213,11 @@ mod plan { }, CreditedDeposit { deposit_id: 1, - amount_to_mint: 5_000_000 - FEE_PER_SIGNATURE - automatic.fee, + amount_to_mint: 5_000_000 - FEE_PER_SIGNATURE - automatic_deposit_fee, }, ] ); - assert_eq!(sweep.automatic_deposit_fees(), automatic.fee); + assert_eq!(sweep.automatic_deposit_fees(), automatic_deposit_fee); } #[test] @@ -235,15 +232,12 @@ mod plan { #[test] fn should_have_no_mints_when_the_fees_exceed_the_sweepable_amount() { - let automatic = AutomaticDeposit { - fee: 1_000_000, - valid_until: 0, - }; - let sweepable_amount = automatic.fee + FEE_PER_SIGNATURE - 1; + let automatic_deposit_fee = 1_000_000; + let sweepable_amount = automatic_deposit_fee + FEE_PER_SIGNATURE - 1; let sweep = planned_sweep([( 0, - automatic_deposit_of(account(1), sweepable_amount, automatic), + automatic_deposit_of(account(1), sweepable_amount, automatic_deposit_fee), )]); assert_eq!( @@ -252,7 +246,7 @@ mod plan { deposit_id: 0, sweepable_amount, fee_share: FEE_PER_SIGNATURE, - automatic_deposit_fee: automatic.fee, + automatic_deposit_fee, }) ); } @@ -381,10 +375,7 @@ mod settle { SweepSettlementError, TransactionError, UiTransactionError, UnreadableOutcome, account, devnet_sweep, planned_sweep, queued_deposit_of, }; - use crate::{ - state::{AutomaticDeposit, QueuedDeposit}, - test_fixtures::fetched, - }; + use crate::{state::QueuedDeposit, test_fixtures::fetched}; type Deviation = fn(&mut EncodedConfirmedTransactionWithStatusMeta); @@ -435,16 +426,13 @@ mod settle { #[test] fn should_reject_a_matching_outcome_whose_fees_exceed_a_sweepable_amount() { - let automatic = AutomaticDeposit { - fee: SECOND_SWEEPABLE_AMOUNT, - valid_until: 0, - }; let deposits = devnet_sweep::deposits().into_iter().map(|(id, deposit)| { - let automatic = (deposit.address == SECOND_DEPOSIT).then_some(automatic); + let automatic_deposit_fee = + (deposit.address == SECOND_DEPOSIT).then_some(SECOND_SWEEPABLE_AMOUNT); ( id, QueuedDeposit { - automatic, + automatic_deposit_fee, ..deposit }, ) @@ -461,7 +449,7 @@ mod settle { deposit_id: 1, sweepable_amount: SECOND_SWEEPABLE_AMOUNT, fee_share: devnet_sweep::FEE / devnet_sweep::DEPOSITS.len() as u64, - automatic_deposit_fee: automatic.fee, + automatic_deposit_fee: SECOND_SWEEPABLE_AMOUNT, } )) ); diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index cfde12c05..1174064d7 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -3,7 +3,7 @@ use crate::{ constants::FEE_PER_SIGNATURE, numeric::{LedgerBurnIndex, LedgerMintIndex}, rpc::BlockHeight, - state::{AutomaticDeposit, DepositBalance}, + state::DepositBalance, }; use cksol_types::DepositSolId; use cksol_types_internal::{InitArgs, UpgradeArgs}; @@ -141,10 +141,10 @@ pub enum EventType { address: Address, #[cbor(n(3), with = "cbor::deposit_balance")] balance: DepositBalance, - /// The fee and window the automatic flow recorded when it detected the deposit, + /// The automatic deposit fee in force when the automatic flow detected the deposit, /// absent for a deposit queued via `deposit_sol`. #[n(4)] - automatic: Option, + automatic_deposit_fee: Option, }, /// The minter read the amount that the finalized sweep transaction moved to its /// main account and enqueued a pending mint for each deposit of that sweep. diff --git a/minter/src/state/event/candid/mod.rs b/minter/src/state/event/candid/mod.rs index af578d634..f9472d982 100644 --- a/minter/src/state/event/candid/mod.rs +++ b/minter/src/state/event/candid/mod.rs @@ -5,7 +5,7 @@ use crate::{ numeric::{LedgerBurnIndex, LedgerMintIndex}, rpc::BlockHeight, state::{ - AutomaticDeposit, DepositBalance, + DepositBalance, event::{ CreditedDeposit, Event, EventType, Signer, TransactionPurpose, VersionedMessage, WithdrawalRequest, @@ -13,9 +13,9 @@ use crate::{ }, }; use cksol_types_internal::event::{ - AutomaticDeposit as CandidAutomaticDeposit, CreditedDeposit as CandidCreditedDeposit, - Event as CandidEvent, EventType as CandidEventType, Signer as CandidSigner, - TransactionPurpose as CandidTransactionPurpose, VersionedTransactionMessage, + CreditedDeposit as CandidCreditedDeposit, Event as CandidEvent, EventType as CandidEventType, + Signer as CandidSigner, TransactionPurpose as CandidTransactionPurpose, + VersionedTransactionMessage, }; use ic_ed25519::PublicKey; use sol_rpc_types::{ @@ -76,13 +76,13 @@ impl From for CandidEventType { account, address, balance, - automatic, + automatic_deposit_fee, } => CandidEventType::QueuedDeposit { deposit_id, account, address: CandidAddress::from(address), balance: Lamport::from(balance), - automatic: automatic.map(CandidAutomaticDeposit::from), + automatic_deposit_fee, }, EventType::CreditedSweep { signature, @@ -173,15 +173,6 @@ impl From for CandidCreditedDeposit { } } -impl From for CandidAutomaticDeposit { - fn from(automatic: AutomaticDeposit) -> Self { - CandidAutomaticDeposit { - fee: automatic.fee, - valid_until: automatic.valid_until, - } - } -} - impl TryFrom for Event { type Error = InvalidCandidEvent; @@ -240,7 +231,7 @@ impl TryFrom for EventType { account, address, balance, - automatic, + automatic_deposit_fee, } => EventType::QueuedDeposit { deposit_id, account, @@ -248,7 +239,7 @@ impl TryFrom for EventType { balance: DepositBalance::new(balance).ok_or( InvalidCandidEvent::DepositBalanceBelowRentExemption(balance), )?, - automatic: automatic.map(AutomaticDeposit::from), + automatic_deposit_fee, }, CandidEventType::CreditedSweep { signature, @@ -343,15 +334,6 @@ impl From for CreditedDeposit { } } -impl From for AutomaticDeposit { - fn from(automatic: CandidAutomaticDeposit) -> Self { - AutomaticDeposit { - fee: automatic.fee, - valid_until: automatic.valid_until, - } - } -} - /// A Candid event that cannot be converted back into a minter event. #[derive(Clone, Debug, PartialEq, Eq, Error)] pub enum InvalidCandidEvent { diff --git a/minter/src/state/event/candid/tests.rs b/minter/src/state/event/candid/tests.rs index 9f0adb015..9e7818876 100644 --- a/minter/src/state/event/candid/tests.rs +++ b/minter/src/state/event/candid/tests.rs @@ -44,7 +44,7 @@ proptest! { account, address: address.into(), balance, - automatic: None, + automatic_deposit_fee: None, }; prop_assert_eq!( EventType::try_from(payload).unwrap_err(), diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 706e7f294..3db652189 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -43,9 +43,9 @@ mod nonce_pool; mod watchlist; pub use deposits::{ - AutomaticDeposit, DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, - QuarantineCause, QuarantinedDeposit, QueuedDeposit, SettledSweep, Sweep, SweepMismatch, - SweepRecoveryError, SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, + DepositBalance, Deposits, InFlightDeposit, MintedSweep, PendingMint, QuarantineCause, + QuarantinedDeposit, QueuedDeposit, SettledSweep, Sweep, SweepMismatch, SweepRecoveryError, + SweepSettlementError, Sweeps, SweptDeposit, Transfer, UnreadableOutcome, }; pub use nonce_pool::{DurableNoncePool, NoncePoolError, NonceRead}; pub use watchlist::{ diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 4810ce9eb..4c802bbb9 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -56,7 +56,7 @@ fn should_decode_a_queued_deposit_recorded_without_the_automatic_part() { account: deposit.account, address: deposit.address, balance: deposit.balance, - automatic: None, + automatic_deposit_fee: None, }, } ); @@ -136,7 +136,7 @@ mod queued_deposits { account: queued_deposit(deposit_id).account, address: queued_deposit(deposit_id).address, balance: queued_deposit(deposit_id).balance, - automatic: None, + automatic_deposit_fee: None, }, }; let init = Event { @@ -195,15 +195,13 @@ mod swept_deposits { state::{audit::replay_events, reset_state}, storage::{reset_events, with_event_iter}, test_fixtures::{ - DEFAULT_BLOCK_HEIGHT, REGISTERED_AT, WATCH_WINDOW_NANOS, + DEFAULT_BLOCK_HEIGHT, events::{credit_sweep, quarantine_sweep, queue_deposits, submit_sweep}, flow::deposit::{CreditedSweepFlow, DepositFlow, SweepFlow}, replayed_recorded_log, sweep_message, }, }; - const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; - const SWEEP_SIGNATURE_INDEX: usize = 0xAA; #[test] @@ -389,22 +387,10 @@ mod swept_deposits { init_state(); init_schnorr_master_key(); let manual = DepositFlow::queue(account(1), 10_000_000); - let first_automatic = DepositFlow::queue_automatic( - account(2), - 20_000_000, - AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: VALID_UNTIL, - }, - ); - let second_automatic = DepositFlow::queue_automatic( - account(3), - 30_000_000, - AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE / 2, - valid_until: VALID_UNTIL, - }, - ); + let first_automatic = + DepositFlow::queue_automatic(account(2), 20_000_000, AUTOMATIC_DEPOSIT_FEE); + let second_automatic = + DepositFlow::queue_automatic(account(3), 30_000_000, AUTOMATIC_DEPOSIT_FEE / 2); let finalized = SweepFlow::of([manual, first_automatic, second_automatic]) .submit(signature(SWEEP_SIGNATURE_INDEX)) .succeed(); @@ -1683,7 +1669,7 @@ mod withdrawal_transactions { account: funding_deposit.account, address: funding_deposit.address, balance: funding_deposit.balance, - automatic: None, + automatic_deposit_fee: None, }, EventType::SubmittedTransaction { signature: signature(9), diff --git a/minter/src/test_fixtures/flow/deposit.rs b/minter/src/test_fixtures/flow/deposit.rs index 8a7001dd5..5903fe2c1 100644 --- a/minter/src/test_fixtures/flow/deposit.rs +++ b/minter/src/test_fixtures/flow/deposit.rs @@ -1,5 +1,5 @@ use crate::{ - state::{AutomaticDeposit, QueuedDeposit, read_state}, + state::{QueuedDeposit, read_state}, test_fixtures::{ automatic_deposit_of, events::{ @@ -26,15 +26,19 @@ impl DepositFlow { Self::queue_deposit(queued_deposit_of(account, sweepable_amount)) } - /// Queues a deposit of `account` detected by the automatic flow with the given fee and - /// window, whose deposit address holds `sweepable_amount` on top of the rent exemption + /// Queues a deposit of `account` detected by the automatic flow under the given fee, + /// whose deposit address holds `sweepable_amount` on top of the rent exemption /// threshold, under the next deposit id of the state. pub fn queue_automatic( account: Account, sweepable_amount: Lamport, - automatic: AutomaticDeposit, + automatic_deposit_fee: Lamport, ) -> QueuedDepositFlow { - Self::queue_deposit(automatic_deposit_of(account, sweepable_amount, automatic)) + Self::queue_deposit(automatic_deposit_of( + account, + sweepable_amount, + automatic_deposit_fee, + )) } fn queue_deposit(deposit: QueuedDeposit) -> QueuedDepositFlow { diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index fb048ce6f..dddbe30ac 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -4,8 +4,8 @@ use crate::{ rpc::{BlockHeight, FetchedTransaction}, sol_transfer::build_batch_withdrawal_message, state::{ - AutomaticDeposit, DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, - WATCH_WINDOW, WatchedAddress, Watchlist, + DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, WATCH_WINDOW, + WatchedAddress, Watchlist, audit::replay_events, event::{Event, EventType, VersionedMessage}, init_once_state, mutate_state, @@ -344,19 +344,19 @@ pub fn queued_deposit_of(account: Account, sweepable_amount: Lamport) -> QueuedD address: deposit_address(account), balance: DepositBalance::new(sweepable_amount + RENT_EXEMPTION_THRESHOLD) .expect("BUG: the balance covers the rent exemption threshold"), - automatic: None, + automatic_deposit_fee: None, } } -/// The deposit of the given account detected by the automatic flow, whose address holds the -/// sweepable amount on top of the rent exemption threshold. +/// The deposit of the given account detected by the automatic flow under the given fee, +/// whose address holds the sweepable amount on top of the rent exemption threshold. pub fn automatic_deposit_of( account: Account, sweepable_amount: Lamport, - automatic: AutomaticDeposit, + automatic_deposit_fee: Lamport, ) -> QueuedDeposit { QueuedDeposit { - automatic: Some(automatic), + automatic_deposit_fee: Some(automatic_deposit_fee), ..queued_deposit_of(account, sweepable_amount) } } @@ -611,7 +611,7 @@ pub mod devnet_sweep { address: crate::address::account_address(&master_key(), &account), balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), - automatic: None, + automatic_deposit_fee: None, }, ) }) @@ -628,7 +628,7 @@ pub mod devnet_sweep { sweepable_amount + crate::constants::RENT_EXEMPTION_THRESHOLD, ) .expect("BUG: the balance covers the rent exemption threshold"), - automatic: None, + automatic_deposit_fee: None, } } @@ -734,7 +734,7 @@ pub mod devnet_sweep { address, balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), - automatic: None, + automatic_deposit_fee: None, }, ) }) @@ -949,7 +949,7 @@ pub mod events { account: deposit.account, address: deposit.address, balance: deposit.balance, - automatic: deposit.automatic, + automatic_deposit_fee: deposit.automatic_deposit_fee, }, &runtime(), ) @@ -1330,7 +1330,7 @@ pub mod arb { rpc::BlockHeight, sol_transfer::MAX_SIGNATURES, state::{ - AutomaticDeposit, DepositBalance, QueuedDeposit, + DepositBalance, QueuedDeposit, event::{ CreditedDeposit, Event, EventType, Signer, TransactionPurpose, WithdrawalRequest, }, @@ -1342,6 +1342,7 @@ pub mod arb { use ic_ed25519::{PrivateKey, PublicKey}; use icrc_ledger_types::icrc1::account::Account; use proptest::prelude::{Just, Strategy, any, prop, prop_oneof}; + use sol_rpc_types::Lamport; use solana_address::Address; use solana_message::{Hash, Instruction, Message}; use solana_signature::Signature; @@ -1395,22 +1396,23 @@ pub mod arb { arb_account(), arb_address(), MIN_BALANCE..=MAX_BALANCE, - prop::option::of(arb_automatic_deposit()), + prop::option::of(arb_automatic_deposit_fee()), ) - .prop_map(|(account, address, balance, automatic)| QueuedDeposit { - account, - address, - balance: DepositBalance::new(balance) - .expect("BUG: the balance covers the rent exemption threshold"), - automatic, - }) + .prop_map( + |(account, address, balance, automatic_deposit_fee)| QueuedDeposit { + account, + address, + balance: DepositBalance::new(balance) + .expect("BUG: the balance covers the rent exemption threshold"), + automatic_deposit_fee, + }, + ) } - /// The automatic part of a deposit whose fee fits in the sweepable amount of - /// [`arb_queued_deposit`] after the fee share of a full sweep. - pub fn arb_automatic_deposit() -> impl Strategy { - (0..FEE_PER_SIGNATURE * (MAX_SIGNATURES - 1), any::()) - .prop_map(|(fee, valid_until)| AutomaticDeposit { fee, valid_until }) + /// An automatic deposit fee that fits in the sweepable amount of [`arb_queued_deposit`] + /// after the fee share of a full sweep. + pub fn arb_automatic_deposit_fee() -> impl Strategy { + 0..FEE_PER_SIGNATURE * (MAX_SIGNATURES - 1) } /// The deposits of one sweep: at least one, at most one per signature. @@ -1614,17 +1616,19 @@ pub mod arb { arb_account(), arb_address(), arb_deposit_balance(), - prop::option::of(arb_automatic_deposit()), + prop::option::of(arb_automatic_deposit_fee()), ) - .prop_map(|(deposit_id, account, address, balance, automatic)| { - EventType::QueuedDeposit { - deposit_id, - account, - address, - balance, - automatic, - } - },), + .prop_map( + |(deposit_id, account, address, balance, automatic_deposit_fee)| { + EventType::QueuedDeposit { + deposit_id, + account, + address, + balance, + automatic_deposit_fee, + } + }, + ), ( arb_signature(), any::(), From bb4f8156320711c95e8cefe38958498de7c142e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 11:23:27 +0000 Subject: [PATCH 068/146] refactor(minter): name the fee of a queued deposit without the flow that set it Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 4 +- libs/types-internal/src/event.rs | 6 +-- minter/cksol_minter.did | 7 ++- minter/src/canbench.rs | 4 +- minter/src/deposit/sweep/mod.rs | 4 +- minter/src/deposit/sweep/tests.rs | 2 +- minter/src/state/audit.rs | 4 +- minter/src/state/deposits/mod.rs | 16 +++---- minter/src/state/deposits/sweeps/mod.rs | 16 +++---- minter/src/state/deposits/sweeps/tests.rs | 26 +++++----- minter/src/state/event.rs | 5 +- minter/src/state/event/candid/mod.rs | 8 ++-- minter/src/state/event/candid/tests.rs | 2 +- minter/src/state/tests.rs | 6 +-- minter/src/test_fixtures/flow/deposit.rs | 8 +--- minter/src/test_fixtures/mod.rs | 58 +++++++++++------------ 16 files changed, 83 insertions(+), 93 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 5d2c0404e..adac071d1 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -882,7 +882,7 @@ mod deposit_sol_tests { }, address: deposit_address.clone(), balance: BALANCE_ABOVE_MINIMUM, - automatic_deposit_fee: None, + deposit_fee: None, }] ); }); @@ -963,7 +963,7 @@ mod deposit_sol_tests { account: DEFAULT_CALLER_ACCOUNT, address: deposit_address.clone(), balance: BALANCE_ABOVE_MINIMUM, - automatic_deposit_fee: None, + deposit_fee: None, }] ); }); diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 93de0f290..24d1d8772 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -76,9 +76,9 @@ pub enum EventType { address: Address, /// The balance of the deposit address when the deposit was queued. balance: Lamport, - /// The automatic deposit fee in force when the automatic flow detected the deposit, - /// deducted from the minted amount; absent for a deposit queued via `deposit_sol`. - automatic_deposit_fee: Option, + /// The fee in lamports deducted from the minted amount; absent for a deposit queued via + /// `deposit_sol`. + deposit_fee: Option, }, /// The minter read the amount that the finalized sweep transaction moved to its /// main account and enqueued a pending mint for each deposit of that sweep. diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 0f04b4bdc..bac8d25ea 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -495,10 +495,9 @@ type EventType = variant { address: Address; // The balance of the deposit address when the deposit was queued. balance: Lamport; - // The automatic deposit fee in force when the automatic flow detected the - // deposit, deducted from the minted amount; absent for a deposit queued via - // `deposit_sol`. - automatic_deposit_fee: opt Lamport; + // The fee in lamports deducted from the minted amount; absent for a deposit + // queued via `deposit_sol`. + deposit_fee: opt Lamport; }; // The minter read the amount that the finalized sweep transaction moved to // its main account and enqueued a pending mint for each deposit of that sweep. diff --git a/minter/src/canbench.rs b/minter/src/canbench.rs index 6db239877..16f53dd10 100644 --- a/minter/src/canbench.rs +++ b/minter/src/canbench.rs @@ -109,14 +109,14 @@ fn queue_and_sweep(deposit_id: u64, account_index: usize, amount: u64, sig: Sign address: deposit_address(account_index), balance: DepositBalance::new(amount + RENT_EXEMPTION_THRESHOLD) .expect("BUG: the balance covers the rent exemption threshold"), - automatic_deposit_fee: None, + deposit_fee: None, }; record(EventType::QueuedDeposit { deposit_id, account, address: deposit.address, balance: deposit.balance, - automatic_deposit_fee: None, + deposit_fee: None, }); let sweep = Sweep::plan([(deposit_id, deposit)], minter_address(&master_key())); record(EventType::SubmittedTransaction { diff --git a/minter/src/deposit/sweep/mod.rs b/minter/src/deposit/sweep/mod.rs index 94fee8c32..d6711f411 100644 --- a/minter/src/deposit/sweep/mod.rs +++ b/minter/src/deposit/sweep/mod.rs @@ -78,7 +78,7 @@ pub async fn deposit_sol( account, address: deposit_address, balance, - automatic_deposit_fee: None, + deposit_fee: None, }, ) }); @@ -106,7 +106,7 @@ pub(crate) fn queue_deposit( account: deposit.account, address: deposit.address, balance: deposit.balance, - automatic_deposit_fee: deposit.automatic_deposit_fee, + deposit_fee: deposit.deposit_fee, }, runtime, ); diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index 4ac408235..be2919bf8 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -240,7 +240,7 @@ fn queued_deposit_event(deposit_id: u64, account: Account, sweepable_amount: Lam account, address: deposit.address, balance: deposit.balance, - automatic_deposit_fee: None, + deposit_fee: None, } } diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 4985bdc83..2fb943166 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -49,7 +49,7 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 account, address, balance, - automatic_deposit_fee, + deposit_fee, } => { state.process_queued_deposit( *deposit_id, @@ -57,7 +57,7 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 account: *account, address: *address, balance: *balance, - automatic_deposit_fee: *automatic_deposit_fee, + deposit_fee: *deposit_fee, }, timestamp, ); diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index fa3569c64..2ce3b761c 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -226,7 +226,7 @@ impl Deposits { deposit.account ); self.latest_ids.insert(deposit.account, deposit_id); - if deposit.automatic_deposit_fee.is_some() { + if deposit.deposit_fee.is_some() { self.automatic_deposit_count += 1; } self.watchlist.remove(&deposit.account); @@ -372,7 +372,7 @@ impl Deposits { mint.deposit_id ); } - sweep.automatic_deposit_fees() + sweep.deposit_fees() } pub(super) fn mint(&mut self, deposit_id: DepositSolId, mint_block_index: LedgerMintIndex) { @@ -429,9 +429,9 @@ pub struct QueuedDeposit { pub address: Address, /// The balance of the deposit address when the deposit was queued. pub balance: DepositBalance, - /// The automatic deposit fee in force when the automatic flow detected the deposit, - /// absent for a deposit queued by `deposit_sol`. - pub automatic_deposit_fee: Option, + /// The fee in lamports deducted from the minted amount, absent for a deposit queued by + /// `deposit_sol`. + pub deposit_fee: Option, } impl QueuedDeposit { @@ -439,9 +439,9 @@ impl QueuedDeposit { self.balance.sweepable_amount() } - /// The automatic deposit fee deducted from the mint, zero for a manual deposit. - pub fn deducted_automatic_deposit_fee(&self) -> Lamport { - self.automatic_deposit_fee.unwrap_or(0) + /// The fee deducted from the mint, zero for a deposit queued by `deposit_sol`. + pub fn deducted_deposit_fee(&self) -> Lamport { + self.deposit_fee.unwrap_or(0) } } diff --git a/minter/src/state/deposits/sweeps/mod.rs b/minter/src/state/deposits/sweeps/mod.rs index 1230f0b8b..e0c18adfb 100644 --- a/minter/src/state/deposits/sweeps/mod.rs +++ b/minter/src/state/deposits/sweeps/mod.rs @@ -335,12 +335,12 @@ impl Sweep { let amount_to_mint = deposit .sweepable_amount() .checked_sub(fee_share) - .and_then(|amount| amount.checked_sub(deposit.deducted_automatic_deposit_fee())) + .and_then(|amount| amount.checked_sub(deposit.deducted_deposit_fee())) .ok_or(SweepMismatch::FeesExceedSweepableAmount { deposit_id: *deposit_id, sweepable_amount: deposit.sweepable_amount(), fee_share, - automatic_deposit_fee: deposit.deducted_automatic_deposit_fee(), + deposit_fee: deposit.deducted_deposit_fee(), })?; Ok(CreditedDeposit { deposit_id: *deposit_id, @@ -350,12 +350,12 @@ impl Sweep { .collect() } - /// The automatic deposit fees the sweep collects: the sum of the fees recorded with its - /// automatic deposits, which stay on the main account on top of the minted amounts. - pub fn automatic_deposit_fees(&self) -> Lamport { + /// The deposit fees the sweep collects: the sum of the fees recorded with its deposits, + /// which stay on the main account on top of the minted amounts. + pub fn deposit_fees(&self) -> Lamport { self.deposits .values() - .map(QueuedDeposit::deducted_automatic_deposit_fee) + .map(QueuedDeposit::deducted_deposit_fee) .sum() } } @@ -421,13 +421,13 @@ pub enum SweepMismatch { #[error("the main account received {actual} lamports instead of the planned {expected}")] UnexpectedAmountReceived { expected: Lamport, actual: Lamport }, #[error( - "the fee share of {fee_share} lamports and the automatic deposit fee of {automatic_deposit_fee} lamports exceed the {sweepable_amount} lamports swept for deposit {deposit_id}" + "the fee share of {fee_share} lamports and the deposit fee of {deposit_fee} lamports exceed the {sweepable_amount} lamports swept for deposit {deposit_id}" )] FeesExceedSweepableAmount { deposit_id: DepositSolId, sweepable_amount: Lamport, fee_share: Lamport, - automatic_deposit_fee: Lamport, + deposit_fee: Lamport, }, } diff --git a/minter/src/state/deposits/sweeps/tests.rs b/minter/src/state/deposits/sweeps/tests.rs index 643191467..aa7980ce9 100644 --- a/minter/src/state/deposits/sweeps/tests.rs +++ b/minter/src/state/deposits/sweeps/tests.rs @@ -180,10 +180,10 @@ mod plan { } #[test] - fn should_deduct_the_automatic_deposit_fee_from_the_mint_of_an_automatic_deposit() { - let automatic_deposit_fee = 2_000_000; + fn should_deduct_the_deposit_fee_from_the_mint_of_an_automatic_deposit() { + let deposit_fee = 2_000_000; let manual = queued_deposit_of(account(1), 3_000_000); - let detected = automatic_deposit_of(account(2), 5_000_000, automatic_deposit_fee); + let detected = automatic_deposit_of(account(2), 5_000_000, deposit_fee); let sweep = planned_sweep([(0, manual), (1, detected)]); @@ -213,11 +213,11 @@ mod plan { }, CreditedDeposit { deposit_id: 1, - amount_to_mint: 5_000_000 - FEE_PER_SIGNATURE - automatic_deposit_fee, + amount_to_mint: 5_000_000 - FEE_PER_SIGNATURE - deposit_fee, }, ] ); - assert_eq!(sweep.automatic_deposit_fees(), automatic_deposit_fee); + assert_eq!(sweep.deposit_fees(), deposit_fee); } #[test] @@ -227,17 +227,17 @@ mod plan { (1, queued_deposit_of(account(2), 2_000_000)), ]); - assert_eq!(sweep.automatic_deposit_fees(), 0); + assert_eq!(sweep.deposit_fees(), 0); } #[test] fn should_have_no_mints_when_the_fees_exceed_the_sweepable_amount() { - let automatic_deposit_fee = 1_000_000; - let sweepable_amount = automatic_deposit_fee + FEE_PER_SIGNATURE - 1; + let deposit_fee = 1_000_000; + let sweepable_amount = deposit_fee + FEE_PER_SIGNATURE - 1; let sweep = planned_sweep([( 0, - automatic_deposit_of(account(1), sweepable_amount, automatic_deposit_fee), + automatic_deposit_of(account(1), sweepable_amount, deposit_fee), )]); assert_eq!( @@ -246,7 +246,7 @@ mod plan { deposit_id: 0, sweepable_amount, fee_share: FEE_PER_SIGNATURE, - automatic_deposit_fee, + deposit_fee, }) ); } @@ -427,12 +427,12 @@ mod settle { #[test] fn should_reject_a_matching_outcome_whose_fees_exceed_a_sweepable_amount() { let deposits = devnet_sweep::deposits().into_iter().map(|(id, deposit)| { - let automatic_deposit_fee = + let deposit_fee = (deposit.address == SECOND_DEPOSIT).then_some(SECOND_SWEEPABLE_AMOUNT); ( id, QueuedDeposit { - automatic_deposit_fee, + deposit_fee, ..deposit }, ) @@ -449,7 +449,7 @@ mod settle { deposit_id: 1, sweepable_amount: SECOND_SWEEPABLE_AMOUNT, fee_share: devnet_sweep::FEE / devnet_sweep::DEPOSITS.len() as u64, - automatic_deposit_fee: SECOND_SWEEPABLE_AMOUNT, + deposit_fee: SECOND_SWEEPABLE_AMOUNT, } )) ); diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 1174064d7..8c458992f 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -141,10 +141,9 @@ pub enum EventType { address: Address, #[cbor(n(3), with = "cbor::deposit_balance")] balance: DepositBalance, - /// The automatic deposit fee in force when the automatic flow detected the deposit, - /// absent for a deposit queued via `deposit_sol`. + /// The fee deducted from the minted amount, absent for a deposit queued via `deposit_sol`. #[n(4)] - automatic_deposit_fee: Option, + deposit_fee: Option, }, /// The minter read the amount that the finalized sweep transaction moved to its /// main account and enqueued a pending mint for each deposit of that sweep. diff --git a/minter/src/state/event/candid/mod.rs b/minter/src/state/event/candid/mod.rs index f9472d982..85171ab82 100644 --- a/minter/src/state/event/candid/mod.rs +++ b/minter/src/state/event/candid/mod.rs @@ -76,13 +76,13 @@ impl From for CandidEventType { account, address, balance, - automatic_deposit_fee, + deposit_fee, } => CandidEventType::QueuedDeposit { deposit_id, account, address: CandidAddress::from(address), balance: Lamport::from(balance), - automatic_deposit_fee, + deposit_fee, }, EventType::CreditedSweep { signature, @@ -231,7 +231,7 @@ impl TryFrom for EventType { account, address, balance, - automatic_deposit_fee, + deposit_fee, } => EventType::QueuedDeposit { deposit_id, account, @@ -239,7 +239,7 @@ impl TryFrom for EventType { balance: DepositBalance::new(balance).ok_or( InvalidCandidEvent::DepositBalanceBelowRentExemption(balance), )?, - automatic_deposit_fee, + deposit_fee, }, CandidEventType::CreditedSweep { signature, diff --git a/minter/src/state/event/candid/tests.rs b/minter/src/state/event/candid/tests.rs index 9e7818876..85f450589 100644 --- a/minter/src/state/event/candid/tests.rs +++ b/minter/src/state/event/candid/tests.rs @@ -44,7 +44,7 @@ proptest! { account, address: address.into(), balance, - automatic_deposit_fee: None, + deposit_fee: None, }; prop_assert_eq!( EventType::try_from(payload).unwrap_err(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 4c802bbb9..d095b5962 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -56,7 +56,7 @@ fn should_decode_a_queued_deposit_recorded_without_the_automatic_part() { account: deposit.account, address: deposit.address, balance: deposit.balance, - automatic_deposit_fee: None, + deposit_fee: None, }, } ); @@ -136,7 +136,7 @@ mod queued_deposits { account: queued_deposit(deposit_id).account, address: queued_deposit(deposit_id).address, balance: queued_deposit(deposit_id).balance, - automatic_deposit_fee: None, + deposit_fee: None, }, }; let init = Event { @@ -1669,7 +1669,7 @@ mod withdrawal_transactions { account: funding_deposit.account, address: funding_deposit.address, balance: funding_deposit.balance, - automatic_deposit_fee: None, + deposit_fee: None, }, EventType::SubmittedTransaction { signature: signature(9), diff --git a/minter/src/test_fixtures/flow/deposit.rs b/minter/src/test_fixtures/flow/deposit.rs index 5903fe2c1..2ad29b7a8 100644 --- a/minter/src/test_fixtures/flow/deposit.rs +++ b/minter/src/test_fixtures/flow/deposit.rs @@ -32,13 +32,9 @@ impl DepositFlow { pub fn queue_automatic( account: Account, sweepable_amount: Lamport, - automatic_deposit_fee: Lamport, + deposit_fee: Lamport, ) -> QueuedDepositFlow { - Self::queue_deposit(automatic_deposit_of( - account, - sweepable_amount, - automatic_deposit_fee, - )) + Self::queue_deposit(automatic_deposit_of(account, sweepable_amount, deposit_fee)) } fn queue_deposit(deposit: QueuedDeposit) -> QueuedDepositFlow { diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index dddbe30ac..261f7f746 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -344,7 +344,7 @@ pub fn queued_deposit_of(account: Account, sweepable_amount: Lamport) -> QueuedD address: deposit_address(account), balance: DepositBalance::new(sweepable_amount + RENT_EXEMPTION_THRESHOLD) .expect("BUG: the balance covers the rent exemption threshold"), - automatic_deposit_fee: None, + deposit_fee: None, } } @@ -353,10 +353,10 @@ pub fn queued_deposit_of(account: Account, sweepable_amount: Lamport) -> QueuedD pub fn automatic_deposit_of( account: Account, sweepable_amount: Lamport, - automatic_deposit_fee: Lamport, + deposit_fee: Lamport, ) -> QueuedDeposit { QueuedDeposit { - automatic_deposit_fee: Some(automatic_deposit_fee), + deposit_fee: Some(deposit_fee), ..queued_deposit_of(account, sweepable_amount) } } @@ -611,7 +611,7 @@ pub mod devnet_sweep { address: crate::address::account_address(&master_key(), &account), balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), - automatic_deposit_fee: None, + deposit_fee: None, }, ) }) @@ -628,7 +628,7 @@ pub mod devnet_sweep { sweepable_amount + crate::constants::RENT_EXEMPTION_THRESHOLD, ) .expect("BUG: the balance covers the rent exemption threshold"), - automatic_deposit_fee: None, + deposit_fee: None, } } @@ -734,7 +734,7 @@ pub mod devnet_sweep { address, balance: DepositBalance::new(balance) .expect("BUG: the balance covers the rent exemption threshold"), - automatic_deposit_fee: None, + deposit_fee: None, }, ) }) @@ -949,7 +949,7 @@ pub mod events { account: deposit.account, address: deposit.address, balance: deposit.balance, - automatic_deposit_fee: deposit.automatic_deposit_fee, + deposit_fee: deposit.deposit_fee, }, &runtime(), ) @@ -1396,22 +1396,20 @@ pub mod arb { arb_account(), arb_address(), MIN_BALANCE..=MAX_BALANCE, - prop::option::of(arb_automatic_deposit_fee()), + prop::option::of(arb_deposit_fee()), ) - .prop_map( - |(account, address, balance, automatic_deposit_fee)| QueuedDeposit { - account, - address, - balance: DepositBalance::new(balance) - .expect("BUG: the balance covers the rent exemption threshold"), - automatic_deposit_fee, - }, - ) + .prop_map(|(account, address, balance, deposit_fee)| QueuedDeposit { + account, + address, + balance: DepositBalance::new(balance) + .expect("BUG: the balance covers the rent exemption threshold"), + deposit_fee, + }) } - /// An automatic deposit fee that fits in the sweepable amount of [`arb_queued_deposit`] + /// A deposit fee that fits in the sweepable amount of [`arb_queued_deposit`] /// after the fee share of a full sweep. - pub fn arb_automatic_deposit_fee() -> impl Strategy { + pub fn arb_deposit_fee() -> impl Strategy { 0..FEE_PER_SIGNATURE * (MAX_SIGNATURES - 1) } @@ -1616,19 +1614,17 @@ pub mod arb { arb_account(), arb_address(), arb_deposit_balance(), - prop::option::of(arb_automatic_deposit_fee()), + prop::option::of(arb_deposit_fee()), ) - .prop_map( - |(deposit_id, account, address, balance, automatic_deposit_fee)| { - EventType::QueuedDeposit { - deposit_id, - account, - address, - balance, - automatic_deposit_fee, - } - }, - ), + .prop_map(|(deposit_id, account, address, balance, deposit_fee)| { + EventType::QueuedDeposit { + deposit_id, + account, + address, + balance, + deposit_fee, + } + },), ( arb_signature(), any::(), From e267b74fe5d5ff6972c0dc3211377b117224d2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:26:56 +0000 Subject: [PATCH 069/146] refactor(minter): return the credited sweep and type the error of its mints The deposits hand the credited sweep back to the state, which reads the fees it collects; the fee accessor of a queued deposit is named after the fee alone; the mints of a sweep fail with their own error type; and the count of automatic deposits, which a fee does not establish, is dropped. Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/mod.rs | 17 ++++------------- minter/src/state/deposits/sweeps/mod.rs | 21 ++++++++++++--------- minter/src/state/deposits/sweeps/tests.rs | 20 ++++++++++---------- minter/src/state/mod.rs | 4 ++-- minter/src/state/tests.rs | 1 - 5 files changed, 28 insertions(+), 35 deletions(-) diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 2ce3b761c..531838944 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -68,7 +68,6 @@ pub struct Deposits { in_flight_ids: BTreeMap, latest_ids: BTreeMap, queued_at_by_id: BTreeMap, - automatic_deposit_count: u64, watchlist: Watchlist, } @@ -146,11 +145,6 @@ impl Deposits { self.latest_ids.get(account).copied() } - /// How many deposits the automatic flow has queued so far. - pub fn automatic_deposit_count(&self) -> u64 { - self.automatic_deposit_count - } - /// Returns the timestamp (in nanoseconds) at which the oldest in-flight deposit was queued. /// A deposit is in flight from the moment it is queued until it is minted, dropped or /// quarantined, whatever sweep stage it has reached in between. @@ -226,9 +220,6 @@ impl Deposits { deposit.account ); self.latest_ids.insert(deposit.account, deposit_id); - if deposit.deposit_fee.is_some() { - self.automatic_deposit_count += 1; - } self.watchlist.remove(&deposit.account); self.queued.insert(deposit_id, deposit); self.queued_at_by_id.insert(deposit_id, queued_at); @@ -325,13 +316,13 @@ impl Deposits { } /// Moves every deposit of the given finalized sweep to the pending mints, each with - /// the amount its mint carries, and returns the automatic deposit fees the sweep collects. + /// the amount its mint carries, and returns the credited sweep. pub(super) fn credit_sweep( &mut self, signature: &Signature, mints: &[CreditedDeposit], timestamp: u64, - ) -> Lamport { + ) -> Sweep { let sweep = self.finalized.remove(signature).unwrap_or_else(|| { panic!("Attempted to credit sweep {signature} that is not finalized") }); @@ -372,7 +363,7 @@ impl Deposits { mint.deposit_id ); } - sweep.deposit_fees() + sweep } pub(super) fn mint(&mut self, deposit_id: DepositSolId, mint_block_index: LedgerMintIndex) { @@ -440,7 +431,7 @@ impl QueuedDeposit { } /// The fee deducted from the mint, zero for a deposit queued by `deposit_sol`. - pub fn deducted_deposit_fee(&self) -> Lamport { + pub fn deposit_fee(&self) -> Lamport { self.deposit_fee.unwrap_or(0) } } diff --git a/minter/src/state/deposits/sweeps/mod.rs b/minter/src/state/deposits/sweeps/mod.rs index e0c18adfb..fc8f21646 100644 --- a/minter/src/state/deposits/sweeps/mod.rs +++ b/minter/src/state/deposits/sweeps/mod.rs @@ -316,7 +316,7 @@ impl Sweep { Ok(SettledSweep { amount_received, - mints: self.mints()?, + mints: self.mints().map_err(SweepMismatch::from)?, }) } @@ -327,7 +327,7 @@ impl Sweep { /// The parameter constraints keep the fees below the sweepable amount of a deposit /// queued under them, so a deposit they do not fit is a mismatch that quarantines the /// sweep rather than a reason to trap. - pub fn mints(&self) -> Result, SweepMismatch> { + pub fn mints(&self) -> Result, SweepMintError> { let fee_share = self.fee.div_ceil(self.deposit_count() as Lamport); self.deposits .iter() @@ -335,12 +335,12 @@ impl Sweep { let amount_to_mint = deposit .sweepable_amount() .checked_sub(fee_share) - .and_then(|amount| amount.checked_sub(deposit.deducted_deposit_fee())) - .ok_or(SweepMismatch::FeesExceedSweepableAmount { + .and_then(|amount| amount.checked_sub(deposit.deposit_fee())) + .ok_or(SweepMintError::FeesExceedSweepableAmount { deposit_id: *deposit_id, sweepable_amount: deposit.sweepable_amount(), fee_share, - deposit_fee: deposit.deducted_deposit_fee(), + deposit_fee: deposit.deposit_fee(), })?; Ok(CreditedDeposit { deposit_id: *deposit_id, @@ -353,10 +353,7 @@ impl Sweep { /// The deposit fees the sweep collects: the sum of the fees recorded with its deposits, /// which stay on the main account on top of the minted amounts. pub fn deposit_fees(&self) -> Lamport { - self.deposits - .values() - .map(QueuedDeposit::deducted_deposit_fee) - .sum() + self.deposits.values().map(QueuedDeposit::deposit_fee).sum() } } @@ -420,6 +417,12 @@ pub enum SweepMismatch { MainAccountDebited { pre: Lamport, post: Lamport }, #[error("the main account received {actual} lamports instead of the planned {expected}")] UnexpectedAmountReceived { expected: Lamport, actual: Lamport }, + #[error(transparent)] + Mint(#[from] SweepMintError), +} + +#[derive(Debug, PartialEq, Eq, Error)] +pub enum SweepMintError { #[error( "the fee share of {fee_share} lamports and the deposit fee of {deposit_fee} lamports exceed the {sweepable_amount} lamports swept for deposit {deposit_id}" )] diff --git a/minter/src/state/deposits/sweeps/tests.rs b/minter/src/state/deposits/sweeps/tests.rs index aa7980ce9..8bd58f5d8 100644 --- a/minter/src/state/deposits/sweeps/tests.rs +++ b/minter/src/state/deposits/sweeps/tests.rs @@ -1,6 +1,6 @@ use super::{ - Sweep, SweepMismatch, SweepRecoveryError, SweepSettlementError, Sweeps, Transfer, - UnreadableOutcome, + Sweep, SweepMintError, SweepMismatch, SweepRecoveryError, SweepSettlementError, Sweeps, + Transfer, UnreadableOutcome, }; use crate::{ constants::{FEE_PER_SIGNATURE, RENT_EXEMPTION_THRESHOLD}, @@ -98,7 +98,7 @@ fn should_panic_when_inserting_a_sweep_with_a_known_signature() { mod plan { use super::{ - FEE_PER_SIGNATURE, Lamport, MINTER_ADDRESS, Sweep, SweepMismatch, Transfer, account, + FEE_PER_SIGNATURE, Lamport, MINTER_ADDRESS, Sweep, SweepMintError, Transfer, account, planned_sweep, queued_deposit_of, }; use crate::{ @@ -242,7 +242,7 @@ mod plan { assert_eq!( sweep.mints(), - Err(SweepMismatch::FeesExceedSweepableAmount { + Err(SweepMintError::FeesExceedSweepableAmount { deposit_id: 0, sweepable_amount, fee_share: FEE_PER_SIGNATURE, @@ -371,9 +371,9 @@ mod message { mod settle { use super::{ - EncodedConfirmedTransactionWithStatusMeta, RENT_EXEMPTION_THRESHOLD, Sweep, SweepMismatch, - SweepSettlementError, TransactionError, UiTransactionError, UnreadableOutcome, account, - devnet_sweep, planned_sweep, queued_deposit_of, + EncodedConfirmedTransactionWithStatusMeta, RENT_EXEMPTION_THRESHOLD, Sweep, SweepMintError, + SweepMismatch, SweepSettlementError, TransactionError, UiTransactionError, + UnreadableOutcome, account, devnet_sweep, planned_sweep, queued_deposit_of, }; use crate::{state::QueuedDeposit, test_fixtures::fetched}; @@ -444,14 +444,14 @@ mod settle { assert_eq!( result, - Err(SweepSettlementError::Mismatch( - SweepMismatch::FeesExceedSweepableAmount { + Err(SweepSettlementError::Mismatch(SweepMismatch::Mint( + SweepMintError::FeesExceedSweepableAmount { deposit_id: 1, sweepable_amount: SECOND_SWEEPABLE_AMOUNT, fee_share: devnet_sweep::FEE / devnet_sweep::DEPOSITS.len() as u64, deposit_fee: SECOND_SWEEPABLE_AMOUNT, } - )) + ))) ); } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 3db652189..6e6dfba02 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -474,8 +474,8 @@ impl State { amount_to_mint <= amount_received, "Attempted to credit sweep {signature} with mints of {amount_to_mint} lamports exceeding the {amount_received} lamports received" ); - let collected_fees = self.deposits.credit_sweep(signature, mints, timestamp); - self.collected_automatic_deposit_fees += collected_fees; + let sweep = self.deposits.credit_sweep(signature, mints, timestamp); + self.collected_automatic_deposit_fees += sweep.deposit_fees(); self.balance += amount_received; } diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index d095b5962..75ae38313 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -414,7 +414,6 @@ mod swept_deposits { AUTOMATIC_DEPOSIT_FEE + AUTOMATIC_DEPOSIT_FEE / 2 ); assert_eq!(s.balance(), credited.amount_received); - assert_eq!(s.deposits().automatic_deposit_count(), 2); }); let replayed = replayed_recorded_log(); From 991fe51a2863a1471b5177568afc6c3e9efde986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:26:56 +0000 Subject: [PATCH 070/146] test(minter): cover the fee deduction through the deposit flow The plan-level fee test and the decode test of the old queued deposit encoding go, the state test asserts the amount received, the rejected Candid deposit carries an arbitrary fee, and the replay of the recorded log takes no extra events. Co-Authored-By: Claude Fable 5.1 --- minter/src/state/deposits/sweeps/tests.rs | 41 ----------------------- minter/src/state/event/candid/tests.rs | 10 ++++-- minter/src/state/tests.rs | 27 +-------------- minter/src/test_fixtures/mod.rs | 7 ---- 4 files changed, 9 insertions(+), 76 deletions(-) diff --git a/minter/src/state/deposits/sweeps/tests.rs b/minter/src/state/deposits/sweeps/tests.rs index 8bd58f5d8..9e292c0ae 100644 --- a/minter/src/state/deposits/sweeps/tests.rs +++ b/minter/src/state/deposits/sweeps/tests.rs @@ -179,47 +179,6 @@ mod plan { ); } - #[test] - fn should_deduct_the_deposit_fee_from_the_mint_of_an_automatic_deposit() { - let deposit_fee = 2_000_000; - let manual = queued_deposit_of(account(1), 3_000_000); - let detected = automatic_deposit_of(account(2), 5_000_000, deposit_fee); - - let sweep = planned_sweep([(0, manual), (1, detected)]); - - assert_eq!(sweep.fee_payer(), 1); - assert_eq!(sweep.expected_received(), 8_000_000 - 2 * FEE_PER_SIGNATURE); - assert_eq!( - sweep.transfers(), - vec![ - Transfer { - deposit_id: 1, - from: detected.address, - amount: 5_000_000 - 2 * FEE_PER_SIGNATURE, - }, - Transfer { - deposit_id: 0, - from: manual.address, - amount: 3_000_000, - }, - ] - ); - assert_eq!( - sweep.mints().unwrap(), - vec![ - CreditedDeposit { - deposit_id: 0, - amount_to_mint: 3_000_000 - FEE_PER_SIGNATURE, - }, - CreditedDeposit { - deposit_id: 1, - amount_to_mint: 5_000_000 - FEE_PER_SIGNATURE - deposit_fee, - }, - ] - ); - assert_eq!(sweep.deposit_fees(), deposit_fee); - } - #[test] fn should_collect_no_fee_from_manual_deposits() { let sweep = planned_sweep([ diff --git a/minter/src/state/event/candid/tests.rs b/minter/src/state/event/candid/tests.rs index 85f450589..46371fc67 100644 --- a/minter/src/state/event/candid/tests.rs +++ b/minter/src/state/event/candid/tests.rs @@ -8,7 +8,12 @@ use cksol_types_internal::event::{ Event as CandidEvent, EventType as CandidEventType, Signer as CandidSigner, TransactionPurpose as CandidTransactionPurpose, VersionedTransactionMessage, }; -use proptest::{prelude::Strategy, prop_assert, prop_assert_eq, proptest}; +use proptest::{ + option, + prelude::{Strategy, any}, + prop_assert, prop_assert_eq, proptest, +}; +use sol_rpc_types::Lamport; proptest! { #[test] @@ -38,13 +43,14 @@ proptest! { account in arb_account(), address in arb_address(), balance in 0..RENT_EXEMPTION_THRESHOLD, + deposit_fee in option::of(any::()), ) { let payload = CandidEventType::QueuedDeposit { deposit_id: 0, account, address: address.into(), balance, - deposit_fee: None, + deposit_fee, }; prop_assert_eq!( EventType::try_from(payload).unwrap_err(), diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 75ae38313..c09b720c9 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -36,32 +36,6 @@ proptest! { } } -/// The automatic part was added to the `QueuedDeposit` event after the minter was deployed, -/// so the events recorded before must still decode, as this encoding recorded by the previous -/// version checks. -#[test] -fn should_decode_a_queued_deposit_recorded_without_the_automatic_part() { - const RECORDED_BEFORE_THE_AUTOMATIC_FLOW: &str = "821a499602d28208840781581d08000000000000000000000000000000000000000000000000000000005820d8c8b6b0295ea41f91d2173a81503fcec3148f69e8e130813d228badbaaf28e41a0087aa00"; - let bytes = hex::decode(RECORDED_BEFORE_THE_AUTOMATIC_FLOW).unwrap(); - let deposit = queued_deposit(7); - - let decoded = Event::from_bytes(Cow::Borrowed(&bytes)); - - assert_eq!( - decoded, - Event { - timestamp: 1_234_567_890, - payload: EventType::QueuedDeposit { - deposit_id: 7, - account: deposit.account, - address: deposit.address, - balance: deposit.balance, - deposit_fee: None, - }, - } - ); -} - mod cache_minter_public_key { use super::*; use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; @@ -398,6 +372,7 @@ mod swept_deposits { let credited = finalized.credit(); + assert_eq!(credited.amount_received, 60_000_000 - 3 * FEE_PER_SIGNATURE); let [manual_mint, first_mint, second_mint] = credited.pending_mints(); assert_eq!(manual_mint.amount_to_mint, 10_000_000 - FEE_PER_SIGNATURE); assert_eq!( diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 261f7f746..087ef463e 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -165,19 +165,12 @@ pub fn minter_public_key_fetched_event() -> EventType { /// events that [`init_state`] and [`init_schnorr_master_key`] stand for, so that it can be /// compared with the live state. pub fn replayed_recorded_log() -> State { - replayed_recorded_log_after([]) -} - -/// Like [`replayed_recorded_log`], with `events` replayed between the init and key-fetched -/// events and the recorded ones. -pub fn replayed_recorded_log_after(events: impl IntoIterator) -> State { let recorded: Vec = with_event_iter(|events| events.collect()); let prefix = [ EventType::Init(init_args_with_nonce_account()), minter_public_key_fetched_event(), ] .into_iter() - .chain(events) .map(|payload| Event { timestamp: 0, payload, From f28f6f1d225b838a2733c155c6f50345f3a6b414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:30:11 +0000 Subject: [PATCH 071/146] feat(minter): read deposit address balances in batches with getMultipleAccounts Co-Authored-By: Claude Fable 5.1 --- minter/Cargo.toml | 2 +- minter/src/constants.rs | 36 ++++++++ minter/src/rpc/mod.rs | 114 ++++++++++++++++++++++- minter/src/rpc/tests.rs | 134 +++++++++++++++++++++++++++- minter/src/test_fixtures/mod.rs | 25 ++++++ minter/src/test_fixtures/runtime.rs | 15 +++- 6 files changed, 318 insertions(+), 8 deletions(-) diff --git a/minter/Cargo.toml b/minter/Cargo.toml index 11a661ccc..d6af7359f 100644 --- a/minter/Cargo.toml +++ b/minter/Cargo.toml @@ -44,6 +44,7 @@ num-traits = { workspace = true } phantom_newtype = { workspace = true } scopeguard = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } sol_rpc_client = { workspace = true } sol_rpc_types = { workspace = true } solana-account-decoder-client-types = { workspace = true } @@ -69,6 +70,5 @@ ic-agent = { workspace = true } mockall = { workspace = true } proptest = { workspace = true } scraper = { workspace = true } -serde_json = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/minter/src/constants.rs b/minter/src/constants.rs index b9292037e..613fae8fb 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -53,6 +53,42 @@ pub const GET_TRANSACTION_CYCLES: u128 = 50_000_000_000; /// unused part is refunded and never charged to the caller of `deposit_sol`. pub const GET_BALANCE_CYCLES: u128 = 10_000_000_000; +/// Maximum number of addresses per `getMultipleAccounts` RPC call. +/// See . +pub const MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS: usize = 100; + +/// Cycles to attach for a `getMultipleAccounts` RPC call reading one address, +/// on top of [`GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS`] for every address. +/// +/// The SOL RPC canister has no typed method for `getMultipleAccounts` yet, so +/// the call goes through its generic `jsonRequest` endpoint, priced like +/// `getBalance` for a single address: about 2.3B cycles with the default +/// 3-out-of-4 provider consensus. The attached amount leaves a wide margin +/// for provider or price changes; the unused part is refunded. +pub const GET_MULTIPLE_ACCOUNTS_BASE_CYCLES: u128 = 10_000_000_000; + +/// Cycles to attach per address of a `getMultipleAccounts` RPC call, covering +/// the roughly 150 bytes of response an address without data adds, which +/// the SOL RPC canister charges about 22M cycles for (see section 3.3.2 of +/// the design); the unused part is refunded. +pub const GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS: u128 = 100_000_000; + +/// The bytes of a `getMultipleAccounts` JSON-RPC response outside the account +/// list: the JSON-RPC envelope, the response context and the allowance for the +/// HTTP headers that the SOL RPC canister includes in its own estimates, since +/// it uses an estimate passed with a request as the response size limit as is. +pub const GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES: u64 = 128 + SOL_RPC_RESPONSE_HEADER_BYTES; + +/// The bytes one account without data adds to a `getMultipleAccounts` JSON-RPC +/// response, at the largest lamport amount and rent epoch. The response size +/// estimate passed to the SOL RPC canister is sized by the number of addresses +/// so that the canister does not retry the call with doubled estimates. +pub const GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS: u64 = 160; + +/// The allowance for the HTTP headers of a response that the SOL RPC canister +/// includes in the response size estimates of its typed methods. +const SOL_RPC_RESPONSE_HEADER_BYTES: u64 = 2 * 1024; + /// Cycles to attach for `getSignatureStatuses` RPC calls. /// /// The SOL RPC canister charges about 2.3B cycles for a `getSignatureStatuses` diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index 7330c8aa1..e47c459dc 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -1,7 +1,10 @@ use crate::{ constants::{ - GET_ACCOUNT_INFO_CYCLES, GET_BALANCE_CYCLES, GET_RECENT_BLOCK_MAX_TRIES, + GET_ACCOUNT_INFO_CYCLES, GET_BALANCE_CYCLES, GET_MULTIPLE_ACCOUNTS_BASE_CYCLES, + GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS, GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES, + GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS, GET_RECENT_BLOCK_MAX_TRIES, GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, + MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, }, runtime::CanisterRuntime, state::read_state, @@ -10,6 +13,8 @@ use cksol_types::DepositSolError; use derive_more::From; use ic_canister_runtime::IcError; use minicbor::{Decode, Encode}; +use serde::Deserialize; +use serde_json::json; use sol_rpc_types::{ CommitmentLevel, GetAccountInfoEncoding, GetTransactionEncoding, Lamport, MultiRpcResult, RpcError, Slot, @@ -157,6 +162,113 @@ impl From for DepositSolError { } } +/// Reads the balances of the given addresses, in their order, at the `finalized` +/// commitment level with a single `getMultipleAccounts` call, treating a missing +/// account as a zero balance. +/// +/// The SOL RPC canister offers no typed `getMultipleAccounts` method yet, so the call goes +/// through its generic `jsonRequest` endpoint, asking for no account data, and the +/// `result` of the JSON-RPC response, which the SOL RPC canister hands back as it is, +/// is parsed here. +/// +/// # Panics +/// +/// If there are no addresses or more than [`MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS`], +/// since the caller chunks the addresses it reads. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "called by the scan timer of the automatic deposit flow, which lands next" + ) +)] +pub async fn get_balances( + runtime: &R, + addresses: &[Address], +) -> Result, GetMultipleAccountsError> { + assert!( + (1..=MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS).contains(&addresses.len()), + "BUG: a getMultipleAccounts call reads between 1 and {MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS} addresses, not {}", + addresses.len() + ); + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getMultipleAccounts", + "params": [ + addresses.iter().map(Address::to_string).collect::>(), + { + "commitment": "finalized", + "encoding": "base64", + "dataSlice": { "offset": 0, "length": 0 } + } + ] + }); + let result = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) + .json_request(request) + .with_response_size_estimate(get_multiple_accounts_response_bytes(addresses.len())) + .with_cycles(get_multiple_accounts_cycles(addresses.len())) + .try_send() + .await; + let response = match result? { + MultiRpcResult::Consistent(Ok(response)) => response, + MultiRpcResult::Consistent(Err(e)) => return Err(GetMultipleAccountsError::RpcError(e)), + MultiRpcResult::Inconsistent(_) => { + return Err(GetMultipleAccountsError::InconsistentRpcResults); + } + }; + let GetMultipleAccountsResult { value: accounts } = serde_json::from_str(&response) + .map_err(|e| GetMultipleAccountsError::UnexpectedResponse(e.to_string()))?; + if accounts.len() != addresses.len() { + return Err(GetMultipleAccountsError::UnexpectedResponse(format!( + "expected {} accounts, got {}", + addresses.len(), + accounts.len() + ))); + } + Ok(accounts + .into_iter() + .map(|account| account.map_or(0, |account| account.lamports)) + .collect()) +} + +/// The cycles to attach to a `getMultipleAccounts` call reading `num_addresses` addresses. +fn get_multiple_accounts_cycles(num_addresses: usize) -> u128 { + GET_MULTIPLE_ACCOUNTS_BASE_CYCLES + + GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS * num_addresses as u128 +} + +/// The size of the response to a `getMultipleAccounts` call reading `num_addresses` +/// addresses without data, headers included, which the SOL RPC canister uses as the +/// response size limit of the call. +fn get_multiple_accounts_response_bytes(num_addresses: usize) -> u64 { + GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES + + GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS * num_addresses as u64 +} + +#[derive(Deserialize)] +struct GetMultipleAccountsResult { + value: Vec>, +} + +#[derive(Deserialize)] +struct AccountLamports { + lamports: Lamport, +} + +#[derive(Debug, PartialEq, Error, From)] +pub enum GetMultipleAccountsError { + #[error("Error while calling SOL RPC canister: {0}")] + IcError(IcError), + #[error("RPC error while fetching balances: {0}")] + RpcError(RpcError), + #[error("Inconsistent RPC results for getMultipleAccounts")] + InconsistentRpcResults, + #[error("Unexpected getMultipleAccounts response: {0}")] + #[from(ignore)] + UnexpectedResponse(String), +} + pub async fn submit_transaction( runtime: &R, transaction: Transaction, diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 3db8fb614..4c92a8984 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -1,9 +1,11 @@ use crate::{ - constants::GET_RECENT_BLOCK_MAX_TRIES, + constants::{GET_RECENT_BLOCK_MAX_TRIES, MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS}, rpc::{ - Block, BlockHeight, GetBalanceError, GetNonceAccountError, GetRecentBlockError, - GetTransactionError, NonceAccount, SubmitTransactionError, get_balance, get_nonce_account, - get_recent_block, get_signature_statuses, get_transaction, submit_transaction, + Block, BlockHeight, GetBalanceError, GetMultipleAccountsError, GetNonceAccountError, + GetRecentBlockError, GetTransactionError, NonceAccount, SubmitTransactionError, + get_balance, get_balances, get_multiple_accounts_cycles, + get_multiple_accounts_response_bytes, get_nonce_account, get_recent_block, + get_signature_statuses, get_transaction, submit_transaction, submit_transaction_skipping_preflight, }, test_fixtures::{ @@ -76,6 +78,130 @@ mod get_balance_tests { } } +mod get_balances_tests { + use super::*; + use crate::test_fixtures::{address, get_multiple_accounts_result}; + use serde_json::json; + use sol_rpc_types::Lamport; + use solana_address::Address; + + type MultiRpcResult = sol_rpc_types::MultiRpcResult; + + #[tokio::test] + async fn should_read_the_finalized_balances_in_order_with_one_sized_call() { + init_state(); + let addresses: Vec<_> = (0..MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) + .map(address) + .collect(); + let balances: Vec> = (0..addresses.len() as Lamport) + .map(|index| (index % 3 != 0).then_some(index)) + .collect(); + let runtime = TestCanisterRuntime::new().add_get_multiple_accounts_response(&balances); + + let result = get_balances(&runtime, &addresses).await; + + let expected: Vec = balances + .iter() + .map(|balance| balance.unwrap_or(0)) + .collect(); + assert_eq!(result, Ok(expected)); + let [call] = runtime.sent_update_calls().try_into().unwrap(); + assert_eq!(call.method, "jsonRequest"); + assert_eq!( + call.cycles, + get_multiple_accounts_cycles(MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) + ); + let (_sources, config, request): (RpcSources, Option, String) = call.args(); + assert_eq!( + config.and_then(|config| config.response_size_estimate), + Some(get_multiple_accounts_response_bytes( + MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS + )) + ); + assert_eq!( + serde_json::from_str::(&request).unwrap(), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getMultipleAccounts", + "params": [ + addresses.iter().map(Address::to_string).collect::>(), + { + "commitment": "finalized", + "encoding": "base64", + "dataSlice": { "offset": 0, "length": 0 } + } + ] + }) + ); + } + + #[tokio::test] + #[should_panic(expected = "BUG: a getMultipleAccounts call reads between 1 and 100 addresses")] + async fn should_panic_on_more_addresses_than_one_call_reads() { + init_state(); + let addresses: Vec<_> = (0..=MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) + .map(address) + .collect(); + + let _ = get_balances(&TestCanisterRuntime::new(), &addresses).await; + } + + #[tokio::test] + async fn should_fail_if_call_fails_or_results_are_wrong() { + init_state(); + let rpc_error = RpcError::ValidationError("Error 1".to_string()); + let inconsistent = vec![( + RpcSource::Supported(SupportedRpcProviderId::AnkrMainnet), + Err(rpc_error.clone()), + )]; + + for (runtime, expected) in [ + ( + TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed), + GetMultipleAccountsError::IcError(IcError::CallPerformFailed), + ), + ( + TestCanisterRuntime::new() + .add_stub_response(MultiRpcResult::Consistent(Err(rpc_error.clone()))), + GetMultipleAccountsError::RpcError(rpc_error.clone()), + ), + ( + TestCanisterRuntime::new() + .add_stub_response(MultiRpcResult::Inconsistent(inconsistent.clone())), + GetMultipleAccountsError::InconsistentRpcResults, + ), + ( + TestCanisterRuntime::new() + .add_stub_response(MultiRpcResult::Consistent(Ok("not json".to_string()))), + GetMultipleAccountsError::UnexpectedResponse( + "expected ident at line 1 column 2".to_string(), + ), + ), + ( + TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( + json!({ "context": { "slot": 1 } }).to_string(), + ))), + GetMultipleAccountsError::UnexpectedResponse( + "missing field `value` at line 1 column 22".to_string(), + ), + ), + ( + TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( + get_multiple_accounts_result(&[Some(1), Some(2)]), + ))), + GetMultipleAccountsError::UnexpectedResponse( + "expected 1 accounts, got 2".to_string(), + ), + ), + ] { + let result = get_balances(&runtime, &[address(1)]).await; + + assert_eq!(result, Err(expected)); + } + } +} + // TODO DEFI-2643: Test behavior with cycles mod get_transaction_tests { use super::*; diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 087ef463e..eecd7ad1d 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -20,6 +20,7 @@ use cksol_types_internal::{Ed25519KeyName, InitArgs, SolanaNetwork}; use ic_cdk_management_canister::SchnorrPublicKeyResult; use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; use icrc_ledger_types::icrc1::account::Account; +use serde_json::json; use sol_rpc_types::{Lamport, MultiRpcResult}; use solana_address::{Address, address}; use solana_nonce::{ @@ -195,6 +196,30 @@ pub fn register(watchlist: &mut Watchlist, now: Timestamp, account: Account) -> .expect("BUG: the watchlist has room for the registration") } +/// The `result` of a `getMultipleAccounts` response with the given balances in order, a +/// missing account for `None`, as the SOL RPC canister hands it back. +pub fn get_multiple_accounts_result(balances: &[Option]) -> String { + let accounts: Vec = balances + .iter() + .map(|balance| match balance { + Some(lamports) => json!({ + "data": ["", "base64"], + "executable": false, + "lamports": lamports, + "owner": "11111111111111111111111111111111", + "rentEpoch": 18446744073709551615_u64, + "space": 0 + }), + None => serde_json::Value::Null, + }) + .collect(); + json!({ + "context": { "apiVersion": "2.0.15", "slot": 341197053 }, + "value": accounts + }) + .to_string() +} + /// Returns a [`Signature`] unique for any `usize` index, derived from `i as u64` via le_bytes. pub fn signature(i: usize) -> solana_signature::Signature { let mut bytes = [0u8; 64]; diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 8f4655312..a51030c67 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,5 +1,5 @@ use super::{ - confirmed_block, + confirmed_block, get_multiple_accounts_result, signer::{MockSchnorrSigner, SignerExpectation, sign_for}, stubs::Stubs, }; @@ -17,7 +17,8 @@ use ic_cdk_management_canister::{SchnorrPublicKeyArgs, SchnorrPublicKeyResult}; use icrc_ledger_types::icrc1::account::Account; use serde::de::DeserializeOwned; use sol_rpc_types::{ - MultiRpcResult, RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, Slot, + Lamport, MultiRpcResult, RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, + Slot, }; use std::{ future::Future, @@ -57,6 +58,14 @@ impl TestCanisterRuntime { TransactionBuilder::new(self, fee_payer, transaction_signature) } + /// Stubs the `getMultipleAccounts` call of a scan with the given balances in order, a + /// missing account for `None`. + pub fn add_get_multiple_accounts_response(self, balances: &[Option]) -> Self { + self.add_stub_response(MultiRpcResult::::Consistent(Ok( + get_multiple_accounts_result(balances), + ))) + } + pub fn add_stub_response(mut self, response: Out) -> Self { self.inter_canister_call_runtime.stub = self .inter_canister_call_runtime @@ -252,6 +261,7 @@ struct RecordingStubRuntime { #[derive(Clone, Debug, PartialEq)] pub struct SentUpdateCall { pub method: String, + pub cycles: u128, args: Vec, } @@ -283,6 +293,7 @@ impl Runtime for RecordingStubRuntime { { self.sent_update_calls.lock().unwrap().push(SentUpdateCall { method: method.to_string(), + cycles, args: encode_args(args).expect("Failed to encode the call arguments"), }); self.stub.update_call(id, method, (), cycles).await From 2d4ecc91e025343e12d9cb822b01adc67a0b7f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:59:22 +0000 Subject: [PATCH 072/146] build: pin the SOL RPC crates to the commit adding getMultipleAccounts The typed `getMultipleAccounts` of dfinity/sol-rpc-canister#394 is not released yet, so the client and types crates are patched to that commit until it is. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 96 +++++++++++++++++++++++++++++++----------------------- Cargo.toml | 4 +++ 2 files changed, 60 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd29f19f4..ce6a19e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,6 +476,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -961,7 +967,7 @@ dependencies = [ "sol_rpc_types", "solana-address 2.3.0", "solana-client", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-keypair", "solana-message", "solana-native-token", @@ -1062,7 +1068,7 @@ dependencies = [ "sol_rpc_types", "solana-account-decoder-client-types", "solana-address 2.3.0", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-message", "solana-nonce", "solana-sdk-ids", @@ -3774,9 +3780,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pbkdf2" @@ -4269,9 +4275,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4926,9 +4932,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5156,8 +5162,7 @@ dependencies = [ [[package]] name = "sol_rpc_client" version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445b6d559df7d036c4153693df9fcb1431a24f563a56f90bfdaf5616a706b13b" +source = "git+https://github.com/dfinity/sol-rpc-canister?rev=92efe4eefc6b0209ee33440aa903383144016cf6#92efe4eefc6b0209ee33440aa903383144016cf6" dependencies = [ "bincode", "candid", @@ -5168,7 +5173,7 @@ dependencies = [ "serde_json", "sol_rpc_types", "solana-account-decoder-client-types", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-instruction", "solana-nonce", "solana-pubkey 4.1.0", @@ -5178,17 +5183,16 @@ dependencies = [ "solana-system-interface 3.1.0", "solana-transaction-error", "solana-transaction-status-client-types", - "strum 0.27.2", + "strum 0.28.0", "thiserror 2.0.18", ] [[package]] name = "sol_rpc_types" version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd926f8ab58e08842d6d67133ef4b5401f1c5c5de6d073d68bba2c2b3502dbb" +source = "git+https://github.com/dfinity/sol-rpc-canister?rev=92efe4eefc6b0209ee33440aa903383144016cf6#92efe4eefc6b0209ee33440aa903383144016cf6" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bincode", "candid", "canlog", @@ -5200,7 +5204,7 @@ dependencies = [ "serde_json", "solana-account-decoder-client-types", "solana-commitment-config", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-instruction", "solana-pubkey 4.1.0", "solana-reward-info", @@ -5208,7 +5212,7 @@ dependencies = [ "solana-transaction", "solana-transaction-error", "solana-transaction-status-client-types", - "strum 0.27.2", + "strum 0.28.0", "thiserror 2.0.18", "url", ] @@ -5328,7 +5332,7 @@ dependencies = [ "solana-program-error", "solana-sanitize", "solana-sha256-hasher", - "wincode", + "wincode 0.4.5", ] [[package]] @@ -5455,7 +5459,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a494cf8eda7d98d9f0144b288bb409c88308d2e86f15cc1045aa77b83304718" dependencies = [ - "solana-hash 4.2.0", + "solana-hash 4.7.0", ] [[package]] @@ -5583,7 +5587,7 @@ checksum = "f5e7b0ba210593ba8ddd39d6d234d81795d1671cebf3026baa10d5dc23ac42f0" dependencies = [ "serde", "serde_derive", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-sdk-ids", "solana-sdk-macro", "solana-sysvar-id", @@ -5632,16 +5636,15 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "337c246447142f660f778cf6cb582beba8e28deb05b3b24bfb9ffd7c562e5f41" dependencies = [ - "solana-hash 4.2.0", + "solana-hash 4.7.0", ] [[package]] name = "solana-hash" -version = "4.2.0" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8064ea1d591ec791be95245058ca40f4f5345d390c200069d0f79bbf55bfae55" +checksum = "c7507df0d2c6350053d125e3df200976fdc68d2a942637d11ac657fe7220949a" dependencies = [ - "borsh", "bytemuck", "bytemuck_derive", "five8", @@ -5649,7 +5652,6 @@ dependencies = [ "serde_derive", "solana-atomic-u64", "solana-sanitize", - "wincode", ] [[package]] @@ -5763,7 +5765,7 @@ dependencies = [ "serde", "serde_derive", "solana-address 2.3.0", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-instruction", "solana-sanitize", "solana-sdk-ids", @@ -5834,7 +5836,7 @@ dependencies = [ "serde", "serde_derive", "solana-fee-calculator", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-pubkey 4.1.0", "solana-sha256-hasher", ] @@ -6250,7 +6252,7 @@ checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" dependencies = [ "sha2 0.10.9", "solana-define-syscall 4.0.1", - "solana-hash 4.2.0", + "solana-hash 4.7.0", ] [[package]] @@ -6264,9 +6266,9 @@ dependencies = [ [[package]] name = "solana-signature" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132a93134f1262aa832f1849b83bec6c9945669b866da18661a427943b9e801e" +checksum = "b0364c7577c3c82a693ce28a1febc8d1b5d1b0a175fdc2114ae6186b69effe1e" dependencies = [ "ed25519-dalek", "five8", @@ -6274,7 +6276,7 @@ dependencies = [ "serde-big-array", "serde_derive", "solana-sanitize", - "wincode", + "wincode 0.5.2", ] [[package]] @@ -6296,7 +6298,7 @@ checksum = "2585f70191623887329dfb5078da3a00e15e3980ea67f42c2e10b07028419f43" dependencies = [ "serde", "serde_derive", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-sdk-ids", "solana-sysvar-id", ] @@ -6468,7 +6470,7 @@ dependencies = [ "solana-epoch-rewards", "solana-epoch-schedule", "solana-fee-calculator", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-instruction", "solana-last-restart-slot", "solana-program-entrypoint", @@ -6556,7 +6558,7 @@ dependencies = [ "serde", "serde_derive", "solana-address 2.3.0", - "solana-hash 4.2.0", + "solana-hash 4.7.0", "solana-instruction", "solana-instruction-error", "solana-message", @@ -6964,6 +6966,7 @@ dependencies = [ "cfg-if", "libc", "psm", + "windows-sys 0.52.0", "windows-sys 0.59.0", ] @@ -7021,11 +7024,11 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.27.2", + "strum_macros 0.28.0", ] [[package]] @@ -7043,9 +7046,9 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -7158,7 +7161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8024,6 +8027,19 @@ dependencies = [ "wincode-derive", ] +[[package]] +name = "wincode" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f42dd20febad683d07044c5f543e57f822512ebebaf2c827705c99a0ad4575" +dependencies = [ + "pastey", + "proc-macro2", + "quote", + "thiserror 2.0.18", + "wincode-derive", +] + [[package]] name = "wincode-derive" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index f39fe5910..e8c5c677a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,3 +83,7 @@ debug = false lto = true strip = true opt-level = 's' + +[patch.crates-io] +sol_rpc_client = { git = "https://github.com/dfinity/sol-rpc-canister", rev = "92efe4eefc6b0209ee33440aa903383144016cf6" } +sol_rpc_types = { git = "https://github.com/dfinity/sol-rpc-canister", rev = "92efe4eefc6b0209ee33440aa903383144016cf6" } From de73408f4dd992f588014096839bdb29e1d594cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:59:23 +0000 Subject: [PATCH 073/146] refactor(minter): read the deposit address balances with the typed getMultipleAccounts Co-Authored-By: Claude Fable 5.1 --- minter/Cargo.toml | 2 +- minter/src/constants.rs | 14 +++--- minter/src/rpc/mod.rs | 67 +++++++++-------------------- minter/src/rpc/tests.rs | 66 +++++++++++----------------- minter/src/test_fixtures/mod.rs | 38 +++++++--------- minter/src/test_fixtures/runtime.rs | 8 ++-- 6 files changed, 73 insertions(+), 122 deletions(-) diff --git a/minter/Cargo.toml b/minter/Cargo.toml index d6af7359f..11a661ccc 100644 --- a/minter/Cargo.toml +++ b/minter/Cargo.toml @@ -44,7 +44,6 @@ num-traits = { workspace = true } phantom_newtype = { workspace = true } scopeguard = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } sol_rpc_client = { workspace = true } sol_rpc_types = { workspace = true } solana-account-decoder-client-types = { workspace = true } @@ -70,5 +69,6 @@ ic-agent = { workspace = true } mockall = { workspace = true } proptest = { workspace = true } scraper = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 613fae8fb..7a01ca6c5 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -60,11 +60,11 @@ pub const MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS: usize = 100; /// Cycles to attach for a `getMultipleAccounts` RPC call reading one address, /// on top of [`GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS`] for every address. /// -/// The SOL RPC canister has no typed method for `getMultipleAccounts` yet, so -/// the call goes through its generic `jsonRequest` endpoint, priced like -/// `getBalance` for a single address: about 2.3B cycles with the default -/// 3-out-of-4 provider consensus. The attached amount leaves a wide margin -/// for provider or price changes; the unused part is refunded. +/// The SOL RPC canister prices the call like `getBalance` for a single +/// address: about 2.3B cycles with the default 3-out-of-4 provider consensus. +/// The attached amount, which the SOL RPC client attaches by default too, +/// leaves a wide margin for provider or price changes; the unused part is +/// refunded. pub const GET_MULTIPLE_ACCOUNTS_BASE_CYCLES: u128 = 10_000_000_000; /// Cycles to attach per address of a `getMultipleAccounts` RPC call, covering @@ -82,7 +82,9 @@ pub const GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES: u64 = 128 + SOL_RPC_RESPONS /// The bytes one account without data adds to a `getMultipleAccounts` JSON-RPC /// response, at the largest lamport amount and rent epoch. The response size /// estimate passed to the SOL RPC canister is sized by the number of addresses -/// so that the canister does not retry the call with doubled estimates. +/// so that the canister does not retry the call with doubled estimates, and +/// stays below its own estimate, which allows 512 bytes per account for the +/// account data the scan does not ask for. pub const GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS: u64 = 160; /// The allowance for the HTTP headers of a response that the SOL RPC canister diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index e47c459dc..a876e5d16 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -13,11 +13,9 @@ use cksol_types::DepositSolError; use derive_more::From; use ic_canister_runtime::IcError; use minicbor::{Decode, Encode}; -use serde::Deserialize; -use serde_json::json; use sol_rpc_types::{ - CommitmentLevel, GetAccountInfoEncoding, GetTransactionEncoding, Lamport, MultiRpcResult, - RpcError, Slot, + CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetTransactionEncoding, Lamport, + MultiRpcResult, RpcError, Slot, }; use solana_account_decoder_client_types::UiAccount; use solana_address::Address; @@ -163,13 +161,8 @@ impl From for DepositSolError { } /// Reads the balances of the given addresses, in their order, at the `finalized` -/// commitment level with a single `getMultipleAccounts` call, treating a missing -/// account as a zero balance. -/// -/// The SOL RPC canister offers no typed `getMultipleAccounts` method yet, so the call goes -/// through its generic `jsonRequest` endpoint, asking for no account data, and the -/// `result` of the JSON-RPC response, which the SOL RPC canister hands back as it is, -/// is parsed here. +/// commitment level with a single `getMultipleAccounts` call asking for no account data, +/// treating a missing account as a zero balance. /// /// # Panics /// @@ -191,40 +184,31 @@ pub async fn get_balances( "BUG: a getMultipleAccounts call reads between 1 and {MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS} addresses, not {}", addresses.len() ); - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "getMultipleAccounts", - "params": [ - addresses.iter().map(Address::to_string).collect::>(), - { - "commitment": "finalized", - "encoding": "base64", - "dataSlice": { "offset": 0, "length": 0 } - } - ] - }); let result = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) - .json_request(request) + .get_multiple_accounts(addresses) + .expect("BUG: the SOL RPC client accepts up to 100 addresses, which the caller checks") + .with_commitment(CommitmentLevel::Finalized) + .with_encoding(GetAccountInfoEncoding::Base64) + .with_data_slice(DataSlice { + length: 0, + offset: 0, + }) .with_response_size_estimate(get_multiple_accounts_response_bytes(addresses.len())) .with_cycles(get_multiple_accounts_cycles(addresses.len())) .try_send() .await; - let response = match result? { - MultiRpcResult::Consistent(Ok(response)) => response, + let accounts = match result? { + MultiRpcResult::Consistent(Ok(accounts)) => accounts, MultiRpcResult::Consistent(Err(e)) => return Err(GetMultipleAccountsError::RpcError(e)), MultiRpcResult::Inconsistent(_) => { return Err(GetMultipleAccountsError::InconsistentRpcResults); } }; - let GetMultipleAccountsResult { value: accounts } = serde_json::from_str(&response) - .map_err(|e| GetMultipleAccountsError::UnexpectedResponse(e.to_string()))?; if accounts.len() != addresses.len() { - return Err(GetMultipleAccountsError::UnexpectedResponse(format!( - "expected {} accounts, got {}", - addresses.len(), - accounts.len() - ))); + return Err(GetMultipleAccountsError::UnexpectedAccountCount { + expected: addresses.len(), + actual: accounts.len(), + }); } Ok(accounts .into_iter() @@ -246,16 +230,6 @@ fn get_multiple_accounts_response_bytes(num_addresses: usize) -> u64 { + GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS * num_addresses as u64 } -#[derive(Deserialize)] -struct GetMultipleAccountsResult { - value: Vec>, -} - -#[derive(Deserialize)] -struct AccountLamports { - lamports: Lamport, -} - #[derive(Debug, PartialEq, Error, From)] pub enum GetMultipleAccountsError { #[error("Error while calling SOL RPC canister: {0}")] @@ -264,9 +238,8 @@ pub enum GetMultipleAccountsError { RpcError(RpcError), #[error("Inconsistent RPC results for getMultipleAccounts")] InconsistentRpcResults, - #[error("Unexpected getMultipleAccounts response: {0}")] - #[from(ignore)] - UnexpectedResponse(String), + #[error("Unexpected getMultipleAccounts response: expected {expected} accounts, got {actual}")] + UnexpectedAccountCount { expected: usize, actual: usize }, } pub async fn submit_transaction( diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 4c92a8984..1f3c23ce1 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -80,12 +80,12 @@ mod get_balance_tests { mod get_balances_tests { use super::*; - use crate::test_fixtures::{address, get_multiple_accounts_result}; - use serde_json::json; - use sol_rpc_types::Lamport; - use solana_address::Address; + use crate::test_fixtures::address; + use sol_rpc_types::{ + CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetMultipleAccountsParams, Lamport, + }; - type MultiRpcResult = sol_rpc_types::MultiRpcResult; + type MultiRpcResult = sol_rpc_types::MultiRpcResult>>; #[tokio::test] async fn should_read_the_finalized_balances_in_order_with_one_sized_call() { @@ -106,12 +106,13 @@ mod get_balances_tests { .collect(); assert_eq!(result, Ok(expected)); let [call] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(call.method, "jsonRequest"); + assert_eq!(call.method, "getMultipleAccounts"); assert_eq!( call.cycles, get_multiple_accounts_cycles(MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) ); - let (_sources, config, request): (RpcSources, Option, String) = call.args(); + let (_sources, config, params): (RpcSources, Option, GetMultipleAccountsParams) = + call.args(); assert_eq!( config.and_then(|config| config.response_size_estimate), Some(get_multiple_accounts_response_bytes( @@ -119,20 +120,17 @@ mod get_balances_tests { )) ); assert_eq!( - serde_json::from_str::(&request).unwrap(), - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "getMultipleAccounts", - "params": [ - addresses.iter().map(Address::to_string).collect::>(), - { - "commitment": "finalized", - "encoding": "base64", - "dataSlice": { "offset": 0, "length": 0 } - } - ] - }) + params, + GetMultipleAccountsParams { + pubkeys: addresses.iter().collect::>().try_into().unwrap(), + commitment: Some(CommitmentLevel::Finalized), + encoding: Some(GetAccountInfoEncoding::Base64), + data_slice: Some(DataSlice { + length: 0, + offset: 0 + }), + min_context_slot: None, + } ); } @@ -172,27 +170,11 @@ mod get_balances_tests { GetMultipleAccountsError::InconsistentRpcResults, ), ( - TestCanisterRuntime::new() - .add_stub_response(MultiRpcResult::Consistent(Ok("not json".to_string()))), - GetMultipleAccountsError::UnexpectedResponse( - "expected ident at line 1 column 2".to_string(), - ), - ), - ( - TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( - json!({ "context": { "slot": 1 } }).to_string(), - ))), - GetMultipleAccountsError::UnexpectedResponse( - "missing field `value` at line 1 column 22".to_string(), - ), - ), - ( - TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok( - get_multiple_accounts_result(&[Some(1), Some(2)]), - ))), - GetMultipleAccountsError::UnexpectedResponse( - "expected 1 accounts, got 2".to_string(), - ), + TestCanisterRuntime::new().add_get_multiple_accounts_response(&[Some(1), Some(2)]), + GetMultipleAccountsError::UnexpectedAccountCount { + expected: 1, + actual: 2, + }, ), ] { let result = get_balances(&runtime, &[address(1)]).await; diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index eecd7ad1d..10d162283 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -20,13 +20,13 @@ use cksol_types_internal::{Ed25519KeyName, InitArgs, SolanaNetwork}; use ic_cdk_management_canister::SchnorrPublicKeyResult; use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; use icrc_ledger_types::icrc1::account::Account; -use serde_json::json; -use sol_rpc_types::{Lamport, MultiRpcResult}; +use sol_rpc_types::{AccountData, AccountEncoding, AccountInfo, Lamport, MultiRpcResult}; use solana_address::{Address, address}; use solana_nonce::{ state::{Data as NonceData, DurableNonce, State as NonceState}, versions::Versions as NonceVersions, }; +use solana_sdk_ids::system_program; use solana_transaction_status_client_types::{ EncodedConfirmedTransactionWithStatusMeta, EncodedTransaction, EncodedTransactionWithStatusMeta, TransactionBinaryEncoding, UiLoadedAddresses, @@ -196,28 +196,22 @@ pub fn register(watchlist: &mut Watchlist, now: Timestamp, account: Account) -> .expect("BUG: the watchlist has room for the registration") } -/// The `result` of a `getMultipleAccounts` response with the given balances in order, a -/// missing account for `None`, as the SOL RPC canister hands it back. -pub fn get_multiple_accounts_result(balances: &[Option]) -> String { - let accounts: Vec = balances +/// The accounts of a `getMultipleAccounts` response with the given balances in order, a +/// missing account for `None`, each without data as the scan asks for them. +pub fn accounts_with_balances(balances: &[Option]) -> Vec> { + balances .iter() - .map(|balance| match balance { - Some(lamports) => json!({ - "data": ["", "base64"], - "executable": false, - "lamports": lamports, - "owner": "11111111111111111111111111111111", - "rentEpoch": 18446744073709551615_u64, - "space": 0 - }), - None => serde_json::Value::Null, + .map(|balance| { + balance.map(|lamports| AccountInfo { + lamports, + data: AccountData::Binary(String::new(), AccountEncoding::Base64), + owner: system_program::ID.to_string(), + executable: false, + rent_epoch: u64::MAX, + space: 0, + }) }) - .collect(); - json!({ - "context": { "apiVersion": "2.0.15", "slot": 341197053 }, - "value": accounts - }) - .to_string() + .collect() } /// Returns a [`Signature`] unique for any `usize` index, derived from `i as u64` via le_bytes. diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index a51030c67..0a8af73c5 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -1,5 +1,5 @@ use super::{ - confirmed_block, get_multiple_accounts_result, + accounts_with_balances, confirmed_block, signer::{MockSchnorrSigner, SignerExpectation, sign_for}, stubs::Stubs, }; @@ -61,9 +61,9 @@ impl TestCanisterRuntime { /// Stubs the `getMultipleAccounts` call of a scan with the given balances in order, a /// missing account for `None`. pub fn add_get_multiple_accounts_response(self, balances: &[Option]) -> Self { - self.add_stub_response(MultiRpcResult::::Consistent(Ok( - get_multiple_accounts_result(balances), - ))) + self.add_stub_response(MultiRpcResult::Consistent(Ok(accounts_with_balances( + balances, + )))) } pub fn add_stub_response(mut self, response: Out) -> Self { From 0b1e28a309d20546f64029e9ab2ea7cddeccb229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:59:23 +0000 Subject: [PATCH 074/146] test(integration-tests): run against the SOL RPC canister built from the pinned commit Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 3 +++ integration_tests/src/lib.rs | 18 +++++++++++++----- justfile | 4 ++++ scripts/build-sol-rpc | 25 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100755 scripts/build-sol-rpc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1670b40d4..2cbc4fe66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,9 @@ jobs: echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH shell: bash + - name: 'Build SOL RPC canister' + run: just sol-rpc-wasm + - name: 'Run integration tests' run: just integration-tests diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 0866685e3..2b823e074 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -178,7 +178,7 @@ impl Setup { env.add_cycles(sol_rpc_canister_id, u64::MAX as u128).await; env.install_canister( sol_rpc_canister_id, - sol_rpc_wasm().await, + sol_rpc_wasm(), Encode!(&sol_rpc_install_args).unwrap(), Some(Self::DEFAULT_CONTROLLER), ) @@ -903,10 +903,18 @@ fn cksol_minter_init_args( }) } -async fn sol_rpc_wasm() -> Vec { - const DOWNLOAD_PATH: &str = "../wasms/sol_rpc_canister.wasm.gz"; - const DOWNLOAD_URL: &str = "https://github.com/dfinity/sol-rpc-canister/releases/latest/download/sol_rpc_canister.wasm.gz"; - canister_wasm(DOWNLOAD_PATH, DOWNLOAD_URL).await +/// The SOL RPC canister built by `just sol-rpc-wasm` from the commit adding the typed +/// `getMultipleAccounts` method, until a release of the SOL RPC canister carries it. +fn sol_rpc_wasm() -> Vec { + const WASM_PATH: &str = "../wasms/sol_rpc_canister-92efe4ee.wasm.gz"; + + let path = PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join(WASM_PATH); + fs::read(&path).unwrap_or_else(|error| { + panic!( + "Failed to read the SOL RPC canister WASM at {}: {error}. Build it with `just sol-rpc-wasm`.", + path.display() + ) + }) } async fn ledger_wasm() -> Vec { diff --git a/justfile b/justfile index ac3b1ed3b..9c46e0d7a 100644 --- a/justfile +++ b/justfile @@ -20,6 +20,10 @@ build: docker-build: ./scripts/docker-build +# Build the SOL RPC canister WASM of the unreleased commit the integration tests need +sol-rpc-wasm: + ./scripts/build-sol-rpc + # Run all tests test: unit-tests integration-tests diff --git a/scripts/build-sol-rpc b/scripts/build-sol-rpc new file mode 100755 index 000000000..b8bc269e0 --- /dev/null +++ b/scripts/build-sol-rpc @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SOL_RPC_REV=92efe4eefc6b0209ee33440aa903383144016cf6 +SOL_RPC_REPO=https://github.com/dfinity/sol-rpc-canister.git + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT="$ROOT_DIR/wasms/sol_rpc_canister-${SOL_RPC_REV:0:8}.wasm.gz" + +if [ -f "$OUTPUT" ]; then + echo "SOL RPC canister already built at $OUTPUT" + exit 0 +fi + +CHECKOUT="$(mktemp -d)" +trap 'rm -rf "$CHECKOUT"' EXIT + +git clone --quiet "$SOL_RPC_REPO" "$CHECKOUT" +git -C "$CHECKOUT" checkout --quiet "$SOL_RPC_REV" +(cd "$CHECKOUT" && ./scripts/docker-build) + +mkdir -p "$ROOT_DIR/wasms" +cp "$CHECKOUT/wasms/sol_rpc_canister.wasm.gz" "$OUTPUT" +sha256sum "$OUTPUT" From 08ee723281de63a3da740f74ea20e78493f01a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 13:33:05 +0000 Subject: [PATCH 075/146] test(integration-tests): download the pinned SOL RPC canister instead of building it Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 3 --- integration_tests/src/lib.rs | 20 +++++++------------- justfile | 4 ---- scripts/build-sol-rpc | 25 ------------------------- 4 files changed, 7 insertions(+), 45 deletions(-) delete mode 100755 scripts/build-sol-rpc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cbc4fe66..1670b40d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,9 +240,6 @@ jobs: echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH shell: bash - - name: 'Build SOL RPC canister' - run: just sol-rpc-wasm - - name: 'Run integration tests' run: just integration-tests diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 2b823e074..84341a06d 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -178,7 +178,7 @@ impl Setup { env.add_cycles(sol_rpc_canister_id, u64::MAX as u128).await; env.install_canister( sol_rpc_canister_id, - sol_rpc_wasm(), + sol_rpc_wasm().await, Encode!(&sol_rpc_install_args).unwrap(), Some(Self::DEFAULT_CONTROLLER), ) @@ -903,18 +903,12 @@ fn cksol_minter_init_args( }) } -/// The SOL RPC canister built by `just sol-rpc-wasm` from the commit adding the typed -/// `getMultipleAccounts` method, until a release of the SOL RPC canister carries it. -fn sol_rpc_wasm() -> Vec { - const WASM_PATH: &str = "../wasms/sol_rpc_canister-92efe4ee.wasm.gz"; - - let path = PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join(WASM_PATH); - fs::read(&path).unwrap_or_else(|error| { - panic!( - "Failed to read the SOL RPC canister WASM at {}: {error}. Build it with `just sol-rpc-wasm`.", - path.display() - ) - }) +/// The SOL RPC canister built from the commit adding the typed `getMultipleAccounts` +/// method, until a release of the SOL RPC canister carries it. +async fn sol_rpc_wasm() -> Vec { + const DOWNLOAD_PATH: &str = "../wasms/sol_rpc_canister_92efe4ee.wasm.gz"; + const DOWNLOAD_URL: &str = "https://dfinity-download-public.s3.amazonaws.com/testdata/sol_rpc/sol_rpc_canister_92efe4eefc6b0209ee33440aa903383144016cf6.wasm.gz"; + canister_wasm(DOWNLOAD_PATH, DOWNLOAD_URL).await } async fn ledger_wasm() -> Vec { diff --git a/justfile b/justfile index 9c46e0d7a..ac3b1ed3b 100644 --- a/justfile +++ b/justfile @@ -20,10 +20,6 @@ build: docker-build: ./scripts/docker-build -# Build the SOL RPC canister WASM of the unreleased commit the integration tests need -sol-rpc-wasm: - ./scripts/build-sol-rpc - # Run all tests test: unit-tests integration-tests diff --git a/scripts/build-sol-rpc b/scripts/build-sol-rpc deleted file mode 100755 index b8bc269e0..000000000 --- a/scripts/build-sol-rpc +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SOL_RPC_REV=92efe4eefc6b0209ee33440aa903383144016cf6 -SOL_RPC_REPO=https://github.com/dfinity/sol-rpc-canister.git - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OUTPUT="$ROOT_DIR/wasms/sol_rpc_canister-${SOL_RPC_REV:0:8}.wasm.gz" - -if [ -f "$OUTPUT" ]; then - echo "SOL RPC canister already built at $OUTPUT" - exit 0 -fi - -CHECKOUT="$(mktemp -d)" -trap 'rm -rf "$CHECKOUT"' EXIT - -git clone --quiet "$SOL_RPC_REPO" "$CHECKOUT" -git -C "$CHECKOUT" checkout --quiet "$SOL_RPC_REV" -(cd "$CHECKOUT" && ./scripts/docker-build) - -mkdir -p "$ROOT_DIR/wasms" -cp "$CHECKOUT/wasms/sol_rpc_canister.wasm.gz" "$OUTPUT" -sha256sum "$OUTPUT" From b1899c043cc489d6f05866444db3059bfd2fa4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 13:50:18 +0000 Subject: [PATCH 076/146] test(minter): test only the minter's handling of the getMultipleAccounts response The parameters, the cycles and the response size estimate the SOL RPC client sends are its own to test, and the one account per requested address is the SOL RPC canister's contract, so the count check goes with its tests. Co-Authored-By: Claude Fable 5.1 --- minter/src/rpc/mod.rs | 25 +++++++--------------- minter/src/rpc/tests.rs | 46 ++++------------------------------------- 2 files changed, 11 insertions(+), 60 deletions(-) diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index a876e5d16..f5dce3027 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -197,23 +197,14 @@ pub async fn get_balances( .with_cycles(get_multiple_accounts_cycles(addresses.len())) .try_send() .await; - let accounts = match result? { - MultiRpcResult::Consistent(Ok(accounts)) => accounts, - MultiRpcResult::Consistent(Err(e)) => return Err(GetMultipleAccountsError::RpcError(e)), - MultiRpcResult::Inconsistent(_) => { - return Err(GetMultipleAccountsError::InconsistentRpcResults); - } - }; - if accounts.len() != addresses.len() { - return Err(GetMultipleAccountsError::UnexpectedAccountCount { - expected: addresses.len(), - actual: accounts.len(), - }); + match result? { + MultiRpcResult::Consistent(Ok(accounts)) => Ok(accounts + .into_iter() + .map(|account| account.map_or(0, |account| account.lamports)) + .collect()), + MultiRpcResult::Consistent(Err(e)) => Err(GetMultipleAccountsError::RpcError(e)), + MultiRpcResult::Inconsistent(_) => Err(GetMultipleAccountsError::InconsistentRpcResults), } - Ok(accounts - .into_iter() - .map(|account| account.map_or(0, |account| account.lamports)) - .collect()) } /// The cycles to attach to a `getMultipleAccounts` call reading `num_addresses` addresses. @@ -238,8 +229,6 @@ pub enum GetMultipleAccountsError { RpcError(RpcError), #[error("Inconsistent RPC results for getMultipleAccounts")] InconsistentRpcResults, - #[error("Unexpected getMultipleAccounts response: expected {expected} accounts, got {actual}")] - UnexpectedAccountCount { expected: usize, actual: usize }, } pub async fn submit_transaction( diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 1f3c23ce1..60bc42638 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -3,10 +3,8 @@ use crate::{ rpc::{ Block, BlockHeight, GetBalanceError, GetMultipleAccountsError, GetNonceAccountError, GetRecentBlockError, GetTransactionError, NonceAccount, SubmitTransactionError, - get_balance, get_balances, get_multiple_accounts_cycles, - get_multiple_accounts_response_bytes, get_nonce_account, get_recent_block, - get_signature_statuses, get_transaction, submit_transaction, - submit_transaction_skipping_preflight, + get_balance, get_balances, get_nonce_account, get_recent_block, get_signature_statuses, + get_transaction, submit_transaction, submit_transaction_skipping_preflight, }, test_fixtures::{ MINTER_ADDRESS, confirmed_block, confirmed_block_at_height, @@ -81,14 +79,12 @@ mod get_balance_tests { mod get_balances_tests { use super::*; use crate::test_fixtures::address; - use sol_rpc_types::{ - CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetMultipleAccountsParams, Lamport, - }; + use sol_rpc_types::Lamport; type MultiRpcResult = sol_rpc_types::MultiRpcResult>>; #[tokio::test] - async fn should_read_the_finalized_balances_in_order_with_one_sized_call() { + async fn should_read_the_balances_in_order_with_a_missing_account_as_zero() { init_state(); let addresses: Vec<_> = (0..MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) .map(address) @@ -105,33 +101,6 @@ mod get_balances_tests { .map(|balance| balance.unwrap_or(0)) .collect(); assert_eq!(result, Ok(expected)); - let [call] = runtime.sent_update_calls().try_into().unwrap(); - assert_eq!(call.method, "getMultipleAccounts"); - assert_eq!( - call.cycles, - get_multiple_accounts_cycles(MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) - ); - let (_sources, config, params): (RpcSources, Option, GetMultipleAccountsParams) = - call.args(); - assert_eq!( - config.and_then(|config| config.response_size_estimate), - Some(get_multiple_accounts_response_bytes( - MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS - )) - ); - assert_eq!( - params, - GetMultipleAccountsParams { - pubkeys: addresses.iter().collect::>().try_into().unwrap(), - commitment: Some(CommitmentLevel::Finalized), - encoding: Some(GetAccountInfoEncoding::Base64), - data_slice: Some(DataSlice { - length: 0, - offset: 0 - }), - min_context_slot: None, - } - ); } #[tokio::test] @@ -169,13 +138,6 @@ mod get_balances_tests { .add_stub_response(MultiRpcResult::Inconsistent(inconsistent.clone())), GetMultipleAccountsError::InconsistentRpcResults, ), - ( - TestCanisterRuntime::new().add_get_multiple_accounts_response(&[Some(1), Some(2)]), - GetMultipleAccountsError::UnexpectedAccountCount { - expected: 1, - actual: 2, - }, - ), ] { let result = get_balances(&runtime, &[address(1)]).await; From d1b1bf6a112507b3b4beb562cd9ad5b4d821cc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:34:09 +0000 Subject: [PATCH 077/146] feat(minter): scan the watched deposit addresses on a timer and queue the detected deposits Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 8 +- integration_tests/src/fixtures.rs | 37 +++ integration_tests/tests/tests.rs | 71 ++++- minter/src/constants.rs | 6 + minter/src/deposit/automatic/mod.rs | 4 + minter/src/deposit/automatic/scan/mod.rs | 176 +++++++++++ minter/src/deposit/automatic/scan/tests.rs | 321 +++++++++++++++++++++ minter/src/main.rs | 12 +- minter/src/metrics.rs | 14 +- minter/src/rpc/mod.rs | 7 - minter/src/state/mod.rs | 6 + minter/src/storage/mod.rs | 30 ++ 12 files changed, 676 insertions(+), 16 deletions(-) create mode 100644 minter/src/deposit/automatic/scan/mod.rs create mode 100644 minter/src/deposit/automatic/scan/tests.rs diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 364a8865e..f7028e112 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -163,13 +163,13 @@ The call does not contact the SOL RPC canister and requires no cycles. It is rej The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window, the start of its schedule and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*. The schedule of an entry is a fixed list of *slots*, measured from the start of its schedule and adapted from the ckETH minter: at 0, 60, 120, 240, 360 and 600 seconds, then every 300 seconds up to 1,800 seconds, i.e. ten slots in the first 30 minutes, followed by hourly slots from 1.5 hours up to 23.5 hours, 33 slots in all. An entry is due at its first slot if it has not been scanned yet, and otherwise once the first slot after its last scan has passed; a scan lands on the next tick at or after that slot. Since the slots are fixed, a scan that comes late, because a tick is skipped, a call fails or the ckSOL minter is stopped for an upgrade, does not shift the slots that follow: the entry is scanned once it is due, and its next slot is the first one after that scan, so slots that passed in the meantime are not made up and the number of scans may fall short of the number of slots. Nothing is lost by that, since a balance scan is idempotent: one read after an outage tells as much as the reads it replaces. The window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter; the last slot lies half an hour before its end, and a slot after the end of the window is never scanned, so the status then reports no next scan. The due time reported by the status is the next slot, so it does not move between two reads. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first slot. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window, the start of its schedule and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*. The schedule of an entry is a fixed list of *slots*, measured from the start of its schedule and adapted from the ckETH minter: at 0, 60, 120, 240, 360 and 600 seconds, then every 300 seconds up to 1,800 seconds, i.e. ten slots in the first 30 minutes, followed by hourly slots from 1.5 hours up to 23.5 hours, 33 slots in all. An entry is due at its first slot if it has not been scanned yet, and otherwise once the first slot after its last scan has passed; a scan lands on the next tick at or after that slot. Since the slots are fixed, a scan that comes late, because a tick is skipped or the ckSOL minter is stopped for an upgrade, does not shift the slots that follow: the entry is scanned once it is due, and its next slot is the first one after that scan, so slots that passed in the meantime are not made up and the number of scans may fall short of the number of slots. Nothing is lost by that, since a balance scan is idempotent: one read after an outage tells as much as the reads it replaces. The window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter; the last slot lies half an hour before its end, and a slot after the end of the window is never scanned, so the status then reports no next scan. The due time reported by the status is the next slot, so it does not move between two reads. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first slot. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is registered again, for a fresh window and schedule starting at the drop, provided the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The fresh schedule starts with the burst, since the scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is not bounded by the original window, so a sweep that keeps failing is detected and re-armed again until it lands, at the ckSOL minter's expense; this is accepted because a dropped sweep is an infrastructure failure that is alerted on and that no user can bring about, and because a bound at the original window would only have capped such a chain at a day while costing every queued deposit its window end. An account on the watchlist never has a deposit in flight. -**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries. A call that fails leaves the progress of its entries untouched, and they are read again at the next tick. +**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call and the response size estimate passed with it, which the SOL RPC canister uses as the response size limit of the call, allowance for the HTTP headers included, are sized by the number of addresses it carries. Pending a typed `getMultipleAccounts` method of the SOL RPC canister, the call goes through its generic `jsonRequest` endpoint, which hands back the bare JSON-RPC `result`, parsed by the ckSOL minter into one balance per address. A call that fails counts as a scan of its entries, which advance to their next slot as after a balance below the minimum, so that a persistent failure of the SOL RPC canister costs the ckSOL minter no more than the schedule and costs the user the scans lost to the failure. An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. @@ -177,7 +177,7 @@ An address whose balance is at or above the minimum deposit amount in force at t **Fee.** An automatically detected deposit pays the **automatic deposit fee** of [Section 3.3.2](#332-cksol-minter-fees) in SOL: the amount minted is the sweepable amount minus the deposit's share of the sweep transaction fee minus the automatic deposit fee, while the main account receives the sweepable amount minus that fee share, as for any sweep. The main account therefore holds the automatic deposit fee on top of the ckSOL minted for the deposit. The ckSOL minter keeps the sum of the fees collected this way as a separate figure, reported by `get_minter_info`, in the metrics and on the dashboard; the main account balance keeps counting it. What to do with this surplus, e.g. converting it into cycles, is out of scope. Minting the fee to a minter-owned account on the ledger, as the ckETH minter does, would keep the ckSOL supply equal to the SOL backing and make the revenue auditable on the ledger; it is not chosen here, so that the fee can be used without a burn. -**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached in two ways. An adversary can churn the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans only after a detection. And while the SOL RPC canister fails persistently, the entries keep their progress and every tick retries; that situation is not under an adversary's control and affects every timer of the ckSOL minter alike, so no retry budget is added for it. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. +**Cost bounds.** Since registration is free, the cost of the automatic flow to the ckSOL minter must be bounded independently of the callers' behaviour. The bounds are the capacity of the watchlist, the window and the schedule, the chunk size of 100 addresses per call and, above all, the tick: every minute in which at least one entry is due costs at least one call. In the worst case, with the watchlist kept full by an adversary, a day costs at most 1,440 ticks plus 1,000 × 33 / 100 chunk calls, about 1,770 calls, i.e. at most 8T cycles (about 11.5 USD at 1T cycles = 1.44 USD) if every call were a full 100-address call, and about 4 to 5T cycles in practice, since most calls are small. This is the bound for registrations that never receive funds. The caller-independent hard bound is a full tick every minute, `MAX_CONCURRENT_RPC_CALLS` × 1,440 = 14,400 calls per day, about 65T cycles. It is reached only by churning the watchlist with funded addresses, having 1,000 deposits detected and their slots refilled every minute, but every detection needs at least the minimum deposit amount on chain and pays the automatic deposit fee, so that attack pays the ckSOL minter far more than it costs it; the same holds for the re-arms, which add scans only after a detection. The bounds hold whether the calls succeed or fail: a failed call advances its entries along the schedule like a read below the minimum, so a persistent failure of the SOL RPC canister costs at most the 33 scans of each entry rather than a retry at every tick, and no retry budget is needed. An adversary needs only about 60 addresses registered a minute apart to keep every tick busy, for about 3.3T cycles per day, and can repeat this every day for free. The honest case costs about the same once a few dozen addresses are watched, since the tick dominates: with N addresses in the hourly tail, a tick runs roughly min(60, N) times per hour. Should this standing cost prove too high, the knob is to coalesce ticks, scanning only when enough entries are due or when the oldest due entry has waited for a grace period, at the price of latency. For comparison, reading each address with `getBalance` would cost 1,000 × 33 × 2.3B = 76T cycles per day under the same attack, which is why `getMultipleAccounts` is a prerequisite of this flow rather than an optimisation. The scan adds at most `MAX_CONCURRENT_RPC_CALLS` in-flight calls to the SOL RPC canister to those of the other timers; each of them is fanned out by that canister into one HTTPS outcall per provider of the consensus round. 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. @@ -555,7 +555,7 @@ Using the [cost estimation endpoints](https://dashboard.internetcomputer.org/can - `getSignatureStatuses`: 2.3B (1 sig.), 10.7B (256 sig., the largest batch) cycles - `getMultipleAccounts`: about 2.3B (1 address) to 4.5B (100 addresses) cycles, assuming about 150 bytes of response per address and the same base cost as `getBalance`; to be measured once the SOL RPC canister offers the method -The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case with every call succeeding, the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt without failed calls: a scan that fails is retried at the next tick, up to the hard bound of [Section 3.1.1](#311-automatic-flow), and a sweep that fails or expires and is re-armed, as described there, costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs these costs like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt without failed calls with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. +The **automatic deposit fee** covers what the automatic flow of [Section 3.1.1](#311-automatic-flow) costs the ckSOL minter for one deposit, which is the cost of the manual flow, paid in cycles by the caller of `deposit_sol`, plus the balance scans, which nobody pays for at call time. In the worst case the watched address is alone on the watchlist and scanned 33 times before the deposit arrives, each scan being a `getMultipleAccounts` call with a single address, i.e. about 76B cycles, while an address sharing full 100-address calls costs about 1.5B cycles of scans. Added to the threshold signature of 26.2B cycles and the roughly 12.3B cycles of RPC calls of the sweep and its finalization, which the `deposit_sol` fee covers in the manual flow, the worst case comes to about 115B cycles, i.e. 0.115 XDR = 0.166 USD = 0.00166 SOL at 1 SOL = 100 USD. This is the cost of a single attempt, whether its scans succeed or fail, since a failed scan counts as one of the 33: a sweep that fails or expires and is re-armed, as described in [Section 3.1.1](#311-automatic-flow), costs another round of scans, another threshold signature and another submission and finalization, while the fee is collected once, when an attempt settles; the ckSOL minter absorbs that cost like the retries of the manual flow listed below. The fee is set to **0.002 SOL** (2,000,000 lamports), which covers the solo worst case of one attempt with a small margin and overcharges the batched case; the collected fees are reported by the minter, so that the value can be recalibrated by proposal once real scan sizes are known. The standing cost of the scan timer itself, which is dominated by the tick rate rather than by the number of watched addresses (see [Section 3.1.1](#311-automatic-flow)), is not covered by the fee and is borne by the ckSOL minter. The **manual deposit fee** is not a parameter but the depositor's share of the shortfall between what the sweep transaction moved and what arrived on the main account, derived from the finalized transaction as described in [Section 3.1.2](#312-manual-flow). Under the current fee schedule that shortfall is the transaction fee of `5000 * k` lamports for `k` signatures, so the share is `ceil(5000 * k / k) = 5000` lamports. In addition, the depositor leaves the rent exemption threshold of 890,880 lamports on the deposit address the first time it is swept. The cycles consumed by the manual flow are charged to the caller of `deposit_sol`: the cost of the `getBalance` call, roughly 2.31B cycles, plus the **deposit_sol fee**. The latter must cover the threshold signature of 26.2B cycles and, for a sweep containing a single deposit, all the RPC calls of the sweep and finalization timers, i.e., `getSlot`, `getBlock`, `sendTransaction`, `getSignatureStatuses`, and `getTransaction`, for about 12.3B cycles when each of them succeeds on its first attempt. A deposit_sol fee of **45B cycles** covers that case. diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index 55293976e..a8b84769e 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -146,6 +146,15 @@ impl MockBuilder { self.expect(get_balance_request(), get_balance_response(balance)) } + /// Mock for the `getMultipleAccounts` call of the scan timer, returning the given + /// balances, in order, for the addresses it reads. + pub fn get_multiple_accounts(self, balances: &[Lamport]) -> Self { + self.expect( + get_multiple_accounts_request(), + get_multiple_accounts_response(balances), + ) + } + /// Mocks for a timer submitting a transaction built on the block at `block_height`: /// `getSlot` → `getBlock` → `sendTransaction`. pub fn submit_transaction(self, block_height: u64) -> Self { @@ -365,6 +374,34 @@ fn get_balance_request() -> JsonRpcRequestMatcher { JsonRpcRequestMatcher::with_method("getBalance") } +fn get_multiple_accounts_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("getMultipleAccounts") +} + +fn get_multiple_accounts_response(balances: &[Lamport]) -> JsonRpcResponse { + let accounts: Vec = balances + .iter() + .map(|lamports| { + json!({ + "data": ["", "base64"], + "executable": false, + "lamports": lamports, + "owner": "11111111111111111111111111111111", + "rentEpoch": 18446744073709551615_u64, + "space": 0 + }) + }) + .collect(); + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "result": { + "context": { "apiVersion": "2.0.15", "slot": 341_197_053 }, + "value": accounts + }, + "id": 1 + })) +} + fn get_balance_response(balance: Lamport) -> JsonRpcResponse { JsonRpcResponse::from(json!({ "jsonrpc": "2.0", diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index adac071d1..7d3f26060 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -16,7 +16,7 @@ use cksol_types::{ }; use cksol_types_internal::{ UpgradeArgs, - event::{EventType, TransactionPurpose}, + event::{AutomaticDeposit, EventType, TransactionPurpose}, log::Priority, }; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; @@ -1229,6 +1229,9 @@ mod deposit_sol_tests { mod automatic_deposit_sol_tests { use super::*; + const SCAN_WATCHED_ADDRESSES_DELAY: Duration = Duration::from_mins(1); + const BALANCE_ABOVE_MINIMUM: Lamport = Setup::DEFAULT_MINIMUM_DEPOSIT_AMOUNT + 1; + #[tokio::test] async fn should_register_the_caller() { let setup = SetupBuilder::new().build().await; @@ -1311,6 +1314,72 @@ mod automatic_deposit_sol_tests { setup.drop().await; } + + #[tokio::test] + async fn should_queue_the_deposit_detected_by_the_scan_timer() { + let setup = SetupBuilder::new().build().await; + let registration = setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should register the caller"); + let valid_until = assert_matches!( + registration.status, + WatchDepositAddressStatus::Watching { valid_until, .. } => valid_until + ); + + setup.advance_time(SCAN_WATCHED_ADDRESSES_DELAY).await; + setup + .execute_http_mocks( + MockBuilder::new() + .get_multiple_accounts(&[BALANCE_ABOVE_MINIMUM]) + .build(), + ) + .await; + + let detected = setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should report the detected deposit"); + assert_eq!( + detected.status, + WatchDepositAddressStatus::Detected { deposit_id: 0 } + ); + assert_eq!(detected.latest_deposit_id, Some(0)); + assert_eq!( + setup.minter().deposit_sol_status(0).await, + DepositSolStatus::Queued { + sweepable_amount: BALANCE_ABOVE_MINIMUM - RENT_EXEMPTION_THRESHOLD + } + ); + setup.minter().assert_that_events().await.satisfy(|events| { + let queued: Vec<&EventType> = events + .iter() + .filter(|e| matches!(e, EventType::QueuedDeposit { .. })) + .collect(); + check!( + queued + == [&EventType::QueuedDeposit { + deposit_id: 0, + account: DEFAULT_CALLER_ACCOUNT, + address: DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap(), + balance: BALANCE_ABOVE_MINIMUM, + automatic: Some(AutomaticDeposit { + fee: Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE, + valid_until, + }), + }] + ); + }); + setup + .check_metrics() + .await + .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="queued"\} 1 \d+"#) + .into() + .drop() + .await; + } } mod anonymous_caller_tests { diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 7a01ca6c5..96c9f57b2 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -15,6 +15,12 @@ pub const GET_RECENT_BLOCK_MAX_TRIES: NonZeroUsize = /// Interval of the timer sweeping queued deposits, the same as withdrawal processing. pub const SWEEP_DEPOSITS_DELAY: Duration = Duration::from_mins(1); +/// Interval of the timer scanning the balances of the watched deposit addresses that are +/// due for a scan. Together with the scan schedule and the capacity of the watchlist, the +/// tick rate bounds what the automatic deposit flow costs the minter (see section 3.1.1 +/// of the design). +pub const SCAN_WATCHED_ADDRESSES_DELAY: Duration = Duration::from_mins(1); + /// Delay before retrying the minter public key fetch after it trapped. Deposits and /// transfer creation stay unavailable until the key is recorded, so the retry is quick. pub const MINTER_PUBLIC_KEY_RETRY_DELAY: Duration = Duration::from_secs(5); diff --git a/minter/src/deposit/automatic/mod.rs b/minter/src/deposit/automatic/mod.rs index 64a639b48..b985052cf 100644 --- a/minter/src/deposit/automatic/mod.rs +++ b/minter/src/deposit/automatic/mod.rs @@ -7,9 +7,13 @@ use crate::{ use cksol_types::{WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus}; use icrc_ledger_types::icrc1::account::Account; +mod scan; #[cfg(test)] mod tests; +pub use crate::constants::SCAN_WATCHED_ADDRESSES_DELAY; +pub use scan::scan_watched_addresses; + pub fn watch_deposit_address( runtime: &R, account: Account, diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs new file mode 100644 index 000000000..99c91162d --- /dev/null +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -0,0 +1,176 @@ +use crate::{ + constants::{MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, MAX_CONCURRENT_RPC_CALLS}, + deposit::sweep::queue_deposit, + guard::TimerGuard, + rpc::{GetMultipleAccountsError, get_balances}, + runtime::CanisterRuntime, + state::{ + AutomaticDeposit, DepositBalance, QueuedDeposit, State, TaskType, WatchedAddress, + mutate_state, read_state, watchlist::MAX_WATCHED_ADDRESSES, + }, + storage::{ScanOutcome, record_deposit_address_scans}, +}; +use canlog::log; +use cksol_types::Lamport; +use cksol_types_internal::log::Priority; +use solana_address::Address; + +#[cfg(test)] +mod tests; + +const _: () = assert!( + MAX_WATCHED_ADDRESSES.get() + <= MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS * MAX_CONCURRENT_RPC_CALLS, + "BUG: one tick must cover the whole watchlist" +); + +/// Reads the balances of the watched deposit addresses that are due for a scan and queues +/// a deposit for every address holding at least the minimum deposit amount, as `deposit_sol` +/// would, with the automatic deposit fee in force. +/// +/// A tick that finds the previous one still waiting for its calls returns at once, and the +/// watchlist holds at most [`MAX_CONCURRENT_RPC_CALLS`] calls of +/// [`MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS`] addresses, so that many calls are in flight +/// at most and one tick covers the whole watchlist. A call that fails counts +/// as a scan of its addresses, which advance along their schedule as after a balance below +/// the minimum, so that the cost of a registration stays within its schedule whatever the +/// SOL RPC canister does. +pub async fn scan_watched_addresses(runtime: R) { + let _guard = match TimerGuard::new(TaskType::ScanWatchedAddresses) { + Ok(guard) => guard, + Err(_) => return, + }; + + let now = runtime.time(); + let due = mutate_state(|state| { + state.watchlist_mut().evict_expired(now); + state + .watchlist() + .due(now) + .into_iter() + .filter(|watched| !state.is_deposit_sol_in_progress(&watched.account)) + .collect::>() + }); + if due.is_empty() { + return; + } + let Some(automatic_deposit_fee) = read_state(|state| state.automatic_deposit_fee()) else { + log!( + Priority::Error, + "Skipping the scan of {} watched deposit addresses: the automatic deposit fee is not configured", + due.len() + ); + return; + }; + + let chunks: Vec<&[WatchedAddress]> = due + .chunks(MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) + .collect(); + let reads = + futures::future::join_all(chunks.iter().map(|chunk| read_balances(&runtime, chunk))).await; + + let scan = Scan { + scanned_at: runtime.time(), + automatic_deposit_fee, + }; + for (chunk, read) in chunks.into_iter().zip(reads) { + match read { + Ok(balances) => { + for (watched, balance) in chunk.iter().zip(balances) { + if let Some(outcome) = apply_balance_read(&runtime, scan, watched, balance) { + record_deposit_address_scans(outcome, 1); + } + } + } + Err(e) => { + log!( + Priority::Info, + "Failed to read the balances of {} watched deposit addresses, which advance to their next scan: {e}", + chunk.len() + ); + record_failed_read(scan.scanned_at, chunk); + record_deposit_address_scans(ScanOutcome::Failed, chunk.len() as u64); + } + } + } +} + +async fn read_balances( + runtime: &R, + chunk: &[WatchedAddress], +) -> Result, GetMultipleAccountsError> { + let addresses: Vec
= chunk.iter().map(|watched| watched.address).collect(); + get_balances(runtime, &addresses).await +} + +/// What one tick of the scan applies to every balance it read: the time of the reads and the +/// automatic deposit fee in force, which a deposit detected by the tick records. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Scan { + scanned_at: u64, + automatic_deposit_fee: Lamport, +} + +/// Applies the balance read for a watched address: queues a deposit if the balance reaches +/// the minimum deposit amount, advances the scan schedule otherwise, and does nothing when +/// the registration the read was made for is gone, replaced or expired, or when a deposit +/// of the account is in flight, since the read says nothing about the current one then. +fn apply_balance_read( + runtime: &R, + scan: Scan, + watched: &WatchedAddress, + balance: Lamport, +) -> Option { + let Scan { + scanned_at, + automatic_deposit_fee, + } = scan; + mutate_state(|state| { + if !is_same_registration(state, scanned_at, watched) + || state.deposits().in_flight_id(&watched.account).is_some() + { + return None; + } + if balance < state.minimum_deposit_amount() { + state + .watchlist_mut() + .record_scan(scanned_at, &watched.account); + return Some(ScanOutcome::BelowMinimum); + } + let balance = DepositBalance::new(balance) + .expect("BUG: the minimum deposit amount covers the rent exemption threshold"); + queue_deposit( + runtime, + state, + QueuedDeposit { + account: watched.account, + address: watched.address, + balance, + automatic: Some(AutomaticDeposit { + fee: automatic_deposit_fee, + valid_until: watched.valid_until, + }), + }, + ); + Some(ScanOutcome::Queued) + }) +} + +fn record_failed_read(scanned_at: u64, chunk: &[WatchedAddress]) { + mutate_state(|state| { + for watched in chunk { + if is_same_registration(state, scanned_at, watched) { + state + .watchlist_mut() + .record_scan(scanned_at, &watched.account); + } + } + }); +} + +fn is_same_registration(state: &State, now: u64, watched: &WatchedAddress) -> bool { + state + .watchlist() + .get(now, &watched.account) + .is_some_and(|current| current.valid_until == watched.valid_until) +} diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs new file mode 100644 index 000000000..8e2c30ba2 --- /dev/null +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -0,0 +1,321 @@ +use crate::{ + constants::{ + MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, MAX_CONCURRENT_RPC_CALLS, RENT_EXEMPTION_THRESHOLD, + }, + deposit::automatic::scan::{Scan, apply_balance_read, scan_watched_addresses}, + guard::deposit_sol_guard, + state::{ + AutomaticDeposit, TaskType, WatchedAddress, event::EventType, mutate_state, read_state, + }, + storage::{ScanOutcome, deposit_address_scan_count}, + test_fixtures::{ + AUTOMATIC_DEPOSIT_FEE, EventsAssert, MINIMUM_DEPOSIT_AMOUNT, MINUTE_NANOS, REGISTERED_AT, + WATCH_WINDOW_NANOS, account, deposit_address, flow::deposit::DepositFlow, + init_schnorr_master_key, init_state, init_state_with_args, queued_deposit_of, + runtime::TestCanisterRuntime, valid_init_args, watch, + }, +}; +use cksol_types::DepositSolStatus; +use cksol_types_internal::InitArgs; +use ic_canister_runtime::IcError; +use sol_rpc_types::{RpcConfig, RpcSources}; + +const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; +const TICK_AT: u64 = REGISTERED_AT + MINUTE_NANOS; + +#[tokio::test] +async fn should_return_early_without_due_addresses() { + setup(); + let runtime = runtime_at(TICK_AT); + + scan_watched_addresses(runtime.clone()).await; + + assert!(runtime.sent_update_calls().is_empty()); + EventsAssert::assert_no_events_recorded(); +} + +#[tokio::test] +async fn should_return_early_if_task_already_active() { + setup(); + watch(REGISTERED_AT, account(1)); + mutate_state(|s| { + s.active_tasks_mut().insert(TaskType::ScanWatchedAddresses); + }); + let runtime = runtime_at(TICK_AT); + + scan_watched_addresses(runtime.clone()).await; + + assert!(runtime.sent_update_calls().is_empty()); +} + +#[tokio::test] +async fn should_queue_a_deposit_reaching_the_minimum_with_the_fee_in_force() { + setup(); + watch(REGISTERED_AT, account(1)); + let runtime = + runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT)]); + + scan_watched_addresses(runtime.clone()).await; + + let expected = queued_deposit_of( + account(1), + MINIMUM_DEPOSIT_AMOUNT - RENT_EXEMPTION_THRESHOLD, + ); + EventsAssert::from_recorded() + .expect_event_eq(EventType::QueuedDeposit { + deposit_id: 0, + account: account(1), + address: expected.address, + balance: expected.balance, + automatic: Some(AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: VALID_UNTIL, + }), + }) + .assert_no_more_events(); + read_state(|s| { + assert!(s.watchlist().is_empty()); + assert_eq!(s.deposits().in_flight_id(&account(1)), Some(0)); + assert_eq!( + s.deposits().status(0), + DepositSolStatus::Queued { + sweepable_amount: MINIMUM_DEPOSIT_AMOUNT - RENT_EXEMPTION_THRESHOLD + } + ); + }); + assert_eq!(deposit_address_scan_count(ScanOutcome::Queued), 1); +} + +#[tokio::test] +async fn should_advance_the_schedule_below_the_minimum_and_treat_a_missing_account_as_empty() { + setup(); + watch(REGISTERED_AT, account(1)); + watch(REGISTERED_AT, account(2)); + let runtime = runtime_at(TICK_AT) + .add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT - 1), None]); + + scan_watched_addresses(runtime.clone()).await; + + EventsAssert::assert_no_events_recorded(); + let scanned_at = TICK_AT + 1; + for account in [account(1), account(2)] { + assert_eq!( + read_state(|s| s.watchlist().get(scanned_at, &account)), + Some(WatchedAddress { + account, + address: deposit_address(account), + valid_until: VALID_UNTIL, + scan_count: 1, + last_scanned_at: Some(scanned_at), + next_scan_at: Some(scanned_at + MINUTE_NANOS), + }) + ); + } + assert_eq!(deposit_address_scan_count(ScanOutcome::BelowMinimum), 2); +} + +#[tokio::test] +async fn should_read_the_addresses_of_the_due_registrations_only() { + setup(); + watch(REGISTERED_AT, account(1)); + watch(REGISTERED_AT, account(2)); + mutate_state(|s| s.watchlist_mut().record_scan(TICK_AT - 1, &account(2))); + let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(0)]); + + scan_watched_addresses(runtime.clone()).await; + + assert_eq!( + runtime.requested_addresses(), + vec![vec![deposit_address(account(1)).to_string()]] + ); +} + +#[tokio::test] +async fn should_skip_the_accounts_with_a_deposit_sol_call_in_progress() { + setup(); + watch(REGISTERED_AT, account(1)); + watch(REGISTERED_AT, account(2)); + let _guard = deposit_sol_guard(account(1)).unwrap(); + let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(0)]); + + scan_watched_addresses(runtime.clone()).await; + + assert_eq!( + runtime.requested_addresses(), + vec![vec![deposit_address(account(2)).to_string()]] + ); +} + +#[tokio::test] +async fn should_skip_the_tick_while_the_automatic_deposit_fee_is_not_configured() { + init_state_with_args(InitArgs { + automatic_deposit_fee: None, + ..valid_init_args() + }); + init_schnorr_master_key(); + let watched = watch(REGISTERED_AT, account(1)); + let runtime = + runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT)]); + + scan_watched_addresses(runtime.clone()).await; + + assert!(runtime.sent_update_calls().is_empty()); + EventsAssert::assert_no_events_recorded(); + assert_eq!( + read_state(|s| s.watchlist().get(TICK_AT, &account(1))), + Some(watched) + ); +} + +#[tokio::test] +async fn should_evict_expired_registrations_at_each_tick() { + setup(); + watch(REGISTERED_AT, account(1)); + let runtime = runtime_at(VALID_UNTIL + 1); + + scan_watched_addresses(runtime.clone()).await; + + assert!(runtime.sent_update_calls().is_empty()); + assert!(read_state(|s| s.watchlist().is_empty())); +} + +#[tokio::test] +async fn should_advance_the_schedule_when_the_call_fails() { + setup(); + watch(REGISTERED_AT, account(1)); + let runtime = runtime_at(TICK_AT).add_stub_error(IcError::CallPerformFailed); + + scan_watched_addresses(runtime.clone()).await; + + EventsAssert::assert_no_events_recorded(); + let scanned_at = TICK_AT + 1; + assert_eq!( + read_state(|s| s.watchlist().get(scanned_at, &account(1))), + Some(WatchedAddress { + account: account(1), + address: deposit_address(account(1)), + valid_until: VALID_UNTIL, + scan_count: 1, + last_scanned_at: Some(scanned_at), + next_scan_at: Some(scanned_at + MINUTE_NANOS), + }) + ); + assert_eq!(deposit_address_scan_count(ScanOutcome::Failed), 1); +} + +#[tokio::test] +async fn should_read_at_most_a_full_watchlist_in_chunks_of_one_call_each() { + setup(); + let total = MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS * MAX_CONCURRENT_RPC_CALLS; + for index in 0..total { + watch(REGISTERED_AT, account(index)); + } + let mut runtime = runtime_at(TICK_AT); + for _ in 0..MAX_CONCURRENT_RPC_CALLS { + runtime = runtime.add_get_multiple_accounts_response( + &vec![Some(0); MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS], + ); + } + + scan_watched_addresses(runtime.clone()).await; + + let requested = runtime.requested_addresses(); + assert_eq!(requested.len(), MAX_CONCURRENT_RPC_CALLS); + assert!( + requested + .iter() + .all(|chunk| chunk.len() == MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS) + ); + assert_eq!( + deposit_address_scan_count(ScanOutcome::BelowMinimum), + total as u64 + ); +} + +#[test] +fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { + setup(); + let stale = watch(REGISTERED_AT, account(1)); + mutate_state(|s| s.watchlist_mut().remove(&account(1))); + let renewed = watch(REGISTERED_AT + MINUTE_NANOS, account(1)); + let runtime = runtime_at(TICK_AT); + + let after_replacement = + apply_balance_read(&runtime, scan_at(TICK_AT), &stale, MINIMUM_DEPOSIT_AMOUNT); + let after_expiry = apply_balance_read( + &runtime, + scan_at(renewed.valid_until + 1), + &renewed, + MINIMUM_DEPOSIT_AMOUNT, + ); + + assert_eq!(after_replacement, None); + assert_eq!(after_expiry, None); + EventsAssert::assert_no_events_recorded(); + assert_eq!( + read_state(|s| s.watchlist().get(TICK_AT, &account(1))), + Some(renewed) + ); +} + +#[test] +fn should_discard_a_read_of_an_account_whose_deposit_is_in_flight() { + setup(); + let watched = watch(REGISTERED_AT, account(1)); + let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); + mutate_state(|s| { + s.watchlist_mut() + .register(REGISTERED_AT, account(1), deposit.address) + .unwrap() + }); + let events_before = EventsAssert::from_recorded(); + + let outcome = apply_balance_read( + &runtime_at(TICK_AT), + scan_at(TICK_AT), + &watched, + MINIMUM_DEPOSIT_AMOUNT, + ); + + assert_eq!(outcome, None); + assert_eq!(EventsAssert::from_recorded(), events_before); + assert_eq!( + read_state(|s| s.deposits().in_flight_id(&account(1))), + Some(deposit.deposit_id) + ); +} + +fn setup() { + init_state(); + init_schnorr_master_key(); +} + +fn scan_at(scanned_at: u64) -> Scan { + Scan { + scanned_at, + automatic_deposit_fee: AUTOMATIC_DEPOSIT_FEE, + } +} + +fn runtime_at(now: u64) -> TestCanisterRuntime { + TestCanisterRuntime::new().with_increasing_time_from(now) +} + +trait RequestedAddressesExt { + fn requested_addresses(&self) -> Vec>; +} + +impl RequestedAddressesExt for TestCanisterRuntime { + fn requested_addresses(&self) -> Vec> { + self.sent_update_calls() + .iter() + .map(|call| { + assert_eq!(call.method, "jsonRequest"); + let (_sources, _config, request): (RpcSources, Option, String) = + call.args(); + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + serde_json::from_value(request["params"][0].clone()).unwrap() + }) + .collect() + } +} diff --git a/minter/src/main.rs b/minter/src/main.rs index 3ff461a46..006dc9401 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -2,9 +2,12 @@ use candid::Principal; use canlog::{Log, Sort}; use cksol_minter::{ address::{fetch_and_record_minter_public_key, minter_address}, - deposit::sweep::{ - PROCESS_PENDING_MINTS_DELAY, SWEEP_DEPOSITS_DELAY, process_pending_mints, - sweep_queued_deposits, + deposit::{ + automatic::{SCAN_WATCHED_ADDRESSES_DELAY, scan_watched_addresses}, + sweep::{ + PROCESS_PENDING_MINTS_DELAY, SWEEP_DEPOSITS_DELAY, process_pending_mints, + sweep_queued_deposits, + }, }, monitor::{FINALIZE_TRANSACTIONS_DELAY, finalize_transactions}, runtime::IcCanisterRuntime, @@ -277,6 +280,9 @@ fn setup_timers() { ic_cdk_timers::set_timer_interval(FINALIZE_TRANSACTIONS_DELAY, async || { finalize_transactions(IcCanisterRuntime::new()).await; }); + ic_cdk_timers::set_timer_interval(SCAN_WATCHED_ADDRESSES_DELAY, async || { + scan_watched_addresses(IcCanisterRuntime::new()).await; + }); } fn main() {} diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index 6b8d40950..ad207ffa2 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -1,5 +1,7 @@ use crate::state::{QuarantineCause, State}; -use crate::storage::{self, FailedCreditReason, FailedMintReason, UndecidedWithdrawalReason}; +use crate::storage::{ + self, FailedCreditReason, FailedMintReason, ScanOutcome, UndecidedWithdrawalReason, +}; use ic_metrics_encoder::MetricsEncoder; const WASM_PAGE_SIZE_IN_BYTES: usize = 65536; @@ -132,6 +134,16 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re storage::failed_mint_attempt_count(reason).metric_value(), )?; } + let mut deposit_address_scans = w.counter_vec( + "deposit_address_scans", + "Number of balance scans of watched deposit addresses since the last upgrade, by outcome: a deposit queued, a balance below the minimum deposit amount, or a failed read.", + )?; + for outcome in ScanOutcome::ALL { + deposit_address_scans = deposit_address_scans.value( + &[("outcome", outcome.label())], + storage::deposit_address_scan_count(outcome).metric_value(), + )?; + } w.encode_gauge( "minter_balance", s.balance().metric_value(), diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index f5dce3027..866fcb7a0 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -168,13 +168,6 @@ impl From for DepositSolError { /// /// If there are no addresses or more than [`MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS`], /// since the caller chunks the addresses it reads. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "called by the scan timer of the automatic deposit flow, which lands next" - ) -)] pub async fn get_balances( runtime: &R, addresses: &[Address], diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 6e6dfba02..d3338d4e4 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -298,6 +298,11 @@ impl State { &mut self.pending_deposit_sol_request_guards } + /// Whether a `deposit_sol` call for the account is waiting for its balance read. + pub fn is_deposit_sol_in_progress(&self, account: &Account) -> bool { + self.pending_deposit_sol_request_guards.contains(account) + } + pub fn pending_withdrawal_request_guards_mut(&mut self) -> &mut BTreeSet { &mut self.pending_withdrawal_request_guards } @@ -1013,6 +1018,7 @@ pub enum TaskType { Mint, FinalizeTransactions, WithdrawalProcessing, + ScanWatchedAddresses, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/minter/src/storage/mod.rs b/minter/src/storage/mod.rs index 1d31b043f..c09e01ad2 100644 --- a/minter/src/storage/mod.rs +++ b/minter/src/storage/mod.rs @@ -40,6 +40,7 @@ pub(crate) struct Metrics { pub failed_mint_attempts: BTreeMap, pub withdrawal_transaction_rebroadcasts: u64, pub undecided_withdrawal_transactions: BTreeMap, + pub deposit_address_scans: BTreeMap, } impl Metrics { @@ -50,10 +51,39 @@ impl Metrics { failed_mint_attempts: BTreeMap::new(), withdrawal_transaction_rebroadcasts: 0, undecided_withdrawal_transactions: BTreeMap::new(), + deposit_address_scans: BTreeMap::new(), } } } +/// What the balance scan of one watched deposit address led to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ScanOutcome { + Queued, + BelowMinimum, + Failed, +} + +impl ScanOutcome { + pub const ALL: [Self; 3] = [Self::Queued, Self::BelowMinimum, Self::Failed]; + + pub fn label(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::BelowMinimum => "below_minimum", + Self::Failed => "failed", + } + } +} + +pub(crate) fn record_deposit_address_scans(outcome: ScanOutcome, count: u64) { + with_unstable_metrics_mut(|m| *m.deposit_address_scans.entry(outcome).or_insert(0) += count); +} + +pub(crate) fn deposit_address_scan_count(outcome: ScanOutcome) -> u64 { + with_unstable_metrics(|m| m.deposit_address_scans.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 { From c9f61cd41c13d214a7a7b8f54141d750409c5c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:52:49 +0000 Subject: [PATCH 078/146] refactor(minter): take the scan time as a newtype Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/scan/mod.rs | 13 ++--- minter/src/deposit/automatic/scan/tests.rs | 60 +++++++++++----------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs index 99c91162d..600dd5b37 100644 --- a/minter/src/deposit/automatic/scan/mod.rs +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -9,6 +9,7 @@ use crate::{ mutate_state, read_state, watchlist::MAX_WATCHED_ADDRESSES, }, storage::{ScanOutcome, record_deposit_address_scans}, + utils::timed_sized_map::Timestamp, }; use canlog::log; use cksol_types::Lamport; @@ -41,7 +42,7 @@ pub async fn scan_watched_addresses(runtime: R) { Err(_) => return, }; - let now = runtime.time(); + let now = Timestamp::from_nanos(runtime.time()); let due = mutate_state(|state| { state.watchlist_mut().evict_expired(now); state @@ -70,7 +71,7 @@ pub async fn scan_watched_addresses(runtime: R) { futures::future::join_all(chunks.iter().map(|chunk| read_balances(&runtime, chunk))).await; let scan = Scan { - scanned_at: runtime.time(), + scanned_at: Timestamp::from_nanos(runtime.time()), automatic_deposit_fee, }; for (chunk, read) in chunks.into_iter().zip(reads) { @@ -107,7 +108,7 @@ async fn read_balances( /// automatic deposit fee in force, which a deposit detected by the tick records. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Scan { - scanned_at: u64, + scanned_at: Timestamp, automatic_deposit_fee: Lamport, } @@ -148,7 +149,7 @@ fn apply_balance_read( balance, automatic: Some(AutomaticDeposit { fee: automatic_deposit_fee, - valid_until: watched.valid_until, + valid_until: watched.valid_until.as_nanos(), }), }, ); @@ -156,7 +157,7 @@ fn apply_balance_read( }) } -fn record_failed_read(scanned_at: u64, chunk: &[WatchedAddress]) { +fn record_failed_read(scanned_at: Timestamp, chunk: &[WatchedAddress]) { mutate_state(|state| { for watched in chunk { if is_same_registration(state, scanned_at, watched) { @@ -168,7 +169,7 @@ fn record_failed_read(scanned_at: u64, chunk: &[WatchedAddress]) { }); } -fn is_same_registration(state: &State, now: u64, watched: &WatchedAddress) -> bool { +fn is_same_registration(state: &State, now: Timestamp, watched: &WatchedAddress) -> bool { state .watchlist() .get(now, &watched.account) diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 8e2c30ba2..eb72d0811 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -10,7 +10,7 @@ use crate::{ storage::{ScanOutcome, deposit_address_scan_count}, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, EventsAssert, MINIMUM_DEPOSIT_AMOUNT, MINUTE_NANOS, REGISTERED_AT, - WATCH_WINDOW_NANOS, account, deposit_address, flow::deposit::DepositFlow, + WATCH_WINDOW_NANOS, account, at, deposit_address, flow::deposit::DepositFlow, init_schnorr_master_key, init_state, init_state_with_args, queued_deposit_of, runtime::TestCanisterRuntime, valid_init_args, watch, }, @@ -37,7 +37,7 @@ async fn should_return_early_without_due_addresses() { #[tokio::test] async fn should_return_early_if_task_already_active() { setup(); - watch(REGISTERED_AT, account(1)); + watch(at(REGISTERED_AT), account(1)); mutate_state(|s| { s.active_tasks_mut().insert(TaskType::ScanWatchedAddresses); }); @@ -51,7 +51,7 @@ async fn should_return_early_if_task_already_active() { #[tokio::test] async fn should_queue_a_deposit_reaching_the_minimum_with_the_fee_in_force() { setup(); - watch(REGISTERED_AT, account(1)); + watch(at(REGISTERED_AT), account(1)); let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT)]); @@ -89,8 +89,8 @@ async fn should_queue_a_deposit_reaching_the_minimum_with_the_fee_in_force() { #[tokio::test] async fn should_advance_the_schedule_below_the_minimum_and_treat_a_missing_account_as_empty() { setup(); - watch(REGISTERED_AT, account(1)); - watch(REGISTERED_AT, account(2)); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT), account(2)); let runtime = runtime_at(TICK_AT) .add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT - 1), None]); @@ -100,14 +100,14 @@ async fn should_advance_the_schedule_below_the_minimum_and_treat_a_missing_accou let scanned_at = TICK_AT + 1; for account in [account(1), account(2)] { assert_eq!( - read_state(|s| s.watchlist().get(scanned_at, &account)), + read_state(|s| s.watchlist().get(at(scanned_at), &account)), Some(WatchedAddress { account, address: deposit_address(account), - valid_until: VALID_UNTIL, + valid_until: at(VALID_UNTIL), scan_count: 1, - last_scanned_at: Some(scanned_at), - next_scan_at: Some(scanned_at + MINUTE_NANOS), + last_scanned_at: Some(at(scanned_at)), + next_scan_at: Some(at(scanned_at + MINUTE_NANOS)), }) ); } @@ -117,9 +117,9 @@ async fn should_advance_the_schedule_below_the_minimum_and_treat_a_missing_accou #[tokio::test] async fn should_read_the_addresses_of_the_due_registrations_only() { setup(); - watch(REGISTERED_AT, account(1)); - watch(REGISTERED_AT, account(2)); - mutate_state(|s| s.watchlist_mut().record_scan(TICK_AT - 1, &account(2))); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT), account(2)); + mutate_state(|s| s.watchlist_mut().record_scan(at(TICK_AT - 1), &account(2))); let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(0)]); scan_watched_addresses(runtime.clone()).await; @@ -133,8 +133,8 @@ async fn should_read_the_addresses_of_the_due_registrations_only() { #[tokio::test] async fn should_skip_the_accounts_with_a_deposit_sol_call_in_progress() { setup(); - watch(REGISTERED_AT, account(1)); - watch(REGISTERED_AT, account(2)); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT), account(2)); let _guard = deposit_sol_guard(account(1)).unwrap(); let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(0)]); @@ -153,7 +153,7 @@ async fn should_skip_the_tick_while_the_automatic_deposit_fee_is_not_configured( ..valid_init_args() }); init_schnorr_master_key(); - let watched = watch(REGISTERED_AT, account(1)); + let watched = watch(at(REGISTERED_AT), account(1)); let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT)]); @@ -162,7 +162,7 @@ async fn should_skip_the_tick_while_the_automatic_deposit_fee_is_not_configured( assert!(runtime.sent_update_calls().is_empty()); EventsAssert::assert_no_events_recorded(); assert_eq!( - read_state(|s| s.watchlist().get(TICK_AT, &account(1))), + read_state(|s| s.watchlist().get(at(TICK_AT), &account(1))), Some(watched) ); } @@ -170,7 +170,7 @@ async fn should_skip_the_tick_while_the_automatic_deposit_fee_is_not_configured( #[tokio::test] async fn should_evict_expired_registrations_at_each_tick() { setup(); - watch(REGISTERED_AT, account(1)); + watch(at(REGISTERED_AT), account(1)); let runtime = runtime_at(VALID_UNTIL + 1); scan_watched_addresses(runtime.clone()).await; @@ -182,7 +182,7 @@ async fn should_evict_expired_registrations_at_each_tick() { #[tokio::test] async fn should_advance_the_schedule_when_the_call_fails() { setup(); - watch(REGISTERED_AT, account(1)); + watch(at(REGISTERED_AT), account(1)); let runtime = runtime_at(TICK_AT).add_stub_error(IcError::CallPerformFailed); scan_watched_addresses(runtime.clone()).await; @@ -190,14 +190,14 @@ async fn should_advance_the_schedule_when_the_call_fails() { EventsAssert::assert_no_events_recorded(); let scanned_at = TICK_AT + 1; assert_eq!( - read_state(|s| s.watchlist().get(scanned_at, &account(1))), + read_state(|s| s.watchlist().get(at(scanned_at), &account(1))), Some(WatchedAddress { account: account(1), address: deposit_address(account(1)), - valid_until: VALID_UNTIL, + valid_until: at(VALID_UNTIL), scan_count: 1, - last_scanned_at: Some(scanned_at), - next_scan_at: Some(scanned_at + MINUTE_NANOS), + last_scanned_at: Some(at(scanned_at)), + next_scan_at: Some(at(scanned_at + MINUTE_NANOS)), }) ); assert_eq!(deposit_address_scan_count(ScanOutcome::Failed), 1); @@ -208,7 +208,7 @@ async fn should_read_at_most_a_full_watchlist_in_chunks_of_one_call_each() { setup(); let total = MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS * MAX_CONCURRENT_RPC_CALLS; for index in 0..total { - watch(REGISTERED_AT, account(index)); + watch(at(REGISTERED_AT), account(index)); } let mut runtime = runtime_at(TICK_AT); for _ in 0..MAX_CONCURRENT_RPC_CALLS { @@ -235,16 +235,16 @@ async fn should_read_at_most_a_full_watchlist_in_chunks_of_one_call_each() { #[test] fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { setup(); - let stale = watch(REGISTERED_AT, account(1)); + let stale = watch(at(REGISTERED_AT), account(1)); mutate_state(|s| s.watchlist_mut().remove(&account(1))); - let renewed = watch(REGISTERED_AT + MINUTE_NANOS, account(1)); + let renewed = watch(at(REGISTERED_AT + MINUTE_NANOS), account(1)); let runtime = runtime_at(TICK_AT); let after_replacement = apply_balance_read(&runtime, scan_at(TICK_AT), &stale, MINIMUM_DEPOSIT_AMOUNT); let after_expiry = apply_balance_read( &runtime, - scan_at(renewed.valid_until + 1), + scan_at(renewed.valid_until.as_nanos() + 1), &renewed, MINIMUM_DEPOSIT_AMOUNT, ); @@ -253,7 +253,7 @@ fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { assert_eq!(after_expiry, None); EventsAssert::assert_no_events_recorded(); assert_eq!( - read_state(|s| s.watchlist().get(TICK_AT, &account(1))), + read_state(|s| s.watchlist().get(at(TICK_AT), &account(1))), Some(renewed) ); } @@ -261,11 +261,11 @@ fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { #[test] fn should_discard_a_read_of_an_account_whose_deposit_is_in_flight() { setup(); - let watched = watch(REGISTERED_AT, account(1)); + let watched = watch(at(REGISTERED_AT), account(1)); let deposit = DepositFlow::queue(account(1), MINIMUM_DEPOSIT_AMOUNT); mutate_state(|s| { s.watchlist_mut() - .register(REGISTERED_AT, account(1), deposit.address) + .register(at(REGISTERED_AT), account(1), deposit.address) .unwrap() }); let events_before = EventsAssert::from_recorded(); @@ -292,7 +292,7 @@ fn setup() { fn scan_at(scanned_at: u64) -> Scan { Scan { - scanned_at, + scanned_at: at(scanned_at), automatic_deposit_fee: AUTOMATIC_DEPOSIT_FEE, } } From c462a4588d5a86ce7b307c0ba6cece289c5d4a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:46:04 +0000 Subject: [PATCH 079/146] refactor(minter): read the watchlist limits through the state and report the first scan as due at the read Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/scan/mod.rs | 4 ++-- minter/src/deposit/automatic/scan/tests.rs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs index 600dd5b37..98867529e 100644 --- a/minter/src/deposit/automatic/scan/mod.rs +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -5,8 +5,8 @@ use crate::{ rpc::{GetMultipleAccountsError, get_balances}, runtime::CanisterRuntime, state::{ - AutomaticDeposit, DepositBalance, QueuedDeposit, State, TaskType, WatchedAddress, - mutate_state, read_state, watchlist::MAX_WATCHED_ADDRESSES, + AutomaticDeposit, DepositBalance, MAX_WATCHED_ADDRESSES, QueuedDeposit, State, TaskType, + WatchedAddress, mutate_state, read_state, }, storage::{ScanOutcome, record_deposit_address_scans}, utils::timed_sized_map::Timestamp, diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index eb72d0811..98783cdc8 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -163,7 +163,10 @@ async fn should_skip_the_tick_while_the_automatic_deposit_fee_is_not_configured( EventsAssert::assert_no_events_recorded(); assert_eq!( read_state(|s| s.watchlist().get(at(TICK_AT), &account(1))), - Some(watched) + Some(WatchedAddress { + next_scan_at: Some(at(TICK_AT)), + ..watched + }) ); } From 1612a14ea778c6bfde6a7fe17b4e6b888ea51872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:11:33 +0000 Subject: [PATCH 080/146] test(minter): expect the fixed scan slots in the scan timer tests Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/scan/tests.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 98783cdc8..0c664fb95 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -107,7 +107,7 @@ async fn should_advance_the_schedule_below_the_minimum_and_treat_a_missing_accou valid_until: at(VALID_UNTIL), scan_count: 1, last_scanned_at: Some(at(scanned_at)), - next_scan_at: Some(at(scanned_at + MINUTE_NANOS)), + next_scan_at: Some(at(REGISTERED_AT + 2 * MINUTE_NANOS)), }) ); } @@ -119,7 +119,7 @@ async fn should_read_the_addresses_of_the_due_registrations_only() { setup(); watch(at(REGISTERED_AT), account(1)); watch(at(REGISTERED_AT), account(2)); - mutate_state(|s| s.watchlist_mut().record_scan(at(TICK_AT - 1), &account(2))); + mutate_state(|s| s.watchlist_mut().record_scan(at(TICK_AT), &account(2))); let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(0)]); scan_watched_addresses(runtime.clone()).await; @@ -163,10 +163,7 @@ async fn should_skip_the_tick_while_the_automatic_deposit_fee_is_not_configured( EventsAssert::assert_no_events_recorded(); assert_eq!( read_state(|s| s.watchlist().get(at(TICK_AT), &account(1))), - Some(WatchedAddress { - next_scan_at: Some(at(TICK_AT)), - ..watched - }) + Some(watched) ); } @@ -200,7 +197,7 @@ async fn should_advance_the_schedule_when_the_call_fails() { valid_until: at(VALID_UNTIL), scan_count: 1, last_scanned_at: Some(at(scanned_at)), - next_scan_at: Some(at(scanned_at + MINUTE_NANOS)), + next_scan_at: Some(at(REGISTERED_AT + 2 * MINUTE_NANOS)), }) ); assert_eq!(deposit_address_scan_count(ScanOutcome::Failed), 1); From 225a5ed6860a9296404b6953ce78e071bbea0ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:39:21 +0000 Subject: [PATCH 081/146] refactor(minter): record the automatic deposit fee alone when a scan queues a deposit Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 11 ++++------- minter/src/deposit/automatic/scan/mod.rs | 9 +++------ minter/src/deposit/automatic/scan/tests.rs | 9 ++------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 7d3f26060..2aa13bf7f 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -16,7 +16,7 @@ use cksol_types::{ }; use cksol_types_internal::{ UpgradeArgs, - event::{AutomaticDeposit, EventType, TransactionPurpose}, + event::{EventType, TransactionPurpose}, log::Priority, }; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; @@ -1323,9 +1323,9 @@ mod automatic_deposit_sol_tests { .watch_deposit_address(None) .await .expect("watch_deposit_address should register the caller"); - let valid_until = assert_matches!( + assert_matches!( registration.status, - WatchDepositAddressStatus::Watching { valid_until, .. } => valid_until + WatchDepositAddressStatus::Watching { .. } ); setup.advance_time(SCAN_WATCHED_ADDRESSES_DELAY).await; @@ -1365,10 +1365,7 @@ mod automatic_deposit_sol_tests { account: DEFAULT_CALLER_ACCOUNT, address: DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap(), balance: BALANCE_ABOVE_MINIMUM, - automatic: Some(AutomaticDeposit { - fee: Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE, - valid_until, - }), + automatic_deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), }] ); }); diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs index 98867529e..1f4870fd9 100644 --- a/minter/src/deposit/automatic/scan/mod.rs +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -5,8 +5,8 @@ use crate::{ rpc::{GetMultipleAccountsError, get_balances}, runtime::CanisterRuntime, state::{ - AutomaticDeposit, DepositBalance, MAX_WATCHED_ADDRESSES, QueuedDeposit, State, TaskType, - WatchedAddress, mutate_state, read_state, + DepositBalance, MAX_WATCHED_ADDRESSES, QueuedDeposit, State, TaskType, WatchedAddress, + mutate_state, read_state, }, storage::{ScanOutcome, record_deposit_address_scans}, utils::timed_sized_map::Timestamp, @@ -147,10 +147,7 @@ fn apply_balance_read( account: watched.account, address: watched.address, balance, - automatic: Some(AutomaticDeposit { - fee: automatic_deposit_fee, - valid_until: watched.valid_until.as_nanos(), - }), + automatic_deposit_fee: Some(automatic_deposit_fee), }, ); Some(ScanOutcome::Queued) diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 0c664fb95..15dcbee77 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -4,9 +4,7 @@ use crate::{ }, deposit::automatic::scan::{Scan, apply_balance_read, scan_watched_addresses}, guard::deposit_sol_guard, - state::{ - AutomaticDeposit, TaskType, WatchedAddress, event::EventType, mutate_state, read_state, - }, + state::{TaskType, WatchedAddress, event::EventType, mutate_state, read_state}, storage::{ScanOutcome, deposit_address_scan_count}, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, EventsAssert, MINIMUM_DEPOSIT_AMOUNT, MINUTE_NANOS, REGISTERED_AT, @@ -67,10 +65,7 @@ async fn should_queue_a_deposit_reaching_the_minimum_with_the_fee_in_force() { account: account(1), address: expected.address, balance: expected.balance, - automatic: Some(AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: VALID_UNTIL, - }), + automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), }) .assert_no_more_events(); read_state(|s| { From 5b094c4a70ac49a7f1b8a234e41e2e65a027a2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 11:24:54 +0000 Subject: [PATCH 082/146] refactor(minter): queue a scanned deposit with its fee under the new name Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 2 +- minter/src/deposit/automatic/scan/mod.rs | 2 +- minter/src/deposit/automatic/scan/tests.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 2aa13bf7f..76d934827 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1365,7 +1365,7 @@ mod automatic_deposit_sol_tests { account: DEFAULT_CALLER_ACCOUNT, address: DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap(), balance: BALANCE_ABOVE_MINIMUM, - automatic_deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), + deposit_fee: Some(Setup::DEFAULT_AUTOMATIC_DEPOSIT_FEE), }] ); }); diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs index 1f4870fd9..33f0cd7e2 100644 --- a/minter/src/deposit/automatic/scan/mod.rs +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -147,7 +147,7 @@ fn apply_balance_read( account: watched.account, address: watched.address, balance, - automatic_deposit_fee: Some(automatic_deposit_fee), + deposit_fee: Some(automatic_deposit_fee), }, ); Some(ScanOutcome::Queued) diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 15dcbee77..0db66b93e 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -65,7 +65,7 @@ async fn should_queue_a_deposit_reaching_the_minimum_with_the_fee_in_force() { account: account(1), address: expected.address, balance: expected.balance, - automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), + deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), }) .assert_no_more_events(); read_state(|s| { From e4dfc6a5df3a1b4db59e79185fa26f5aae96404e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 13:04:46 +0000 Subject: [PATCH 083/146] test(minter): read the scanned addresses from the typed getMultipleAccounts parameters Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/automatic/scan/tests.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 0db66b93e..344d508c5 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -16,7 +16,7 @@ use crate::{ use cksol_types::DepositSolStatus; use cksol_types_internal::InitArgs; use ic_canister_runtime::IcError; -use sol_rpc_types::{RpcConfig, RpcSources}; +use sol_rpc_types::{GetMultipleAccountsParams, RpcConfig, RpcSources}; const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; const TICK_AT: u64 = REGISTERED_AT + MINUTE_NANOS; @@ -305,11 +305,16 @@ impl RequestedAddressesExt for TestCanisterRuntime { self.sent_update_calls() .iter() .map(|call| { - assert_eq!(call.method, "jsonRequest"); - let (_sources, _config, request): (RpcSources, Option, String) = - call.args(); - let request: serde_json::Value = serde_json::from_str(&request).unwrap(); - serde_json::from_value(request["params"][0].clone()).unwrap() + assert_eq!(call.method, "getMultipleAccounts"); + let (_sources, _config, params): ( + RpcSources, + Option, + GetMultipleAccountsParams, + ) = call.args(); + Vec::from(params.pubkeys) + .into_iter() + .map(String::from) + .collect() }) .collect() } From 3d503ac61822b3fd897414043cfb9bfe4e52a8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:41:26 +0000 Subject: [PATCH 084/146] feat(minter): let deposit_sol yield to a deposit queued by a scan during its balance read Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/sweep/mod.rs | 23 +++++++++++ minter/src/deposit/sweep/tests.rs | 61 +++++++++++++++++++++++++++-- minter/src/test_fixtures/runtime.rs | 1 + 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/minter/src/deposit/sweep/mod.rs b/minter/src/deposit/sweep/mod.rs index d6711f411..fa2d0b73a 100644 --- a/minter/src/deposit/sweep/mod.rs +++ b/minter/src/deposit/sweep/mod.rs @@ -55,10 +55,22 @@ pub async fn deposit_sol( let master_key = minter_public_key().map_err(|e| DepositSolError::TemporarilyUnavailable(e.to_string()))?; let deposit_address = account_address(&master_key, &account); + let latest_deposit_before_read = read_state(|state| state.deposits().latest_id(&account)); let result = get_balance(runtime, deposit_address) .await .map_err(DepositSolError::from) .and_then(|balance| balance_above_minimum(balance, minimum_deposit_amount)); + if let Some(deposit_id) = deposit_queued_meanwhile(&account, latest_deposit_before_read) { + charge_rpc_call( + runtime, + RpcCallCharge { + attached_cycles: GET_BALANCE_CYCLES, + fee_on_success: 0, + }, + &result, + ); + return Ok(deposit_id); + } charge_rpc_call( runtime, RpcCallCharge { @@ -124,6 +136,17 @@ pub fn deposit_status(deposit_id: DepositSolId) -> DepositSolStatus { read_state(|state| state.deposits().status(deposit_id)) } +/// The deposit a balance scan of the automatic flow queued for the account while the +/// balance read of `deposit_sol` was in flight, whatever stage it reached since: the read +/// must not queue a second deposit from the same balance. +fn deposit_queued_meanwhile( + account: &Account, + latest_deposit_before_read: Option, +) -> Option { + read_state(|state| state.deposits().latest_id(account)) + .filter(|latest| Some(*latest) != latest_deposit_before_read) +} + fn balance_above_minimum( balance: Lamport, minimum_deposit_amount: Lamport, diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index be2919bf8..13f2db3fa 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -1,11 +1,12 @@ use crate::{ constants::{GET_BALANCE_CYCLES, RENT_EXEMPTION_THRESHOLD}, deposit::sweep::{deposit_sol, deposit_status}, - state::{event::EventType, read_state}, + state::{AutomaticDeposit, event::EventType, read_state}, storage::with_event_iter, test_fixtures::{ - DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, EventsAssert, MINIMUM_DEPOSIT_AMOUNT, - MINTER_ACCOUNT, account, deposit::DEPOSITOR_ACCOUNT, events, init_schnorr_master_key, + AUTOMATIC_DEPOSIT_FEE, DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, EventsAssert, + MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, REGISTERED_AT, WATCH_WINDOW_NANOS, account, + deposit::DEPOSITOR_ACCOUNT, events, flow::deposit::DepositFlow, init_schnorr_master_key, init_state, queued_deposit_of, runtime::TestCanisterRuntime, signature, }, }; @@ -16,6 +17,7 @@ use ic_canister_runtime::IcError; use ic_cdk::call::RejectCode; use icrc_ledger_types::icrc1::account::Account; use sol_rpc_types::MultiRpcResult; +use tokio::join; const GET_BALANCE_REFUND: u128 = GET_BALANCE_CYCLES / 2; const EXPLICIT_DEFAULT_SUBACCOUNT: Account = Account { @@ -151,6 +153,59 @@ async fn should_queue_deposits_from_minimum_with_sequential_ids() { .assert_no_more_events(); } +#[tokio::test] +async fn should_return_the_deposit_a_scan_queued_during_the_balance_read_and_charge_the_read_only() +{ + let runtime = runtime().add_get_balance_response(MINIMUM_DEPOSIT_AMOUNT); + + assert_yields_to_the_deposit_queued_by_a_scan_during_the_read(runtime).await; +} + +#[tokio::test] +async fn should_return_the_deposit_a_scan_queued_during_a_rejected_balance_read_and_charge_the_read_only() + { + let runtime = runtime().add_stub_error(IcError::CallRejected { + code: RejectCode::SysTransient, + message: "SOL RPC canister is stopped".to_string(), + }); + + assert_yields_to_the_deposit_queued_by_a_scan_during_the_read(runtime).await; +} + +async fn assert_yields_to_the_deposit_queued_by_a_scan_during_the_read( + runtime: TestCanisterRuntime, +) { + init_state(); + init_schnorr_master_key(); + let automatic = AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + }; + + let (result, scanned) = join!(deposit_sol(&runtime, DEPOSITOR_ACCOUNT), async { + DepositFlow::queue_automatic(DEPOSITOR_ACCOUNT, MINIMUM_DEPOSIT_AMOUNT, automatic) + }); + + assert_eq!(result, Ok(scanned.deposit_id)); + assert_eq!( + runtime.msg_cycles_accepted(), + [GET_BALANCE_CYCLES - GET_BALANCE_REFUND] + ); + EventsAssert::from_recorded() + .expect_event_eq(EventType::QueuedDeposit { + deposit_id: scanned.deposit_id, + account: DEPOSITOR_ACCOUNT, + address: scanned.address, + balance: queued_deposit_of(DEPOSITOR_ACCOUNT, scanned.sweepable_amount).balance, + automatic: Some(automatic), + }) + .assert_no_more_events(); + assert_eq!( + read_state(|state| state.deposits().in_flight_id(&DEPOSITOR_ACCOUNT)), + Some(scanned.deposit_id) + ); +} + #[tokio::test] async fn should_reject_an_account_whose_latest_deposit_is_quarantined() { init_state(); diff --git a/minter/src/test_fixtures/runtime.rs b/minter/src/test_fixtures/runtime.rs index 0a8af73c5..f60cd066f 100644 --- a/minter/src/test_fixtures/runtime.rs +++ b/minter/src/test_fixtures/runtime.rs @@ -296,6 +296,7 @@ impl Runtime for RecordingStubRuntime { cycles, args: encode_args(args).expect("Failed to encode the call arguments"), }); + suspend_like_an_inter_canister_call().await; self.stub.update_call(id, method, (), cycles).await } From b95d04f5a22af2d50a070827c0652d84617e6c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:47:17 +0000 Subject: [PATCH 085/146] test(minter): queue the scanned deposit of the yield test with the fee alone Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/sweep/tests.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index 13f2db3fa..474d960df 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -1,13 +1,13 @@ use crate::{ constants::{GET_BALANCE_CYCLES, RENT_EXEMPTION_THRESHOLD}, deposit::sweep::{deposit_sol, deposit_status}, - state::{AutomaticDeposit, event::EventType, read_state}, + state::{event::EventType, read_state}, storage::with_event_iter, test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, DEPOSIT_SOL_FEE, DEPOSIT_SOL_REQUIRED_CYCLES, EventsAssert, - MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, REGISTERED_AT, WATCH_WINDOW_NANOS, account, - deposit::DEPOSITOR_ACCOUNT, events, flow::deposit::DepositFlow, init_schnorr_master_key, - init_state, queued_deposit_of, runtime::TestCanisterRuntime, signature, + MINIMUM_DEPOSIT_AMOUNT, MINTER_ACCOUNT, account, deposit::DEPOSITOR_ACCOUNT, events, + flow::deposit::DepositFlow, init_schnorr_master_key, init_state, queued_deposit_of, + runtime::TestCanisterRuntime, signature, }, }; use assert_matches::assert_matches; @@ -177,13 +177,13 @@ async fn assert_yields_to_the_deposit_queued_by_a_scan_during_the_read( ) { init_state(); init_schnorr_master_key(); - let automatic = AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, - }; let (result, scanned) = join!(deposit_sol(&runtime, DEPOSITOR_ACCOUNT), async { - DepositFlow::queue_automatic(DEPOSITOR_ACCOUNT, MINIMUM_DEPOSIT_AMOUNT, automatic) + DepositFlow::queue_automatic( + DEPOSITOR_ACCOUNT, + MINIMUM_DEPOSIT_AMOUNT, + AUTOMATIC_DEPOSIT_FEE, + ) }); assert_eq!(result, Ok(scanned.deposit_id)); @@ -197,7 +197,7 @@ async fn assert_yields_to_the_deposit_queued_by_a_scan_during_the_read( account: DEPOSITOR_ACCOUNT, address: scanned.address, balance: queued_deposit_of(DEPOSITOR_ACCOUNT, scanned.sweepable_amount).balance, - automatic: Some(automatic), + automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), }) .assert_no_more_events(); assert_eq!( From d2ca808a3c1250c317bda384afa55cf67589fc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 11:26:50 +0000 Subject: [PATCH 086/146] test(minter): expect the scanned deposit of the yield test with its fee under the new name Co-Authored-By: Claude Fable 5.1 --- minter/src/deposit/sweep/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index 474d960df..7a74a3d92 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -197,7 +197,7 @@ async fn assert_yields_to_the_deposit_queued_by_a_scan_during_the_read( account: DEPOSITOR_ACCOUNT, address: scanned.address, balance: queued_deposit_of(DEPOSITOR_ACCOUNT, scanned.sweepable_amount).balance, - automatic_deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), + deposit_fee: Some(AUTOMATIC_DEPOSIT_FEE), }) .assert_no_more_events(); assert_eq!( From 132bfa6d721592814115c343cdbefaabab9e4659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 14:06:44 +0000 Subject: [PATCH 087/146] docs: state that the balance scan uses the typed getMultipleAccounts endpoint Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index f7028e112..ad5e05241 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -169,7 +169,7 @@ The tick and the first gaps are coarser than the 30 second cadence of the ckETH Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is registered again, for a fresh window and schedule starting at the drop, provided the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The fresh schedule starts with the burst, since the scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is not bounded by the original window, so a sweep that keeps failing is detected and re-armed again until it lands, at the ckSOL minter's expense; this is accepted because a dropped sweep is an infrastructure failure that is alerted on and that no user can bring about, and because a bound at the original window would only have capped such a chain at a day while costing every queued deposit its window end. An account on the watchlist never has a deposit in flight. -**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call and the response size estimate passed with it, which the SOL RPC canister uses as the response size limit of the call, allowance for the HTTP headers included, are sized by the number of addresses it carries. Pending a typed `getMultipleAccounts` method of the SOL RPC canister, the call goes through its generic `jsonRequest` endpoint, which hands back the bare JSON-RPC `result`, parsed by the ckSOL minter into one balance per address. A call that fails counts as a scan of its entries, which advance to their next slot as after a balance below the minimum, so that a persistent failure of the SOL RPC canister costs the ckSOL minter no more than the schedule and costs the user the scans lost to the failure. +**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call and the response size estimate passed with it, which the SOL RPC canister uses as the response size limit of the call, allowance for the HTTP headers included, are sized by the number of addresses it carries. The call uses the typed `getMultipleAccounts` endpoint of the SOL RPC canister, so the automatic flow requires a version of that canister which offers it. A call that fails counts as a scan of its entries, which advance to their next slot as after a balance below the minimum, so that a persistent failure of the SOL RPC canister costs the ckSOL minter no more than the schedule and costs the user the scans lost to the failure. An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. From 6995961d92151e91d4c6719ce60df3a285b4364d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:37:16 +0000 Subject: [PATCH 088/146] feat(minter): re-arm the automatic deposits of a dropped sweep Co-Authored-By: Claude Fable 5.1 --- minter/src/state/audit.rs | 4 +- minter/src/state/deposits/mod.rs | 6 +- minter/src/state/deposits/tests.rs | 3 +- minter/src/state/mod.rs | 25 ++++-- minter/src/state/tests.rs | 100 ++++++++++++++++++++++- minter/src/state/watchlist/mod.rs | 62 +++++++++++--- minter/src/state/watchlist/tests.rs | 121 +++++++++++++++++++++++++++- 7 files changed, 294 insertions(+), 27 deletions(-) diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 2fb943166..1148a1872 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -39,10 +39,10 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 state.process_transaction_succeeded(signature); } EventType::FailedTransaction { signature } => { - state.process_transaction_failed(signature); + state.process_transaction_failed(signature, timestamp); } EventType::ExpiredTransaction { signature } => { - state.process_transaction_expired(signature); + state.process_transaction_expired(signature, timestamp); } EventType::QueuedDeposit { deposit_id, diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 531838944..12f33566a 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -256,8 +256,9 @@ impl Deposits { expected_received } - /// Drops every deposit of the given swept sweep and releases their accounts. - pub(super) fn drop_swept(&mut self, signature: &Signature) { + /// Drops every deposit of the given swept sweep, releases their accounts and returns + /// the dropped deposits in the order of their ids. + pub(super) fn drop_swept(&mut self, signature: &Signature) -> Vec { let sweep = self .swept .remove(signature) @@ -273,6 +274,7 @@ impl Deposits { }, ); } + sweep.deposits().values().copied().collect() } /// Moves every deposit of the given finalized sweep to the quarantine, keeping their diff --git a/minter/src/state/deposits/tests.rs b/minter/src/state/deposits/tests.rs index 1cb8d3dbe..49f291c76 100644 --- a/minter/src/state/deposits/tests.rs +++ b/minter/src/state/deposits/tests.rs @@ -270,8 +270,9 @@ mod drop_swept { &sweep_signature, ); - deposits.drop_swept(&sweep_signature); + let dropped_deposits = deposits.drop_swept(&sweep_signature); + assert_eq!(dropped_deposits, vec![first, third]); assert!(deposits.swept().is_empty()); let dropped = |deposit| SweptDeposit { deposit, diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index d3338d4e4..14ac9fd16 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -14,9 +14,10 @@ use crate::{ utils::insertion_ordered_map::InsertionOrderedMap, }; use candid::Principal; +use canlog::log; use cksol_types::{DepositSolId, TxFinalizedStatus, WithdrawSolStatus}; use cksol_types_internal::SolanaNetwork; -use cksol_types_internal::{Ed25519KeyName, InitArgs, UpgradeArgs}; +use cksol_types_internal::{Ed25519KeyName, InitArgs, UpgradeArgs, log::Priority}; use ic_canister_runtime::Runtime; use ic_ed25519::PublicKey; use icrc_ledger_types::icrc1::account::Account; @@ -232,7 +233,7 @@ impl State { &self.created_withdrawal_txs } - pub fn process_transaction_expired(&mut self, signature: &Signature) { + pub fn process_transaction_expired(&mut self, signature: &Signature, timestamp: u64) { assert!( !self.succeeded_transactions.contains(signature), "BUG: cannot mark already succeeded transaction {signature} as expired" @@ -248,13 +249,27 @@ impl State { panic!("BUG: cannot mark non-submitted transaction {signature} as expired") }); match transaction { - MinterTransaction::SweepDeposit { .. } => self.deposits.drop_swept(signature), + MinterTransaction::SweepDeposit { .. } => self.drop_sweep(signature, timestamp), MinterTransaction::Withdrawal { .. } => { panic!("BUG: durable-nonce withdrawal transaction {signature} cannot expire") } } } + /// Drops the deposits of the sweep and puts the ones the automatic flow detected back on + /// the watchlist for the rest of their window, so that the funds still on their deposit + /// addresses are swept again without the user's intervention. + fn drop_sweep(&mut self, signature: &Signature, timestamp: u64) { + for deposit in self.deposits.drop_swept(signature) { + let rearmed = self.watchlist.rearm(timestamp, &deposit); + log!( + Priority::Debug, + "Re-arm of the watch of account {:?} after sweep {signature} was dropped: {rearmed:?}", + deposit.account + ); + } + } + pub fn succeeded_transactions(&self) -> &BTreeSet { &self.succeeded_transactions } @@ -802,7 +817,7 @@ impl State { }); } - fn process_transaction_failed(&mut self, signature: &Signature) { + fn process_transaction_failed(&mut self, signature: &Signature, timestamp: u64) { assert!( !self.succeeded_transactions.contains(signature), "Attempted to mark already succeeded transaction {signature:?} as failed" @@ -814,7 +829,7 @@ impl State { panic!("Attempted to mark unknown transaction {signature:?} as failed") }); match &transaction { - MinterTransaction::SweepDeposit { .. } => self.deposits.drop_swept(signature), + MinterTransaction::SweepDeposit { .. } => self.drop_sweep(signature, timestamp), MinterTransaction::Withdrawal { nonce_account, .. } => { self.nonce_pool.free(nonce_account) } diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index c09b720c9..0679f621c 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -166,13 +166,13 @@ mod watched_deposit_addresses { mod swept_deposits { use super::*; use crate::{ - state::{audit::replay_events, reset_state}, + state::{audit::replay_events, reset_state, watchlist::MAX_WATCHED_ADDRESSES}, storage::{reset_events, with_event_iter}, test_fixtures::{ DEFAULT_BLOCK_HEIGHT, 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, }, }; @@ -249,6 +249,102 @@ mod swept_deposits { } } + #[test] + fn should_rearm_the_automatic_deposits_of_a_failed_sweep_within_their_window() { + let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); + + assert_rearms_the_automatic_deposits_within_their_window_when_dropped_by( + EventType::FailedTransaction { + signature: sweep_signature, + }, + ); + } + + #[test] + fn should_rearm_the_automatic_deposits_of_an_expired_sweep_within_their_window() { + let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); + + assert_rearms_the_automatic_deposits_within_their_window_when_dropped_by( + EventType::ExpiredTransaction { + signature: sweep_signature, + }, + ); + } + + fn assert_rearms_the_automatic_deposits_within_their_window_when_dropped_by( + drop_event: EventType, + ) { + const DROPPED_AT: u64 = VALID_UNTIL - 1; + init_state(); + init_schnorr_master_key(); + let automatic = AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: VALID_UNTIL, + }; + let manual = DepositFlow::queue(account(1), 10_000_000); + let within_window = DepositFlow::queue_automatic(account(2), 20_000_000, automatic); + let after_window = DepositFlow::queue_automatic( + account(3), + 30_000_000, + AutomaticDeposit { + valid_until: DROPPED_AT - 1, + ..automatic + }, + ); + SweepFlow::of([manual, within_window, after_window]) + .submit(signature(SWEEP_SIGNATURE_INDEX)); + + mutate_state(|state| { + process_event( + state, + drop_event, + &TestCanisterRuntime::new().add_times([DROPPED_AT, DROPPED_AT]), + ) + }); + + let rearmed = WatchedAddress { + account: account(2), + address: within_window.address, + valid_until: VALID_UNTIL, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(REGISTERED_AT), + }; + read_state(|s| { + assert_eq!(s.deposits().dropped().len(), 3); + assert_eq!(s.watchlist().iter().collect::>(), vec![rearmed]); + assert_eq!(s.watchlist().due(DROPPED_AT), vec![rearmed]); + }); + let replayed = replayed_recorded_log(); + read_state(|live_state| assert_eq!(&replayed, live_state)); + } + + #[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, + AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: VALID_UNTIL, + }, + ) + .sweep(signature(SWEEP_SIGNATURE_INDEX)); + for index in 1..=MAX_WATCHED_ADDRESSES.get() { + watch(VALID_UNTIL - 1, account(index)); + } + + fail_transaction(dropped.signature); + + read_state(|s| { + assert_eq!(s.watchlist().get(VALID_UNTIL - 1, &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(); diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index af03ebb5f..4de50ab90 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -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}; @@ -99,6 +102,15 @@ 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, + WindowClosed, + WatchlistFull, +} + impl Default for Watchlist { fn default() -> Self { Self { @@ -116,19 +128,47 @@ impl Watchlist { account: Account, address: Address, ) -> Result { - match self + let inserted = self .entries - .insert(now, account, ScanProgress::unscanned(address, now)) - { + .insert(now, account, ScanProgress::unscanned(address, now)); + self.admitted(now, &account, inserted).map_err(|e| match e { + RearmError::WatchlistFull => WatchlistFull, + RearmError::WindowClosed | RearmError::ManualDeposit => { + unreachable!("BUG: a registration for the window starting now is admitted or full") + } + }) + } + + /// Puts the account of a dropped deposit back on the watchlist for the rest of the + /// window the automatic flow detected the deposit in, with a new schedule starting now. An + /// account that is already watched keeps its registration, and a window that has closed + /// admits nothing. + pub fn rearm( + &mut self, + now: Timestamp, + dropped: &QueuedDeposit, + ) -> Result { + let automatic = dropped.automatic.ok_or(RearmError::ManualDeposit)?; + let entry = Entry { + value: ScanProgress::unscanned(dropped.address, now), + expires_at: Timestamp::from_nanos(automatic.valid_until), + }; + let inserted = self.entries.insert_entry(now, dropped.account, entry); + self.admitted(now, &dropped.account, inserted) + } + + fn admitted( + &self, + now: Timestamp, + account: &Account, + inserted: Result, InsertError>, + ) -> Result { + match inserted { Ok(_) | Err(InsertError::AlreadyPresent { .. }) => Ok(self - .get(now, &account) + .get(now, account) .expect("BUG: a registration is live right after its insertion")), - Err(InsertError::AtCapacity { .. }) => Err(WatchlistFull), - Err(InsertError::Expired { .. }) => { - unreachable!( - "BUG: a registration for the window starting now never expires on insertion" - ) - } + Err(InsertError::AtCapacity { .. }) => Err(RearmError::WatchlistFull), + Err(InsertError::Expired { .. }) => Err(RearmError::WindowClosed), } } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 033d379a5..1ea29002a 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -1,10 +1,13 @@ use crate::{ - state::watchlist::{ - MAX_WATCHED_ADDRESSES, WatchedAddress, Watchlist, WatchlistFull, scan_slots, + state::{ + AutomaticDeposit, QueuedDeposit, + watchlist::{ + MAX_WATCHED_ADDRESSES, RearmError, WatchedAddress, Watchlist, WatchlistFull, scan_slots, + }, }, test_fixtures::{ - HOUR_NANOS, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, deposit_address, - register, + AUTOMATIC_DEPOSIT_FEE, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINUTE_NANOS, REGISTERED_AT, + WATCH_WINDOW_NANOS, account, at, automatic_deposit_of, deposit_address, register, }, }; use icrc_ledger_types::icrc1::account::Account; @@ -186,6 +189,116 @@ fn should_count_the_live_registrations_and_report_the_oldest() { assert_eq!(watchlist.len(), 2); } +#[test] +fn should_rearm_an_account_for_the_rest_of_its_window_from_the_first_slot() { + let mut watchlist = Watchlist::default(); + let rearmed_at = REGISTERED_AT + 2 * HOUR_NANOS; + + let rearmed = watchlist + .rearm(at(rearmed_at), &dropped(account(1), VALID_UNTIL)) + .unwrap(); + + assert_eq!( + rearmed, + WatchedAddress { + next_scan_at: Some(at(rearmed_at)), + ..unscanned(account(1)) + } + ); + assert_eq!(watchlist.due(at(rearmed_at)), vec![rearmed]); + watchlist.record_scan(at(rearmed_at), &account(1)); + assert_eq!( + watchlist + .get(at(rearmed_at), &account(1)) + .unwrap() + .next_scan_at, + Some(at(rearmed_at + MINUTE_NANOS)) + ); + assert_eq!(watchlist.get(at(VALID_UNTIL + 1), &account(1)), None); +} + +#[test] +fn should_rearm_at_the_very_end_of_the_window() { + let mut watchlist = Watchlist::default(); + + let rearmed = watchlist + .rearm(at(VALID_UNTIL), &dropped(account(1), VALID_UNTIL)) + .unwrap(); + + assert_eq!(rearmed.valid_until, at(VALID_UNTIL)); + assert_eq!(watchlist.due(at(VALID_UNTIL)), vec![rearmed]); +} + +#[test] +fn should_not_rearm_an_account_whose_window_has_closed() { + let mut watchlist = Watchlist::default(); + + let result = watchlist.rearm(at(VALID_UNTIL + 1), &dropped(account(1), VALID_UNTIL)); + + assert_eq!(result, Err(RearmError::WindowClosed)); + assert!(watchlist.is_empty()); +} + +#[test] +fn should_not_rearm_an_account_when_full_of_live_entries() { + let mut watchlist = Watchlist::default(); + for index in 1..=MAX_WATCHED_ADDRESSES.get() { + register(&mut watchlist, REGISTERED_AT, account(index)); + } + + let result = watchlist.rearm( + at(REGISTERED_AT + HOUR_NANOS), + &dropped(account(0), VALID_UNTIL), + ); + + assert_eq!(result, Err(RearmError::WatchlistFull)); + assert_eq!( + watchlist.get(at(REGISTERED_AT + HOUR_NANOS), &account(0)), + None + ); +} + +#[test] +fn should_not_rearm_a_manual_deposit() { + let mut watchlist = Watchlist::default(); + let manual = QueuedDeposit { + automatic: None, + ..dropped(account(1), VALID_UNTIL) + }; + + let result = watchlist.rearm(at(REGISTERED_AT), &manual); + + assert_eq!(result, Err(RearmError::ManualDeposit)); + assert!(watchlist.is_empty()); +} + +#[test] +fn should_keep_the_registration_of_an_account_that_is_already_watched() { + let mut watchlist = Watchlist::default(); + let registered = register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); + + let rearmed = watchlist + .rearm( + at(REGISTERED_AT + 2 * HOUR_NANOS), + &dropped(account(1), VALID_UNTIL), + ) + .unwrap(); + + assert_eq!(rearmed, registered); + assert_eq!(watchlist.len(), 1); +} + +fn dropped(account: Account, valid_until: u64) -> QueuedDeposit { + automatic_deposit_of( + account, + MINIMUM_DEPOSIT_AMOUNT, + AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until, + }, + ) +} + fn unscanned(account: Account) -> WatchedAddress { WatchedAddress { account, From c5638ae22065f0907019af550a71c654d4478431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:53:27 +0000 Subject: [PATCH 089/146] refactor(minter): re-arm with the drop time as a newtype Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 6 ++++-- minter/src/state/tests.rs | 12 ++++++------ minter/src/state/watchlist/tests.rs | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 14ac9fd16..c1742abd0 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -11,7 +11,7 @@ use crate::{ state::event::{ CreditedDeposit, Signer, TransactionPurpose, VersionedMessage, WithdrawalRequest, }, - utils::insertion_ordered_map::InsertionOrderedMap, + utils::{insertion_ordered_map::InsertionOrderedMap, timed_sized_map::Timestamp}, }; use candid::Principal; use canlog::log; @@ -261,7 +261,9 @@ impl State { /// addresses are swept again without the user's intervention. fn drop_sweep(&mut self, signature: &Signature, timestamp: u64) { for deposit in self.deposits.drop_swept(signature) { - let rearmed = self.watchlist.rearm(timestamp, &deposit); + let rearmed = self + .watchlist + .rearm(Timestamp::from_nanos(timestamp), &deposit); log!( Priority::Debug, "Re-arm of the watch of account {:?} after sweep {signature} was dropped: {rearmed:?}", diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 0679f621c..000b10d7a 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -169,7 +169,7 @@ mod swept_deposits { state::{audit::replay_events, reset_state, watchlist::MAX_WATCHED_ADDRESSES}, 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, watch, @@ -305,15 +305,15 @@ mod swept_deposits { let rearmed = WatchedAddress { account: account(2), address: within_window.address, - valid_until: VALID_UNTIL, + valid_until: at(VALID_UNTIL), scan_count: 0, last_scanned_at: None, - next_scan_at: Some(REGISTERED_AT), + next_scan_at: Some(at(REGISTERED_AT)), }; read_state(|s| { assert_eq!(s.deposits().dropped().len(), 3); assert_eq!(s.watchlist().iter().collect::>(), vec![rearmed]); - assert_eq!(s.watchlist().due(DROPPED_AT), vec![rearmed]); + assert_eq!(s.watchlist().due(at(DROPPED_AT)), vec![rearmed]); }); let replayed = replayed_recorded_log(); read_state(|live_state| assert_eq!(&replayed, live_state)); @@ -333,13 +333,13 @@ mod swept_deposits { ) .sweep(signature(SWEEP_SIGNATURE_INDEX)); for index in 1..=MAX_WATCHED_ADDRESSES.get() { - watch(VALID_UNTIL - 1, account(index)); + watch(at(VALID_UNTIL - 1), account(index)); } fail_transaction(dropped.signature); read_state(|s| { - assert_eq!(s.watchlist().get(VALID_UNTIL - 1, &account(0)), None); + assert_eq!(s.watchlist().get(at(VALID_UNTIL - 1), &account(0)), None); assert_eq!(s.watchlist().len(), MAX_WATCHED_ADDRESSES.get()); assert_eq!(s.deposits().in_flight_id(&account(0)), None); }); diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 1ea29002a..088cf09bb 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -243,7 +243,7 @@ fn should_not_rearm_an_account_whose_window_has_closed() { fn should_not_rearm_an_account_when_full_of_live_entries() { let mut watchlist = Watchlist::default(); for index in 1..=MAX_WATCHED_ADDRESSES.get() { - register(&mut watchlist, REGISTERED_AT, account(index)); + register(&mut watchlist, at(REGISTERED_AT), account(index)); } let result = watchlist.rearm( @@ -275,7 +275,7 @@ fn should_not_rearm_a_manual_deposit() { #[test] fn should_keep_the_registration_of_an_account_that_is_already_watched() { let mut watchlist = Watchlist::default(); - let registered = register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); + let registered = register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); let rearmed = watchlist .rearm( From 1094bee044bf38f0f6b359d02f42f35e602882e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:40:15 +0000 Subject: [PATCH 090/146] feat(minter): snapshot the watchlist before every upgrade Co-Authored-By: Claude Fable 5.1 --- integration_tests/src/fixtures.rs | 1 + integration_tests/tests/tests.rs | 67 ++++++++++++++++++++++++-- libs/types-internal/src/event.rs | 25 ++++++++++ minter/cksol_minter.did | 28 +++++++++++ minter/src/lifecycle.rs | 19 ++++++++ minter/src/lifecycle/tests.rs | 50 ++++++++++++++++++++ minter/src/main.rs | 5 ++ minter/src/state/audit.rs | 3 ++ minter/src/state/event.rs | 36 ++++++++++++++ minter/src/state/event/candid/mod.rs | 51 +++++++++++++++++++- minter/src/state/mod.rs | 7 ++- minter/src/state/tests.rs | 57 +++++++++++++++++++++- minter/src/state/watchlist/mod.rs | 59 ++++++++++++++++++++++- minter/src/state/watchlist/tests.rs | 70 ++++++++++++++++++++++++++++ minter/src/test_fixtures/mod.rs | 38 ++++++++++++++- 15 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 minter/src/lifecycle/tests.rs diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index a8b84769e..b230f47d4 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -36,6 +36,7 @@ pub const MINTER_ADDRESS: Address = address!("2YKPWqP51gUFEYo1FD2K3LoRE6Cd2f99E1 pub const DEPOSIT_AMOUNT: Lamport = 500_000_000; /// Minimum balance left on a deposit address to keep it rent-exempt. pub const RENT_EXEMPTION_THRESHOLD: Lamport = 890_880; +pub const MAX_WATCHED_ADDRESSES: u64 = 1_000; pub const WATCH_WINDOW: Duration = Duration::from_hours(24); pub const WATCH_WINDOW_NANOS: u64 = WATCH_WINDOW.as_nanos() as u64; diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 76d934827..a4e412a36 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -4,8 +4,9 @@ use candid::{Nat, Principal}; use cksol_int_tests::{ CkSolMinter, Setup, SetupBuilder, fixtures::{ - DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, MINTER_ADDRESS, - MockBuilder, RENT_EXEMPTION_THRESHOLD, SharedMockHttpOutcalls, WATCH_WINDOW_NANOS, + DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, + MAX_WATCHED_ADDRESSES, MINTER_ADDRESS, MockBuilder, RENT_EXEMPTION_THRESHOLD, + SharedMockHttpOutcalls, WATCH_WINDOW_NANOS, }, validator::FEE_PER_SIGNATURE, }; @@ -16,7 +17,7 @@ use cksol_types::{ }; use cksol_types_internal::{ UpgradeArgs, - event::{EventType, TransactionPurpose}, + event::{EventType, TransactionPurpose, WatchedDepositAddress}, log::Priority, }; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; @@ -275,9 +276,18 @@ mod lifecycle { .count() <= 1 ); + check!( + events + .iter() + .filter(|e| matches!(e, EventType::WatchedDepositAddresses { .. })) + .count() + == 1 + ); check!(events[1..].iter().all(|e| matches!( e, - EventType::Upgrade(_) | EventType::MinterPublicKeyFetched { .. } + EventType::Upgrade(_) + | EventType::MinterPublicKeyFetched { .. } + | EventType::WatchedDepositAddresses { .. } ))); }); @@ -1315,6 +1325,55 @@ mod automatic_deposit_sol_tests { setup.drop().await; } + #[tokio::test] + async fn should_restore_the_watchlist_across_an_upgrade() { + let setup = SetupBuilder::new().build().await; + let registration = setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should register the caller"); + + setup + .minter() + .upgrade(UpgradeArgs::default()) + .await + .expect("upgrade should succeed"); + + let restored = setup + .minter() + .watch_deposit_address(None) + .await + .expect("watch_deposit_address should report the restored registration"); + assert_eq!(restored, registration); + let valid_until = assert_matches!( + registration.status, + WatchDepositAddressStatus::Watching { valid_until, .. } => valid_until + ); + setup.minter().assert_that_events().await.satisfy(|events| { + let snapshots: Vec<&EventType> = events + .iter() + .filter(|e| matches!(e, EventType::WatchedDepositAddresses { .. })) + .collect(); + check!( + snapshots + == [&EventType::WatchedDepositAddresses { + window_nanos: WATCH_WINDOW_NANOS, + capacity: MAX_WATCHED_ADDRESSES, + entries: vec![WatchedDepositAddress { + account: DEFAULT_CALLER_ACCOUNT, + address: DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap(), + valid_until, + scan_count: 0, + last_scanned_at: None, + }], + }] + ); + }); + + setup.drop().await; + } + #[tokio::test] async fn should_queue_the_deposit_detected_by_the_scan_timer() { let setup = SetupBuilder::new().build().await; diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 24d1d8772..7a85935fc 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -143,6 +143,31 @@ pub enum EventType { /// The nonce value the transaction carries in place of a recent blockhash. nonce_value: Hash, }, + /// The snapshot of the watchlist of the automatic deposit flow taken before an upgrade, + /// which replay restores verbatim, since registrations are not recorded as events. + WatchedDepositAddresses { + /// The window of a registration, in nanoseconds. + window_nanos: u64, + /// The number of live registrations the watchlist holds at most. + capacity: u64, + /// Every registration, live or expired but not yet evicted. + entries: Vec, + }, +} + +/// One registration of a `WatchedDepositAddresses` event with its scan progress. +#[derive(Clone, Debug, PartialEq, CandidType, Deserialize)] +pub struct WatchedDepositAddress { + /// The account credited with the deposits detected on the deposit address. + pub account: Account, + /// The deposit address derived from the account. + pub address: Address, + /// The end of the registration window, in nanoseconds since the epoch. + pub valid_until: u64, + /// The number of scans of the deposit address so far. + pub scan_count: u32, + /// The time of the last scan, in nanoseconds since the epoch, if any. + pub last_scanned_at: Option, } /// The mint enqueued for one deposit of a `CreditedSweep` event. diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index bac8d25ea..7daf71b3d 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -441,6 +441,23 @@ type TransactionPurpose = variant { }; }; +// One registration of a `WatchedDepositAddresses` event with its scan progress. +// +// *WARNING*: This type is used exclusively by the debug `get_events` endpoint. +// Backwards-compatibility is not guaranteed. +type WatchedDepositAddress = record { + // The account credited with the deposits detected on the deposit address. + account: Account; + // The deposit address derived from the account. + address: Address; + // The end of the registration window, in nanoseconds since the epoch. + valid_until: nat64; + // The number of scans of the deposit address so far. + scan_count: nat32; + // The time of the last scan, in nanoseconds since the epoch, if any. + last_scanned_at: opt nat64; +}; + // A minter event payload. // // *WARNING*: This type is used exclusively by the debug `get_events` endpoint. @@ -563,6 +580,17 @@ type EventType = variant { // The nonce value the transaction carries in place of a recent blockhash. nonce_value: Hash; }; + // The snapshot of the watchlist of the automatic deposit flow taken before + // an upgrade, which replay restores verbatim, since registrations are not + // recorded as events. + WatchedDepositAddresses : record { + // The window of a registration, in nanoseconds. + window_nanos: nat64; + // The number of live registrations the watchlist holds at most. + capacity: nat64; + // Every registration, live or expired but not yet evicted. + entries: vec WatchedDepositAddress; + }; }; // A minter event. diff --git a/minter/src/lifecycle.rs b/minter/src/lifecycle.rs index e4ad8f488..a10674c64 100644 --- a/minter/src/lifecycle.rs +++ b/minter/src/lifecycle.rs @@ -11,6 +11,9 @@ use crate::{ use canlog::log; use cksol_types_internal::{InitArgs, UpgradeArgs, log::Priority}; +#[cfg(test)] +mod tests; + pub fn init(init_args: InitArgs, runtime: R) { log!( Priority::Info, @@ -20,6 +23,22 @@ pub fn init(init_args: InitArgs, runtime: R) { record_event(EventType::Init(init_args), &runtime); } +/// Records the watchlist of the automatic deposit flow, which lives on the heap, as a +/// snapshot event, so that replaying the event log after the upgrade restores it. The +/// snapshot is recorded even when the watchlist is empty, since replay restores the latest +/// one: skipping it would let an older snapshot resurrect registrations evicted since. +pub fn pre_upgrade(runtime: R) { + mutate_state(|s| { + let snapshot = s.watchlist().snapshot(); + log!( + Priority::Info, + "[pre_upgrade]: recording the snapshot of {} watched deposit addresses", + snapshot.entries.len() + ); + process_event(s, EventType::WatchedDepositAddresses(snapshot), &runtime); + }); +} + pub fn post_upgrade(upgrade_args: Option, runtime: R) { let start = runtime.instruction_counter(); diff --git a/minter/src/lifecycle/tests.rs b/minter/src/lifecycle/tests.rs new file mode 100644 index 000000000..b212687b0 --- /dev/null +++ b/minter/src/lifecycle/tests.rs @@ -0,0 +1,50 @@ +use crate::{ + lifecycle::pre_upgrade, + state::{ + event::{EventType, WatchlistSnapshot}, + mutate_state, read_state, + watchlist::MAX_WATCHED_ADDRESSES, + }, + test_fixtures::{ + EventsAssert, REGISTERED_AT, WATCH_WINDOW_NANOS, account, init_state, + runtime::TestCanisterRuntime, watch, + }, +}; + +#[test] +fn should_record_an_empty_snapshot_on_every_upgrade() { + init_state(); + + pre_upgrade(TestCanisterRuntime::new().add_times([REGISTERED_AT, REGISTERED_AT])); + + EventsAssert::from_recorded() + .expect_event_eq(EventType::WatchedDepositAddresses(WatchlistSnapshot { + window_nanos: WATCH_WINDOW_NANOS, + capacity: MAX_WATCHED_ADDRESSES.get() as u64, + entries: vec![], + })) + .assert_no_more_events(); +} + +#[test] +fn should_record_the_watchlist_without_evicting_expired_registrations() { + init_state(); + watch(REGISTERED_AT, account(1)); + mutate_state(|s| { + s.watchlist_mut() + .record_scan(REGISTERED_AT + 1, &account(1)); + }); + watch(REGISTERED_AT + 2, account(2)); + let watchlist_before = read_state(|s| s.watchlist().snapshot()); + let long_after_the_window = REGISTERED_AT + 10 * WATCH_WINDOW_NANOS; + + pre_upgrade( + TestCanisterRuntime::new().add_times([long_after_the_window, long_after_the_window]), + ); + + assert_eq!(watchlist_before.entries.len(), 2); + EventsAssert::from_recorded() + .expect_event_eq(EventType::WatchedDepositAddresses(watchlist_before.clone())) + .assert_no_more_events(); + assert_eq!(read_state(|s| s.watchlist().snapshot()), watchlist_before); +} diff --git a/minter/src/main.rs b/minter/src/main.rs index 006dc9401..f0eb836c3 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -46,6 +46,11 @@ fn init(args: MinterArg) { #[ic_cdk::init] fn init() {} +#[ic_cdk::pre_upgrade] +fn pre_upgrade() { + cksol_minter::lifecycle::pre_upgrade(IcCanisterRuntime::new()); +} + #[ic_cdk::post_upgrade] fn post_upgrade(args: Option) { match args { diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 1148a1872..db210944e 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -97,6 +97,9 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 } => { state.process_transaction_created(burn_indices, nonce_account, *nonce_value); } + EventType::WatchedDepositAddresses(snapshot) => { + state.restore_watchlist(snapshot); + } } } diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 8c458992f..181ad7349 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -227,6 +227,42 @@ pub enum EventType { #[cbor(n(2), with = "cbor::hash")] nonce_value: Hash, }, + /// The snapshot of the watchlist of the automatic deposit flow taken before an upgrade, + /// which replay restores verbatim, since registrations are not recorded as events. + #[n(15)] + WatchedDepositAddresses(#[n(0)] WatchlistSnapshot), +} + +/// Payload of the `WatchedDepositAddresses` event: the limits of the watchlist in force when +/// the snapshot was taken and every registration, live or expired but not yet evicted, in +/// the order the watchlist is rebuilt from. +#[derive(Clone, Eq, PartialEq, Debug, Decode, Encode)] +pub struct WatchlistSnapshot { + /// The window of a registration, in nanoseconds. + #[n(0)] + pub window_nanos: u64, + /// The number of live registrations the watchlist holds at most. + #[n(1)] + pub capacity: u64, + #[n(2)] + pub entries: Vec, +} + +/// One registration of a `WatchedDepositAddresses` event with its scan progress. +#[derive(Clone, Eq, PartialEq, Debug, Decode, Encode)] +pub struct WatchedDepositAddress { + #[n(0)] + pub account: Account, + #[cbor(n(1), with = "cbor::address")] + pub address: Address, + /// The end of the registration window, in nanoseconds since the epoch. + #[n(2)] + pub valid_until: u64, + #[n(3)] + pub scan_count: u32, + /// The time of the last scan, in nanoseconds since the epoch, if any. + #[n(4)] + pub last_scanned_at: Option, } /// The mint enqueued for one deposit of a `CreditedSweep` event. diff --git a/minter/src/state/event/candid/mod.rs b/minter/src/state/event/candid/mod.rs index 85171ab82..42fc9bb50 100644 --- a/minter/src/state/event/candid/mod.rs +++ b/minter/src/state/event/candid/mod.rs @@ -8,14 +8,14 @@ use crate::{ DepositBalance, event::{ CreditedDeposit, Event, EventType, Signer, TransactionPurpose, VersionedMessage, - WithdrawalRequest, + WatchedDepositAddress, WatchlistSnapshot, WithdrawalRequest, }, }, }; use cksol_types_internal::event::{ CreditedDeposit as CandidCreditedDeposit, Event as CandidEvent, EventType as CandidEventType, Signer as CandidSigner, TransactionPurpose as CandidTransactionPurpose, - VersionedTransactionMessage, + VersionedTransactionMessage, WatchedDepositAddress as CandidWatchedDepositAddress, }; use ic_ed25519::PublicKey; use sol_rpc_types::{ @@ -122,6 +122,17 @@ impl From for CandidEventType { nonce_account: CandidAddress::from(nonce_account), nonce_value: CandidHash::from(nonce_value), }, + EventType::WatchedDepositAddresses(snapshot) => { + CandidEventType::WatchedDepositAddresses { + window_nanos: snapshot.window_nanos, + capacity: snapshot.capacity, + entries: snapshot + .entries + .into_iter() + .map(CandidWatchedDepositAddress::from) + .collect(), + } + } } } } @@ -281,10 +292,46 @@ impl TryFrom for EventType { nonce_account: Address::from(nonce_account), nonce_value: Hash::from(nonce_value), }, + CandidEventType::WatchedDepositAddresses { + window_nanos, + capacity, + entries, + } => EventType::WatchedDepositAddresses(WatchlistSnapshot { + window_nanos, + capacity, + entries: entries + .into_iter() + .map(WatchedDepositAddress::from) + .collect(), + }), }) } } +impl From for CandidWatchedDepositAddress { + fn from(entry: WatchedDepositAddress) -> Self { + CandidWatchedDepositAddress { + account: entry.account, + address: CandidAddress::from(entry.address), + valid_until: entry.valid_until, + scan_count: entry.scan_count, + last_scanned_at: entry.last_scanned_at, + } + } +} + +impl From for WatchedDepositAddress { + fn from(entry: CandidWatchedDepositAddress) -> Self { + WatchedDepositAddress { + account: entry.account, + address: Address::from(entry.address), + valid_until: entry.valid_until, + scan_count: entry.scan_count, + last_scanned_at: entry.last_scanned_at, + } + } +} + impl TryFrom for VersionedMessage { type Error = InvalidCandidEvent; diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 30c7419a4..e8d73c023 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -9,7 +9,8 @@ use crate::{ build_batch_withdrawal_message, }, state::event::{ - CreditedDeposit, Signer, TransactionPurpose, VersionedMessage, WithdrawalRequest, + CreditedDeposit, Signer, TransactionPurpose, VersionedMessage, WatchlistSnapshot, + WithdrawalRequest, }, utils::{insertion_ordered_map::InsertionOrderedMap, timed_sized_map::Timestamp}, }; @@ -485,6 +486,10 @@ impl State { self.deposits.queue(deposit_id, deposit, queued_at); } + fn restore_watchlist(&mut self, snapshot: &WatchlistSnapshot) { + self.watchlist = Watchlist::from_snapshot(snapshot); + } + fn process_credited_sweep( &mut self, signature: &Signature, diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index d2e2c675b..d8d8a83b3 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -143,8 +143,12 @@ mod queued_deposits { mod watched_deposit_addresses { use super::*; - use crate::test_fixtures::{ - MINIMUM_DEPOSIT_AMOUNT, REGISTERED_AT, at, flow::deposit::DepositFlow, watch, + use crate::{ + state::WatchedAddress, + test_fixtures::{ + MINIMUM_DEPOSIT_AMOUNT, REGISTERED_AT, WATCH_WINDOW_NANOS, at, deposit_address, + flow::deposit::DepositFlow, replayed_recorded_log_after, watch, + }, }; #[test] @@ -161,6 +165,55 @@ mod watched_deposit_addresses { assert!(s.watchlist().get(at(REGISTERED_AT), &account(2)).is_some()); }); } + + #[test] + fn should_restore_the_latest_snapshot_and_apply_the_events_that_follow_it() { + init_state(); + init_schnorr_master_key(); + watch(REGISTERED_AT, account(1)); + watch(REGISTERED_AT, account(2)); + let stale_snapshot = read_state(|s| s.watchlist().snapshot()); + mutate_state(|s| s.watchlist_mut().remove(&account(2))); + let latest_snapshot = read_state(|s| s.watchlist().snapshot()); + let detected = DepositFlow::queue_automatic( + account(1), + 20_000_000, + AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + }, + ) + .sweep(signature(0xAA)); + read_state(|s| assert!(s.watchlist().is_empty())); + let dropped_at = REGISTERED_AT + 1; + mutate_state(|state| { + process_event( + state, + EventType::FailedTransaction { + signature: detected.signature, + }, + &TestCanisterRuntime::new().add_times([dropped_at, dropped_at]), + ) + }); + + let replayed = replayed_recorded_log_after([ + EventType::WatchedDepositAddresses(stale_snapshot), + EventType::WatchedDepositAddresses(latest_snapshot), + ]); + + read_state(|live_state| assert_eq!(&replayed, live_state)); + assert_eq!( + replayed.watchlist().iter().collect::>(), + vec![WatchedAddress { + account: account(1), + address: deposit_address(account(1)), + valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(REGISTERED_AT), + }] + ); + } } mod swept_deposits { diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 4bd861092..af6f07ccb 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -1,5 +1,8 @@ use crate::{ - state::QueuedDeposit, + state::{ + QueuedDeposit, + event::{WatchedDepositAddress, WatchlistSnapshot}, + }, utils::timed_sized_map::{Entry, InsertError, TimedSizedMap, Timestamp}, }; use icrc_ledger_types::icrc1::account::Account; @@ -119,6 +122,60 @@ impl Default for Watchlist { } impl Watchlist { + /// Rebuilds the watchlist verbatim from a snapshot: under the limits the snapshot records, + /// with the entries as stored, expired ones included, so that the state replayed from the + /// event log equals the state that recorded the snapshot. + /// + /// # Panics + /// + /// If the snapshot records no capacity or lists an account twice, since it was not + /// produced by [`Self::snapshot`] then. + pub fn from_snapshot(snapshot: &WatchlistSnapshot) -> Self { + let capacity = usize::try_from(snapshot.capacity).unwrap_or(usize::MAX); + let entries = snapshot.entries.iter().map(|entry| { + ( + entry.account, + Entry { + value: ScanProgress { + address: entry.address, + scheduled_from: Timestamp::from_nanos(entry.scheduled_from), + scan_count: entry.scan_count, + last_scanned_at: entry.last_scanned_at, + }, + expires_at: Timestamp::from_nanos(entry.valid_until), + }, + ) + }); + Self { + entries: TimedSizedMap::from_ordered_entries( + Duration::from_nanos(snapshot.window_nanos), + NonZeroUsize::new(capacity) + .expect("BUG: a watchlist snapshot records a non-zero capacity"), + entries, + ), + } + } + + /// The snapshot [`Self::from_snapshot`] rebuilds this watchlist from. + pub fn snapshot(&self) -> WatchlistSnapshot { + WatchlistSnapshot { + window_nanos: self.window_nanos(), + capacity: self.entries.capacity().get() as u64, + entries: self + .entries + .iter_by_expiry() + .map(|(account, entry)| WatchedDepositAddress { + account: *account, + address: entry.value.address, + valid_until: entry.expires_at.as_nanos(), + scheduled_from: entry.value.scheduled_from.as_nanos(), + scan_count: entry.value.scan_count, + last_scanned_at: entry.value.last_scanned_at, + }) + .collect(), + } + } + /// Registers the deposit address of the account for a window and a schedule starting now, /// or returns the live registration of the account as it is, without extending it. pub fn register( diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 0c9c8dcf9..9a094ebcf 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -1,6 +1,7 @@ use crate::{ state::{ QueuedDeposit, + event::{WatchedDepositAddress, WatchlistSnapshot}, watchlist::{ MAX_WATCHED_ADDRESSES, RearmError, WatchedAddress, Watchlist, WatchlistFull, scan_slots, }, @@ -264,6 +265,75 @@ fn should_keep_the_registration_of_an_account_that_is_already_watched() { assert_eq!(watchlist.len(), 1); } +#[test] +fn should_rebuild_the_watchlist_verbatim_from_its_snapshot() { + let mut watchlist = Watchlist::default(); + register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); + register(&mut watchlist, REGISTERED_AT, account(3)); + register(&mut watchlist, REGISTERED_AT, account(2)); + watchlist.record_scan(REGISTERED_AT + MINUTE_NANOS, &account(2)); + let snapshot = watchlist.snapshot(); + + let rebuilt = Watchlist::from_snapshot(&snapshot); + + assert_eq!(rebuilt, watchlist); + assert_eq!( + snapshot + .entries + .iter() + .map(|entry| entry.account) + .collect::>(), + vec![account(3), account(2), account(1)] + ); + assert_eq!(snapshot.window_nanos, WATCH_WINDOW_NANOS); + assert_eq!(snapshot.capacity, MAX_WATCHED_ADDRESSES.get() as u64); +} + +#[test] +fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { + let entries: Vec = (0..=MAX_WATCHED_ADDRESSES.get()) + .map(|index| WatchedDepositAddress { + account: account(index), + address: deposit_address(account(index)), + valid_until: VALID_UNTIL, + scan_count: 3, + last_scanned_at: Some(REGISTERED_AT + HOUR_NANOS), + }) + .collect(); + let snapshot = WatchlistSnapshot { + window_nanos: WATCH_WINDOW_NANOS, + capacity: MAX_WATCHED_ADDRESSES.get() as u64, + entries: entries.clone(), + }; + + let rebuilt = Watchlist::from_snapshot(&snapshot); + + assert_eq!(rebuilt.len(), entries.len()); + assert_eq!(rebuilt.snapshot(), snapshot); + assert_eq!(rebuilt.live_len(VALID_UNTIL + 1), 0); + assert_eq!( + rebuilt.get(VALID_UNTIL, &account(0)), + Some(WatchedAddress { + account: account(0), + address: deposit_address(account(0)), + valid_until: VALID_UNTIL, + scan_count: 3, + last_scanned_at: Some(REGISTERED_AT + HOUR_NANOS), + next_scan_at: Some(REGISTERED_AT + HOUR_NANOS + 2 * MINUTE_NANOS), + }) + ); +} + +#[test] +#[should_panic(expected = "non-zero capacity")] +fn should_panic_when_rebuilding_from_a_snapshot_without_capacity() { + Watchlist::from_snapshot(&WatchlistSnapshot { + window_nanos: WATCH_WINDOW_NANOS, + capacity: 0, + entries: vec![], + }); +} + fn dropped(account: Account) -> QueuedDeposit { automatic_deposit_of(account, MINIMUM_DEPOSIT_AMOUNT, AUTOMATIC_DEPOSIT_FEE) } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 10d162283..94b5e5daf 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -1344,7 +1344,8 @@ pub mod arb { state::{ DepositBalance, QueuedDeposit, event::{ - CreditedDeposit, Event, EventType, Signer, TransactionPurpose, WithdrawalRequest, + CreditedDeposit, Event, EventType, Signer, TransactionPurpose, + WatchedDepositAddress, WatchlistSnapshot, WithdrawalRequest, }, }, }; @@ -1675,9 +1676,44 @@ pub mod arb { nonce_value, } }), + arb_watchlist_snapshot().prop_map(EventType::WatchedDepositAddresses), ] } + fn arb_watchlist_snapshot() -> impl Strategy { + ( + any::(), + 1..=u64::MAX, + prop::collection::vec(arb_watched_deposit_address(), 0..5), + ) + .prop_map(|(window_nanos, capacity, entries)| WatchlistSnapshot { + window_nanos, + capacity, + entries, + }) + } + + fn arb_watched_deposit_address() -> impl Strategy { + ( + arb_account(), + arb_address(), + any::(), + any::(), + prop::option::of(any::()), + ) + .prop_map( + |(account, address, valid_until, scan_count, last_scanned_at)| { + WatchedDepositAddress { + account, + address, + valid_until, + scan_count, + last_scanned_at, + } + }, + ) + } + fn arb_credited_deposit() -> impl Strategy { (any::(), any::()).prop_map(|(deposit_id, amount_to_mint)| CreditedDeposit { deposit_id, From f1bfda8e34f01402697139c9676e3ccfb8a3afef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:47:20 +0000 Subject: [PATCH 091/146] refactor(minter): re-arm through the watchlist of the deposits Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 3 ++- minter/src/state/tests.rs | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index c1742abd0..0563c12d0 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -262,7 +262,8 @@ impl State { fn drop_sweep(&mut self, signature: &Signature, timestamp: u64) { for deposit in self.deposits.drop_swept(signature) { let rearmed = self - .watchlist + .deposits + .watchlist_mut() .rearm(Timestamp::from_nanos(timestamp), &deposit); log!( Priority::Debug, diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 000b10d7a..610c9b145 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -308,11 +308,14 @@ mod swept_deposits { valid_until: at(VALID_UNTIL), scan_count: 0, last_scanned_at: None, - next_scan_at: Some(at(REGISTERED_AT)), + next_scan_at: Some(at(DROPPED_AT)), }; read_state(|s| { assert_eq!(s.deposits().dropped().len(), 3); - assert_eq!(s.watchlist().iter().collect::>(), vec![rearmed]); + assert_eq!( + s.watchlist().iter(at(DROPPED_AT)).collect::>(), + vec![rearmed] + ); assert_eq!(s.watchlist().due(at(DROPPED_AT)), vec![rearmed]); }); let replayed = replayed_recorded_log(); From eaffc66018c2dfb4092f6aec6d4aab06fbf546a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:55:27 +0000 Subject: [PATCH 092/146] refactor(minter): convert the snapshot times at the event boundary Co-Authored-By: Claude Fable 5.1 --- minter/src/lifecycle/tests.rs | 8 ++++---- minter/src/state/tests.rs | 8 ++++---- minter/src/state/watchlist/mod.rs | 4 ++-- minter/src/state/watchlist/tests.rs | 18 +++++++++--------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/minter/src/lifecycle/tests.rs b/minter/src/lifecycle/tests.rs index b212687b0..f623448ee 100644 --- a/minter/src/lifecycle/tests.rs +++ b/minter/src/lifecycle/tests.rs @@ -6,7 +6,7 @@ use crate::{ watchlist::MAX_WATCHED_ADDRESSES, }, test_fixtures::{ - EventsAssert, REGISTERED_AT, WATCH_WINDOW_NANOS, account, init_state, + EventsAssert, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, init_state, runtime::TestCanisterRuntime, watch, }, }; @@ -29,12 +29,12 @@ fn should_record_an_empty_snapshot_on_every_upgrade() { #[test] fn should_record_the_watchlist_without_evicting_expired_registrations() { init_state(); - watch(REGISTERED_AT, account(1)); + watch(at(REGISTERED_AT), account(1)); mutate_state(|s| { s.watchlist_mut() - .record_scan(REGISTERED_AT + 1, &account(1)); + .record_scan(at(REGISTERED_AT + 1), &account(1)); }); - watch(REGISTERED_AT + 2, account(2)); + watch(at(REGISTERED_AT + 2), account(2)); let watchlist_before = read_state(|s| s.watchlist().snapshot()); let long_after_the_window = REGISTERED_AT + 10 * WATCH_WINDOW_NANOS; diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index d8d8a83b3..29e9223e6 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -170,8 +170,8 @@ mod watched_deposit_addresses { fn should_restore_the_latest_snapshot_and_apply_the_events_that_follow_it() { init_state(); init_schnorr_master_key(); - watch(REGISTERED_AT, account(1)); - watch(REGISTERED_AT, account(2)); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT), account(2)); let stale_snapshot = read_state(|s| s.watchlist().snapshot()); mutate_state(|s| s.watchlist_mut().remove(&account(2))); let latest_snapshot = read_state(|s| s.watchlist().snapshot()); @@ -207,10 +207,10 @@ mod watched_deposit_addresses { vec![WatchedAddress { account: account(1), address: deposit_address(account(1)), - valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + valid_until: at(REGISTERED_AT + WATCH_WINDOW_NANOS), scan_count: 0, last_scanned_at: None, - next_scan_at: Some(REGISTERED_AT), + next_scan_at: Some(at(REGISTERED_AT)), }] ); } diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index af6f07ccb..695dfd0b4 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -140,7 +140,7 @@ impl Watchlist { address: entry.address, scheduled_from: Timestamp::from_nanos(entry.scheduled_from), scan_count: entry.scan_count, - last_scanned_at: entry.last_scanned_at, + last_scanned_at: entry.last_scanned_at.map(Timestamp::from_nanos), }, expires_at: Timestamp::from_nanos(entry.valid_until), }, @@ -170,7 +170,7 @@ impl Watchlist { valid_until: entry.expires_at.as_nanos(), scheduled_from: entry.value.scheduled_from.as_nanos(), scan_count: entry.value.scan_count, - last_scanned_at: entry.value.last_scanned_at, + last_scanned_at: entry.value.last_scanned_at.map(Timestamp::as_nanos), }) .collect(), } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 9a094ebcf..04053a1e2 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -268,10 +268,10 @@ fn should_keep_the_registration_of_an_account_that_is_already_watched() { #[test] fn should_rebuild_the_watchlist_verbatim_from_its_snapshot() { let mut watchlist = Watchlist::default(); - register(&mut watchlist, REGISTERED_AT + HOUR_NANOS, account(1)); - register(&mut watchlist, REGISTERED_AT, account(3)); - register(&mut watchlist, REGISTERED_AT, account(2)); - watchlist.record_scan(REGISTERED_AT + MINUTE_NANOS, &account(2)); + register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); + register(&mut watchlist, at(REGISTERED_AT), account(3)); + register(&mut watchlist, at(REGISTERED_AT), account(2)); + watchlist.record_scan(at(REGISTERED_AT + MINUTE_NANOS), &account(2)); let snapshot = watchlist.snapshot(); let rebuilt = Watchlist::from_snapshot(&snapshot); @@ -310,16 +310,16 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { assert_eq!(rebuilt.len(), entries.len()); assert_eq!(rebuilt.snapshot(), snapshot); - assert_eq!(rebuilt.live_len(VALID_UNTIL + 1), 0); + assert_eq!(rebuilt.live_len(at(VALID_UNTIL + 1)), 0); assert_eq!( - rebuilt.get(VALID_UNTIL, &account(0)), + rebuilt.get(at(VALID_UNTIL), &account(0)), Some(WatchedAddress { account: account(0), address: deposit_address(account(0)), - valid_until: VALID_UNTIL, + valid_until: at(VALID_UNTIL), scan_count: 3, - last_scanned_at: Some(REGISTERED_AT + HOUR_NANOS), - next_scan_at: Some(REGISTERED_AT + HOUR_NANOS + 2 * MINUTE_NANOS), + last_scanned_at: Some(at(REGISTERED_AT + HOUR_NANOS)), + next_scan_at: Some(at(REGISTERED_AT + HOUR_NANOS + 2 * MINUTE_NANOS)), }) ); } From 2c14619e9eaa34de76d163f818354a97d03123a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:12:30 +0000 Subject: [PATCH 093/146] test(minter): drop the read time from the watchlist iteration in the re-arm test Co-Authored-By: Claude Fable 5.1 --- minter/src/state/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 610c9b145..1c3dab3e9 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -312,10 +312,7 @@ mod swept_deposits { }; read_state(|s| { assert_eq!(s.deposits().dropped().len(), 3); - assert_eq!( - s.watchlist().iter(at(DROPPED_AT)).collect::>(), - vec![rearmed] - ); + assert_eq!(s.watchlist().iter().collect::>(), vec![rearmed]); assert_eq!(s.watchlist().due(at(DROPPED_AT)), vec![rearmed]); }); let replayed = replayed_recorded_log(); From fe81cf6e183d3a64372e9b1f28e36d7d575f493b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:48:09 +0000 Subject: [PATCH 094/146] refactor(minter): snapshot and restore the watchlist of the deposits Co-Authored-By: Claude Fable 5.1 --- minter/src/lifecycle/tests.rs | 2 +- minter/src/state/mod.rs | 2 +- minter/src/state/tests.rs | 7 +++++-- minter/src/state/watchlist/mod.rs | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/minter/src/lifecycle/tests.rs b/minter/src/lifecycle/tests.rs index f623448ee..83c6f72a8 100644 --- a/minter/src/lifecycle/tests.rs +++ b/minter/src/lifecycle/tests.rs @@ -1,9 +1,9 @@ use crate::{ lifecycle::pre_upgrade, state::{ + MAX_WATCHED_ADDRESSES, event::{EventType, WatchlistSnapshot}, mutate_state, read_state, - watchlist::MAX_WATCHED_ADDRESSES, }, test_fixtures::{ EventsAssert, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, init_state, diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index e8d73c023..e93b64e15 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -487,7 +487,7 @@ impl State { } fn restore_watchlist(&mut self, snapshot: &WatchlistSnapshot) { - self.watchlist = Watchlist::from_snapshot(snapshot); + *self.deposits.watchlist_mut() = Watchlist::from_snapshot(snapshot); } fn process_credited_sweep( diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 29e9223e6..b8b5b6ea0 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -203,14 +203,17 @@ mod watched_deposit_addresses { read_state(|live_state| assert_eq!(&replayed, live_state)); assert_eq!( - replayed.watchlist().iter().collect::>(), + replayed + .watchlist() + .iter(at(dropped_at)) + .collect::>(), vec![WatchedAddress { account: account(1), address: deposit_address(account(1)), valid_until: at(REGISTERED_AT + WATCH_WINDOW_NANOS), scan_count: 0, last_scanned_at: None, - next_scan_at: Some(at(REGISTERED_AT)), + next_scan_at: Some(at(dropped_at)), }] ); } diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 695dfd0b4..ac88e1e31 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -159,7 +159,7 @@ impl Watchlist { /// The snapshot [`Self::from_snapshot`] rebuilds this watchlist from. pub fn snapshot(&self) -> WatchlistSnapshot { WatchlistSnapshot { - window_nanos: self.window_nanos(), + window_nanos: u64::try_from(self.entries.ttl().as_nanos()).unwrap_or(u64::MAX), capacity: self.entries.capacity().get() as u64, entries: self .entries From 3fece15eff74967bab77511bf7975e92641989f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:43:44 +0000 Subject: [PATCH 095/146] refactor(minter): re-arm a dropped automatic deposit for a fresh window The re-arm registers the account again instead of restoring the window end recorded with the deposit, so a drop after the original window re-arms as well and the window-closed error disappears. Co-Authored-By: Claude Fable 5.1 --- minter/src/state/mod.rs | 4 +- minter/src/state/tests.rs | 64 +++++++++-------------------- minter/src/state/watchlist/mod.rs | 47 ++++++++------------- minter/src/state/watchlist/tests.rs | 59 +++++++------------------- 4 files changed, 51 insertions(+), 123 deletions(-) diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 0563c12d0..30c7419a4 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -257,8 +257,8 @@ impl State { } /// Drops the deposits of the sweep and puts the ones the automatic flow detected back on - /// the watchlist for the rest of their window, so that the funds still on their deposit - /// addresses are swept again without the user's intervention. + /// the watchlist for a fresh window, so that the funds still on their deposit addresses + /// are swept again without the user's intervention. fn drop_sweep(&mut self, signature: &Signature, timestamp: u64) { for deposit in self.deposits.drop_swept(signature) { let rearmed = self diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 1c3dab3e9..d2e2c675b 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -250,49 +250,30 @@ mod swept_deposits { } #[test] - fn should_rearm_the_automatic_deposits_of_a_failed_sweep_within_their_window() { + fn should_rearm_the_automatic_deposits_of_a_failed_sweep() { let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); - assert_rearms_the_automatic_deposits_within_their_window_when_dropped_by( - EventType::FailedTransaction { - signature: sweep_signature, - }, - ); + assert_rearms_the_automatic_deposits_when_dropped_by(EventType::FailedTransaction { + signature: sweep_signature, + }); } #[test] - fn should_rearm_the_automatic_deposits_of_an_expired_sweep_within_their_window() { + fn should_rearm_the_automatic_deposits_of_an_expired_sweep() { let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); - assert_rearms_the_automatic_deposits_within_their_window_when_dropped_by( - EventType::ExpiredTransaction { - signature: sweep_signature, - }, - ); + assert_rearms_the_automatic_deposits_when_dropped_by(EventType::ExpiredTransaction { + signature: sweep_signature, + }); } - fn assert_rearms_the_automatic_deposits_within_their_window_when_dropped_by( - drop_event: EventType, - ) { - const DROPPED_AT: u64 = VALID_UNTIL - 1; + fn assert_rearms_the_automatic_deposits_when_dropped_by(drop_event: EventType) { + const DROPPED_AT: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS + 1; init_state(); init_schnorr_master_key(); - let automatic = AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: VALID_UNTIL, - }; let manual = DepositFlow::queue(account(1), 10_000_000); - let within_window = DepositFlow::queue_automatic(account(2), 20_000_000, automatic); - let after_window = DepositFlow::queue_automatic( - account(3), - 30_000_000, - AutomaticDeposit { - valid_until: DROPPED_AT - 1, - ..automatic - }, - ); - SweepFlow::of([manual, within_window, after_window]) - .submit(signature(SWEEP_SIGNATURE_INDEX)); + let automatic = DepositFlow::queue_automatic(account(2), 20_000_000, AUTOMATIC_DEPOSIT_FEE); + SweepFlow::of([manual, automatic]).submit(signature(SWEEP_SIGNATURE_INDEX)); mutate_state(|state| { process_event( @@ -304,14 +285,14 @@ mod swept_deposits { let rearmed = WatchedAddress { account: account(2), - address: within_window.address, - valid_until: at(VALID_UNTIL), + 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(), 3); + assert_eq!(s.deposits().dropped().len(), 2); assert_eq!(s.watchlist().iter().collect::>(), vec![rearmed]); assert_eq!(s.watchlist().due(at(DROPPED_AT)), vec![rearmed]); }); @@ -323,23 +304,16 @@ mod swept_deposits { 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, - AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: VALID_UNTIL, - }, - ) - .sweep(signature(SWEEP_SIGNATURE_INDEX)); + 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(VALID_UNTIL - 1), account(index)); + watch(at(REGISTERED_AT), account(index)); } fail_transaction(dropped.signature); read_state(|s| { - assert_eq!(s.watchlist().get(at(VALID_UNTIL - 1), &account(0)), None); + 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); }); diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 4de50ab90..c28908486 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -107,7 +107,6 @@ pub struct WatchlistFull; pub enum RearmError { /// The deposit was queued by `deposit_sol`, whose caller registered nothing. ManualDeposit, - WindowClosed, WatchlistFull, } @@ -131,45 +130,31 @@ impl Watchlist { let inserted = self .entries .insert(now, account, ScanProgress::unscanned(address, now)); - self.admitted(now, &account, inserted).map_err(|e| match e { - RearmError::WatchlistFull => WatchlistFull, - RearmError::WindowClosed | RearmError::ManualDeposit => { - unreachable!("BUG: a registration for the window starting now is admitted or full") + match inserted { + Ok(_) | Err(InsertError::AlreadyPresent { .. }) => Ok(self + .get(now, &account) + .expect("BUG: a registration is live right after its insertion")), + Err(InsertError::AtCapacity { .. }) => Err(WatchlistFull), + Err(InsertError::Expired { .. }) => { + unreachable!("BUG: a registration for the window starting now cannot have expired") } - }) + } } - /// Puts the account of a dropped deposit back on the watchlist for the rest of the - /// window the automatic flow detected the deposit in, with a new schedule starting now. An - /// account that is already watched keeps its registration, and a window that has closed - /// admits nothing. + /// 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. An account that is already watched keeps + /// its registration. pub fn rearm( &mut self, now: Timestamp, dropped: &QueuedDeposit, ) -> Result { - let automatic = dropped.automatic.ok_or(RearmError::ManualDeposit)?; - let entry = Entry { - value: ScanProgress::unscanned(dropped.address, now), - expires_at: Timestamp::from_nanos(automatic.valid_until), - }; - let inserted = self.entries.insert_entry(now, dropped.account, entry); - self.admitted(now, &dropped.account, inserted) - } - - fn admitted( - &self, - now: Timestamp, - account: &Account, - inserted: Result, InsertError>, - ) -> Result { - match inserted { - Ok(_) | Err(InsertError::AlreadyPresent { .. }) => Ok(self - .get(now, account) - .expect("BUG: a registration is live right after its insertion")), - Err(InsertError::AtCapacity { .. }) => Err(RearmError::WatchlistFull), - Err(InsertError::Expired { .. }) => Err(RearmError::WindowClosed), + if dropped.automatic_deposit_fee.is_none() { + return Err(RearmError::ManualDeposit); } + self.register(now, dropped.account, dropped.address) + .map_err(|WatchlistFull| RearmError::WatchlistFull) } /// The live registration of the account, if any. diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 088cf09bb..5a7988ed1 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -1,6 +1,6 @@ use crate::{ state::{ - AutomaticDeposit, QueuedDeposit, + QueuedDeposit, watchlist::{ MAX_WATCHED_ADDRESSES, RearmError, WatchedAddress, Watchlist, WatchlistFull, scan_slots, }, @@ -190,17 +190,18 @@ fn should_count_the_live_registrations_and_report_the_oldest() { } #[test] -fn should_rearm_an_account_for_the_rest_of_its_window_from_the_first_slot() { +fn should_rearm_an_account_for_a_fresh_window_from_the_first_slot() { let mut watchlist = Watchlist::default(); let rearmed_at = REGISTERED_AT + 2 * HOUR_NANOS; let rearmed = watchlist - .rearm(at(rearmed_at), &dropped(account(1), VALID_UNTIL)) + .rearm(at(rearmed_at), &dropped(account(1))) .unwrap(); assert_eq!( rearmed, WatchedAddress { + valid_until: at(rearmed_at + WATCH_WINDOW_NANOS), next_scan_at: Some(at(rearmed_at)), ..unscanned(account(1)) } @@ -214,29 +215,10 @@ fn should_rearm_an_account_for_the_rest_of_its_window_from_the_first_slot() { .next_scan_at, Some(at(rearmed_at + MINUTE_NANOS)) ); - assert_eq!(watchlist.get(at(VALID_UNTIL + 1), &account(1)), None); -} - -#[test] -fn should_rearm_at_the_very_end_of_the_window() { - let mut watchlist = Watchlist::default(); - - let rearmed = watchlist - .rearm(at(VALID_UNTIL), &dropped(account(1), VALID_UNTIL)) - .unwrap(); - - assert_eq!(rearmed.valid_until, at(VALID_UNTIL)); - assert_eq!(watchlist.due(at(VALID_UNTIL)), vec![rearmed]); -} - -#[test] -fn should_not_rearm_an_account_whose_window_has_closed() { - let mut watchlist = Watchlist::default(); - - let result = watchlist.rearm(at(VALID_UNTIL + 1), &dropped(account(1), VALID_UNTIL)); - - assert_eq!(result, Err(RearmError::WindowClosed)); - assert!(watchlist.is_empty()); + assert_eq!( + watchlist.get(at(rearmed_at + WATCH_WINDOW_NANOS + 1), &account(1)), + None + ); } #[test] @@ -246,10 +228,7 @@ fn should_not_rearm_an_account_when_full_of_live_entries() { register(&mut watchlist, at(REGISTERED_AT), account(index)); } - let result = watchlist.rearm( - at(REGISTERED_AT + HOUR_NANOS), - &dropped(account(0), VALID_UNTIL), - ); + let result = watchlist.rearm(at(REGISTERED_AT + HOUR_NANOS), &dropped(account(0))); assert_eq!(result, Err(RearmError::WatchlistFull)); assert_eq!( @@ -262,8 +241,8 @@ fn should_not_rearm_an_account_when_full_of_live_entries() { fn should_not_rearm_a_manual_deposit() { let mut watchlist = Watchlist::default(); let manual = QueuedDeposit { - automatic: None, - ..dropped(account(1), VALID_UNTIL) + automatic_deposit_fee: None, + ..dropped(account(1)) }; let result = watchlist.rearm(at(REGISTERED_AT), &manual); @@ -278,25 +257,15 @@ fn should_keep_the_registration_of_an_account_that_is_already_watched() { let registered = register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); let rearmed = watchlist - .rearm( - at(REGISTERED_AT + 2 * HOUR_NANOS), - &dropped(account(1), VALID_UNTIL), - ) + .rearm(at(REGISTERED_AT + 2 * HOUR_NANOS), &dropped(account(1))) .unwrap(); assert_eq!(rearmed, registered); assert_eq!(watchlist.len(), 1); } -fn dropped(account: Account, valid_until: u64) -> QueuedDeposit { - automatic_deposit_of( - account, - MINIMUM_DEPOSIT_AMOUNT, - AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until, - }, - ) +fn dropped(account: Account) -> QueuedDeposit { + automatic_deposit_of(account, MINIMUM_DEPOSIT_AMOUNT, AUTOMATIC_DEPOSIT_FEE) } fn unscanned(account: Account) -> WatchedAddress { From 57839e050af9965600a5fb16a5792f3e92ce31b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:52:54 +0000 Subject: [PATCH 096/146] test: compare the restored registration up to its due time Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index a4e412a36..4274f0547 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -12,8 +12,8 @@ use cksol_int_tests::{ }; use cksol_types::{ DepositSolArgs, DepositSolError, DepositSolStatus, GetDepositAddressArgs, Lamport, MinterInfo, - TxFinalizedStatus, WatchDepositAddressError, WatchDepositAddressStatus, WithdrawSolArgs, - WithdrawSolError, WithdrawSolStatus, + TxFinalizedStatus, WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus, + WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus, }; use cksol_types_internal::{ UpgradeArgs, @@ -1345,7 +1345,30 @@ mod automatic_deposit_sol_tests { .watch_deposit_address(None) .await .expect("watch_deposit_address should report the restored registration"); - assert_eq!(restored, registration); + assert_matches!( + (®istration.status, &restored.status), + ( + WatchDepositAddressStatus::Watching { + valid_until: registered_until, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(registered_at), + }, + WatchDepositAddressStatus::Watching { + valid_until: restored_until, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(read_at), + }, + ) if registered_until == restored_until && read_at >= registered_at + ); + assert_eq!( + WatchDepositAddressOk { + status: restored.status.clone(), + ..registration.clone() + }, + restored + ); let valid_until = assert_matches!( registration.status, WatchDepositAddressStatus::Watching { valid_until, .. } => valid_until From 5b7036f96211c045c1e7cd1d13c3bff95ac7f84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 11:25:46 +0000 Subject: [PATCH 097/146] refactor(minter): tell a manual deposit apart by its fee under the new name Co-Authored-By: Claude Fable 5.1 --- minter/src/state/watchlist/mod.rs | 2 +- minter/src/state/watchlist/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index c28908486..4bd861092 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -150,7 +150,7 @@ impl Watchlist { now: Timestamp, dropped: &QueuedDeposit, ) -> Result { - if dropped.automatic_deposit_fee.is_none() { + if dropped.deposit_fee.is_none() { return Err(RearmError::ManualDeposit); } self.register(now, dropped.account, dropped.address) diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 5a7988ed1..0c9c8dcf9 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -241,7 +241,7 @@ fn should_not_rearm_an_account_when_full_of_live_entries() { fn should_not_rearm_a_manual_deposit() { let mut watchlist = Watchlist::default(); let manual = QueuedDeposit { - automatic_deposit_fee: None, + deposit_fee: None, ..dropped(account(1)) }; From 739b7ce230ff67a9d5fcc5cd686a3d4ce410a092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:15:06 +0000 Subject: [PATCH 098/146] feat(minter): carry the start of the scan schedule in the watchlist snapshot Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 39 ++++++++-------------------- libs/types-internal/src/event.rs | 2 ++ minter/cksol_minter.did | 2 ++ minter/src/state/event.rs | 5 +++- minter/src/state/event/candid/mod.rs | 2 ++ minter/src/state/tests.rs | 5 +--- minter/src/state/watchlist/tests.rs | 3 ++- minter/src/test_fixtures/mod.rs | 4 ++- 8 files changed, 27 insertions(+), 35 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 4274f0547..76745378f 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -12,8 +12,8 @@ use cksol_int_tests::{ }; use cksol_types::{ DepositSolArgs, DepositSolError, DepositSolStatus, GetDepositAddressArgs, Lamport, MinterInfo, - TxFinalizedStatus, WatchDepositAddressError, WatchDepositAddressOk, WatchDepositAddressStatus, - WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus, + TxFinalizedStatus, WatchDepositAddressError, WatchDepositAddressStatus, WithdrawSolArgs, + WithdrawSolError, WithdrawSolStatus, }; use cksol_types_internal::{ UpgradeArgs, @@ -1345,33 +1345,15 @@ mod automatic_deposit_sol_tests { .watch_deposit_address(None) .await .expect("watch_deposit_address should report the restored registration"); - assert_matches!( - (®istration.status, &restored.status), - ( - WatchDepositAddressStatus::Watching { - valid_until: registered_until, - scan_count: 0, - last_scanned_at: None, - next_scan_at: Some(registered_at), - }, - WatchDepositAddressStatus::Watching { - valid_until: restored_until, - scan_count: 0, - last_scanned_at: None, - next_scan_at: Some(read_at), - }, - ) if registered_until == restored_until && read_at >= registered_at - ); - assert_eq!( - WatchDepositAddressOk { - status: restored.status.clone(), - ..registration.clone() - }, - restored - ); - let valid_until = assert_matches!( + assert_eq!(restored, registration); + let (valid_until, registered_at) = assert_matches!( registration.status, - WatchDepositAddressStatus::Watching { valid_until, .. } => valid_until + WatchDepositAddressStatus::Watching { + valid_until, + scan_count: 0, + last_scanned_at: None, + next_scan_at: Some(registered_at), + } => (valid_until, registered_at) ); setup.minter().assert_that_events().await.satisfy(|events| { let snapshots: Vec<&EventType> = events @@ -1387,6 +1369,7 @@ mod automatic_deposit_sol_tests { account: DEFAULT_CALLER_ACCOUNT, address: DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap(), valid_until, + scheduled_from: registered_at, scan_count: 0, last_scanned_at: None, }], diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 7a85935fc..44d24dc02 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -164,6 +164,8 @@ pub struct WatchedDepositAddress { pub address: Address, /// The end of the registration window, in nanoseconds since the epoch. pub valid_until: u64, + /// The start of the scan schedule, in nanoseconds since the epoch. + pub scheduled_from: u64, /// The number of scans of the deposit address so far. pub scan_count: u32, /// The time of the last scan, in nanoseconds since the epoch, if any. diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 7daf71b3d..cbc8a0600 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -452,6 +452,8 @@ type WatchedDepositAddress = record { address: Address; // The end of the registration window, in nanoseconds since the epoch. valid_until: nat64; + // The start of the scan schedule, in nanoseconds since the epoch. + scheduled_from: nat64; // The number of scans of the deposit address so far. scan_count: nat32; // The time of the last scan, in nanoseconds since the epoch, if any. diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 181ad7349..851b7864e 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -258,10 +258,13 @@ pub struct WatchedDepositAddress { /// The end of the registration window, in nanoseconds since the epoch. #[n(2)] pub valid_until: u64, + /// The start of the scan schedule, in nanoseconds since the epoch. #[n(3)] + pub scheduled_from: u64, + #[n(4)] pub scan_count: u32, /// The time of the last scan, in nanoseconds since the epoch, if any. - #[n(4)] + #[n(5)] pub last_scanned_at: Option, } diff --git a/minter/src/state/event/candid/mod.rs b/minter/src/state/event/candid/mod.rs index 42fc9bb50..82f344893 100644 --- a/minter/src/state/event/candid/mod.rs +++ b/minter/src/state/event/candid/mod.rs @@ -314,6 +314,7 @@ impl From for CandidWatchedDepositAddress { account: entry.account, address: CandidAddress::from(entry.address), valid_until: entry.valid_until, + scheduled_from: entry.scheduled_from, scan_count: entry.scan_count, last_scanned_at: entry.last_scanned_at, } @@ -326,6 +327,7 @@ impl From for WatchedDepositAddress { account: entry.account, address: Address::from(entry.address), valid_until: entry.valid_until, + scheduled_from: entry.scheduled_from, scan_count: entry.scan_count, last_scanned_at: entry.last_scanned_at, } diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index b8b5b6ea0..1967e8524 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -203,10 +203,7 @@ mod watched_deposit_addresses { read_state(|live_state| assert_eq!(&replayed, live_state)); assert_eq!( - replayed - .watchlist() - .iter(at(dropped_at)) - .collect::>(), + replayed.watchlist().iter().collect::>(), vec![WatchedAddress { account: account(1), address: deposit_address(account(1)), diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 04053a1e2..b3db18977 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -296,6 +296,7 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { account: account(index), address: deposit_address(account(index)), valid_until: VALID_UNTIL, + scheduled_from: REGISTERED_AT, scan_count: 3, last_scanned_at: Some(REGISTERED_AT + HOUR_NANOS), }) @@ -319,7 +320,7 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { valid_until: at(VALID_UNTIL), scan_count: 3, last_scanned_at: Some(at(REGISTERED_AT + HOUR_NANOS)), - next_scan_at: Some(at(REGISTERED_AT + HOUR_NANOS + 2 * MINUTE_NANOS)), + next_scan_at: Some(at(REGISTERED_AT + 90 * MINUTE_NANOS)), }) ); } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 94b5e5daf..651e1a303 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -1698,15 +1698,17 @@ pub mod arb { arb_account(), arb_address(), any::(), + any::(), any::(), prop::option::of(any::()), ) .prop_map( - |(account, address, valid_until, scan_count, last_scanned_at)| { + |(account, address, valid_until, scheduled_from, scan_count, last_scanned_at)| { WatchedDepositAddress { account, address, valid_until, + scheduled_from, scan_count, last_scanned_at, } From e2b2f213ce8adf388703b2728fe3e5ef1026f15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:46:19 +0000 Subject: [PATCH 099/146] test(minter): expect the fresh window of the re-armed account after restoring a snapshot Co-Authored-By: Claude Fable 5.1 --- minter/src/state/tests.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 1967e8524..c8ca0af8c 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -175,15 +175,8 @@ mod watched_deposit_addresses { let stale_snapshot = read_state(|s| s.watchlist().snapshot()); mutate_state(|s| s.watchlist_mut().remove(&account(2))); let latest_snapshot = read_state(|s| s.watchlist().snapshot()); - let detected = DepositFlow::queue_automatic( - account(1), - 20_000_000, - AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, - }, - ) - .sweep(signature(0xAA)); + let detected = DepositFlow::queue_automatic(account(1), 20_000_000, AUTOMATIC_DEPOSIT_FEE) + .sweep(signature(0xAA)); read_state(|s| assert!(s.watchlist().is_empty())); let dropped_at = REGISTERED_AT + 1; mutate_state(|state| { @@ -207,7 +200,7 @@ mod watched_deposit_addresses { vec![WatchedAddress { account: account(1), address: deposit_address(account(1)), - valid_until: at(REGISTERED_AT + WATCH_WINDOW_NANOS), + valid_until: at(dropped_at + WATCH_WINDOW_NANOS), scan_count: 0, last_scanned_at: None, next_scan_at: Some(at(dropped_at)), From 2100e68f3f006f298f10eb4483dbabe5a0482bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:28:21 +0000 Subject: [PATCH 100/146] test(minter): replay the recorded log after given events for the snapshot tests Co-Authored-By: Claude Fable 5.1 --- minter/src/test_fixtures/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 651e1a303..5170a1b1f 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -166,12 +166,19 @@ pub fn minter_public_key_fetched_event() -> EventType { /// events that [`init_state`] and [`init_schnorr_master_key`] stand for, so that it can be /// compared with the live state. pub fn replayed_recorded_log() -> State { + replayed_recorded_log_after([]) +} + +/// Like [`replayed_recorded_log`], with `events` replayed between the init and key-fetched +/// events and the recorded ones. +pub fn replayed_recorded_log_after(events: impl IntoIterator) -> State { let recorded: Vec = with_event_iter(|events| events.collect()); let prefix = [ EventType::Init(init_args_with_nonce_account()), minter_public_key_fetched_event(), ] .into_iter() + .chain(events) .map(|payload| Event { timestamp: 0, payload, From 745e58a1c7e96d3ef60766268022c614c239ee0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:44:26 +0000 Subject: [PATCH 101/146] feat(minter): report the automatic deposit flow in the metrics and on the dashboard Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 11 +++ minter/src/dashboard/mod.rs | 50 ++++++++++++ minter/src/dashboard/tests.rs | 133 +++++++++++++++++++++++++++++-- minter/src/metrics.rs | 21 +++++ minter/templates/dashboard.html | 35 ++++++++ 5 files changed, 245 insertions(+), 5 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 76745378f..9c3b2c103 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1437,6 +1437,8 @@ mod automatic_deposit_sol_tests { setup .check_metrics() .await + .assert_contains_metric_matching(r#"watched_deposit_addresses 0 \d+"#) + .assert_contains_metric_matching(r#"automatic_deposits_queued 1 \d+"#) .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="queued"\} 1 \d+"#) .into() .drop() @@ -1547,6 +1549,15 @@ 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#"oldest_watched_deposit_age_seconds 0 \d+"#) + .assert_contains_metric_matching(r#"automatic_deposits_queued 0 \d+"#) + .assert_contains_metric_matching(r#"collected_automatic_deposit_fees 0 \d+"#) + .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#"failed_credit_attempts\{reason="not_found"\} 0 \d+"#, ) diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 499dc7483..0daba3d3a 100644 --- a/minter/src/dashboard/mod.rs +++ b/minter/src/dashboard/mod.rs @@ -41,6 +41,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 +58,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 +71,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 +235,17 @@ 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 in nanoseconds since the epoch, or `-` before the first scan. + pub last_scanned_at: String, +} + /// A swept deposit whose ckSOL mint landed on the ledger. #[derive(Clone)] pub struct DashboardMintedSweep { @@ -251,9 +268,12 @@ 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 { @@ -383,6 +403,29 @@ impl DashboardTemplate { pagination.other_params("withdrawals_start"), ); + let watched_deposit_addresses: Vec = state + .watchlist() + .iter() + .map(|watched| DashboardWatchedAddress { + account: watched.account.to_string(), + address: watched.address.to_string(), + valid_until: watched.valid_until.to_string(), + scan_count: watched.scan_count.to_string(), + last_scanned_at: watched + .last_scanned_at + .map_or_else(|| "-".to_string(), |at| at.to_string()), + }) + .collect(); + let watched_deposit_addresses_table = DashboardPaginatedTable::from_items( + &watched_deposit_addresses, + pagination.watched_deposit_addresses_start, + DEFAULT_PAGE_SIZE, + 5, + "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 +438,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..7bfc7824e 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -1,17 +1,18 @@ use crate::dashboard::{DashboardPaginationParameters, DashboardTemplate, lamports_to_sol}; -use crate::state::read_state; +use crate::state::{AutomaticDeposit, mutate_state, read_state}; use crate::test_fixtures::{ - MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, WITHDRAWAL_FEE, account, + AUTOMATIC_DEPOSIT_FEE, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, REGISTERED_AT, + WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, 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 askama::Template; -use cksol_types_internal::SolanaNetwork; +use cksol_types_internal::{InitArgs, SolanaNetwork}; fn dashboard() -> DashboardTemplate { dashboard_with_pagination(DashboardPaginationParameters::default()) @@ -56,16 +57,138 @@ 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, + AutomaticDeposit { + fee: AUTOMATIC_DEPOSIT_FEE, + valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, + }, + ) + .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(REGISTERED_AT + 1, account(1)); + watch(REGISTERED_AT, account(2)); + mutate_state(|s| { + s.watchlist_mut() + .record_scan(REGISTERED_AT + 60, &account(2)); + }); + + 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(), + &(REGISTERED_AT + WATCH_WINDOW_NANOS).to_string(), + "1", + &(REGISTERED_AT + 60).to_string(), + ], + "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(), + &(REGISTERED_AT + 1 + WATCH_WINDOW_NANOS).to_string(), + "0", + "-", + ], + "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_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(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 + ); + + 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/metrics.rs b/minter/src/metrics.rs index ad207ffa2..18f793637 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -114,6 +114,27 @@ 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 = 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( + "oldest_watched_deposit_age_seconds", + age_seconds(s.watchlist().oldest_registered_at(now)).metric_value(), + "Age of the oldest open registration of the automatic deposit flow in seconds. Returns 0 if no deposit address is watched.", + )?; + w.encode_counter( + "automatic_deposits_queued", + s.deposits().automatic_deposit_count().metric_value(), + "Number of deposits queued by the balance scans of the automatic deposit flow.", + )?; + w.encode_gauge( + "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.", diff --git a/minter/templates/dashboard.html b/minter/templates/dashboard.html index 7f681029b..0ee546279 100644 --- a/minter/templates/dashboard.html +++ b/minter/templates/dashboard.html @@ -93,8 +93,43 @@

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 Until (ns)ScansLast Scanned At (ns)
{{ watched.account }}{% call solscan_address_link(watched.address) %}{{ watched.valid_until }}{{ watched.scan_count }}{{ watched.last_scanned_at }}
+ {% endif %} {% if !quarantined_swept_deposits_table.is_empty() %}

Quarantined Swept Deposits

From b5da4f5ef047b2b92b642d2447f5d914c26c399b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 09:56:18 +0000 Subject: [PATCH 102/146] refactor(minter): convert the watchlist times for the metrics and the dashboard Co-Authored-By: Claude Fable 5.1 --- minter/src/dashboard/mod.rs | 4 ++-- minter/src/dashboard/tests.rs | 10 +++++----- minter/src/metrics.rs | 10 ++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 0daba3d3a..0fb3294f2 100644 --- a/minter/src/dashboard/mod.rs +++ b/minter/src/dashboard/mod.rs @@ -409,11 +409,11 @@ impl DashboardTemplate { .map(|watched| DashboardWatchedAddress { account: watched.account.to_string(), address: watched.address.to_string(), - valid_until: watched.valid_until.to_string(), + valid_until: watched.valid_until.as_nanos().to_string(), scan_count: watched.scan_count.to_string(), last_scanned_at: watched .last_scanned_at - .map_or_else(|| "-".to_string(), |at| at.to_string()), + .map_or_else(|| "-".to_string(), |at| at.as_nanos().to_string()), }) .collect(); let watched_deposit_addresses_table = DashboardPaginatedTable::from_items( diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index 7bfc7824e..49240096b 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -2,7 +2,7 @@ use crate::dashboard::{DashboardPaginationParameters, DashboardTemplate, lamport use crate::state::{AutomaticDeposit, mutate_state, read_state}; use crate::test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, REGISTERED_AT, - WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, deposit_address, + WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, at, deposit_address, events::{ accept_withdrawal, fail_transaction, quarantine_sweep, queue_deposit, submit_sweep, submit_withdrawal, succeed_transaction, @@ -111,11 +111,11 @@ fn should_display_the_collected_automatic_deposit_fees() { fn should_display_watched_deposit_addresses_by_ascending_window_end() { init_state(); init_schnorr_master_key(); - watch(REGISTERED_AT + 1, account(1)); - watch(REGISTERED_AT, account(2)); + watch(at(REGISTERED_AT + 1), account(1)); + watch(at(REGISTERED_AT), account(2)); mutate_state(|s| { s.watchlist_mut() - .record_scan(REGISTERED_AT + 60, &account(2)); + .record_scan(at(REGISTERED_AT + 60), &account(2)); }); DashboardAssert::assert_that(dashboard()) @@ -154,7 +154,7 @@ fn should_paginate_watched_deposit_addresses_across_multiple_pages() { init_state(); init_schnorr_master_key(); for index in 0..DEFAULT_PAGE_SIZE + 1 { - watch(REGISTERED_AT, account(index)); + watch(at(REGISTERED_AT), account(index)); } let page1 = dashboard(); diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index 18f793637..a3bf1d9b5 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -2,6 +2,7 @@ use crate::state::{QuarantineCause, State}; use crate::storage::{ self, FailedCreditReason, FailedMintReason, ScanOutcome, UndecidedWithdrawalReason, }; +use crate::utils::timed_sized_map::Timestamp; use ic_metrics_encoder::MetricsEncoder; const WASM_PAGE_SIZE_IN_BYTES: usize = 65536; @@ -114,7 +115,7 @@ 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 = ic_cdk::api::time(); + let now = Timestamp::from_nanos(ic_cdk::api::time()); w.encode_gauge( "watched_deposit_addresses", s.watchlist().live_len(now).metric_value(), @@ -122,7 +123,12 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re )?; w.encode_gauge( "oldest_watched_deposit_age_seconds", - age_seconds(s.watchlist().oldest_registered_at(now)).metric_value(), + age_seconds( + s.watchlist() + .oldest_registered_at(now) + .map(Timestamp::as_nanos), + ) + .metric_value(), "Age of the oldest open registration of the automatic deposit flow in seconds. Returns 0 if no deposit address is watched.", )?; w.encode_counter( From 69da25a1ea3abe8c35cc19db120cb6a160bd24da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 12:49:26 +0000 Subject: [PATCH 103/146] refactor(minter): list the watched addresses as of the dashboard's time Co-Authored-By: Claude Fable 5.1 --- minter/src/dashboard/mod.rs | 9 +++++++-- minter/src/dashboard/tests.rs | 2 +- minter/src/main.rs | 5 ++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 0fb3294f2..4c5a6e29a 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; @@ -277,7 +278,11 @@ pub struct DashboardTemplate { } 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()) @@ -405,7 +410,7 @@ impl DashboardTemplate { let watched_deposit_addresses: Vec = state .watchlist() - .iter() + .iter(now) .map(|watched| DashboardWatchedAddress { account: watched.account.to_string(), address: watched.address.to_string(), diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index 49240096b..00d283bab 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -19,7 +19,7 @@ fn dashboard() -> DashboardTemplate { } fn dashboard_with_pagination(pagination: DashboardPaginationParameters) -> DashboardTemplate { - read_state(|state| DashboardTemplate::from_state(state, pagination)) + read_state(|state| DashboardTemplate::from_state(state, at(REGISTERED_AT), pagination)) } fn init_state_with_network(network: SolanaNetwork) { 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()) From 2ef7726abef29c60d7f8d06f7e3098a2e643c61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 14:17:35 +0000 Subject: [PATCH 104/146] refactor(minter): drop the read time from the dashboard's watchlist listing Co-Authored-By: Claude Fable 5.1 --- minter/src/dashboard/mod.rs | 9 ++------- minter/src/dashboard/tests.rs | 2 +- minter/src/main.rs | 5 +---- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 4c5a6e29a..0fb3294f2 100644 --- a/minter/src/dashboard/mod.rs +++ b/minter/src/dashboard/mod.rs @@ -1,7 +1,6 @@ use crate::{ address::minter_address, state::{QuarantineCause, State}, - utils::timed_sized_map::Timestamp, }; use askama::Template; use candid::Principal; @@ -278,11 +277,7 @@ pub struct DashboardTemplate { } impl DashboardTemplate { - pub fn from_state( - state: &State, - now: Timestamp, - pagination: DashboardPaginationParameters, - ) -> Self { + pub fn from_state(state: &State, pagination: DashboardPaginationParameters) -> Self { let minter_address = state .minter_public_key() .map(|key| minter_address(key).to_string()) @@ -410,7 +405,7 @@ impl DashboardTemplate { let watched_deposit_addresses: Vec = state .watchlist() - .iter(now) + .iter() .map(|watched| DashboardWatchedAddress { account: watched.account.to_string(), address: watched.address.to_string(), diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index 00d283bab..49240096b 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -19,7 +19,7 @@ fn dashboard() -> DashboardTemplate { } fn dashboard_with_pagination(pagination: DashboardPaginationParameters) -> DashboardTemplate { - read_state(|state| DashboardTemplate::from_state(state, at(REGISTERED_AT), pagination)) + read_state(|state| DashboardTemplate::from_state(state, pagination)) } fn init_state_with_network(network: SolanaNetwork) { diff --git a/minter/src/main.rs b/minter/src/main.rs index 6d187010f..f0eb836c3 100644 --- a/minter/src/main.rs +++ b/minter/src/main.rs @@ -12,7 +12,6 @@ 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::{ @@ -167,9 +166,7 @@ fn http_request(request: HttpRequest) -> HttpResponse { .build(); } }; - let now = Timestamp::from_nanos(ic_cdk::api::time()); - let dashboard = - read_state(|state| DashboardTemplate::from_state(state, now, pagination)); + let dashboard = read_state(|state| DashboardTemplate::from_state(state, pagination)); HttpResponseBuilder::ok() .header("Content-Type", "text/html; charset=utf-8") .with_body_and_content_length(dashboard.render().unwrap()) From 1e66a7cc356b2c36b25cb262c5745013a13cc189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 09:48:16 +0000 Subject: [PATCH 105/146] test(minter): credit the dashboard's automatic deposit with the fee alone Co-Authored-By: Claude Fable 5.1 --- minter/src/dashboard/tests.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index 49240096b..0829ad82b 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -1,5 +1,5 @@ use crate::dashboard::{DashboardPaginationParameters, DashboardTemplate, lamports_to_sol}; -use crate::state::{AutomaticDeposit, mutate_state, read_state}; +use crate::state::{mutate_state, read_state}; use crate::test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, REGISTERED_AT, WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, at, deposit_address, @@ -88,17 +88,10 @@ fn should_display_an_unconfigured_automatic_deposit_fee_as_not_available() { fn should_display_the_collected_automatic_deposit_fees() { init_state(); init_schnorr_master_key(); - DepositFlow::queue_automatic( - account(1), - 400_000_000, - AutomaticDeposit { - fee: AUTOMATIC_DEPOSIT_FEE, - valid_until: REGISTERED_AT + WATCH_WINDOW_NANOS, - }, - ) - .sweep(signature(0xAA)) - .succeed() - .credit(); + 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", From cb9e0856cfc4bcdf8c1baab53e4062d39d199ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:29:33 +0000 Subject: [PATCH 106/146] refactor(minter): report the automatically queued deposits through the scan outcomes alone Co-Authored-By: Claude Fable 5.1 --- integration_tests/tests/tests.rs | 2 -- minter/src/metrics.rs | 5 ----- 2 files changed, 7 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 9c3b2c103..a509eeb64 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1438,7 +1438,6 @@ mod automatic_deposit_sol_tests { .check_metrics() .await .assert_contains_metric_matching(r#"watched_deposit_addresses 0 \d+"#) - .assert_contains_metric_matching(r#"automatic_deposits_queued 1 \d+"#) .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="queued"\} 1 \d+"#) .into() .drop() @@ -1551,7 +1550,6 @@ mod metrics_tests { .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#"oldest_watched_deposit_age_seconds 0 \d+"#) - .assert_contains_metric_matching(r#"automatic_deposits_queued 0 \d+"#) .assert_contains_metric_matching(r#"collected_automatic_deposit_fees 0 \d+"#) .assert_contains_metric_matching(r#"deposit_address_scans\{outcome="queued"\} 0 \d+"#) .assert_contains_metric_matching( diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index a3bf1d9b5..ab8b9e65e 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -131,11 +131,6 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re .metric_value(), "Age of the oldest open registration of the automatic deposit flow in seconds. Returns 0 if no deposit address is watched.", )?; - w.encode_counter( - "automatic_deposits_queued", - s.deposits().automatic_deposit_count().metric_value(), - "Number of deposits queued by the balance scans of the automatic deposit flow.", - )?; w.encode_gauge( "collected_automatic_deposit_fees", s.collected_automatic_deposit_fees().metric_value(), From e9fb5906c4e4e9e0fe977c5f8576e3b86a3e0e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 14 Oct 2026 08:44:47 +0000 Subject: [PATCH 107/146] test: sweep watched and manual deposits together on solana-test-validator Co-Authored-By: Claude Fable 5.1 --- docs/src/development/design.md | 4 +- docs/src/usage/cli.md | 18 ++- docs/src/usage/how-it-works.md | 12 ++ integration_tests/src/lib.rs | 30 +++- .../tests/solana_test_validator.rs | 147 +++++++++++++++++- minter/CHANGELOG.md | 4 + 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index ad5e05241..094b04ae6 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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; an optional field of the `QueuedDeposit` event carrying the fee of an automatic deposit, absent for manual deposits; 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; an optional field of the `QueuedDeposit` event carrying the fee of an automatic deposit, absent for manual deposits; 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`, 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..82c0e3396 100644 --- a/docs/src/usage/cli.md +++ b/docs/src/usage/cli.md @@ -41,9 +41,25 @@ 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. + ## 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..0f6007c35 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. + +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 is returned in every case. 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. + ## 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 84341a06d..abcbe580b 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, @@ -598,6 +598,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/minter/CHANGELOG.md b/minter/CHANGELOG.md index cc8f242d3..2d45ad714 100644 --- a/minter/CHANGELOG.md +++ b/minter/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. The watchlist is snapshotted in a new event on every upgrade, and the watched addresses, the scans by outcome, the automatically queued deposits and the collected fees are reported in the metrics and on the dashboard ([#274](https://github.com/dfinity/cksol/pull/274)) + ## [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. From 774ccdaed7c671bb080e7bd1da924b71d8e27fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 15 Oct 2026 12:30:07 +0000 Subject: [PATCH 108/146] docs(minter): drop the queued-deposit count from the changelog metrics list Co-Authored-By: Claude Fable 5.1 --- minter/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minter/CHANGELOG.md b/minter/CHANGELOG.md index 2d45ad714..378b8ec4e 100644 --- a/minter/CHANGELOG.md +++ b/minter/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. The watchlist is snapshotted in a new event on every upgrade, and the watched addresses, the scans by outcome, the automatically queued deposits and the collected fees are reported in the metrics and on the dashboard ([#274](https://github.com/dfinity/cksol/pull/274)) +- 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. The watchlist is snapshotted in a new event on every upgrade, and the watched addresses, the scans by outcome and the collected fees are reported in the metrics and on the dashboard ([#274](https://github.com/dfinity/cksol/pull/274)) ## [0.1.0] - 2026-10-09 From 8de0d0ad8f0649103c3191293103dad4aa1a9864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:18:20 +0000 Subject: [PATCH 109/146] fix(minter): reject a getMultipleAccounts response without one account per address The balances are paired with the addresses by position, and the SOL RPC canister does not guarantee one account per requested address in the pinned version, so a short response would leave the addresses past its end unread. A response with the wrong number of accounts is now an error. Co-Authored-By: Claude Opus 5.5 --- minter/src/rpc/mod.rs | 28 ++++++++++++++++++++-------- minter/src/rpc/tests.rs | 14 ++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index f5dce3027..fd781c03a 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -162,7 +162,8 @@ impl From for DepositSolError { /// Reads the balances of the given addresses, in their order, at the `finalized` /// commitment level with a single `getMultipleAccounts` call asking for no account data, -/// treating a missing account as a zero balance. +/// treating a missing account as a zero balance. A response without exactly one account +/// per address is an error, since the balances are paired with the addresses by position. /// /// # Panics /// @@ -197,14 +198,23 @@ pub async fn get_balances( .with_cycles(get_multiple_accounts_cycles(addresses.len())) .try_send() .await; - match result? { - MultiRpcResult::Consistent(Ok(accounts)) => Ok(accounts - .into_iter() - .map(|account| account.map_or(0, |account| account.lamports)) - .collect()), - MultiRpcResult::Consistent(Err(e)) => Err(GetMultipleAccountsError::RpcError(e)), - MultiRpcResult::Inconsistent(_) => Err(GetMultipleAccountsError::InconsistentRpcResults), + let accounts = match result? { + MultiRpcResult::Consistent(Ok(accounts)) => accounts, + MultiRpcResult::Consistent(Err(e)) => return Err(GetMultipleAccountsError::RpcError(e)), + MultiRpcResult::Inconsistent(_) => { + return Err(GetMultipleAccountsError::InconsistentRpcResults); + } + }; + if accounts.len() != addresses.len() { + return Err(GetMultipleAccountsError::UnexpectedAccountCount { + expected: addresses.len(), + actual: accounts.len(), + }); } + Ok(accounts + .into_iter() + .map(|account| account.map_or(0, |account| account.lamports)) + .collect()) } /// The cycles to attach to a `getMultipleAccounts` call reading `num_addresses` addresses. @@ -229,6 +239,8 @@ pub enum GetMultipleAccountsError { RpcError(RpcError), #[error("Inconsistent RPC results for getMultipleAccounts")] InconsistentRpcResults, + #[error("Unexpected getMultipleAccounts response: expected {expected} accounts, got {actual}")] + UnexpectedAccountCount { expected: usize, actual: usize }, } pub async fn submit_transaction( diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 60bc42638..f3c93637e 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -138,6 +138,20 @@ mod get_balances_tests { .add_stub_response(MultiRpcResult::Inconsistent(inconsistent.clone())), GetMultipleAccountsError::InconsistentRpcResults, ), + ( + TestCanisterRuntime::new().add_get_multiple_accounts_response(&[]), + GetMultipleAccountsError::UnexpectedAccountCount { + expected: 1, + actual: 0, + }, + ), + ( + TestCanisterRuntime::new().add_get_multiple_accounts_response(&[Some(1), None]), + GetMultipleAccountsError::UnexpectedAccountCount { + expected: 1, + actual: 2, + }, + ), ] { let result = get_balances(&runtime, &[address(1)]).await; From d6c7759c4bc96b287378a192375eec6ebcc60957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:19:51 +0000 Subject: [PATCH 110/146] test(minter): check the getMultipleAccounts request of a balance read Pins the parameters, the response size estimate and the cycles the minter chooses for 1 and 100 addresses, which the tests of the response alone leave unchecked. Co-Authored-By: Claude Opus 5.5 --- minter/src/rpc/tests.rs | 47 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index f3c93637e..846513d8b 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -79,7 +79,7 @@ mod get_balance_tests { mod get_balances_tests { use super::*; use crate::test_fixtures::address; - use sol_rpc_types::Lamport; + use sol_rpc_types::{DataSlice, GetAccountInfoEncoding, GetMultipleAccountsParams, Lamport}; type MultiRpcResult = sol_rpc_types::MultiRpcResult>>; @@ -103,6 +103,51 @@ mod get_balances_tests { assert_eq!(result, Ok(expected)); } + #[tokio::test] + async fn should_request_the_finalized_balances_without_account_data() { + init_state(); + for (num_addresses, expected_response_size_estimate, expected_cycles) in [ + (1, 2_336, 10_100_000_000), + ( + MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, + 18_176, + 20_000_000_000, + ), + ] { + let addresses: Vec<_> = (0..num_addresses).map(address).collect(); + let runtime = TestCanisterRuntime::new() + .add_get_multiple_accounts_response(&vec![None; num_addresses]); + + let result = get_balances(&runtime, &addresses).await; + + assert_eq!(result, Ok(vec![0; num_addresses])); + let [call] = runtime.sent_update_calls().try_into().unwrap(); + assert_eq!(call.method, "getMultipleAccounts"); + assert_eq!(call.cycles, expected_cycles); + let (_sources, config, params): ( + RpcSources, + Option, + GetMultipleAccountsParams, + ) = call.args(); + assert_eq!( + config.and_then(|config| config.response_size_estimate), + Some(expected_response_size_estimate) + ); + assert_eq!( + params, + GetMultipleAccountsParams { + commitment: Some(CommitmentLevel::Finalized), + encoding: Some(GetAccountInfoEncoding::Base64), + data_slice: Some(DataSlice { + length: 0, + offset: 0, + }), + ..GetMultipleAccountsParams::try_from(addresses).unwrap() + } + ); + } + } + #[tokio::test] #[should_panic(expected = "BUG: a getMultipleAccounts call reads between 1 and 100 addresses")] async fn should_panic_on_more_addresses_than_one_call_reads() { From 3352ecb57ef0dba5631de40f7867ccb2e8cf13c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:20:46 +0000 Subject: [PATCH 111/146] refactor(minter): leave the getMultipleAccounts response size estimate to the SOL RPC canister As for every other call, the SOL RPC canister prices the call with its own default estimate, which allows 192 bytes per account without data in its upcoming release, close to what the minter estimated, so the minter no longer mirrors the canister's internal sizes. Co-Authored-By: Claude Opus 5.5 --- minter/src/constants.rs | 18 ------------------ minter/src/rpc/mod.rs | 12 +----------- minter/src/rpc/tests.rs | 12 ++++-------- 3 files changed, 5 insertions(+), 37 deletions(-) diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 7a01ca6c5..70202279b 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -73,24 +73,6 @@ pub const GET_MULTIPLE_ACCOUNTS_BASE_CYCLES: u128 = 10_000_000_000; /// the design); the unused part is refunded. pub const GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS: u128 = 100_000_000; -/// The bytes of a `getMultipleAccounts` JSON-RPC response outside the account -/// list: the JSON-RPC envelope, the response context and the allowance for the -/// HTTP headers that the SOL RPC canister includes in its own estimates, since -/// it uses an estimate passed with a request as the response size limit as is. -pub const GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES: u64 = 128 + SOL_RPC_RESPONSE_HEADER_BYTES; - -/// The bytes one account without data adds to a `getMultipleAccounts` JSON-RPC -/// response, at the largest lamport amount and rent epoch. The response size -/// estimate passed to the SOL RPC canister is sized by the number of addresses -/// so that the canister does not retry the call with doubled estimates, and -/// stays below its own estimate, which allows 512 bytes per account for the -/// account data the scan does not ask for. -pub const GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS: u64 = 160; - -/// The allowance for the HTTP headers of a response that the SOL RPC canister -/// includes in the response size estimates of its typed methods. -const SOL_RPC_RESPONSE_HEADER_BYTES: u64 = 2 * 1024; - /// Cycles to attach for `getSignatureStatuses` RPC calls. /// /// The SOL RPC canister charges about 2.3B cycles for a `getSignatureStatuses` diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index fd781c03a..0c6edafaf 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -1,8 +1,7 @@ use crate::{ constants::{ GET_ACCOUNT_INFO_CYCLES, GET_BALANCE_CYCLES, GET_MULTIPLE_ACCOUNTS_BASE_CYCLES, - GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS, GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES, - GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS, GET_RECENT_BLOCK_MAX_TRIES, + GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS, GET_RECENT_BLOCK_MAX_TRIES, GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, }, @@ -194,7 +193,6 @@ pub async fn get_balances( length: 0, offset: 0, }) - .with_response_size_estimate(get_multiple_accounts_response_bytes(addresses.len())) .with_cycles(get_multiple_accounts_cycles(addresses.len())) .try_send() .await; @@ -223,14 +221,6 @@ fn get_multiple_accounts_cycles(num_addresses: usize) -> u128 { + GET_MULTIPLE_ACCOUNTS_CYCLES_PER_ADDRESS * num_addresses as u128 } -/// The size of the response to a `getMultipleAccounts` call reading `num_addresses` -/// addresses without data, headers included, which the SOL RPC canister uses as the -/// response size limit of the call. -fn get_multiple_accounts_response_bytes(num_addresses: usize) -> u64 { - GET_MULTIPLE_ACCOUNTS_RESPONSE_BASE_BYTES - + GET_MULTIPLE_ACCOUNTS_RESPONSE_BYTES_PER_ADDRESS * num_addresses as u64 -} - #[derive(Debug, PartialEq, Error, From)] pub enum GetMultipleAccountsError { #[error("Error while calling SOL RPC canister: {0}")] diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 846513d8b..7385974c4 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -106,13 +106,9 @@ mod get_balances_tests { #[tokio::test] async fn should_request_the_finalized_balances_without_account_data() { init_state(); - for (num_addresses, expected_response_size_estimate, expected_cycles) in [ - (1, 2_336, 10_100_000_000), - ( - MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, - 18_176, - 20_000_000_000, - ), + for (num_addresses, expected_cycles) in [ + (1, 10_100_000_000), + (MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, 20_000_000_000), ] { let addresses: Vec<_> = (0..num_addresses).map(address).collect(); let runtime = TestCanisterRuntime::new() @@ -131,7 +127,7 @@ mod get_balances_tests { ) = call.args(); assert_eq!( config.and_then(|config| config.response_size_estimate), - Some(expected_response_size_estimate) + None ); assert_eq!( params, From 31d29764b9ce572456bdea5c015da0f57cbb7764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:35:24 +0000 Subject: [PATCH 112/146] fix(minter): record a scan at the time its reads are sent Recording it when the responses arrive skipped a slot that passed while the reads were in flight. The registration the reads were made for must still be live when they arrive. Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- minter/src/deposit/automatic/scan/mod.rs | 22 ++++++--- minter/src/deposit/automatic/scan/tests.rs | 57 +++++++++++++--------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 330d14422..83845ec71 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -163,7 +163,7 @@ The call does not contact the SOL RPC canister and requires no cycles. It is rej The manual flow remains available while an address is watched: a `deposit_sol` call that queues a deposit removes the account from the watchlist, and since its caller paid in cycles, no SOL fee is deducted from that deposit. -**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window, the start of its schedule and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*. The schedule of an entry is a fixed list of *slots*, measured from the start of its schedule and adapted from the ckETH minter: at 0, 60, 120, 240, 360 and 600 seconds, then every 300 seconds up to 1,800 seconds, i.e. ten slots in the first 30 minutes, followed by hourly slots from 1.5 hours up to 23.5 hours, 33 slots in all. An entry is due at its first slot if it has not been scanned yet, and otherwise once the first slot after its last scan has passed; a scan lands on the next tick at or after that slot. Since the slots are fixed, a scan that comes late, because a tick is skipped or the ckSOL minter is stopped for an upgrade, does not shift the slots that follow: the entry is scanned once it is due, and its next slot is the first one after that scan, so slots that passed in the meantime are not made up and the number of scans may fall short of the number of slots. Nothing is lost by that, since a balance scan is idempotent: one read after an outage tells as much as the reads it replaces. The window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter; the last slot lies half an hour before its end, and a slot after the end of the window is never scanned, so the status then reports no next scan. The due time reported by the status is the next slot, so it does not move between two reads. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first slot. +**Watchlist and schedule.** The watchlist holds at most `MAX_WATCHED_ADDRESSES = 1,000` live entries, each with the account, its deposit address, the end of its window, the start of its schedule and its scan progress, i.e. the number of scans and the time of the last one. A timer runs every minute, under the same kind of guard as the other timers of the ckSOL minter, so that a tick which finds the previous one still waiting for its calls is skipped rather than run concurrently, and scans the entries that are *due*. The schedule of an entry is a fixed list of *slots*, measured from the start of its schedule and adapted from the ckETH minter: at 0, 60, 120, 240, 360 and 600 seconds, then every 300 seconds up to 1,800 seconds, i.e. ten slots in the first 30 minutes, followed by hourly slots from 1.5 hours up to 23.5 hours, 33 slots in all. An entry is due at its first slot if it has not been scanned yet, and otherwise once the first slot after its last scan has passed; a scan lands on the next tick at or after that slot. Since the slots are fixed, a scan that comes late, because a tick is skipped or the ckSOL minter is stopped for an upgrade, does not shift the slots that follow: the entry is scanned once it is due, and its next slot is the first one after that scan, taken at the time its reads are sent, so that a slot passing while they are in flight is still scanned, whereas slots that passed before are not made up and the number of scans may fall short of the number of slots. Nothing is lost by that, since a balance scan is idempotent: one read after an outage tells as much as the reads it replaces. The window of a registration is fixed when the call returns, at 24 hours after registration, as in the ckETH minter; the last slot lies half an hour before its end, and a slot after the end of the window is never scanned, so the status then reports no next scan. The due time reported by the status is the next slot, so it does not move between two reads. The window end is the one boundary clients and the re-arm rely on: a transfer finalized after the last scan is not noticed until the account is registered again, which the next `watch_deposit_address` call does once the window has passed, starting a new window and reporting its first slot. The tick and the first gaps are coarser than the 30 second cadence of the ckETH minter, deliberately. Solana's 400 ms block time plays no role in a balance poll: a transfer is visible at the `finalized` commitment level about 13 seconds after it lands, and a consensus round of HTTPS outcalls through the SOL RPC canister takes several seconds, so a detection latency of one to two minutes is close to the floor of what the stack delivers, and a withdrawal from an exchange takes minutes anyway. The tick rate is also the dominant term of the cost bound derived below. The schedule is measured in wall-clock time, since the ckSOL minter does not track the Solana block height continuously, unlike the ckETH minter, which measures elapsed blocks. diff --git a/minter/src/deposit/automatic/scan/mod.rs b/minter/src/deposit/automatic/scan/mod.rs index 33f0cd7e2..44ebe443c 100644 --- a/minter/src/deposit/automatic/scan/mod.rs +++ b/minter/src/deposit/automatic/scan/mod.rs @@ -71,7 +71,8 @@ pub async fn scan_watched_addresses(runtime: R) { futures::future::join_all(chunks.iter().map(|chunk| read_balances(&runtime, chunk))).await; let scan = Scan { - scanned_at: Timestamp::from_nanos(runtime.time()), + scanned_at: now, + read_at: Timestamp::from_nanos(runtime.time()), automatic_deposit_fee, }; for (chunk, read) in chunks.into_iter().zip(reads) { @@ -89,7 +90,7 @@ 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.scanned_at, chunk); + record_failed_read(scan, chunk); record_deposit_address_scans(ScanOutcome::Failed, chunk.len() as u64); } } @@ -104,11 +105,15 @@ async fn read_balances( get_balances(runtime, &addresses).await } -/// What one tick of the scan applies to every balance it read: the time of the reads and the -/// automatic deposit fee in force, which a deposit detected by the tick records. +/// What one tick of the scan applies to every balance it read: the time the reads were sent, +/// which the scan is recorded at so that a slot passing while they are in flight is still +/// scanned, the time their responses arrived, at which the registration they were made for +/// must still be live, and the automatic deposit fee in force, which a deposit detected by +/// the tick records. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Scan { scanned_at: Timestamp, + read_at: Timestamp, automatic_deposit_fee: Lamport, } @@ -124,10 +129,11 @@ fn apply_balance_read( ) -> Option { let Scan { scanned_at, + read_at, automatic_deposit_fee, } = scan; mutate_state(|state| { - if !is_same_registration(state, scanned_at, watched) + if !is_same_registration(state, read_at, watched) || state.deposits().in_flight_id(&watched.account).is_some() { return None; @@ -154,13 +160,13 @@ fn apply_balance_read( }) } -fn record_failed_read(scanned_at: Timestamp, chunk: &[WatchedAddress]) { +fn record_failed_read(scan: Scan, chunk: &[WatchedAddress]) { mutate_state(|state| { for watched in chunk { - if is_same_registration(state, scanned_at, watched) { + if is_same_registration(state, scan.read_at, watched) { state .watchlist_mut() - .record_scan(scanned_at, &watched.account); + .record_scan(scan.scanned_at, &watched.account); } } }); diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index 344d508c5..e81701fd2 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -16,10 +16,13 @@ use crate::{ use cksol_types::DepositSolStatus; use cksol_types_internal::InitArgs; use ic_canister_runtime::IcError; +use icrc_ledger_types::icrc1::account::Account; use sol_rpc_types::{GetMultipleAccountsParams, RpcConfig, RpcSources}; const VALID_UNTIL: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS; const TICK_AT: u64 = REGISTERED_AT + MINUTE_NANOS; +/// When the reads of the tick at [`TICK_AT`] return, after the next slot of the schedule. +const READ_AT: u64 = TICK_AT + MINUTE_NANOS + MINUTE_NANOS / 12; #[tokio::test] async fn should_return_early_without_due_addresses() { @@ -86,24 +89,16 @@ async fn should_advance_the_schedule_below_the_minimum_and_treat_a_missing_accou setup(); watch(at(REGISTERED_AT), account(1)); watch(at(REGISTERED_AT), account(2)); - let runtime = runtime_at(TICK_AT) + let runtime = runtime_with_reads_returning_after_the_next_slot() .add_get_multiple_accounts_response(&[Some(MINIMUM_DEPOSIT_AMOUNT - 1), None]); scan_watched_addresses(runtime.clone()).await; EventsAssert::assert_no_events_recorded(); - let scanned_at = TICK_AT + 1; for account in [account(1), account(2)] { assert_eq!( - read_state(|s| s.watchlist().get(at(scanned_at), &account)), - Some(WatchedAddress { - account, - address: deposit_address(account), - valid_until: at(VALID_UNTIL), - scan_count: 1, - last_scanned_at: Some(at(scanned_at)), - next_scan_at: Some(at(REGISTERED_AT + 2 * MINUTE_NANOS)), - }) + read_state(|s| s.watchlist().get(at(READ_AT), &account)), + Some(scanned_at_tick(account)) ); } assert_eq!(deposit_address_scan_count(ScanOutcome::BelowMinimum), 2); @@ -178,22 +173,15 @@ async fn should_evict_expired_registrations_at_each_tick() { async fn should_advance_the_schedule_when_the_call_fails() { setup(); watch(at(REGISTERED_AT), account(1)); - let runtime = runtime_at(TICK_AT).add_stub_error(IcError::CallPerformFailed); + let runtime = runtime_with_reads_returning_after_the_next_slot() + .add_stub_error(IcError::CallPerformFailed); scan_watched_addresses(runtime.clone()).await; EventsAssert::assert_no_events_recorded(); - let scanned_at = TICK_AT + 1; assert_eq!( - read_state(|s| s.watchlist().get(at(scanned_at), &account(1))), - Some(WatchedAddress { - account: account(1), - address: deposit_address(account(1)), - valid_until: at(VALID_UNTIL), - scan_count: 1, - last_scanned_at: Some(at(scanned_at)), - next_scan_at: Some(at(REGISTERED_AT + 2 * MINUTE_NANOS)), - }) + read_state(|s| s.watchlist().get(at(READ_AT), &account(1))), + Some(scanned_at_tick(account(1))) ); assert_eq!(deposit_address_scan_count(ScanOutcome::Failed), 1); } @@ -239,7 +227,10 @@ fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { apply_balance_read(&runtime, scan_at(TICK_AT), &stale, MINIMUM_DEPOSIT_AMOUNT); let after_expiry = apply_balance_read( &runtime, - scan_at(renewed.valid_until.as_nanos() + 1), + Scan { + read_at: at(renewed.valid_until.as_nanos() + 1), + ..scan_at(TICK_AT) + }, &renewed, MINIMUM_DEPOSIT_AMOUNT, ); @@ -288,6 +279,7 @@ fn setup() { fn scan_at(scanned_at: u64) -> Scan { Scan { scanned_at: at(scanned_at), + read_at: at(scanned_at), automatic_deposit_fee: AUTOMATIC_DEPOSIT_FEE, } } @@ -296,6 +288,25 @@ fn runtime_at(now: u64) -> TestCanisterRuntime { TestCanisterRuntime::new().with_increasing_time_from(now) } +/// A tick at [`TICK_AT`], the second slot of the schedule, whose reads return at [`READ_AT`], +/// after the third slot. +fn runtime_with_reads_returning_after_the_next_slot() -> TestCanisterRuntime { + TestCanisterRuntime::new().add_times([TICK_AT, READ_AT]) +} + +/// A registration scanned once by the tick at [`TICK_AT`], whose next scan is due at the +/// following slot, even when the reads of the tick returned after it. +fn scanned_at_tick(account: Account) -> WatchedAddress { + WatchedAddress { + account, + address: deposit_address(account), + valid_until: at(VALID_UNTIL), + scan_count: 1, + last_scanned_at: Some(at(TICK_AT)), + next_scan_at: Some(at(REGISTERED_AT + 2 * MINUTE_NANOS)), + } +} + trait RequestedAddressesExt { fn requested_addresses(&self) -> Vec>; } From d7febe9a937fd28cf9b7d6b208a7637c8374603c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:36:18 +0000 Subject: [PATCH 113/146] test(minter): cover the guard of a tick and the registration check of a failed read A tick that finds the previous one still waiting for its calls makes no call, and a failed read of a registration that was replaced or expired meanwhile leaves the current registration untouched. Co-Authored-By: Claude Opus 5.5 --- minter/src/deposit/automatic/scan/tests.rs | 29 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/minter/src/deposit/automatic/scan/tests.rs b/minter/src/deposit/automatic/scan/tests.rs index e81701fd2..2c83d0890 100644 --- a/minter/src/deposit/automatic/scan/tests.rs +++ b/minter/src/deposit/automatic/scan/tests.rs @@ -2,7 +2,9 @@ use crate::{ constants::{ MAX_ADDRESSES_PER_GET_MULTIPLE_ACCOUNTS, MAX_CONCURRENT_RPC_CALLS, RENT_EXEMPTION_THRESHOLD, }, - deposit::automatic::scan::{Scan, apply_balance_read, scan_watched_addresses}, + deposit::automatic::scan::{ + Scan, apply_balance_read, record_failed_read, scan_watched_addresses, + }, guard::deposit_sol_guard, state::{TaskType, WatchedAddress, event::EventType, mutate_state, read_state}, storage::{ScanOutcome, deposit_address_scan_count}, @@ -49,6 +51,20 @@ async fn should_return_early_if_task_already_active() { assert!(runtime.sent_update_calls().is_empty()); } +#[tokio::test] +async fn should_skip_a_tick_while_the_previous_one_waits_for_its_calls() { + setup(); + watch(at(REGISTERED_AT), account(1)); + let runtime = runtime_at(TICK_AT).add_get_multiple_accounts_response(&[Some(0)]); + + tokio::join!( + scan_watched_addresses(runtime.clone()), + scan_watched_addresses(runtime.clone()) + ); + + assert_eq!(runtime.requested_addresses().len(), 1); +} + #[tokio::test] async fn should_queue_a_deposit_reaching_the_minimum_with_the_fee_in_force() { setup(); @@ -222,18 +238,21 @@ fn should_discard_a_read_of_a_registration_replaced_or_expired_meanwhile() { mutate_state(|s| s.watchlist_mut().remove(&account(1))); let renewed = watch(at(REGISTERED_AT + MINUTE_NANOS), account(1)); let runtime = runtime_at(TICK_AT); + let read_after_expiry = Scan { + read_at: at(renewed.valid_until.as_nanos() + 1), + ..scan_at(TICK_AT) + }; let after_replacement = apply_balance_read(&runtime, scan_at(TICK_AT), &stale, MINIMUM_DEPOSIT_AMOUNT); let after_expiry = apply_balance_read( &runtime, - Scan { - read_at: at(renewed.valid_until.as_nanos() + 1), - ..scan_at(TICK_AT) - }, + read_after_expiry, &renewed, MINIMUM_DEPOSIT_AMOUNT, ); + record_failed_read(scan_at(TICK_AT), &[stale]); + record_failed_read(read_after_expiry, &[renewed]); assert_eq!(after_replacement, None); assert_eq!(after_expiry, None); From aaa0560abc87578abc4e0133c73606b5b3da5aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:37:00 +0000 Subject: [PATCH 114/146] test(minter): return the deposit a scan queued and minted during the balance read of deposit_sol Pins why deposit_sol compares the latest deposit id of the account rather than the one in flight: a deposit queued by a scan and minted before the balance read returns must not free the account for a second deposit from the stale balance. Co-Authored-By: Claude Opus 5.5 --- minter/src/deposit/sweep/tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index f32d8d7c2..bf70c0718 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -200,6 +200,28 @@ async fn should_return_the_deposit_a_scan_queued_during_a_rejected_balance_read_ assert_yields_to_the_deposit_queued_by_a_scan_during_the_read(runtime).await; } +#[tokio::test] +async fn should_return_the_deposit_a_scan_queued_and_minted_during_the_balance_read() { + init_state(); + init_schnorr_master_key(); + let runtime = runtime().add_get_balance_response(MINIMUM_DEPOSIT_AMOUNT); + + let (result, minted) = join!(deposit_sol(&runtime, DEPOSITOR_ACCOUNT), async { + DepositFlow::queue_automatic( + DEPOSITOR_ACCOUNT, + MINIMUM_DEPOSIT_AMOUNT, + AUTOMATIC_DEPOSIT_FEE, + ) + .sweep(signature(1)) + .succeed() + .credit() + .single_pending_mint() + .mint(1) + }); + + assert_eq!(result, Ok(minted.deposit_id)); +} + async fn assert_yields_to_the_deposit_queued_by_a_scan_during_the_read( runtime: TestCanisterRuntime, ) { From bee57d858ff8f65dde4e9d5a4d2b6a3e365eb635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:37:05 +0000 Subject: [PATCH 115/146] test(minter): shorten a test name that rustfmt could not fit on one line Co-Authored-By: Claude Opus 5.5 --- minter/src/deposit/sweep/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/minter/src/deposit/sweep/tests.rs b/minter/src/deposit/sweep/tests.rs index bf70c0718..24f8a98f5 100644 --- a/minter/src/deposit/sweep/tests.rs +++ b/minter/src/deposit/sweep/tests.rs @@ -190,8 +190,7 @@ async fn should_return_the_deposit_a_scan_queued_during_the_balance_read_and_cha } #[tokio::test] -async fn should_return_the_deposit_a_scan_queued_during_a_rejected_balance_read_and_charge_the_read_only() - { +async fn should_return_the_deposit_a_scan_queued_during_a_rejected_read_and_charge_the_read_only() { let runtime = runtime().add_stub_error(IcError::CallRejected { code: RejectCode::SysTransient, message: "SOL RPC canister is stopped".to_string(), From 039c606cb241575b9f6fd3d63c5f153612ea3492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:37:37 +0000 Subject: [PATCH 116/146] docs(minter): say which deposit deposit_sol returns when a scan queued one Since a balance scan of the automatic flow can queue the deposit an account's deposit_sol call returns, its mint may be reduced by the automatic deposit fee. Co-Authored-By: Claude Opus 5.5 --- minter/cksol_minter.did | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 86eda0391..21fe499f7 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -611,6 +611,10 @@ service : (MinterArg) -> { // Queues the deposit address of the given account for a sweep // to the minter's main account and returns the identifier of // the queued deposit, which can be used to query its status. + // If a deposit of the account is already in flight, or a balance scan + // of the automatic flow queues one during the call, returns the + // identifier of that deposit instead, whose mint is reduced by the + // automatic deposit fee if a scan detected it. // // If the owner is not set, it defaults to the caller's principal. // The resolved owner must be a non-anonymous principal. From 522423d9c3555d82ea8185a28ef35bfe187bcdd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 27 Oct 2026 10:37:52 +0000 Subject: [PATCH 117/146] docs: leave the response size of a balance scan to the SOL RPC canister Follows the minter, which no longer passes a response size estimate with getMultipleAccounts. Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 83845ec71..c8797b962 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -169,7 +169,7 @@ The tick and the first gaps are coarser than the 30 second cadence of the ckETH Entries leave the watchlist when a scan queues a deposit, when a `deposit_sol` call queues a deposit, or when their window has expired; expired entries are evicted lazily, on registration and at each tick, and do not count against the capacity. A minted deposit does not re-arm the watch: the next deposit to the same address needs a new registration, which is free. A dropped sweep does: when the sweep of an automatically detected deposit fails or expires (see [Section 3.1.2](#312-manual-flow)), the account is registered again, for a fresh window and schedule starting at the drop, provided the watchlist has room, so that the funds still sitting on the deposit address are swept again without the user's intervention. The re-arm counts against the capacity like a registration, since the slot was released at detection and may have been taken since; when the watchlist is full of live entries, the deposit stays dropped and its funds wait for the user, as for a manual deposit, so that the capacity bound and everything derived from it keep holding. The fresh schedule starts with the burst, since the scan progress left the watchlist with the detection and the funds are known to be there, which makes the burst the right cadence; the extra scans only ever follow a detection, which requires real funds on the address, so they are not available to an adversary for free. The re-arm is not bounded by the original window, so a sweep that keeps failing is detected and re-armed again until it lands, at the ckSOL minter's expense; this is accepted because a dropped sweep is an infrastructure failure that is alerted on and that no user can bring about, and because a bound at the original window would only have capped such a chain at a day while costing every queued deposit its window end. An account on the watchlist never has a deposit in flight. -**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call and the response size estimate passed with it, which the SOL RPC canister uses as the response size limit of the call, allowance for the HTTP headers included, are sized by the number of addresses it carries. The call uses the typed `getMultipleAccounts` endpoint of the SOL RPC canister, so the automatic flow requires a version of that canister which offers it. A call that fails counts as a scan of its entries, which advance to their next slot as after a balance below the minimum, so that a persistent failure of the SOL RPC canister costs the ckSOL minter no more than the schedule and costs the user the scans lost to the failure. +**Detection.** A tick collects the due entries, reads the balances of their deposit addresses with `getMultipleAccounts` at the `finalized` commitment level, up to 100 addresses per call and at most `MAX_CONCURRENT_RPC_CALLS` calls concurrently, so that one tick covers the full watchlist, and treats a missing account as a zero balance. Deposit addresses are plain system accounts without data, so the request asks for no account data and the response is about 150 bytes per address; the cycles attached to a call are sized by the number of addresses it carries, while its response size is left to the default estimate of the SOL RPC canister, as for every other call (see [Section 3.3.2](#332-cksol-minter-fees)). The call uses the typed `getMultipleAccounts` endpoint of the SOL RPC canister, so the automatic flow requires a version of that canister which offers it. A call that fails counts as a scan of its entries, which advance to their next slot as after a balance below the minimum, so that a persistent failure of the SOL RPC canister costs the ckSOL minter no more than the schedule and costs the user the scans lost to the failure. An address whose balance is at or above the minimum deposit amount in force at that moment is queued exactly as `deposit_sol` queues it: the sweepable amount is the balance minus the rent exemption threshold, the deposit gets the next deposit id, and the `QueuedDeposit` event is recorded, carrying in addition the automatic deposit fee in force at that moment. The recorded fee is the one deducted when the deposit is credited, so a fee change by upgrade after detection does not affect it: a deposit that an upgrade finds queued, swept or finalized is credited afterwards with the fee restored from its `QueuedDeposit`, and a deposit already credited has its mint amount recorded in `CreditedSweep`, as for any sweep; the constraint of [Section 3.3.4](#334-parameter-constraints) is checked against the current parameters at every init and upgrade, so a deposit detected under them always mints a positive amount. The trust model is that of the manual flow: the balance is read at `finalized` with the 3-out-of-4 provider consensus and becomes the planned sweep amount, and a wrong balance cannot cause an over-mint, since ckSOL is minted only after the sweep has been finalized and settled against the finalized transaction. This differs from the ckETH minter, where the scanned balance is only a trigger. An address whose balance is below the minimum, including one that holds only the rent exemption threshold left by a previous sweep, advances to its next scan. From a27df5b73e4c9c2d733458dc57b5e3a0f9696e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 12:50:33 +0000 Subject: [PATCH 118/146] refactor(minter): re-arm a dropped sweep's automatic deposits outside the state transition The re-arm ran in the state transition of FailedTransaction and ExpiredTransaction, so post_upgrade replayed it against a watchlist without the transient registrations it was made against, and replayed its log line at every upgrade. The live caller now records the drop and then re-arms through process_dropped_sweep, which logs the outcome once, with a refused re-arm at Info. The state transition only drops the deposits again. Co-Authored-By: Claude Opus 5.5 --- minter/src/monitor/mod.rs | 10 ++- minter/src/monitor/tests.rs | 53 +++++++++++-- minter/src/state/audit.rs | 53 ++++++++++++- minter/src/state/deposits/mod.rs | 6 +- minter/src/state/deposits/tests.rs | 3 +- minter/src/state/event.rs | 9 ++- minter/src/state/mod.rs | 30 ++------ minter/src/state/tests.rs | 120 +++++++++++++++++------------ 8 files changed, 194 insertions(+), 90 deletions(-) diff --git a/minter/src/monitor/mod.rs b/minter/src/monitor/mod.rs index 4e8b4585c..6a387a14e 100644 --- a/minter/src/monitor/mod.rs +++ b/minter/src/monitor/mod.rs @@ -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; @@ -105,7 +107,7 @@ async fn check_sweep_transactions(runtime: &R) -> bool { "Transaction {signature} finalized with on-chain error: {error}" ); mutate_state(|state| { - process_event( + process_dropped_sweep( state, EventType::FailedTransaction { signature: *signature, @@ -168,7 +170,7 @@ fn expire_transactions( } log!(Priority::Info, "Transaction {signature} expired"); mutate_state(|state| { - process_event( + process_dropped_sweep( state, EventType::ExpiredTransaction { signature: *signature, diff --git a/minter/src/monitor/tests.rs b/minter/src/monitor/tests.rs index fbe2d3e23..65de1e71b 100644 --- a/minter/src/monitor/tests.rs +++ b/minter/src/monitor/tests.rs @@ -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::{ @@ -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![account(1)], + "{outcome}" + ); + }); + } + } + fn confirmed_status() -> TransactionStatus { TransactionStatus { slot: 0, diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 1148a1872..8479ee3af 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -3,9 +3,13 @@ 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(state: &mut State, payload: EventType, runtime: &R) { @@ -13,6 +17,51 @@ pub fn process_event(state: &mut State, payload: EventType, 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 goes through here. +/// +/// 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( + 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 { @@ -39,10 +88,10 @@ fn apply_state_transition(state: &mut State, payload: &EventType, timestamp: u64 state.process_transaction_succeeded(signature); } EventType::FailedTransaction { signature } => { - state.process_transaction_failed(signature, timestamp); + state.process_transaction_failed(signature); } EventType::ExpiredTransaction { signature } => { - state.process_transaction_expired(signature, timestamp); + state.process_transaction_expired(signature); } EventType::QueuedDeposit { deposit_id, diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 12f33566a..531838944 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -256,9 +256,8 @@ impl Deposits { expected_received } - /// Drops every deposit of the given swept sweep, releases their accounts and returns - /// the dropped deposits in the order of their ids. - pub(super) fn drop_swept(&mut self, signature: &Signature) -> Vec { + /// Drops every deposit of the given swept sweep and releases their accounts. + pub(super) fn drop_swept(&mut self, signature: &Signature) { let sweep = self .swept .remove(signature) @@ -274,7 +273,6 @@ impl Deposits { }, ); } - sweep.deposits().values().copied().collect() } /// Moves every deposit of the given finalized sweep to the quarantine, keeping their diff --git a/minter/src/state/deposits/tests.rs b/minter/src/state/deposits/tests.rs index 49f291c76..1cb8d3dbe 100644 --- a/minter/src/state/deposits/tests.rs +++ b/minter/src/state/deposits/tests.rs @@ -270,9 +270,8 @@ mod drop_swept { &sweep_signature, ); - let dropped_deposits = deposits.drop_swept(&sweep_signature); + deposits.drop_swept(&sweep_signature); - assert_eq!(dropped_deposits, vec![first, third]); assert!(deposits.swept().is_empty()); let dropped = |deposit| SweptDeposit { deposit, diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 8c458992f..d7d57088c 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -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; the event is then recorded + /// through [`process_dropped_sweep`], which re-arms the automatic ones. + /// + /// [`process_dropped_sweep`]: crate::state::audit::process_dropped_sweep #[n(6)] FailedTransaction { /// The signature of the failed Solana transaction. @@ -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 then recorded + /// through [`process_dropped_sweep`], which re-arms the automatic ones. + /// + /// [`process_dropped_sweep`]: crate::state::audit::process_dropped_sweep #[n(7)] ExpiredTransaction { /// The signature of the expired Solana transaction. diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 01d70602a..11a992ac3 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -11,13 +11,12 @@ use crate::{ state::event::{ CreditedDeposit, Signer, TransactionPurpose, VersionedMessage, WithdrawalRequest, }, - utils::{insertion_ordered_map::InsertionOrderedMap, timed_sized_map::Timestamp}, + utils::insertion_ordered_map::InsertionOrderedMap, }; use candid::Principal; -use canlog::log; use cksol_types::{DepositSolId, TxFinalizedStatus, WithdrawSolStatus}; use cksol_types_internal::SolanaNetwork; -use cksol_types_internal::{Ed25519KeyName, InitArgs, UpgradeArgs, log::Priority}; +use cksol_types_internal::{Ed25519KeyName, InitArgs, UpgradeArgs}; use ic_canister_runtime::Runtime; use ic_ed25519::PublicKey; use icrc_ledger_types::icrc1::account::Account; @@ -233,7 +232,7 @@ impl State { &self.created_withdrawal_txs } - pub fn process_transaction_expired(&mut self, signature: &Signature, timestamp: u64) { + pub fn process_transaction_expired(&mut self, signature: &Signature) { assert!( !self.succeeded_transactions.contains(signature), "BUG: cannot mark already succeeded transaction {signature} as expired" @@ -249,30 +248,13 @@ impl State { panic!("BUG: cannot mark non-submitted transaction {signature} as expired") }); match transaction { - MinterTransaction::SweepDeposit { .. } => self.drop_sweep(signature, timestamp), + MinterTransaction::SweepDeposit { .. } => self.deposits.drop_swept(signature), MinterTransaction::Withdrawal { .. } => { panic!("BUG: durable-nonce withdrawal transaction {signature} cannot expire") } } } - /// Drops the deposits of the sweep and puts the ones the automatic flow detected 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. - fn drop_sweep(&mut self, signature: &Signature, timestamp: u64) { - for deposit in self.deposits.drop_swept(signature) { - let rearmed = self - .deposits - .watchlist_mut() - .rearm(Timestamp::from_nanos(timestamp), &deposit); - log!( - Priority::Debug, - "Re-arm of the watch of account {:?} after sweep {signature} was dropped: {rearmed:?}", - deposit.account - ); - } - } - pub fn succeeded_transactions(&self) -> &BTreeSet { &self.succeeded_transactions } @@ -823,7 +805,7 @@ impl State { }); } - fn process_transaction_failed(&mut self, signature: &Signature, timestamp: u64) { + fn process_transaction_failed(&mut self, signature: &Signature) { assert!( !self.succeeded_transactions.contains(signature), "Attempted to mark already succeeded transaction {signature:?} as failed" @@ -835,7 +817,7 @@ impl State { panic!("Attempted to mark unknown transaction {signature:?} as failed") }); match &transaction { - MinterTransaction::SweepDeposit { .. } => self.drop_sweep(signature, timestamp), + MinterTransaction::SweepDeposit { .. } => self.deposits.drop_swept(signature), MinterTransaction::Withdrawal { nonce_account, .. } => { self.nonce_pool.free(nonce_account) } diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 60c03fe93..94e23d967 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -166,7 +166,11 @@ mod watched_deposit_addresses { mod swept_deposits { use super::*; use crate::{ - state::{audit::replay_events, reset_state, watchlist::MAX_WATCHED_ADDRESSES}, + state::{ + audit::{process_dropped_sweep, replay_events}, + reset_state, + watchlist::MAX_WATCHED_ADDRESSES, + }, storage::{reset_events, with_event_iter}, test_fixtures::{ DEFAULT_BLOCK_HEIGHT, REGISTERED_AT, WATCH_WINDOW_NANOS, at, @@ -250,54 +254,66 @@ mod swept_deposits { } #[test] - fn should_rearm_the_automatic_deposits_of_a_failed_sweep() { - let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); - - assert_rearms_the_automatic_deposits_when_dropped_by(EventType::FailedTransaction { - signature: sweep_signature, - }); - } - - #[test] - fn should_rearm_the_automatic_deposits_of_an_expired_sweep() { - let sweep_signature = signature(SWEEP_SIGNATURE_INDEX); - - assert_rearms_the_automatic_deposits_when_dropped_by(EventType::ExpiredTransaction { - signature: sweep_signature, - }); - } - - fn assert_rearms_the_automatic_deposits_when_dropped_by(drop_event: EventType) { + fn should_rearm_the_automatic_deposits_of_a_dropped_sweep_without_replaying_the_rearm() { const DROPPED_AT: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS + 1; - 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(signature(SWEEP_SIGNATURE_INDEX)); - - mutate_state(|state| { - process_event( - state, - drop_event, - &TestCanisterRuntime::new().add_times([DROPPED_AT, DROPPED_AT]), - ) - }); + 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); - assert_eq!(s.watchlist().iter().collect::>(), vec![rearmed]); - assert_eq!(s.watchlist().due(at(DROPPED_AT)), vec![rearmed]); - }); - let replayed = replayed_recorded_log(); - read_state(|live_state| assert_eq!(&replayed, live_state)); + 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![rearmed], + "{drop_event:?}" + ); + assert_eq!( + s.watchlist().due(at(DROPPED_AT)), + vec![rearmed], + "{drop_event:?}" + ); + }); + let replayed = replayed_recorded_log(); + assert!(replayed.watchlist().is_empty(), "{drop_event:?}"); + read_state(|live_state| { + assert_eq!( + replayed.deposits().dropped(), + live_state.deposits().dropped(), + "{drop_event:?}" + ) + }); + } } #[test] @@ -310,7 +326,15 @@ mod swept_deposits { watch(at(REGISTERED_AT), account(index)); } - fail_transaction(dropped.signature); + 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); From 1b04ccb9f0012ffb6e4421fff1cf21e8d75f505c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 12:51:29 +0000 Subject: [PATCH 119/146] docs(minter): document the re-arm of dropped automatic deposits The docs of the swept deposits and of the Dropped status still said that only deposit_sol follows a dropped sweep. The re-arm no longer claims to keep an existing registration, since the account of a deposit in flight is never watched, and the test of that impossible case goes. Co-Authored-By: Claude Opus 5.5 --- libs/types/src/lib.rs | 5 ++++- minter/cksol_minter.did | 6 +++++- minter/src/state/deposits/mod.rs | 3 ++- minter/src/state/watchlist/mod.rs | 5 +++-- minter/src/state/watchlist/tests.rs | 13 ------------- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 9b755ac0e..7900e01b6 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -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, diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 21fe499f7..46ee24fd8 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -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; diff --git a/minter/src/state/deposits/mod.rs b/minter/src/state/deposits/mod.rs index 531838944..701dc6af9 100644 --- a/minter/src/state/deposits/mod.rs +++ b/minter/src/state/deposits/mod.rs @@ -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. diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 4847f55b8..79e847019 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -140,8 +140,9 @@ 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. An account that is already watched keeps - /// its registration. + /// 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, diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 0c9c8dcf9..c5d0bd7dc 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -251,19 +251,6 @@ fn should_not_rearm_a_manual_deposit() { assert!(watchlist.is_empty()); } -#[test] -fn should_keep_the_registration_of_an_account_that_is_already_watched() { - let mut watchlist = Watchlist::default(); - let registered = register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(1)); - - let rearmed = watchlist - .rearm(at(REGISTERED_AT + 2 * HOUR_NANOS), &dropped(account(1))) - .unwrap(); - - assert_eq!(rearmed, registered); - assert_eq!(watchlist.len(), 1); -} - fn dropped(account: Account) -> QueuedDeposit { automatic_deposit_of(account, MINIMUM_DEPOSIT_AMOUNT, AUTOMATIC_DEPOSIT_FEE) } From cd92fe45986d27a3ea81378f5a37c3297b3a6a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 12:55:58 +0000 Subject: [PATCH 120/146] docs: say that the re-arm of a dropped sweep is not replayed from the event log Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index c8797b962..df5720fcb 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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 From abc774912944b2b7825ed44021b28ae276fccc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 12:56:22 +0000 Subject: [PATCH 121/146] test(minter): compare the whole replayed state after a re-arm with the live one Co-Authored-By: Claude Opus 5.5 --- minter/src/state/tests.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 94e23d967..4ab4c0730 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -169,7 +169,7 @@ mod swept_deposits { state::{ audit::{process_dropped_sweep, replay_events}, reset_state, - watchlist::MAX_WATCHED_ADDRESSES, + watchlist::{MAX_WATCHED_ADDRESSES, Watchlist}, }, storage::{reset_events, with_event_iter}, test_fixtures::{ @@ -304,15 +304,13 @@ mod swept_deposits { "{drop_event:?}" ); }); - let replayed = replayed_recorded_log(); - assert!(replayed.watchlist().is_empty(), "{drop_event:?}"); - read_state(|live_state| { - assert_eq!( - replayed.deposits().dropped(), - live_state.deposits().dropped(), - "{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:?}" + ); } } From dda2fc8ddad9e8210bb9f5c9131954def19a5bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 12:56:53 +0000 Subject: [PATCH 122/146] docs(minter): clarify which drops go through process_dropped_sweep Co-Authored-By: Claude Opus 5.5 --- minter/src/state/audit.rs | 3 ++- minter/src/state/event.rs | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/minter/src/state/audit.rs b/minter/src/state/audit.rs index 8479ee3af..e22d9bf6b 100644 --- a/minter/src/state/audit.rs +++ b/minter/src/state/audit.rs @@ -20,7 +20,8 @@ pub fn process_event(state: &mut State, payload: EventType, /// 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 goes through here. +/// 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. diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index d7d57088c..a3cb5e6c5 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -113,8 +113,8 @@ pub enum EventType { signature: Signature, }, /// A previously submitted Solana transaction has failed. - /// The deposits of a failed sweep transaction are dropped; the event is then recorded - /// through [`process_dropped_sweep`], which re-arms the automatic ones. + /// 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)] @@ -125,8 +125,8 @@ 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 event is then recorded - /// through [`process_dropped_sweep`], which re-arms the automatic ones. + /// 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)] From 0c7c1cc2df14007a6e83684eac056e1870761cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 14:08:31 +0000 Subject: [PATCH 123/146] fix(minter): restore the watchlist under the window and capacity of the running version The snapshot recorded the window and the capacity of the watchlist it was taken from, which itself came from the previous snapshot, so a change of WATCH_WINDOW or MAX_WATCHED_ADDRESSES in a later version would never have taken effect. The snapshot now records only the entries, and the restore uses the constants. Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- integration_tests/src/fixtures.rs | 1 - integration_tests/tests/tests.rs | 7 ++----- libs/types-internal/src/event.rs | 4 ---- minter/cksol_minter.did | 4 ---- minter/src/lifecycle/tests.rs | 3 --- minter/src/state/event.rs | 12 +++--------- minter/src/state/event/candid/mod.rs | 22 ++++++++-------------- minter/src/state/watchlist/mod.rs | 20 +++++++++----------- minter/src/state/watchlist/tests.rs | 24 +++++++++--------------- minter/src/test_fixtures/mod.rs | 12 ++---------- 11 files changed, 34 insertions(+), 77 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index df5720fcb..3aff6bc86 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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` 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. +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 every entry with its window end and 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 its entries verbatim, without evicting expired ones or enforcing the capacity, 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 window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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 diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index 302c16ea7..04ff6c18b 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -32,7 +32,6 @@ pub const MINTER_ADDRESS: Address = address!("2YKPWqP51gUFEYo1FD2K3LoRE6Cd2f99E1 pub const DEPOSIT_AMOUNT: Lamport = 500_000_000; /// Minimum balance left on a deposit address to keep it rent-exempt. pub const RENT_EXEMPTION_THRESHOLD: Lamport = 890_880; -pub const MAX_WATCHED_ADDRESSES: u64 = 1_000; pub const WATCH_WINDOW: Duration = Duration::from_hours(24); pub const WATCH_WINDOW_NANOS: u64 = WATCH_WINDOW.as_nanos() as u64; diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index ac6c57c16..acc0f0671 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -4,9 +4,8 @@ use candid::{Nat, Principal}; use cksol_int_tests::{ CkSolMinter, Setup, SetupBuilder, fixtures::{ - DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, - MAX_WATCHED_ADDRESSES, MINTER_ADDRESS, MockBuilder, RENT_EXEMPTION_THRESHOLD, - WATCH_WINDOW_NANOS, + DEFAULT_CALLER_ACCOUNT, DEFAULT_CALLER_DEPOSIT_ADDRESS, DEPOSIT_AMOUNT, MINTER_ADDRESS, + MockBuilder, RENT_EXEMPTION_THRESHOLD, WATCH_WINDOW_NANOS, }, validator::FEE_PER_SIGNATURE, }; @@ -1322,8 +1321,6 @@ mod automatic_deposit_sol_tests { check!( snapshots == [&EventType::WatchedDepositAddresses { - window_nanos: WATCH_WINDOW_NANOS, - capacity: MAX_WATCHED_ADDRESSES, entries: vec![WatchedDepositAddress { account: DEFAULT_CALLER_ACCOUNT, address: DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap(), diff --git a/libs/types-internal/src/event.rs b/libs/types-internal/src/event.rs index 44d24dc02..06ea0d936 100644 --- a/libs/types-internal/src/event.rs +++ b/libs/types-internal/src/event.rs @@ -146,10 +146,6 @@ pub enum EventType { /// The snapshot of the watchlist of the automatic deposit flow taken before an upgrade, /// which replay restores verbatim, since registrations are not recorded as events. WatchedDepositAddresses { - /// The window of a registration, in nanoseconds. - window_nanos: u64, - /// The number of live registrations the watchlist holds at most. - capacity: u64, /// Every registration, live or expired but not yet evicted. entries: Vec, }, diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 81cd7460f..812638156 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -599,10 +599,6 @@ type EventType = variant { // an upgrade, which replay restores verbatim, since registrations are not // recorded as events. WatchedDepositAddresses : record { - // The window of a registration, in nanoseconds. - window_nanos: nat64; - // The number of live registrations the watchlist holds at most. - capacity: nat64; // Every registration, live or expired but not yet evicted. entries: vec WatchedDepositAddress; }; diff --git a/minter/src/lifecycle/tests.rs b/minter/src/lifecycle/tests.rs index 83c6f72a8..07cd25ad3 100644 --- a/minter/src/lifecycle/tests.rs +++ b/minter/src/lifecycle/tests.rs @@ -1,7 +1,6 @@ use crate::{ lifecycle::pre_upgrade, state::{ - MAX_WATCHED_ADDRESSES, event::{EventType, WatchlistSnapshot}, mutate_state, read_state, }, @@ -19,8 +18,6 @@ fn should_record_an_empty_snapshot_on_every_upgrade() { EventsAssert::from_recorded() .expect_event_eq(EventType::WatchedDepositAddresses(WatchlistSnapshot { - window_nanos: WATCH_WINDOW_NANOS, - capacity: MAX_WATCHED_ADDRESSES.get() as u64, entries: vec![], })) .assert_no_more_events(); diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 38ad2d6b7..c2a255ee6 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -240,18 +240,12 @@ pub enum EventType { WatchedDepositAddresses(#[n(0)] WatchlistSnapshot), } -/// Payload of the `WatchedDepositAddresses` event: the limits of the watchlist in force when -/// the snapshot was taken and every registration, live or expired but not yet evicted, in -/// the order the watchlist is rebuilt from. +/// Payload of the `WatchedDepositAddresses` event: every registration, live or expired but +/// not yet evicted, in the order the watchlist is rebuilt from. The window and the capacity +/// are not recorded, since the watchlist is rebuilt under those of the running version. #[derive(Clone, Eq, PartialEq, Debug, Decode, Encode)] pub struct WatchlistSnapshot { - /// The window of a registration, in nanoseconds. #[n(0)] - pub window_nanos: u64, - /// The number of live registrations the watchlist holds at most. - #[n(1)] - pub capacity: u64, - #[n(2)] pub entries: Vec, } diff --git a/minter/src/state/event/candid/mod.rs b/minter/src/state/event/candid/mod.rs index 82f344893..547db83b9 100644 --- a/minter/src/state/event/candid/mod.rs +++ b/minter/src/state/event/candid/mod.rs @@ -124,8 +124,6 @@ impl From for CandidEventType { }, EventType::WatchedDepositAddresses(snapshot) => { CandidEventType::WatchedDepositAddresses { - window_nanos: snapshot.window_nanos, - capacity: snapshot.capacity, entries: snapshot .entries .into_iter() @@ -292,18 +290,14 @@ impl TryFrom for EventType { nonce_account: Address::from(nonce_account), nonce_value: Hash::from(nonce_value), }, - CandidEventType::WatchedDepositAddresses { - window_nanos, - capacity, - entries, - } => EventType::WatchedDepositAddresses(WatchlistSnapshot { - window_nanos, - capacity, - entries: entries - .into_iter() - .map(WatchedDepositAddress::from) - .collect(), - }), + CandidEventType::WatchedDepositAddresses { entries } => { + EventType::WatchedDepositAddresses(WatchlistSnapshot { + entries: entries + .into_iter() + .map(WatchedDepositAddress::from) + .collect(), + }) + } }) } } diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 0a309f639..85de9f62b 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -122,16 +122,17 @@ impl Default for Watchlist { } impl Watchlist { - /// Rebuilds the watchlist verbatim from a snapshot: under the limits the snapshot records, - /// with the entries as stored, expired ones included, so that the state replayed from the - /// event log equals the state that recorded the snapshot. + /// Rebuilds the watchlist from a snapshot: with the entries as stored, expired ones + /// included and without a capacity check, so that the state replayed from the event log + /// equals the state that recorded the snapshot, but under the [`WATCH_WINDOW`] and + /// [`MAX_WATCHED_ADDRESSES`] of the running version, so that a change of either takes + /// effect at the upgrade that ships it. /// /// # Panics /// - /// If the snapshot records no capacity or lists an account twice, since it was not - /// produced by [`Self::snapshot`] then. + /// If the snapshot lists an account twice, since it was not produced by + /// [`Self::snapshot`] then. pub fn from_snapshot(snapshot: &WatchlistSnapshot) -> Self { - let capacity = usize::try_from(snapshot.capacity).unwrap_or(usize::MAX); let entries = snapshot.entries.iter().map(|entry| { ( entry.account, @@ -148,9 +149,8 @@ impl Watchlist { }); Self { entries: TimedSizedMap::from_ordered_entries( - Duration::from_nanos(snapshot.window_nanos), - NonZeroUsize::new(capacity) - .expect("BUG: a watchlist snapshot records a non-zero capacity"), + WATCH_WINDOW, + MAX_WATCHED_ADDRESSES, entries, ), } @@ -159,8 +159,6 @@ impl Watchlist { /// The snapshot [`Self::from_snapshot`] rebuilds this watchlist from. pub fn snapshot(&self) -> WatchlistSnapshot { WatchlistSnapshot { - window_nanos: u64::try_from(self.entries.ttl().as_nanos()).unwrap_or(u64::MAX), - capacity: self.entries.capacity().get() as u64, entries: self .entries .iter_by_expiry() diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 8548843b7..cd1f46063 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -272,8 +272,6 @@ fn should_rebuild_the_watchlist_verbatim_from_its_snapshot() { .collect::>(), vec![account(3), account(2), account(1)] ); - assert_eq!(snapshot.window_nanos, WATCH_WINDOW_NANOS); - assert_eq!(snapshot.capacity, MAX_WATCHED_ADDRESSES.get() as u64); } #[test] @@ -289,14 +287,20 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { }) .collect(); let snapshot = WatchlistSnapshot { - window_nanos: WATCH_WINDOW_NANOS, - capacity: MAX_WATCHED_ADDRESSES.get() as u64, entries: entries.clone(), }; - let rebuilt = Watchlist::from_snapshot(&snapshot); + let mut rebuilt = Watchlist::from_snapshot(&snapshot); assert_eq!(rebuilt.len(), entries.len()); + assert_eq!( + rebuilt.register( + at(REGISTERED_AT), + account(entries.len()), + deposit_address(account(entries.len())) + ), + Err(WatchlistFull) + ); assert_eq!(rebuilt.snapshot(), snapshot); assert_eq!(rebuilt.live_len(at(VALID_UNTIL + 1)), 0); assert_eq!( @@ -312,16 +316,6 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { ); } -#[test] -#[should_panic(expected = "non-zero capacity")] -fn should_panic_when_rebuilding_from_a_snapshot_without_capacity() { - Watchlist::from_snapshot(&WatchlistSnapshot { - window_nanos: WATCH_WINDOW_NANOS, - capacity: 0, - entries: vec![], - }); -} - fn dropped(account: Account) -> QueuedDeposit { automatic_deposit_of(account, MINIMUM_DEPOSIT_AMOUNT, AUTOMATIC_DEPOSIT_FEE) } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 5170a1b1f..a3faf9f05 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -1688,16 +1688,8 @@ pub mod arb { } fn arb_watchlist_snapshot() -> impl Strategy { - ( - any::(), - 1..=u64::MAX, - prop::collection::vec(arb_watched_deposit_address(), 0..5), - ) - .prop_map(|(window_nanos, capacity, entries)| WatchlistSnapshot { - window_nanos, - capacity, - entries, - }) + prop::collection::vec(arb_watched_deposit_address(), 0..5) + .prop_map(|entries| WatchlistSnapshot { entries }) } fn arb_watched_deposit_address() -> impl Strategy { From 24c527b03aac1d228b1931db9ec7290f405d248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 14:09:19 +0000 Subject: [PATCH 124/146] fix(minter): report the registration time of a watched address from its schedule start The time was derived from the window end and the current window, which is off for a registration restored after a change of WATCH_WINDOW. Co-Authored-By: Claude Opus 5.5 --- minter/src/state/watchlist/mod.rs | 13 ++++++------- minter/src/state/watchlist/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 85de9f62b..3c021cece 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -260,10 +260,13 @@ impl Watchlist { self.live(now).count() } - /// When the oldest live registration was made, if any. + /// When the oldest live registration was made, if any: the start of its schedule, which + /// does not depend on the window, unlike its end, after a change of [`WATCH_WINDOW`]. pub fn oldest_registered_at(&self, now: Timestamp) -> Option { - self.live(now) - .map(|watched| self.registered_at(watched.valid_until)) + self.entries + .iter_by_expiry() + .filter(|(_, entry)| entry.is_live(now)) + .map(|(_, entry)| entry.value.scheduled_from) .min() } @@ -285,8 +288,4 @@ impl Watchlist { next_scan_at: progress.next_scan_at(entry.expires_at), } } - - fn registered_at(&self, valid_until: Timestamp) -> Timestamp { - valid_until.saturating_sub(self.entries.ttl()) - } } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index cd1f46063..ee41cba20 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -316,6 +316,26 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { ); } +#[test] +fn should_report_when_a_registration_restored_under_another_window_was_made() { + let registered_under_a_longer_window = WatchedDepositAddress { + account: account(1), + address: deposit_address(account(1)), + valid_until: REGISTERED_AT + 2 * WATCH_WINDOW_NANOS, + scheduled_from: REGISTERED_AT, + scan_count: 0, + last_scanned_at: None, + }; + let rebuilt = Watchlist::from_snapshot(&WatchlistSnapshot { + entries: vec![registered_under_a_longer_window], + }); + + assert_eq!( + rebuilt.oldest_registered_at(at(VALID_UNTIL + 1)), + Some(at(REGISTERED_AT)) + ); +} + fn dropped(account: Account) -> QueuedDeposit { automatic_deposit_of(account, MINIMUM_DEPOSIT_AMOUNT, AUTOMATIC_DEPOSIT_FEE) } From aa02e481ef1acf8376e2fcfbacf481cb9df54166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 14:10:28 +0000 Subject: [PATCH 125/146] feat(minter): record no snapshot of an empty watchlist, as the ckETH minter Recording a snapshot on every upgrade made every upgrade to this version one-way: the previous version cannot decode the new event and traps on replay. Without registrations nothing is recorded, so a minter whose automatic flow was never used can still be rolled back. Replay may then restore an older snapshot, which brings back nothing but expired entries. Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- integration_tests/tests/tests.rs | 11 +---------- minter/src/lifecycle.rs | 12 +++++++++--- minter/src/lifecycle/tests.rs | 13 +++---------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 3aff6bc86..a19ed80d3 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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 every entry with its window end and 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 its entries verbatim, without evicting expired ones or enforcing the capacity, 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 window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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. +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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, 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 window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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 diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index acc0f0671..5b33b639b 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -274,18 +274,9 @@ mod lifecycle { .count() <= 1 ); - check!( - events - .iter() - .filter(|e| matches!(e, EventType::WatchedDepositAddresses { .. })) - .count() - == 1 - ); check!(events[1..].iter().all(|e| matches!( e, - EventType::Upgrade(_) - | EventType::MinterPublicKeyFetched { .. } - | EventType::WatchedDepositAddresses { .. } + EventType::Upgrade(_) | EventType::MinterPublicKeyFetched { .. } ))); }); diff --git a/minter/src/lifecycle.rs b/minter/src/lifecycle.rs index a10674c64..050fffeb5 100644 --- a/minter/src/lifecycle.rs +++ b/minter/src/lifecycle.rs @@ -24,12 +24,18 @@ pub fn init(init_args: InitArgs, runtime: R) { } /// Records the watchlist of the automatic deposit flow, which lives on the heap, as a -/// snapshot event, so that replaying the event log after the upgrade restores it. The -/// snapshot is recorded even when the watchlist is empty, since replay restores the latest -/// one: skipping it would let an older snapshot resurrect registrations evicted since. +/// snapshot event, so that replaying the event log after the upgrade restores it. +/// +/// As in the ckETH minter, an empty watchlist records nothing, so that a minter whose +/// automatic flow was never used keeps an event log the previous version can replay, and can +/// be rolled back. Replay then restores an older snapshot, if any, which brings back nothing +/// but expired entries: an entry leaves the watchlist without an event only by expiring. pub fn pre_upgrade(runtime: R) { mutate_state(|s| { let snapshot = s.watchlist().snapshot(); + if snapshot.entries.is_empty() { + return; + } log!( Priority::Info, "[pre_upgrade]: recording the snapshot of {} watched deposit addresses", diff --git a/minter/src/lifecycle/tests.rs b/minter/src/lifecycle/tests.rs index 07cd25ad3..a26770039 100644 --- a/minter/src/lifecycle/tests.rs +++ b/minter/src/lifecycle/tests.rs @@ -1,9 +1,6 @@ use crate::{ lifecycle::pre_upgrade, - state::{ - event::{EventType, WatchlistSnapshot}, - mutate_state, read_state, - }, + state::{event::EventType, mutate_state, read_state}, test_fixtures::{ EventsAssert, REGISTERED_AT, WATCH_WINDOW_NANOS, account, at, init_state, runtime::TestCanisterRuntime, watch, @@ -11,16 +8,12 @@ use crate::{ }; #[test] -fn should_record_an_empty_snapshot_on_every_upgrade() { +fn should_record_no_snapshot_of_an_empty_watchlist() { init_state(); pre_upgrade(TestCanisterRuntime::new().add_times([REGISTERED_AT, REGISTERED_AT])); - EventsAssert::from_recorded() - .expect_event_eq(EventType::WatchedDepositAddresses(WatchlistSnapshot { - entries: vec![], - })) - .assert_no_more_events(); + EventsAssert::from_recorded().assert_no_more_events(); } #[test] From 633de572c1d395c3a7fe7fb07ba34b7ca2dfb334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 14:13:22 +0000 Subject: [PATCH 126/146] test(minter): replay the snapshot recorded by pre_upgrade in the watchlist tests The previous test inserted snapshots ahead of the recorded events, whose watchlist ended up the same with or without the restore, so a no-op restore passed every test. The tests now record the snapshot through pre_upgrade and compare the replayed watchlist with the one before the upgrade, including a re-armed and a scanned registration, and check that an upgrade without a snapshot brings back nothing but expired entries. Co-Authored-By: Claude Opus 5.5 --- minter/src/state/tests.rs | 70 ++++++++++++++++++++++++--------- minter/src/test_fixtures/mod.rs | 7 ---- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 8dea7c38b..0ca32e6e2 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -143,9 +143,13 @@ mod queued_deposits { mod watched_deposit_addresses { use super::*; - use crate::test_fixtures::{ - MINIMUM_DEPOSIT_AMOUNT, REGISTERED_AT, at, flow::deposit::DepositFlow, - replayed_recorded_log_after, watch, + use crate::{ + lifecycle::pre_upgrade, + state::{audit::process_dropped_sweep, watchlist::Watchlist}, + test_fixtures::{ + HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, + at, flow::deposit::DepositFlow, replayed_recorded_log, watch, + }, }; #[test] @@ -164,35 +168,63 @@ mod watched_deposit_addresses { } #[test] - fn should_restore_the_latest_snapshot_and_apply_the_events_that_follow_it() { + fn should_restore_the_watchlist_recorded_before_the_upgrade() { + const DROPPED_AT: u64 = REGISTERED_AT + 2 * MINUTE_NANOS; + const UPGRADED_AT: u64 = REGISTERED_AT + HOUR_NANOS; init_state(); init_schnorr_master_key(); watch(at(REGISTERED_AT), account(1)); - watch(at(REGISTERED_AT), account(2)); - let stale_snapshot = read_state(|s| s.watchlist().snapshot()); - mutate_state(|s| s.watchlist_mut().remove(&account(2))); - let latest_snapshot = read_state(|s| s.watchlist().snapshot()); - let detected = DepositFlow::queue_automatic(account(1), 20_000_000, AUTOMATIC_DEPOSIT_FEE) + mutate_state(|s| { + s.watchlist_mut() + .record_scan(at(REGISTERED_AT + MINUTE_NANOS), &account(1)) + }); + let dropped = DepositFlow::queue_automatic(account(2), 20_000_000, AUTOMATIC_DEPOSIT_FEE) .sweep(signature(0xAA)); - read_state(|s| assert!(s.watchlist().is_empty())); - let dropped_at = REGISTERED_AT + 1; mutate_state(|state| { - process_event( + process_dropped_sweep( state, EventType::FailedTransaction { - signature: detected.signature, + signature: dropped.signature, }, - &TestCanisterRuntime::new().add_times([dropped_at, dropped_at]), + &TestCanisterRuntime::new().add_times([DROPPED_AT; 3]), ) }); + let watchlist_before = read_state(|s| s.watchlist().clone()); + assert_eq!(watchlist_before.len(), 2); - let replayed = replayed_recorded_log_after([ - EventType::WatchedDepositAddresses(stale_snapshot), - EventType::WatchedDepositAddresses(latest_snapshot), - ]); + pre_upgrade(TestCanisterRuntime::new().add_times([UPGRADED_AT; 2])); + + let replayed = replayed_recorded_log(); + assert_eq!(replayed.watchlist(), &watchlist_before); + read_state(|live_state| assert_eq!(&replayed, live_state)); + } + #[test] + fn should_restore_nothing_but_expired_entries_when_no_snapshot_was_recorded() { + const UPGRADED_AGAIN_AT: u64 = REGISTERED_AT + WATCH_WINDOW_NANOS + 1; + init_state(); + init_schnorr_master_key(); + watch(at(REGISTERED_AT), account(1)); + watch(at(REGISTERED_AT), account(2)); + pre_upgrade(TestCanisterRuntime::new().add_times([REGISTERED_AT; 2])); + DepositFlow::queue(account(2), MINIMUM_DEPOSIT_AMOUNT); + mutate_state(|s| s.watchlist_mut().evict_expired(at(UPGRADED_AGAIN_AT))); + read_state(|s| assert!(s.watchlist().is_empty())); + + pre_upgrade(TestCanisterRuntime::new().add_times([UPGRADED_AGAIN_AT; 2])); + + let mut replayed = replayed_recorded_log(); + assert_eq!( + replayed + .watchlist() + .iter() + .map(|watched| watched.account) + .collect::>(), + vec![account(1)] + ); + assert_eq!(replayed.watchlist().live_len(at(UPGRADED_AGAIN_AT)), 0); + *replayed.watchlist_mut() = Watchlist::default(); read_state(|live_state| assert_eq!(&replayed, live_state)); - assert!(replayed.watchlist().is_empty()); } } diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index a3faf9f05..07f71108f 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -166,19 +166,12 @@ pub fn minter_public_key_fetched_event() -> EventType { /// events that [`init_state`] and [`init_schnorr_master_key`] stand for, so that it can be /// compared with the live state. pub fn replayed_recorded_log() -> State { - replayed_recorded_log_after([]) -} - -/// Like [`replayed_recorded_log`], with `events` replayed between the init and key-fetched -/// events and the recorded ones. -pub fn replayed_recorded_log_after(events: impl IntoIterator) -> State { let recorded: Vec = with_event_iter(|events| events.collect()); let prefix = [ EventType::Init(init_args_with_nonce_account()), minter_public_key_fetched_event(), ] .into_iter() - .chain(events) .map(|payload| Event { timestamp: 0, payload, From aac46313492512ef0995f0f1350388b2b4662874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Fri, 30 Oct 2026 14:13:27 +0000 Subject: [PATCH 127/146] docs: name skip_pre_upgrade as the way out of a pre_upgrade that keeps trapping Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index a19ed80d3..b2f7609ab 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, 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 window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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. +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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, 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 window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of losing the registrations made since the last snapshot, which users then register again for free. 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 From 1f35c9da2fa936b7ed299e50370d93999ed73929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 3 Nov 2026 12:17:41 +0000 Subject: [PATCH 128/146] build: depend on the SOL RPC release that adds getMultipleAccounts Replace the pins to the dfinity/sol-rpc-canister#394 commit with the released sol_rpc_client 7.0.0 and sol_rpc_types 4.0.0, and run the integration tests against the SOL RPC canister v1.4.0 release wasm. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 13 +++++++------ Cargo.toml | 8 ++------ integration_tests/src/lib.rs | 7 +++---- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7f9191bb..31d35d477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5160,8 +5160,9 @@ dependencies = [ [[package]] name = "sol_rpc_client" -version = "6.0.0" -source = "git+https://github.com/dfinity/sol-rpc-canister?rev=92efe4eefc6b0209ee33440aa903383144016cf6#92efe4eefc6b0209ee33440aa903383144016cf6" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1551ce9eb2930f8542d655fa8a69af90d46d37722f51a49e33a7e6d6e79acb4e" dependencies = [ "bincode", "candid", @@ -5188,8 +5189,9 @@ dependencies = [ [[package]] name = "sol_rpc_types" -version = "3.1.2" -source = "git+https://github.com/dfinity/sol-rpc-canister?rev=92efe4eefc6b0209ee33440aa903383144016cf6#92efe4eefc6b0209ee33440aa903383144016cf6" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17445c36f9cc1056d2b463a0ff348a32b8b845bf04d2f94607e9e79e5e1a2d2" dependencies = [ "base64 0.23.1", "bincode", @@ -6965,7 +6967,6 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", "windows-sys 0.59.0", ] @@ -7160,7 +7161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index e8c5c677a..d2c389421 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,8 +57,8 @@ serde = "1.0.228" serde_json = "1.0.149" serial_test = "3.2.0" sha2 = "0.10.9" -sol_rpc_client = "6.0.0" -sol_rpc_types = "3.1.2" +sol_rpc_client = "7.0.0" +sol_rpc_types = "4.0.0" solana-account-decoder-client-types = "3.1.11" solana-address = "2.3.0" solana-client = "3.1.8" @@ -83,7 +83,3 @@ debug = false lto = true strip = true opt-level = 's' - -[patch.crates-io] -sol_rpc_client = { git = "https://github.com/dfinity/sol-rpc-canister", rev = "92efe4eefc6b0209ee33440aa903383144016cf6" } -sol_rpc_types = { git = "https://github.com/dfinity/sol-rpc-canister", rev = "92efe4eefc6b0209ee33440aa903383144016cf6" } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index a242dbed8..85f186d38 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -968,11 +968,10 @@ fn cksol_minter_init_args( }) } -/// The SOL RPC canister built from the commit adding the typed `getMultipleAccounts` -/// method, until a release of the SOL RPC canister carries it. +/// The first release of the SOL RPC canister with the typed `getMultipleAccounts` method. async fn sol_rpc_wasm() -> Vec { - const DOWNLOAD_PATH: &str = "../wasms/sol_rpc_canister_92efe4ee.wasm.gz"; - const DOWNLOAD_URL: &str = "https://dfinity-download-public.s3.amazonaws.com/testdata/sol_rpc/sol_rpc_canister_92efe4eefc6b0209ee33440aa903383144016cf6.wasm.gz"; + const DOWNLOAD_PATH: &str = "../wasms/sol_rpc_canister-v1.4.0.wasm.gz"; + const DOWNLOAD_URL: &str = "https://github.com/dfinity/sol-rpc-canister/releases/download/sol_rpc_canister-v1.4.0/sol_rpc_canister.wasm.gz"; canister_wasm(DOWNLOAD_PATH, DOWNLOAD_URL).await } From e1ed5820b00fcfd2205dfcaf9cdd74e61786285b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 4 Nov 2026 13:16:39 +0000 Subject: [PATCH 129/146] docs: qualify the replayed watchlist equality and what skip_pre_upgrade loses Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- minter/src/state/watchlist/mod.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index b2f7609ab..50eeb3514 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, 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 window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of losing the registrations made since the last snapshot, which users then register again for free. 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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, so that the state rebuilt from the log equals the state that produced it, except for the expired entries that an older snapshot brings back as described above; expiry is evaluated against the current time when an entry is accessed. The window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of the watchlist changes made since the last snapshot: the registrations and the re-arms of dropped sweeps made since then are lost, so their users have to call `watch_deposit_address` again, which is free (a dropped deposit stays visible as `Dropped` through `deposit_sol_status`), and the entries of that snapshot come back with their older scan progress, so they repeat at most the scans made since. 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 diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 3c021cece..905d57482 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -128,6 +128,10 @@ impl Watchlist { /// [`MAX_WATCHED_ADDRESSES`] of the running version, so that a change of either takes /// effect at the upgrade that ships it. /// + /// The equality holds up to expired entries: when a later upgrade records no snapshot + /// because the watchlist is empty, replay keeps the entries of this snapshot that no + /// `QueuedDeposit` event removed, all of which have expired by then. + /// /// # Panics /// /// If the snapshot lists an account twice, since it was not produced by From d9b2b7d38d9cdacc02c589be8148a8c10e524d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 4 Nov 2026 13:18:07 +0000 Subject: [PATCH 130/146] test(minter): check that a restored expired entry is not due and can be registered again Co-Authored-By: Claude Opus 5.5 --- minter/src/state/tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 0ca32e6e2..333e6b73b 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -148,7 +148,7 @@ mod watched_deposit_addresses { state::{audit::process_dropped_sweep, watchlist::Watchlist}, test_fixtures::{ HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINUTE_NANOS, REGISTERED_AT, WATCH_WINDOW_NANOS, - at, flow::deposit::DepositFlow, replayed_recorded_log, watch, + at, flow::deposit::DepositFlow, register, replayed_recorded_log, watch, }, }; @@ -223,6 +223,13 @@ mod watched_deposit_addresses { vec![account(1)] ); assert_eq!(replayed.watchlist().live_len(at(UPGRADED_AGAIN_AT)), 0); + assert_eq!(replayed.watchlist().due(at(UPGRADED_AGAIN_AT)), vec![]); + let registered_again = + register(replayed.watchlist_mut(), at(UPGRADED_AGAIN_AT), account(1)); + assert_eq!( + (registered_again.valid_until, registered_again.scan_count), + (at(UPGRADED_AGAIN_AT + WATCH_WINDOW_NANOS), 0) + ); *replayed.watchlist_mut() = Watchlist::default(); read_state(|live_state| assert_eq!(&replayed, live_state)); } From 74e5f2403a89af612d29215ac7e935a4b99f64d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Wed, 4 Nov 2026 16:42:41 +0000 Subject: [PATCH 131/146] docs: state the measured size of a full watchlist snapshot Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 50eeb3514..685b71697 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, about 100 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, so that the state rebuilt from the log equals the state that produced it, except for the expired entries that an older snapshot brings back as described above; expiry is evaluated against the current time when an entry is accessed. The window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of the watchlist changes made since the last snapshot: the registrations and the re-arms of dropped sweeps made since then are lost, so their users have to call `watch_deposit_address` again, which is free (a dropped deposit stays visible as `Dropped` through `deposit_sol_status`), and the entries of that snapshot come back with their older scan progress, so they repeat at most the scans made since. 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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, at most about 130 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, so that the state rebuilt from the log equals the state that produced it, except for the expired entries that an older snapshot brings back as described above; expiry is evaluated against the current time when an entry is accessed. The window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of the watchlist changes made since the last snapshot: the registrations and the re-arms of dropped sweeps made since then are lost, so their users have to call `watch_deposit_address` again, which is free (a dropped deposit stays visible as `Dropped` through `deposit_sol_status`), and the entries of that snapshot come back with their older scan progress, so they repeat at most the scans made since. 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 From 0cdf3a4b9b249f1f07578f90917fc30a818bc9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Thu, 5 Nov 2026 09:43:47 +0000 Subject: [PATCH 132/146] fix(minter): export the collected automatic deposit fees as a counter The sum only grows, across upgrades too, since replaying the event log rebuilds it whole before the first query can read it. Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 1 + minter/src/metrics.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 57a645e07..3038aabd1 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1498,6 +1498,7 @@ mod metrics_tests { .assert_contains_metric_matching(r#"watched_deposit_addresses 0 \d+"#) .assert_contains_metric_matching(r#"oldest_watched_deposit_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+"#, diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index ab8b9e65e..65adc6999 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -131,7 +131,7 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re .metric_value(), "Age of the oldest open registration of the automatic deposit flow in seconds. Returns 0 if no deposit address is watched.", )?; - w.encode_gauge( + 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.", From 1062e18d09c8e5e9ce74859e8a84d6918280742e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Thu, 5 Nov 2026 09:44:44 +0000 Subject: [PATCH 133/146] feat(minter): export the age of the earliest due scan instead of the oldest registration The age of the oldest registration stays within the window whether the scan timer runs or not, so no threshold separates a healthy minter from a broken one. A due scan that has not run yet ages only while the timer does not keep up, e.g. when a tick whose calls never return keeps its guard, which an alert then sees within minutes. Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 2 +- minter/src/metrics.rs | 6 +-- minter/src/state/watchlist/mod.rs | 14 +++---- minter/src/state/watchlist/tests.rs | 62 ++++++++++++++++------------- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 3038aabd1..9615ee588 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1496,7 +1496,7 @@ mod metrics_tests { .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#"oldest_watched_deposit_age_seconds 0 \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+"#) diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index 65adc6999..b283da43e 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -122,14 +122,14 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re "Number of deposit addresses registered for the automatic deposit flow whose window is still open.", )?; w.encode_gauge( - "oldest_watched_deposit_age_seconds", + "oldest_due_deposit_address_scan_age_seconds", age_seconds( s.watchlist() - .oldest_registered_at(now) + .oldest_due_scan_at(now) .map(Timestamp::as_nanos), ) .metric_value(), - "Age of the oldest open registration of the automatic deposit flow in seconds. Returns 0 if no deposit address is watched.", + "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", diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 905d57482..fc63c4d05 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -264,13 +264,13 @@ impl Watchlist { self.live(now).count() } - /// When the oldest live registration was made, if any: the start of its schedule, which - /// does not depend on the window, unlike its end, after a change of [`WATCH_WINDOW`]. - pub fn oldest_registered_at(&self, now: Timestamp) -> Option { - self.entries - .iter_by_expiry() - .filter(|(_, entry)| entry.is_live(now)) - .map(|(_, entry)| entry.value.scheduled_from) + /// 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() } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index ee41cba20..bc77a8d62 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -167,27 +167,53 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { } #[test] -fn should_count_the_live_registrations_and_report_the_oldest() { +fn should_count_the_live_registrations() { let mut watchlist = Watchlist::default(); register(&mut watchlist, at(REGISTERED_AT), account(1)); register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(2)); assert_eq!(watchlist.live_len(at(VALID_UNTIL)), 2); + assert_eq!(watchlist.live_len(at(VALID_UNTIL + 1)), 1); + assert_eq!(watchlist.live_len(at(VALID_UNTIL + HOUR_NANOS + 1)), 0); + 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_registered_at(at(VALID_UNTIL)), + watchlist.oldest_due_scan_at(at(REGISTERED_AT + HOUR_NANOS)), Some(at(REGISTERED_AT)) ); - assert_eq!(watchlist.live_len(at(VALID_UNTIL + 1)), 1); + + watchlist.record_scan(at(REGISTERED_AT + HOUR_NANOS), &account(1)); + assert_eq!( - watchlist.oldest_registered_at(at(VALID_UNTIL + 1)), - Some(at(REGISTERED_AT + HOUR_NANOS)) + watchlist.oldest_due_scan_at(at(REGISTERED_AT + HOUR_NANOS)), + Some(at(REGISTERED_AT + MINUTE_NANOS)) ); - assert_eq!(watchlist.live_len(at(VALID_UNTIL + HOUR_NANOS + 1)), 0); + + watchlist.record_scan(at(REGISTERED_AT + HOUR_NANOS), &account(2)); + assert_eq!( - watchlist.oldest_registered_at(at(VALID_UNTIL + HOUR_NANOS + 1)), + watchlist.oldest_due_scan_at(at(REGISTERED_AT + HOUR_NANOS)), None ); - assert_eq!(watchlist.len(), 2); +} + +#[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] @@ -316,26 +342,6 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { ); } -#[test] -fn should_report_when_a_registration_restored_under_another_window_was_made() { - let registered_under_a_longer_window = WatchedDepositAddress { - account: account(1), - address: deposit_address(account(1)), - valid_until: REGISTERED_AT + 2 * WATCH_WINDOW_NANOS, - scheduled_from: REGISTERED_AT, - scan_count: 0, - last_scanned_at: None, - }; - let rebuilt = Watchlist::from_snapshot(&WatchlistSnapshot { - entries: vec![registered_under_a_longer_window], - }); - - assert_eq!( - rebuilt.oldest_registered_at(at(VALID_UNTIL + 1)), - Some(at(REGISTERED_AT)) - ); -} - fn dropped(account: Account) -> QueuedDeposit { automatic_deposit_of(account, MINIMUM_DEPOSIT_AMOUNT, AUTOMATIC_DEPOSIT_FEE) } From 10f7c59170dbcaa65d2a9d89986eac7ed04debbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Thu, 5 Nov 2026 09:45:22 +0000 Subject: [PATCH 134/146] feat(minter): export the capacity of the watchlist An alert on a filling watchlist can then compare the number of live registrations with the capacity in force instead of hard-coding it. Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 1 + minter/src/metrics.rs | 5 +++++ minter/src/state/watchlist/mod.rs | 6 ++++++ minter/src/state/watchlist/tests.rs | 2 ++ 4 files changed, 14 insertions(+) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 9615ee588..01a26334c 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1496,6 +1496,7 @@ mod metrics_tests { .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") diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index b283da43e..ef84737a1 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -121,6 +121,11 @@ pub fn encode_metrics(w: &mut MetricsEncoder>, s: &State) -> std::io::Re 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( diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index fc63c4d05..1532dada4 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -260,6 +260,12 @@ 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() } diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index bc77a8d62..de9f6fc32 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] @@ -319,6 +320,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), From edfdb1cc411ded041f6e886e91d7ce163e65229f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Thu, 5 Nov 2026 09:46:25 +0000 Subject: [PATCH 135/146] test(integration): check the watchlist metrics while a scan is due and after it ran Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 01a26334c..eead30993 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1190,6 +1190,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() { @@ -1390,6 +1391,48 @@ mod automatic_deposit_sol_tests { .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 { From 510c84b87dd1187c397713e83367cead61920142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Thu, 5 Nov 2026 09:48:22 +0000 Subject: [PATCH 136/146] fix(minter): list only the live registrations on the dashboard An expired registration stays in the watchlist until the next tick evicts it, and replaying an older snapshot can bring back a batch of them, none of which is watched any more. Co-Authored-By: Claude Opus 5.5 --- minter/src/dashboard/mod.rs | 9 ++++++-- minter/src/dashboard/tests.rs | 36 ++++++++++++++++++++++++++++--- minter/src/main.rs | 5 ++++- minter/src/state/watchlist/mod.rs | 3 ++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 0fb3294f2..5ffc2e582 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; @@ -277,7 +278,11 @@ pub struct DashboardTemplate { } 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()) @@ -405,7 +410,7 @@ impl DashboardTemplate { let watched_deposit_addresses: Vec = state .watchlist() - .iter() + .live(now) .map(|watched| DashboardWatchedAddress { account: watched.account.to_string(), address: watched.address.to_string(), diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index 0829ad82b..ad613a206 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -1,8 +1,8 @@ use crate::dashboard::{DashboardPaginationParameters, DashboardTemplate, lamports_to_sol}; use crate::state::{mutate_state, read_state}; use crate::test_fixtures::{ - AUTOMATIC_DEPOSIT_FEE, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, REGISTERED_AT, - WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, at, deposit_address, + AUTOMATIC_DEPOSIT_FEE, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, + 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, @@ -11,6 +11,7 @@ use crate::test_fixtures::{ init_balance, init_schnorr_master_key, init_state, init_state_with_args, ledger_canister_id, signature, sol_rpc_canister_id, valid_init_args, watch, }; +use crate::utils::timed_sized_map::Timestamp; use askama::Template; use cksol_types_internal::{InitArgs, SolanaNetwork}; @@ -19,7 +20,11 @@ fn dashboard() -> DashboardTemplate { } 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) { @@ -140,6 +145,31 @@ fn should_display_watched_deposit_addresses_by_ascending_window_end() { ); } +#[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; 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/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 1532dada4..d817c11a1 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -280,7 +280,8 @@ impl Watchlist { .min() } - fn live(&self, now: Timestamp) -> impl Iterator { + /// 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)) From f03c39f0696bcb2a54ab50acd32d3c1e920e4924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Thu, 5 Nov 2026 09:50:29 +0000 Subject: [PATCH 137/146] feat(minter): show the times of the watched addresses as dates, with the next scan Times in nanoseconds can't be read at a glance; they are shown as on the dashboard of the ckETH minter. The next scan of each address shows when the scan timer is behind. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + minter/Cargo.toml | 1 + minter/src/dashboard/mod.rs | 29 ++++++++++++++++++++++++++--- minter/src/dashboard/tests.rs | 27 ++++++++++++++++++++++----- minter/templates/dashboard.html | 6 ++++-- 6 files changed, 55 insertions(+), 10 deletions(-) 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/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 5ffc2e582..00d754786 100644 --- a/minter/src/dashboard/mod.rs +++ b/minter/src/dashboard/mod.rs @@ -22,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 => "", @@ -243,8 +260,11 @@ pub struct DashboardWatchedAddress { pub address: String, pub valid_until: String, pub scan_count: String, - /// The time of the last scan in nanoseconds since the epoch, or `-` before the first scan. + /// 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. @@ -414,11 +434,14 @@ impl DashboardTemplate { .map(|watched| DashboardWatchedAddress { account: watched.account.to_string(), address: watched.address.to_string(), - valid_until: watched.valid_until.as_nanos().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(), |at| at.as_nanos().to_string()), + .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( diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index ad613a206..e516d0c55 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -2,7 +2,7 @@ use crate::dashboard::{DashboardPaginationParameters, DashboardTemplate, lamport use crate::state::{mutate_state, read_state}; use crate::test_fixtures::{ AUTOMATIC_DEPOSIT_FEE, HOUR_NANOS, MINIMUM_DEPOSIT_AMOUNT, MINIMUM_WITHDRAWAL_AMOUNT, - REGISTERED_AT, WATCH_WINDOW_NANOS, WITHDRAWAL_FEE, account, at, deposit_address, + 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, @@ -111,9 +111,12 @@ fn should_display_watched_deposit_addresses_by_ascending_window_end() { 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 + 60), &account(2)); + .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()) @@ -122,9 +125,10 @@ fn should_display_watched_deposit_addresses_by_ascending_window_end() { &[ &account(2).to_string(), &deposit_address(account(2)).to_string(), - &(REGISTERED_AT + WATCH_WINDOW_NANOS).to_string(), + "2023-11-15T22:13:20+00:00", "1", - &(REGISTERED_AT + 60).to_string(), + "2023-11-14T22:14:20+00:00", + "2023-11-14T22:15:20+00:00", ], "watched deposit addresses", ) @@ -133,9 +137,22 @@ fn should_display_watched_deposit_addresses_by_ascending_window_end() { &[ &account(1).to_string(), &deposit_address(account(1)).to_string(), - &(REGISTERED_AT + 1 + WATCH_WINDOW_NANOS).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", ) diff --git a/minter/templates/dashboard.html b/minter/templates/dashboard.html index 0ee546279..393433032 100644 --- a/minter/templates/dashboard.html +++ b/minter/templates/dashboard.html @@ -111,9 +111,10 @@

Watched Deposit Addresses

Account Deposit Address - Valid Until (ns) + Valid Until Scans - Last Scanned At (ns) + Last Scanned At + Next Scan At @@ -124,6 +125,7 @@

Watched Deposit Addresses

{{ watched.valid_until }} {{ watched.scan_count }} {{ watched.last_scanned_at }} + {{ watched.next_scan_at }} {% endfor %} {% if watched_deposit_addresses_table.has_more_than_one_page() %}{{ watched_deposit_addresses_table.pagination|safe }}{% endif %} From d35c7485635c0559bf0a0379d98e442d146ea3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 12:39:30 +0000 Subject: [PATCH 138/146] refactor(minter): remove the unused registration time of the oldest watched address The age of the oldest live registration is bounded by the watch window, so it was replaced by the scan lag as a metric and has no caller. Co-Authored-By: Claude Opus 5.5 --- minter/src/state/watchlist/mod.rs | 10 ---------- minter/src/state/watchlist/tests.rs | 23 +++++++---------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/minter/src/state/watchlist/mod.rs b/minter/src/state/watchlist/mod.rs index 041ecd1d1..98dc6247d 100644 --- a/minter/src/state/watchlist/mod.rs +++ b/minter/src/state/watchlist/mod.rs @@ -264,16 +264,6 @@ impl Watchlist { self.live(now).count() } - /// When the oldest live registration was made, if any: the start of its schedule, which - /// does not depend on the window, unlike its end, after a change of [`WATCH_WINDOW`]. - pub fn oldest_registered_at(&self, now: Timestamp) -> Option { - self.entries - .iter_by_expiry() - .filter(|(_, entry)| entry.is_live(now)) - .map(|(_, entry)| entry.value.scheduled_from) - .min() - } - fn live(&self, now: Timestamp) -> impl Iterator { self.entries .iter_by_expiry() diff --git a/minter/src/state/watchlist/tests.rs b/minter/src/state/watchlist/tests.rs index 7904a4a18..d44a57113 100644 --- a/minter/src/state/watchlist/tests.rs +++ b/minter/src/state/watchlist/tests.rs @@ -167,26 +167,14 @@ fn should_ignore_a_scan_of_an_account_that_is_not_watched() { } #[test] -fn should_count_the_live_registrations_and_report_the_oldest() { +fn should_count_the_live_registrations() { let mut watchlist = Watchlist::default(); register(&mut watchlist, at(REGISTERED_AT), account(1)); register(&mut watchlist, at(REGISTERED_AT + HOUR_NANOS), account(2)); assert_eq!(watchlist.live_len(at(VALID_UNTIL)), 2); - assert_eq!( - watchlist.oldest_registered_at(at(VALID_UNTIL)), - Some(at(REGISTERED_AT)) - ); assert_eq!(watchlist.live_len(at(VALID_UNTIL + 1)), 1); - assert_eq!( - watchlist.oldest_registered_at(at(VALID_UNTIL + 1)), - Some(at(REGISTERED_AT + HOUR_NANOS)) - ); assert_eq!(watchlist.live_len(at(VALID_UNTIL + HOUR_NANOS + 1)), 0); - assert_eq!( - watchlist.oldest_registered_at(at(VALID_UNTIL + HOUR_NANOS + 1)), - None - ); assert_eq!(watchlist.len(), 2); } @@ -331,7 +319,7 @@ fn should_keep_expired_registrations_and_exceed_the_capacity_when_rebuilding() { } #[test] -fn should_report_when_a_registration_restored_under_another_window_was_made() { +fn should_schedule_a_registration_restored_under_another_window_from_its_start() { let registered_under_a_longer_window = WatchedDepositAddress { account: account(1), address: deposit_address(account(1)), @@ -344,9 +332,12 @@ fn should_report_when_a_registration_restored_under_another_window_was_made() { entries: vec![registered_under_a_longer_window], }); + let first_slot = scan_slots().next().unwrap(); assert_eq!( - rebuilt.oldest_registered_at(at(VALID_UNTIL + 1)), - Some(at(REGISTERED_AT)) + rebuilt + .get(at(VALID_UNTIL + 1), &account(1)) + .and_then(|watched| watched.next_scan_at), + Some(at(REGISTERED_AT).saturating_add(first_slot)) ); } From a748de8c3b630b0f7a3c73d9ee782b0cf7c31f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 12:39:30 +0000 Subject: [PATCH 139/146] docs: tie the rollback of an upgrade to the automatic flow being disabled Without a snapshot, the previous version would still mint the automatic deposits already queued without deducting their fee. Co-Authored-By: Claude Opus 5.5 --- docs/src/development/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/development/design.md b/docs/src/development/design.md index 64f0ce2ac..eb9d9da52 100644 --- a/docs/src/development/design.md +++ b/docs/src/development/design.md @@ -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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, at most about 130 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow was never used leaves an event log that the previous version can still replay, and the upgrade can be rolled back; once a snapshot has been recorded, the previous version can no longer decode the log, and a faulty version has to be fixed forward. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, so that the state rebuilt from the log equals the state that produced it, except for the expired entries that an older snapshot brings back as described above; expiry is evaluated against the current time when an entry is accessed. The window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of the watchlist changes made since the last snapshot: the registrations and the re-arms of dropped sweeps made since then are lost, so their users have to call `watch_deposit_address` again, which is free (a dropped deposit stays visible as `Dropped` through `deposit_sol_status`), and the entries of that snapshot come back with their older scan progress, so they repeat at most the scans made since. 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 every entry with its window end and scan progress, so its size is bounded by the capacity of the watchlist, at most about 130 KB for 1,000 entries. As in the ckETH minter, it is recorded only when the watchlist holds entries, so that upgrading a minter whose automatic flow is not enabled, because no automatic deposit fee is configured, leaves an event log that the previous version can still replay, and the upgrade can be rolled back. Once the automatic flow has been enabled, a faulty version has to be fixed forward: a recorded snapshot cannot be decoded by the previous version, which then fails to upgrade, and without one the previous version would take the automatic deposits already queued for manual ones and mint them without deducting their fee. Skipping an empty watchlist lets replay restore an older snapshot, but that brings back nothing except expired entries: an entry leaves the watchlist without an event only by expiring, since a queued deposit removes its entry through the replayed `QueuedDeposit` event, so every entry of an older snapshot that was still live at the upgrade would still be on the watchlist, which would then not be empty. Such entries are not scanned, do not count against the capacity and are evicted lazily. Replaying a snapshot restores its entries verbatim, without evicting expired ones or enforcing the capacity, so that the state rebuilt from the log equals the state that produced it, except for the expired entries that an older snapshot brings back as described above; expiry is evaluated against the current time when an entry is accessed. The window and the capacity are those of the running version rather than recorded in the snapshot, so that a change of either takes effect at the upgrade that ships it: entries keep the window end they were registered with, and a watchlist restored above a reduced capacity refuses registrations until enough entries have expired. 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; should it persist, the upgrade can be installed with `skip_pre_upgrade`, at the price of the watchlist changes made since the last snapshot: the registrations and the re-arms of dropped sweeps made since then are lost, so their users have to call `watch_deposit_address` again, which is free (a dropped deposit stays visible as `Dropped` through `deposit_sol_status`), and the entries of that snapshot come back with their older scan progress, so they repeat at most the scans made since. 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 From 60bd931fb8f87d13528ded5c31f1681de46f5333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 12:41:54 +0000 Subject: [PATCH 140/146] feat(minter): export the outcomes of re-arming dropped automatic deposits A sweep that keeps failing re-arms its automatic deposits over and over, and a full watchlist leaves them to their users; both are visible only in the logs. Co-Authored-By: Claude Opus 5.5 --- minter/src/metrics.rs | 13 ++++++++++++- minter/src/state/audit.rs | 26 ++++++++++++++++---------- minter/src/state/tests.rs | 17 +++++++++++++++++ minter/src/storage/mod.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/minter/src/metrics.rs b/minter/src/metrics.rs index ef84737a1..f5c124b1a 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -1,6 +1,7 @@ use crate::state::{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; @@ -171,6 +172,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/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 { From c9601077f16a7c24af7c9572d7160585066fea76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 12:48:53 +0000 Subject: [PATCH 141/146] feat(minter): export the dropped deposits by origin A dropped automatic deposit is re-armed while a dropped manual deposit waits for its user, so the two call for different follow-ups. Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 11 ++++++++++- minter/src/metrics.rs | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 7735449e9..f4fe2861e 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1078,7 +1078,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 @@ -1551,6 +1552,14 @@ mod metrics_tests { 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/minter/src/metrics.rs b/minter/src/metrics.rs index f5c124b1a..5f550d439 100644 --- a/minter/src/metrics.rs +++ b/minter/src/metrics.rs @@ -1,4 +1,4 @@ -use crate::state::{QuarantineCause, State}; +use crate::state::{DepositOrigin, QuarantineCause, State}; use crate::storage::{ self, FailedCreditReason, FailedMintReason, RearmOutcome, ScanOutcome, UndecidedWithdrawalReason, @@ -50,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() { From ed84e0f079673ecc59bb907d6491f952699b1a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 13:55:35 +0000 Subject: [PATCH 142/146] fix(minter): span the pagination of the watched addresses across all six columns Co-Authored-By: Claude Opus 5.5 --- minter/src/dashboard/mod.rs | 2 +- minter/src/dashboard/tests.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/minter/src/dashboard/mod.rs b/minter/src/dashboard/mod.rs index 00d754786..8695826a0 100644 --- a/minter/src/dashboard/mod.rs +++ b/minter/src/dashboard/mod.rs @@ -448,7 +448,7 @@ impl DashboardTemplate { &watched_deposit_addresses, pagination.watched_deposit_addresses_start, DEFAULT_PAGE_SIZE, - 5, + 6, "watched-deposit-addresses", "watched_deposit_addresses_start", pagination.other_params("watched_deposit_addresses_start"), diff --git a/minter/src/dashboard/tests.rs b/minter/src/dashboard/tests.rs index e516d0c55..1ac76dc64 100644 --- a/minter/src/dashboard/tests.rs +++ b/minter/src/dashboard/tests.rs @@ -206,6 +206,10 @@ fn should_paginate_watched_deposit_addresses_across_multiple_pages() { 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, From 91bdceb540c48429b039d1b1a6e84b3fbc6b7622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 13:58:59 +0000 Subject: [PATCH 143/146] test(integration): mint a detected automatic deposit minus its fee and collect the fee Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index f4fe2861e..19e287548 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1396,6 +1396,79 @@ mod automatic_deposit_sol_tests { .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; + + 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(0).await, + DepositSolStatus::Swept { signature } => signature + ); + + 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; + 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; From f3e4b381ee4d6b39c95f3242b8ee02f9a6cd9426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 14:25:11 +0000 Subject: [PATCH 144/146] test(integration): share the sweep and the finalization of a deposit between tests Co-Authored-By: Claude Opus 5.5 --- integration_tests/tests/tests.rs | 118 ++++++++----------------------- 1 file changed, 28 insertions(+), 90 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 19e287548..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 @@ -1110,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, @@ -1415,29 +1374,8 @@ mod automatic_deposit_sol_tests { ) .await; - 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(0).await, - DepositSolStatus::Swept { signature } => signature - ); - - 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; + 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; From 8019ffff96f1e6ea9baa6673d8a0141348e9c3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 14:55:29 +0000 Subject: [PATCH 145/146] fix(minter): count as failed scans only the registrations a failed read advances A registration removed, replaced or expired while the read was in flight was counted, unlike after a successful read. Co-Authored-By: Claude Opus 5.5 --- minter/src/deposit/automatic/scan/mod.rs | 14 ++++++++++---- minter/src/deposit/automatic/scan/tests.rs | 6 ++++-- 2 files changed, 14 insertions(+), 6 deletions(-) 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))), From 1f61b8bc922885da8cd5052451669cbe2cf323c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Bj=C3=B6rkqvist?= Date: Tue, 10 Nov 2026 14:57:09 +0000 Subject: [PATCH 146/146] docs: cover the last scan, a dropped automatic deposit and the rollout of the automatic flow Qualify when the id of the latest deposit is returned, list the user-visible changes of the stack in the changelogs, and enable the flow in a second upgrade. Co-Authored-By: Claude Opus 5.5 --- docs/src/development/deployment.md | 16 ++++++++++++++++ docs/src/development/design.md | 2 +- docs/src/usage/cli.md | 2 ++ docs/src/usage/how-it-works.md | 4 ++-- libs/types/CHANGELOG.md | 8 ++++++++ minter/CHANGELOG.md | 12 +++++++++++- 6 files changed, 40 insertions(+), 4 deletions(-) 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 0f748e823..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). diff --git a/docs/src/usage/cli.md b/docs/src/usage/cli.md index 82c0e3396..810204134 100644 --- a/docs/src/usage/cli.md +++ b/docs/src/usage/cli.md @@ -57,6 +57,8 @@ A successful response returns the deposit address, the minimum deposit amount, t 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`, 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: diff --git a/docs/src/usage/how-it-works.md b/docs/src/usage/how-it-works.md index 0f6007c35..6652edfde 100644 --- a/docs/src/usage/how-it-works.md +++ b/docs/src/usage/how-it-works.md @@ -48,11 +48,11 @@ If you cannot call the minter after the transfer, for example because the SOL co 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. +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 is returned in every case. 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. +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 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 378b8ec4e..465ea40eb 100644 --- a/minter/CHANGELOG.md +++ b/minter/CHANGELOG.md @@ -7,9 +7,19 @@ 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. The watchlist is snapshotted in a new event on every upgrade, and the watched addresses, the scans by outcome and the collected fees are reported in the metrics and on the dashboard ([#274](https://github.com/dfinity/cksol/pull/274)) +- 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