Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

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

6 changes: 3 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ The ckSOL minter's main address is the nonce authority of every account in the p

For the oracle to be sound, a stale read must never be mistaken for an advance: nonce values are opaque hashes, so a value differing from the in-flight transaction's nonce could by itself be the account's past as well as its future, and a provider lagging behind an already observed state serves exactly such a past value. Since only the ckSOL minter can advance the nonce, the account's complete value history is the set of nonce values the ckSOL minter has bound to transactions, which the event log already records. Every read is therefore classified against that set: the bound value means the transaction has not landed; any other previously seen value is a stale response and yields no decision; a never-seen value can only be the account's new frontier, which proves the advance.

The pool size bounds the withdrawal throughput, since every withdrawal transaction occupies one nonce account while it is in flight. When no free nonce account is available, the affected withdrawal batches simply remain queued until a nonce account frees up; the processing timer retries after a short delay only while both a free nonce account and an affordable batch remain, and otherwise waits for its regular interval, so an exhausted pool stops retrying entirely and a stale nonce read, whose reservation is released, is retried at the delayed cadence rather than in a zero-delay loop. As a nonce account costs nothing beyond its rent exemption minimum, the pool can be sized generously; **5 accounts** are proposed initially, allowing 50 concurrent in-flight withdrawals at 10 transfers per transaction.
The pool size bounds the withdrawal throughput, since every withdrawal transaction occupies one nonce account while it is in flight. When no free nonce account is available, the affected withdrawal batches simply remain queued until a nonce account frees up; the processing timer retries after a short delay only while both a free nonce account and an affordable batch remain, or while a bound withdrawal transaction is still unsigned (after a signing failure, or because a round signs at most 10 transactions), and otherwise waits for its regular interval, so an exhausted pool whose bound transactions are all signed stops retrying entirely and a stale nonce read is retried at the delayed cadence rather than in a zero-delay loop. As a nonce account costs nothing beyond its rent exemption minimum, the pool can be sized generously; **5 accounts** are proposed initially, allowing 50 concurrent in-flight withdrawals at 10 transfers per transaction.

Deposit sweeps continue to use recent block hashes. The double-pay hazard is specific to withdrawals: a sweep only moves funds between addresses controlled by the ckSOL minter, nothing is credited before the finalized transaction has been positively observed, and an expired sweep is dropped rather than resubmitted, as described in [Section 3.1.3](#313-manual-flow).

Expand Down 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. 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.
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 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 `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 @@ -542,7 +542,7 @@ sequenceDiagram

Note over Minter: ⏱️ Timer fires
activate Minter
Note over Minter: Reserve a free nonce account from the pool
Note over Minter: Pick a free nonce account from the pool
Minter->>+RPC: getAccountInfo(nonce_account)
RPC->>+Solana: getAccountInfo(nonce_account)
Solana-->>-RPC: nonce account state
Expand Down
1 change: 1 addition & 0 deletions integration_tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ solana-hash = { workspace = true }
solana-keypair = { workspace = true }
solana-message = { workspace = true }
solana-native-token = { workspace = true }
solana-nonce = { workspace = true }
solana-signature = { workspace = true }
solana-system-interface = { workspace = true, features = ["bincode"] }
solana-system-transaction = { workspace = true }
Expand Down
64 changes: 64 additions & 0 deletions integration_tests/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ use pocket_ic::nonblocking::PocketIc;
use serde_json::json;
use sol_rpc_types::Lamport;
use solana_address::{Address, address};
use solana_hash::Hash;
use solana_nonce::{
state::{Data, DurableNonce, State},
versions::Versions,
};
use std::sync::Arc;
use tokio::sync::Mutex;

Expand Down Expand Up @@ -149,6 +154,31 @@ impl MockBuilder {
)
}

/// Mock for `getAccountInfo` returning an initialized durable nonce account
/// whose nonce authority is the minter's main address.
pub fn get_nonce_account(self) -> Self {
self.expect(
get_account_info_request(),
get_account_info_nonce_response(),
)
}

/// Mocks for the withdrawal timer submitting a durable-nonce transaction:
/// `getAccountInfo` reading the nonce account → `sendTransaction`.
pub fn submit_withdrawal_transaction(self) -> Self {
self.get_nonce_account().expect(
send_transaction_request(),
send_transaction_response(SUBMITTED_SIGNATURE),
)
}

/// Mocks for `finalize_transactions` finding only in-flight withdrawal
/// transactions, which carry a durable nonce and need no current block:
/// a single `getSignatureStatuses` reporting them as finalized.
pub fn finalize_withdrawal_transaction(self, signature: &Signature) -> Self {
self.check_signature_statuses(signature, get_signature_statuses_finalized_response())
}

/// Mocks for `finalize_transactions` finding the pending transaction with the given
/// signature expired at `block_height`: `getSlot` → `getBlock` → `getSignatureStatuses`
/// reporting it as not found.
Expand Down Expand Up @@ -262,6 +292,40 @@ fn sweep_transaction_response(sweepable_amount: Lamport) -> JsonRpcResponse {
}))
}

fn get_account_info_request() -> JsonRpcRequestMatcher {
JsonRpcRequestMatcher::with_method("getAccountInfo")
}

fn get_account_info_nonce_response() -> JsonRpcResponse {
JsonRpcResponse::from(json!({
"jsonrpc": "2.0",
"result": {
"context": { "apiVersion": "2.0.15", "slot": 341_197_053 },
"value": {
"data": [nonce_account_data(), "base64"],
"executable": false,
"lamports": 1_447_680,
"owner": "11111111111111111111111111111111",
"rentEpoch": 18_446_744_073_709_551_615_u64,
"space": 80
}
},
"id": 1
}))
}

fn nonce_account_data() -> String {
let nonce_account = Versions::new(State::Initialized(Data::new(
MINTER_ADDRESS,
DurableNonce::from_blockhash(&Hash::from([0x4E; 32])),
FEE_PER_SIGNATURE,
)));
STANDARD.encode(
bincode::serialize(&nonce_account)
.expect("BUG: serializing a nonce account should succeed"),
)
}

fn get_balance_request() -> JsonRpcRequestMatcher {
JsonRpcRequestMatcher::with_method("getBalance")
}
Expand Down
50 changes: 46 additions & 4 deletions integration_tests/src/validator.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::{Setup, SetupBuilder, fixtures::RENT_EXEMPTION_THRESHOLD};
use crate::{
Setup, SetupBuilder,
fixtures::{MINTER_ADDRESS, RENT_EXEMPTION_THRESHOLD},
};
use cksol_types::WithdrawalStatus;
use icrc_ledger_types::icrc1::account::Account;
use sol_rpc_types::{InstallArgs, Lamport, OverrideProvider, RegexSubstitution, RoundingError};
Expand Down Expand Up @@ -127,12 +130,27 @@ impl SolanaTestValidator {
rpc.get_fee_for_message(&transfer.message).await.ok()
}

/// Creates a test setup whose SOL RPC canister talks to this validator.
/// Creates a test setup whose SOL RPC canister talks to this validator,
/// with a real durable nonce account created on the validator for the
/// minter's deterministic main address before the minter is installed, so
/// that withdrawal transactions can be submitted against it.
pub async fn setup(&self) -> Setup {
self.setup_builder().build().await
let nonce_accounts = self
.create_nonce_accounts(1, &MINTER_ADDRESS)
.await
.iter()
.map(Address::to_string)
.collect();
self.setup_builder()
.with_nonce_accounts(nonce_accounts)
.build()
.await
}

/// A [`SetupBuilder`] preconfigured so the SOL RPC canister talks to this validator.
/// A [`SetupBuilder`] preconfigured so the SOL RPC canister talks to this
/// validator. The default nonce account pool is a placeholder that does not
/// exist on the validator, so a test whose minter submits withdrawals must
/// pass accounts from [`Self::create_nonce_accounts`] or use [`Self::setup`].
pub fn setup_builder(&self) -> SetupBuilder {
SetupBuilder::new()
.with_proxy_canister()
Expand Down Expand Up @@ -263,6 +281,30 @@ impl SolanaTestValidator {
addresses
}

/// The nonce value currently stored by the given durable nonce account,
/// read at `finalized` commitment.
///
/// # Panics
///
/// Panics if the account does not exist or is not an initialized nonce account.
pub async fn get_nonce_value(&self, address: &Address) -> solana_hash::Hash {
let account = self
.rpc_client()
.get_account_with_commitment(address, CommitmentConfig::finalized())
.await
.expect("Failed to read the nonce account")
.value
.unwrap_or_else(|| panic!("Nonce account {address} does not exist"));
let versions: solana_nonce::versions::Versions = bincode::deserialize(&account.data)
.unwrap_or_else(|e| panic!("Account {address} is not a nonce account: {e}"));
match versions.state() {
solana_nonce::state::State::Initialized(data) => data.blockhash(),
solana_nonce::state::State::Uninitialized => {
panic!("Nonce account {address} is not initialized")
}
}
}

pub async fn airdrop_and_confirm(&self, address: Address, airdrop_amount: Lamport) {
let rpc = self.rpc_client();

Expand Down
11 changes: 11 additions & 0 deletions integration_tests/tests/solana_test_validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,24 @@ async fn should_deposit_and_withdraw() {
))
.await;

let nonce_account: Address = setup.minter().get_minter_info().await.nonce_accounts[0]
.parse()
.expect("the minter reports well-formed nonce accounts");
let nonce_value_before = validator.get_nonce_value(&nonce_account).await;

// Advance time to trigger withdrawal processing and monitor timers
setup.advance_time(Duration::from_mins(10)).await;

for &burn_index in &burn_indices {
wait_for_withdrawal_finalized(&setup, burn_index).await;
}

// The landed withdrawal transaction advanced the durable nonce it carried.
assert_ne!(
validator.get_nonce_value(&nonce_account).await,
nonce_value_before
);

// Verify all ICRC accounts are drained
for account in &accounts {
let balance = setup.ledger().balance_of(*account).await;
Expand Down
Loading
Loading