Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1f64080
feat(minter): track the lifecycle of durable nonce accounts in the pool
gregorydemay Oct 6, 2026
0cc9de7
feat(minter): model durable-nonce withdrawals as submitted transactions
gregorydemay Oct 6, 2026
bd2bc58
feat(minter): record withdrawal transactions as created and signed ev…
gregorydemay Oct 6, 2026
506be44
bench(minter): record the replay cost of the nonce withdrawal events
gregorydemay Oct 6, 2026
e07e351
refactor(minter): record signed withdrawals with SubmittedTransaction
gregorydemay Oct 6, 2026
3c8d919
docs: bind withdrawals before signing and record them before sending
gregorydemay Oct 6, 2026
f614064
bench(minter): record the replay cost of withdrawals submitted after …
gregorydemay Oct 6, 2026
c209f67
test(minter): rename create_withdrawal fixture to create_withdrawal_b…
gregorydemay Oct 7, 2026
46ec1a8
test(minter): rename submit_nonce_withdrawal fixture to submit_withdr…
gregorydemay Oct 7, 2026
c7bd210
test(minter): rename nonce_withdrawal_message fixture to withdrawal_b…
gregorydemay Oct 7, 2026
e18c974
fix(minter): use checked arithmetic to total a created withdrawal tra…
gregorydemay Oct 7, 2026
0c08845
refactor(minter): match exhaustively on the nonce account state when …
gregorydemay Oct 7, 2026
c9e6863
refactor(minter): match exhaustively on the nonce account state when …
gregorydemay Oct 7, 2026
d24863f
fix(minter): list pending and created withdrawals together newest-fir…
gregorydemay Oct 7, 2026
81a53c5
fix(minter): finalize transactions even when the current block cannot…
gregorydemay Oct 7, 2026
21f0706
fix(minter): verify a submitted withdrawal carries exactly its bound …
gregorydemay Oct 7, 2026
29985c3
refactor(minter): mirror MinterTransaction in the SubmittedTransactio…
gregorydemay Oct 7, 2026
616fa2f
bench(minter): record the replay cost of verifying the full withdrawa…
gregorydemay Oct 7, 2026
e1b4964
test(minter): cover freeing a bound nonce account and rebinding it
gregorydemay Oct 7, 2026
90e55fb
test(minter): pass the nonce value instead of a seed to the withdrawa…
gregorydemay Oct 7, 2026
e6e1008
fix(minter): count created withdrawal requests as pending in the metrics
gregorydemay Oct 7, 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
8 changes: 4 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ There is a **minimum withdrawal amount**, which is defined in [Section 3.3.3](#3

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.3](#313-manual-flow)), the main account always covers the minted supply, and a withdrawal never waits for or triggers a consolidation. The only delay a user can experience is between a `deposit_sol` call and the mint of their own deposit.

Contrary to sweeps, a withdrawal transaction does not follow the transaction submission flow of [Section 3.1.4](#314-consolidation), 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 nonce account is reserved for a batch synchronously, before the first await point, so that concurrently processed batches can never pick the same account. Once the message is built, a `CreatedTransaction` event records the unsigned transaction together with the burn indices of the withdrawals it serves and the nonce account and nonce value it uses, *before* the threshold signature is requested: should the signing fail or be interrupted, the reservation survives, and the ckSOL minter signs the recorded message again instead of building a new one, so that no two different messages are ever signed for the same nonce value. The signed transaction is likewise persisted before it is sent, so that the identical transaction can later be re-broadcast.
Contrary to sweeps, a withdrawal transaction does not follow the transaction submission flow of [Section 3.1.4](#314-consolidation), 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 nonce account is reserved for a batch synchronously, before the first await point, so that concurrently processed batches can never pick the same account. Before the threshold signature is requested, a `CreatedTransaction` 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.

```mermaid
sequenceDiagram
Expand All @@ -547,11 +547,11 @@ sequenceDiagram
RPC->>+Solana: getAccountInfo(nonce_account)
Solana-->>-RPC: nonce account state
RPC-->>-Minter: nonce account state
Note over Minter: Build transaction: AdvanceNonceAccount first,<br/>then one transfer per withdrawal,<br/>nonce value in place of the recent block hash
Note over Minter: Record CreatedTransaction (unsigned transaction,<br/>nonce account, nonce value)
Note over Minter: Record CreatedTransaction (burn indices,<br/>nonce account, nonce value)
Note over Minter: Build transaction from the binding: AdvanceNonceAccount first,<br/>then one transfer per withdrawal,<br/>nonce value in place of the recent block hash
Minter->>+Signer: sign_with_schnorr(Ed25519, main derivation path, message)
Signer-->>-Minter: signature
Note over Minter: Serialize and persist signed transaction
Note over Minter: Record SubmittedTransaction (signed transaction)
Minter->>+RPC: sendTransaction(transaction)
RPC->>+Solana: sendTransaction(transaction)
Solana-->>-RPC: signature
Expand Down
7 changes: 3 additions & 4 deletions integration_tests/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,11 +747,10 @@ mod withdrawal_tests {
check!(events.iter().any(|e| matches!(
e,
EventType::SubmittedTransaction {
purpose: TransactionPurpose::WithdrawSol { burn_indices },
block_height,
purpose: TransactionPurpose::Withdrawal { burn_indices, block_height },
..
} if burn_indices == &[block_index]
&& block_height == &SUBMISSION_BLOCK_HEIGHT
&& *block_height == SUBMISSION_BLOCK_HEIGHT
)));
});

Expand Down Expand Up @@ -999,7 +998,7 @@ mod deposit_sol_tests {
e,
EventType::SubmittedTransaction {
signature,
purpose: TransactionPurpose::SweepDeposits { deposit_ids },
purpose: TransactionPurpose::SweepDeposit { deposit_ids, .. },
..
} if *signature == sweep_signature && deposit_ids == &[deposit_id]
)));
Expand Down
44 changes: 34 additions & 10 deletions libs/types-internal/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{InitArgs, UpgradeArgs};
use candid::CandidType;
use icrc_ledger_types::icrc1::account::Account;
use serde::Deserialize;
use sol_rpc_types::{Lamport, Pubkey as Address, Signature};
use sol_rpc_types::{Hash, Lamport, Pubkey as Address, Signature};

/// A minter event that can be serialized to Candid.
#[derive(Clone, Debug, PartialEq, CandidType, Deserialize)]
Expand Down Expand Up @@ -44,10 +44,9 @@ pub enum EventType {
transaction: VersionedTransactionMessage,
/// The signers in signature order (fee payer first).
signers: Vec<Signer>,
/// The purpose of this transaction.
/// The purpose of this transaction, with what the minter needs to
/// track it until it is finalized.
purpose: TransactionPurpose,
/// The block height of the block whose blockhash the transaction uses.
block_height: u64,
},
/// A previously submitted transaction was resubmitted with a new signature.
ResubmittedTransaction {
Expand Down Expand Up @@ -139,6 +138,17 @@ pub enum EventType {
/// The identifier of the deposit whose pending mint was quarantined.
deposit_id: u64,
},
/// The minter bound a durable nonce account and its nonce value to the
/// withdrawal requests of the given burn indices, before requesting the
/// threshold signature. The binding determines the transaction message.
CreatedTransaction {
/// The ledger burn indices of the withdrawal requests served by this transaction.
burn_indices: Vec<u64>,
/// The durable nonce account bound to this transaction.
nonce_account: Address,
/// The nonce value the transaction carries in place of a recent blockhash.
nonce_value: Hash,
},
}

/// The mint enqueued for one deposit of a `CreditedSweep` event.
Expand All @@ -164,15 +174,29 @@ pub enum Signer {
/// The purpose of a submitted Solana transaction.
#[derive(Clone, Debug, PartialEq, CandidType, Deserialize)]
pub enum TransactionPurpose {
/// Send withdrawals to users' Solana addresses.
WithdrawSol {
/// Sweep the deposit addresses of deposits queued by `deposit_sol` into
/// the minter's main account. The transaction uses a recent blockhash and
/// is dropped once the blockhash expires.
SweepDeposit {
/// The ids of the swept deposits.
deposit_ids: Vec<u64>,
/// The block height of the block whose blockhash the transaction uses.
block_height: u64,
},
/// Send withdrawals to users' Solana addresses. The transaction uses a
/// recent blockhash and is resubmitted once the blockhash expires.
Withdrawal {
/// The burn transaction indices on the ckSOL ledger.
burn_indices: Vec<u64>,
/// The block height of the block whose blockhash the transaction uses.
block_height: u64,
},
/// Sweep the deposit addresses of deposits queued by `deposit_sol` into the minter's main account.
SweepDeposits {
/// The ids of the swept deposits.
deposit_ids: Vec<u64>,
/// Send withdrawals to users' Solana addresses. The transaction carries
/// the nonce value of a durable nonce account instead of a recent
/// blockhash, so it never expires.
NonceWithdrawal {
/// The burn transaction indices on the ckSOL ledger.
burn_indices: Vec<u64>,
},
}

Expand Down
2 changes: 1 addition & 1 deletion minter/canbench_results.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ benches:
post_upgrade_10k_events:
total:
calls: 1
instructions: 194836563
instructions: 244554653
Comment thread
gregorydemay marked this conversation as resolved.
heap_increase: 0
stable_memory_increase: 0
scopes: {}
Expand Down
47 changes: 37 additions & 10 deletions minter/cksol_minter.did
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type GetDepositAddressArgs = record {
// Example: "5LrcE2f6uvydKRquEJ8xp19heGxSvqsVbcqUeFoiWbXe8JNip7ftPQNTAVPyTK7ijVdpkzmKKaAQR7MWMmujAhXD"
type Signature = text;

// A 32-byte Solana hash as a base-58 encoded string,
// such as the nonce value stored by a durable nonce account.
type Hash = text;

// Smallest denomination of SOL, the native token on Solana,
// i.e. 1_000_000_000 Lamports is 1 SOL
type Lamport = nat64;
Expand Down Expand Up @@ -345,16 +349,29 @@ type Signer = variant {
// *WARNING*: This type is used exclusively by the debug `get_events` endpoint.
// Backwards-compatibility is not guaranteed.
type TransactionPurpose = variant {
// Withdraw SOL to users' Solana addresses.
WithdrawSol : record {
// The ledger burn indices of the withdrawal requests included in this transaction.
burn_indices: vec LedgerBurnIndex;
};
// Sweep the deposit addresses of deposits queued by `deposit_sol` into the
// minter's main account.
SweepDeposits : record {
// minter's main account. The transaction uses a recent blockhash and is
// dropped once the blockhash expires.
SweepDeposit : record {
// The ids of the swept deposits.
deposit_ids: vec DepositSolId;
// The block height of the block whose blockhash the transaction uses.
block_height: nat64;
};
// Withdraw SOL to users' Solana addresses. The transaction uses a recent
// blockhash and is resubmitted once the blockhash expires.
Withdrawal : record {
// The ledger burn indices of the withdrawal requests included in this transaction.
burn_indices: vec LedgerBurnIndex;
// The block height of the block whose blockhash the transaction uses.
block_height: nat64;
};
// Withdraw SOL to users' Solana addresses. The transaction carries the
// nonce value of a durable nonce account instead of a recent blockhash,
// so it never expires.
NonceWithdrawal : record {
// The ledger burn indices of the withdrawal requests included in this transaction.
burn_indices: vec LedgerBurnIndex;
};
};

Expand All @@ -380,10 +397,9 @@ type EventType = variant {
transaction: VersionedTransactionMessage;
// The signers in signature order (fee payer first).
signers: vec Signer;
// The purpose of this transaction.
// The purpose of this transaction, with what the minter needs to
// track it until it is finalized.
purpose: TransactionPurpose;
// The block height of the block whose blockhash the transaction uses.
block_height: nat64;
};
// A previously submitted transaction was resubmitted with a new signature.
ResubmittedTransaction : record {
Expand Down Expand Up @@ -476,6 +492,17 @@ type EventType = variant {
// The identifier of the deposit whose pending mint was quarantined.
deposit_id: DepositSolId;
};
// The minter bound a durable nonce account and its nonce value to the
// withdrawal requests of the given burn indices, before requesting the
// threshold signature. The binding determines the transaction message.
CreatedTransaction : record {
// The ledger burn indices of the withdrawal requests served by this transaction.
burn_indices: vec LedgerBurnIndex;
// The durable nonce account bound to this transaction.
nonce_account: Address;
// The nonce value the transaction carries in place of a recent blockhash.
nonce_value: Hash;
};
};

// A minter event.
Expand Down
89 changes: 54 additions & 35 deletions minter/src/canbench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::{
numeric::{LedgerBurnIndex, LedgerMintIndex},
rpc::BlockHeight,
runtime::IcCanisterRuntime,
sol_transfer::build_batch_withdrawal_message,
state::{
DepositBalance, QueuedDeposit, SchnorrPublicKey, Sweep,
audit::{process_event, replay_events},
Expand All @@ -27,7 +28,6 @@ const INDEX_OFFSET_QUARANTINE: usize = 10_000;
const INDEX_OFFSET_WITHDRAWAL: usize = 20_000;
const INDEX_OFFSET_DROPPED: usize = 30_000;
const INDEX_OFFSET_EXPIRED: usize = 40_000;
const INDEX_OFFSET_RESUBMIT: usize = 50_000;

fn init_args() -> InitArgs {
InitArgs {
Expand All @@ -40,10 +40,20 @@ fn init_args() -> InitArgs {
deposit_sol_required_cycles: 1_000_000_000_000,
solana_network: SolanaNetwork::Mainnet,
deposit_sol_fee: 10_000_000_000,
nonce_accounts: vec![],
nonce_accounts: vec![nonce_account().to_string()],
}
}

fn nonce_account() -> solana_address::Address {
solana_address::Address::from([0x4E; 32])
}

fn nonce_value(i: usize) -> solana_hash::Hash {
let mut bytes = [0u8; 32];
bytes[..8].copy_from_slice(&(i as u64).to_le_bytes());
solana_hash::Hash::from(bytes)
}

fn signature(i: usize) -> Signature {
let mut bytes = [0u8; 64];
bytes[..8].copy_from_slice(&(i as u64).to_le_bytes());
Expand Down Expand Up @@ -72,9 +82,18 @@ fn master_key() -> SchnorrPublicKey {
}
}

fn message() -> solana_message::Message {
let payer = solana_address::Address::from([0x42; 32]);
solana_message::Message::new_with_blockhash(&[], Some(&payer), &solana_message::Hash::default())
fn nonce_withdrawal_message(
nonce_value: solana_hash::Hash,
destination: solana_address::Address,
amount: u64,
) -> solana_message::Message {
build_batch_withdrawal_message(
&minter_address(&master_key()),
&nonce_account(),
nonce_value,
&[(destination, amount)],
)
.expect("BUG: a single-transfer withdrawal message fits in a transaction")
}

fn record(event: EventType) {
Expand All @@ -101,10 +120,10 @@ fn queue_and_sweep(deposit_id: u64, account_index: usize, amount: u64, sig: Sign
signature: sig,
message: VersionedMessage::Legacy(sweep.sweep_message(solana_message::Hash::default())),
signers: vec![Signer::Account(account)],
purpose: TransactionPurpose::SweepDeposits {
purpose: TransactionPurpose::SweepDeposit {
deposit_ids: vec![deposit_id],
block_height: BlockHeight::new(0),
},
block_height: BlockHeight::new(0),
});
}

Expand All @@ -117,22 +136,31 @@ fn deposit_address(account_index: usize) -> solana_address::Address {
fn accept_and_submit_withdrawal(account_index: usize, burn_index: u64, sig: Signature) {
const WITHDRAWAL_FEE: u64 = 5_000_000;
const WITHDRAWAL_AMOUNT: u64 = 10_000_000;
const AMOUNT_TO_TRANSFER: u64 = WITHDRAWAL_AMOUNT - WITHDRAWAL_FEE;

let destination = [0u8; 32];
let burn_indices = vec![LedgerBurnIndex::from(burn_index)];
record(EventType::AcceptedWithdrawalRequest(WithdrawalRequest {
account: account(account_index),
solana_address: [0u8; 32],
solana_address: destination,
burn_block_index: LedgerBurnIndex::from(burn_index),
burned_amount: WITHDRAWAL_AMOUNT,
amount_to_transfer: WITHDRAWAL_AMOUNT - WITHDRAWAL_FEE,
amount_to_transfer: AMOUNT_TO_TRANSFER,
}));
record(EventType::CreatedTransaction {
burn_indices: burn_indices.clone(),
nonce_account: nonce_account(),
nonce_value: nonce_value(account_index),
});
record(EventType::SubmittedTransaction {
signature: sig,
message: VersionedMessage::Legacy(message()),
message: VersionedMessage::Legacy(nonce_withdrawal_message(
nonce_value(account_index),
solana_address::Address::from(destination),
AMOUNT_TO_TRANSFER,
)),
signers: vec![Signer::Minter],
purpose: TransactionPurpose::WithdrawSol {
burn_indices: vec![LedgerBurnIndex::from(burn_index)],
},
block_height: BlockHeight::new(0),
purpose: TransactionPurpose::NonceWithdrawal { burn_indices },
});
}

Expand Down Expand Up @@ -193,8 +221,8 @@ fn setup_10k_events() {
record(EventType::QuarantinedSweep { signature: sig });
}

// Withdrawal cycles: accept withdrawal → submit withdrawal → succeed
// 500 × 3 = 1500 events
// Withdrawal cycles: accept withdrawal → create transaction → submit → succeed
// 500 × 4 = 2000 events
for i in 0..500 {
let sig = signature(INDEX_OFFSET_WITHDRAWAL + i);

Expand All @@ -213,28 +241,19 @@ fn setup_10k_events() {
record(EventType::FailedTransaction { signature: sig });
}

// Expired + resubmitted withdrawal cycles: accept → submit → expire → resubmit → succeed
// 300 × 5 = 1500 events
// Expired sweeps: queue → sweep → expire
// 300 × 3 = 900 events
for i in 0..300 {
let old_sig = signature(INDEX_OFFSET_EXPIRED + i);
let new_sig = signature(INDEX_OFFSET_RESUBMIT + i);

accept_and_submit_withdrawal(
INDEX_OFFSET_EXPIRED + i,
(INDEX_OFFSET_EXPIRED + i) as u64,
old_sig,
);
record(EventType::ExpiredTransaction { signature: old_sig });
record(EventType::ResubmittedTransaction {
old_signature: old_sig,
new_signature: new_sig,
new_block_height: BlockHeight::new(1),
});
record(EventType::SucceededTransaction { signature: new_sig });
let deposit_id = next_deposit_id;
next_deposit_id += 1;
let sig = signature(INDEX_OFFSET_EXPIRED + i);

queue_and_sweep(deposit_id, INDEX_OFFSET_EXPIRED + i, amount, sig);
record(EventType::ExpiredTransaction { signature: sig });
}

// Total: 1 (init) + 1 (minter public key) + 5000 + 800 + 1500 + 1500 + 1500 = 10302 events
assert_eq!(total_event_count(), 10302);
// Total: 1 (init) + 1 (minter public key) + 5000 + 800 + 2000 + 1500 + 900 = 10202 events
assert_eq!(total_event_count(), 10202);
reset_state();
}

Expand Down
Loading
Loading