Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6e11266
feat(minter): mint the pending deposits of credited sweeps on a timer
gregorydemay Sep 28, 2026
bec6844
feat(minter): list minted swept deposits on the dashboard
gregorydemay Sep 28, 2026
6517474
test(minter): assert the mint and ledger balance of a swept deposit
gregorydemay Sep 28, 2026
34c50d3
test(minter): reject deposit_sol while a pending mint is quarantined
gregorydemay Sep 28, 2026
8d5700f
refactor(minter): mint the pending deposits of a round one after the …
gregorydemay Oct 2, 2026
124b2dd
test(minter): check that a mint round without pending mints or with a…
gregorydemay Oct 2, 2026
bc02097
promoted to dir
gregorydemay Oct 2, 2026
e46e7d7
feat(minter): identify the swept deposit in the memo of its mint
gregorydemay Oct 2, 2026
242f2f4
fix(minter): quarantine a pending mint whose callback traps after the…
gregorydemay Oct 2, 2026
99aff93
test(minter): table every reason a pending mint is quarantined
gregorydemay Oct 2, 2026
4fbf34f
test(minter): build the pending mint of the mint tests with a deposit…
gregorydemay Oct 2, 2026
ca1e449
test(minter): build the minted swept deposits of the dashboard tests …
gregorydemay Oct 2, 2026
ffce3ed
test(minter): build the credited sweep of the state tests with the de…
gregorydemay Oct 2, 2026
990c756
test(minter): credit a sweep in one step in the mint and quarantine t…
gregorydemay Oct 2, 2026
5c7f9ba
docs(minter): cover both quarantine causes in the quarantined_swept_d…
gregorydemay Oct 5, 2026
bd92335
fix(minter): render a dashboard page of minted deposits without forma…
gregorydemay Oct 5, 2026
3a16f94
fix(minter): rename the minted_sweeps metric to minted_swept_deposits
gregorydemay Oct 5, 2026
e950a81
docs: include the minted amount in the Minted status of the deposit l…
gregorydemay Oct 5, 2026
980e970
test: move wait_for_deposit_minted onto the e2e Setup
gregorydemay Oct 5, 2026
47ac36e
docs: cover the abandoned-mint cause in the public quarantine documen…
gregorydemay Oct 5, 2026
778f218
fix(minter): detect mint-round progress on the round's selected deposits
gregorydemay Oct 5, 2026
0cfdaaa
refactor(types): tighten the maximum serialized memo size to 81 bytes
gregorydemay Oct 5, 2026
4bc1e49
test(minter): record the minter public key in the swept deposit tests
gregorydemay Oct 5, 2026
60e4ed7
docs(minter): pin the ICRC-1 ledger link to a commit
gregorydemay Oct 5, 2026
9427e5e
docs(types): say which max_memo_length rejects which mints
gregorydemay Oct 5, 2026
92b2bfc
test: wait for the mint with the Setup helper
gregorydemay Oct 5, 2026
6d939d2
test(minter): build the finalized sweep of the credit_sweep tests wit…
gregorydemay Oct 5, 2026
f9ed65d
test(minter): keep only what the state level adds to the swept deposi…
gregorydemay Oct 5, 2026
f375483
feat(minter): record why a swept deposit was quarantined
gregorydemay Oct 5, 2026
f8673f3
feat(minter): show the cause and the owed mint of a quarantined deposit
gregorydemay Oct 5, 2026
4d8dc9d
feat(minter): count quarantined deposits by cause
gregorydemay Oct 5, 2026
94ceebf
test: deploy the ledger with the memo size it will run with
gregorydemay Oct 6, 2026
64cb881
fix(test): let a timer round run on an instance that does not produce…
gregorydemay Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,9 @@ The ckSOL minter then fetches the transaction with `getTransaction` and *settles

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.

Pending mints are processed on a timer: each one is a single `icrc1_transfer` call whose memo contains the sweep signature. 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.
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.

A user can follow the progress with `deposit_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 `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.
A user can follow the progress with `deposit_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:

Expand Down
6 changes: 6 additions & 0 deletions integration_tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ license.workspace = true
[dependencies]
assert_matches = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
bincode = { workspace = true }
candid = { workspace = true }
canlog = { workspace = true }
cksol-types = { path = "../libs/types" }
Expand All @@ -31,10 +33,14 @@ sol_rpc_client = { workspace = true }
sol_rpc_types = { workspace = true }
solana-address = { workspace = true }
solana-client = { workspace = true }
solana-hash = { workspace = true }
solana-keypair = { workspace = true }
solana-message = { workspace = true }
solana-native-token = { workspace = true }
solana-signature = { workspace = true }
solana-system-interface = { workspace = true, features = ["bincode"] }
solana-system-transaction = { workspace = true }
solana-transaction = { workspace = true, features = ["bincode"] }
tokio = { workspace = true }

[dev-dependencies]
Expand Down
68 changes: 67 additions & 1 deletion integration_tests/src/fixtures.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::Setup;
use crate::{Setup, validator::FEE_PER_SIGNATURE};
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::STANDARD};
use cksol_types::{GetDepositAddressArgs, ProcessDepositArgs, Signature};
use ic_pocket_canister_runtime::{
ExecuteHttpOutcallMocks, JsonRpcRequestMatcher, JsonRpcResponse, MockHttpOutcalls,
Expand Down Expand Up @@ -222,6 +223,16 @@ impl MockBuilder {
.check_signature_statuses(get_signature_statuses_finalized_response())
}

/// Mock for `getTransaction` for a sweep of [`DEFAULT_CALLER_DEPOSIT_ADDRESS`] under the
/// given signature, reporting metadata that credits the minter's main account with the
/// sweepable amount minus the transaction fee of one signature.
pub fn get_sweep_transaction(self, signature: &Signature, sweepable_amount: Lamport) -> Self {
self.expect(
get_transaction_request(&signature.to_string()),
sweep_transaction_response(sweepable_amount),
)
}

fn check_signature_statuses(self, response: JsonRpcResponse) -> Self {
self.expect(get_signature_statuses_request(), response)
}
Expand Down Expand Up @@ -310,6 +321,61 @@ pub fn get_deposit_transaction_response() -> JsonRpcResponse {
}))
}

/// JSON-RPC `getTransaction` response for a sweep transaction moving the sweepable
/// amount of [`DEFAULT_CALLER_DEPOSIT_ADDRESS`], minus the fee it pays as the only
/// signer, to [`MINTER_ADDRESS`].
fn sweep_transaction_response(sweepable_amount: Lamport) -> JsonRpcResponse {
const MAIN_BALANCE_BEFORE_SWEEP: Lamport = 5_000_000_000;
let deposit_address: Address = DEFAULT_CALLER_DEPOSIT_ADDRESS.parse().unwrap();
let transfer_amount = sweepable_amount - FEE_PER_SIGNATURE;
let message = solana_message::Message::new_with_blockhash(
&[solana_system_interface::instruction::transfer(
&deposit_address,
&MINTER_ADDRESS,
transfer_amount,
)],
Some(&deposit_address),
&solana_hash::Hash::default(),
);
let transaction = solana_transaction::versioned::VersionedTransaction::from(
solana_transaction::Transaction::new_unsigned(message),
);
let encoded_transaction = STANDARD.encode(
bincode::serialize(&transaction).expect("serializing the transaction should succeed"),
);
JsonRpcResponse::from(json!({
"jsonrpc": "2.0",
"result": {
"blockTime": 1700000000_i64,
"meta": {
"computeUnitsConsumed": 150,
"err": null,
"fee": FEE_PER_SIGNATURE,
"innerInstructions": [],
"loadedAddresses": { "readonly": [], "writable": [] },
"logMessages": [],
"postBalances": [
RENT_EXEMPTION_THRESHOLD,
MAIN_BALANCE_BEFORE_SWEEP + transfer_amount,
1
],
"postTokenBalances": [],
"preBalances": [
RENT_EXEMPTION_THRESHOLD + sweepable_amount,
MAIN_BALANCE_BEFORE_SWEEP,
1
],
"preTokenBalances": [],
"rewards": [],
"status": { "Ok": null }
},
"slot": 350_000_000_u64,
"transaction": [encoded_transaction, "base64"]
},
"id": 1
}))
}

fn get_balance_request() -> JsonRpcRequestMatcher {
JsonRpcRequestMatcher::with_method("getBalance")
}
Expand Down
9 changes: 7 additions & 2 deletions integration_tests/src/ledger_init_args.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
//! Since the init args for the ICRC1 ledger are not published in a public crate,
//! redefine them here to initialize the canister correctly.
use candid::{CandidType, Deserialize, Nat, Principal};
use cksol_types::MAX_SERIALIZED_MEMO_BYTES;
use icrc_ledger_types::{icrc::generic_value::Value, icrc1::account::Account};
use serde::Serialize;

pub const LEDGER_TRANSFER_FEE: u64 = 50;
/// The `max_memo_length` the ckSOL ledger is deployed with, matching the value most
/// ICRC-1 ledgers use.
///
/// One byte below the largest memo the minter can produce, so the mint of a deposit
/// id beyond `u32::MAX` would not fit. Raising it takes a ledger upgrade.
const LEDGER_MAX_MEMO_BYTES: u16 = 80;
const NNS_ROOT_PRINCIPAL: Principal = Principal::from_slice(&[0_u8, 0, 0, 0, 0, 0, 0, 3, 1, 1]);
const FEE_COLLECTOR_SUBACCOUNT: [u8; 32] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x0f,
Expand Down Expand Up @@ -42,7 +47,7 @@ pub fn ledger_init_args(
cycles_for_archive_creation: Some(100_000_000_000_000),
max_transactions_per_response: None,
},
max_memo_length: Some(MAX_SERIALIZED_MEMO_BYTES),
max_memo_length: Some(LEDGER_MAX_MEMO_BYTES),
feature_flags: None,
index_principal: None,
})
Expand Down
40 changes: 35 additions & 5 deletions integration_tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ impl SetupBuilder {

pub struct Setup {
env: Option<PocketIc>,
/// Whether the instance produces blocks on its own, which decides how
/// [`Setup::advance_time_and_settle`] lets a timer round run.
auto_progress: bool,
minter_canister_id: CanisterId,
ledger_canister_id: CanisterId,
sol_rpc_canister_id: CanisterId,
Expand Down Expand Up @@ -204,7 +207,8 @@ impl Setup {
None
};

let env = if let PocketIcMode::LiveMode = make_live {
let auto_progress = matches!(make_live, PocketIcMode::LiveMode);
let env = if auto_progress {
let mut env = env;
let _ = env.make_live(None).await;
env
Expand All @@ -218,6 +222,7 @@ impl Setup {

Self {
env: Some(env),
auto_progress,
minter_canister_id,
ledger_canister_id,
sol_rpc_canister_id,
Expand Down Expand Up @@ -317,13 +322,38 @@ impl Setup {
self.env.as_ref().unwrap().advance_time(duration).await
}

/// Advances time and then lets the timers that became due complete their
/// HTTP outcalls. An outcall still in flight when time is advanced again
/// times out, and a timer needs several sequential outcalls per round.
/// Advances time and then lets the timers that became due complete their round.
///
/// An instance that produces blocks on its own only needs wall-clock time for the
/// HTTP outcalls of the round to come back, and an outcall still in flight when
/// time is advanced again times out. An instance that does not produce blocks runs
/// nothing until it is ticked, and a round takes several sequential messages.
pub async fn advance_time_and_settle(&self, duration: Duration) {
const OUTCALL_SETTLE_DELAY: Duration = Duration::from_secs(2);
const TICKS_PER_ROUND: usize = 10;

self.advance_time(duration).await;
tokio::time::sleep(OUTCALL_SETTLE_DELAY).await;
if self.auto_progress {
tokio::time::sleep(OUTCALL_SETTLE_DELAY).await;
} else {
for _ in 0..TICKS_PER_ROUND {
self.tick().await;
}
}
}

/// Advances time until the deposit is minted and returns the minted amount.
pub async fn wait_for_deposit_minted(&self, deposit_id: DepositSolId) -> Lamport {
const MAX_ITERATIONS: usize = 30;
for _ in 0..MAX_ITERATIONS {
if let DepositSolStatus::Minted { minted_amount, .. } =
self.minter().deposit_status(deposit_id).await
{
return minted_amount;
}
self.advance_time_and_settle(Duration::from_mins(1)).await;
}
panic!("Deposit {deposit_id} was not minted within timeout");
}

pub async fn execute_http_mocks(&self, mut mocks: impl ExecuteHttpOutcallMocks) {
Expand Down
12 changes: 12 additions & 0 deletions integration_tests/tests/solana_test_validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,5 +345,17 @@ async fn should_sweep_a_full_batch_of_deposits_in_one_transaction() {
);
}

for ((&deposit_id, &account), &deposit_amount) in
deposit_ids.iter().zip(&accounts).zip(&deposit_amounts)
{
let expected_minted_amount = deposit_amount - RENT_EXEMPTION_THRESHOLD - FEE_PER_SIGNATURE;
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
);
}

setup.drop().await;
}
33 changes: 31 additions & 2 deletions integration_tests/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use cksol_int_tests::{
default_process_deposit_args, deposit_transaction_signature,
get_deposit_transaction_response,
},
validator::FEE_PER_SIGNATURE,
};
use cksol_types::{
DepositId, DepositSolArgs, DepositSolError, DepositSolStatus, DepositStatus,
Expand Down Expand Up @@ -1191,7 +1192,8 @@ mod deposit_sol_tests {
}

#[tokio::test]
async fn should_sweep_queued_deposit_after_timer() {
async fn should_sweep_finalize_and_mint_queued_deposit_after_timers() {
const SWEEPABLE_AMOUNT: Lamport = BALANCE_ABOVE_MINIMUM - RENT_EXEMPTION_THRESHOLD;
let setup = SetupBuilder::new().with_proxy_canister().build().await;
let deposit_id = setup
.minter()
Expand Down Expand Up @@ -1228,6 +1230,31 @@ mod deposit_sol_tests {
)));
});

setup.advance_time(FINALIZE_TRANSACTIONS_DELAY).await;
setup
.execute_http_mocks(
MockBuilder::with_start_id(16)
.finalize_transaction(SUBMISSION_BLOCK_HEIGHT)
.get_sweep_transaction(&sweep_signature, SWEEPABLE_AMOUNT)
.build(),
)
.await;

setup.wait_for_deposit_minted(deposit_id).await;

let minted_amount = SWEEPABLE_AMOUNT - FEE_PER_SIGNATURE;
assert_eq!(
setup.minter().deposit_status(deposit_id).await,
DepositSolStatus::Minted {
block_index: 0,
minted_amount,
}
);
assert_eq!(
setup.ledger().balance_of(DEFAULT_CALLER_ACCOUNT).await,
minted_amount
);

setup.drop().await;
}

Expand Down Expand Up @@ -1364,7 +1391,9 @@ mod deposit_sol_tests {
let setup = setup
.check_metrics()
.await
.assert_contains_metric_matching(r"quarantined_swept_deposits 1 \d+")
.assert_contains_metric_matching(
r#"quarantined_swept_deposits\{cause="sweep_unreadable"\} 1 \d+"#,
)
.into();

let result = setup.minter().deposit_sol(DEFAULT_CALLER_ACCOUNT).await;
Expand Down
25 changes: 25 additions & 0 deletions libs/types-internal/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ pub enum EventType {
/// The chain code used to derive subkeys (32 bytes).
chain_code: Vec<u8>,
},
/// The minter minted ckSOL on the ledger for a swept deposit whose sweep
/// was credited.
MintedSweptDeposit {
/// The identifier of the minted deposit.
deposit_id: u64,
/// The mint transaction index on the ckSOL ledger.
mint_block_index: u64,
},
/// The pending mint of a swept deposit cannot be retried: either it became
/// older than the 24-hour deduplication window of the ckSOL ledger, or the
/// ledger definitively rejected it. Retrying the transfer with the same
/// arguments fails forever, and fresh arguments could double mint.
///
/// The deposit is quarantined to avoid any double minting and will not be
/// further processed without manual intervention.
///
/// If the minter was down past the deduplication window, the underlying
/// transfer may nevertheless have landed on the ledger. Manual resolution
/// must therefore first search the ledger for a mint whose memo carries the
/// sweep signature before crediting by hand, otherwise a double mint
/// results.
Comment thread
gregorydemay marked this conversation as resolved.
QuarantinedPendingMint {
/// The identifier of the deposit whose pending mint was quarantined.
deposit_id: u64,
},
}

/// The mint enqueued for one deposit of a `CreditedSweep` event.
Expand Down
Loading
Loading