From 4709e7021807a5ce89209d857482f27aabaaa475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Mon, 5 Oct 2026 14:22:49 +0000 Subject: [PATCH 1/9] feat(minter): read and verify durable nonce accounts Add a getAccountInfo wrapper that reads a durable nonce account at finalized commitment and parses its authority and nonce value, and a verified read that traps when the authority is not the minter's main address, since a wrong-authority account in the pool is a serious operator error. The read path stays unwired until withdrawal submission uses it. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + Cargo.toml | 2 + minter/Cargo.toml | 2 + minter/src/constants.rs | 9 ++++ minter/src/rpc/mod.rs | 72 ++++++++++++++++++++++++++++-- minter/src/rpc/tests.rs | 70 +++++++++++++++++++++++++++-- minter/src/test_fixtures/mod.rs | 55 ++++++++++++++++++++++- minter/src/withdraw/mod.rs | 1 + minter/src/withdraw/nonce/mod.rs | 30 +++++++++++++ minter/src/withdraw/nonce/tests.rs | 36 +++++++++++++++ 10 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 minter/src/withdraw/nonce/mod.rs create mode 100644 minter/src/withdraw/nonce/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9ebdd20c..39f8bc51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -988,9 +988,11 @@ dependencies = [ "sha2", "sol_rpc_client", "sol_rpc_types", + "solana-account-decoder-client-types", "solana-address 2.3.0", "solana-hash 4.2.0", "solana-message", + "solana-nonce", "solana-sdk-ids", "solana-signature", "solana-system-interface 3.1.0", diff --git a/Cargo.toml b/Cargo.toml index 07b0068c..d919ade0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,12 +57,14 @@ serial_test = "3.2.0" sha2 = "0.10.9" sol_rpc_client = "6.0.0" sol_rpc_types = "3.1.2" +solana-account-decoder-client-types = "3.1.11" solana-address = "2.3.0" solana-client = "3.1.8" solana-hash = "4.1.0" solana-keypair = "3.1.2" solana-message = "3.1.0" solana-native-token = "3.0.0" +solana-nonce = { version = "3.1.0", features = ["serde"] } solana-sdk-ids = "3.1.0" solana-signature = "3.1.0" solana-system-interface = "3.1.0" diff --git a/minter/Cargo.toml b/minter/Cargo.toml index c474fbdb..ae19674a 100644 --- a/minter/Cargo.toml +++ b/minter/Cargo.toml @@ -46,9 +46,11 @@ scopeguard = { workspace = true } serde = { workspace = true } sol_rpc_client = { workspace = true } sol_rpc_types = { workspace = true } +solana-account-decoder-client-types = { workspace = true } solana-address = { workspace = true } solana-hash = { workspace = true } solana-message = { workspace = true } +solana-nonce = { workspace = true } solana-sdk-ids = { workspace = true } solana-signature = { workspace = true } solana-system-interface = { workspace = true, features = ["bincode"] } diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 31cc0b73..2781519b 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -50,6 +50,15 @@ pub const GET_BALANCE_CYCLES: u128 = 10_000_000_000; /// Cycles to attach for `getSignatureStatuses` RPC calls. pub const GET_SIGNATURE_STATUSES_CYCLES: u128 = 1_000_000_000_000; +/// Cycles to attach for `getAccountInfo` RPC calls. +/// +/// The SOL RPC canister charges about 2.1B cycles for a `getAccountInfo` +/// request with the default 3-out-of-4 provider consensus, comparable to +/// `getBalance` since a nonce account holds only 80 bytes of state. The +/// attached amount leaves a wide margin for provider or price changes; the +/// unused part is refunded. +pub const GET_ACCOUNT_INFO_CYCLES: u128 = 10_000_000_000; + /// Cost in lamports per signature included in a Solana transaction. /// /// See . diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index 8aba2abb..f7265f78 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -1,7 +1,7 @@ use crate::{ constants::{ - GET_BALANCE_CYCLES, GET_RECENT_BLOCK_MAX_TRIES, GET_SIGNATURE_STATUSES_CYCLES, - GET_TRANSACTION_CYCLES, MAX_HTTP_OUTCALL_RESPONSE_BYTES, + GET_ACCOUNT_INFO_CYCLES, GET_BALANCE_CYCLES, GET_RECENT_BLOCK_MAX_TRIES, + GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, MAX_HTTP_OUTCALL_RESPONSE_BYTES, }, runtime::CanisterRuntime, state::read_state, @@ -11,10 +11,13 @@ use derive_more::From; use ic_canister_runtime::IcError; use minicbor::{Decode, Encode}; use sol_rpc_types::{ - CommitmentLevel, GetTransactionEncoding, Lamport, MultiRpcResult, RpcError, Slot, + CommitmentLevel, GetAccountInfoEncoding, GetTransactionEncoding, Lamport, MultiRpcResult, + RpcError, Slot, }; +use solana_account_decoder_client_types::UiAccount; use solana_address::Address; use solana_hash::Hash; +use solana_nonce::{state::State as NonceState, versions::Versions as NonceVersions}; use solana_signature::Signature; use solana_transaction::{Transaction, versioned::VersionedTransaction}; use solana_transaction_status_client_types::{ @@ -177,6 +180,69 @@ pub enum SubmitTransactionError { InconsistentRpcResults, } +pub async fn get_nonce_account( + runtime: &R, + address: Address, +) -> Result { + let result = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())) + .get_account_info(address) + .with_encoding(GetAccountInfoEncoding::Base64) + .with_commitment(CommitmentLevel::Finalized) + .with_cycles(GET_ACCOUNT_INFO_CYCLES) + .try_send() + .await; + match result? { + MultiRpcResult::Consistent(Ok(Some(account))) => NonceAccount::try_from(account), + MultiRpcResult::Consistent(Ok(None)) => Err(GetNonceAccountError::AccountNotFound), + MultiRpcResult::Consistent(Err(e)) => Err(GetNonceAccountError::RpcError(e)), + MultiRpcResult::Inconsistent(_) => Err(GetNonceAccountError::InconsistentRpcResults), + } +} + +/// The on-chain state of an initialized durable nonce account. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NonceAccount { + pub authority: Address, + pub nonce: Hash, +} + +impl TryFrom for NonceAccount { + type Error = GetNonceAccountError; + + fn try_from(account: UiAccount) -> Result { + let data = account.data.decode().ok_or_else(|| { + GetNonceAccountError::NotAnInitializedNonceAccount( + "undecodable account data".to_string(), + ) + })?; + let versions: NonceVersions = bincode::deserialize(&data) + .map_err(|e| GetNonceAccountError::NotAnInitializedNonceAccount(e.to_string()))?; + match versions.state() { + NonceState::Uninitialized => Err(GetNonceAccountError::NotAnInitializedNonceAccount( + "the nonce account is uninitialized".to_string(), + )), + NonceState::Initialized(data) => Ok(Self { + authority: data.authority, + nonce: data.blockhash(), + }), + } + } +} + +#[derive(Debug, PartialEq, Error)] +pub enum GetNonceAccountError { + #[error("Error while calling SOL RPC canister: {0}")] + IcError(#[from] IcError), + #[error("RPC error while fetching nonce account: {0}")] + RpcError(RpcError), + #[error("Inconsistent RPC results for getAccountInfo")] + InconsistentRpcResults, + #[error("Nonce account not found")] + AccountNotFound, + #[error("Not an initialized nonce account: {0}")] + NotAnInitializedNonceAccount(String), +} + pub async fn get_recent_block( runtime: &R, ) -> Result { diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 1f68c6d8..391a99e2 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -1,16 +1,18 @@ use crate::{ constants::GET_RECENT_BLOCK_MAX_TRIES, rpc::{ - Block, BlockHeight, GetBalanceError, GetRecentBlockError, GetTransactionError, - SubmitTransactionError, get_balance, get_recent_block, get_transaction, submit_transaction, + Block, BlockHeight, GetBalanceError, GetNonceAccountError, GetRecentBlockError, + GetTransactionError, NonceAccount, SubmitTransactionError, get_balance, get_nonce_account, + get_recent_block, get_transaction, submit_transaction, }, test_fixtures::{ - confirmed_block, confirmed_block_at_height, + MINTER_ADDRESS, confirmed_block, confirmed_block_at_height, deposit::{ DEPOSIT_ADDRESS, legacy_deposit_transaction, legacy_deposit_transaction_signature, }, - fetched, init_state, + durable_nonce, fetched, init_state, nonce_account_address, nonce_account_info, runtime::TestCanisterRuntime, + uninitialized_nonce_account_info, }, }; use assert_matches::assert_matches; @@ -325,6 +327,66 @@ mod submit_transaction_tests { } } +mod get_nonce_account_tests { + use super::*; + use sol_rpc_types::{AccountData, AccountEncoding}; + + type GetAccountInfoResult = sol_rpc_types::MultiRpcResult>; + + #[tokio::test] + async fn should_return_the_authority_and_nonce_value() { + init_state(); + + let runtime = TestCanisterRuntime::new().add_stub_response( + GetAccountInfoResult::Consistent(Ok(Some(nonce_account_info(MINTER_ADDRESS, 1)))), + ); + + let result = get_nonce_account(&runtime, nonce_account_address()).await; + + assert_eq!( + result, + Ok(NonceAccount { + authority: MINTER_ADDRESS, + nonce: durable_nonce(1), + }) + ); + } + + #[tokio::test] + async fn should_fail_if_account_not_found() { + init_state(); + + let runtime = TestCanisterRuntime::new() + .add_stub_response(GetAccountInfoResult::Consistent(Ok(None))); + + let result = get_nonce_account(&runtime, nonce_account_address()).await; + + assert_eq!(result, Err(GetNonceAccountError::AccountNotFound)); + } + + #[tokio::test] + async fn should_fail_if_account_is_not_an_initialized_nonce_account() { + init_state(); + + let undecodable_account = sol_rpc_types::AccountInfo { + data: AccountData::Binary("not base64!".to_string(), AccountEncoding::Base64), + ..nonce_account_info(MINTER_ADDRESS, 1) + }; + + for account in [uninitialized_nonce_account_info(), undecodable_account] { + let runtime = TestCanisterRuntime::new() + .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some(account)))); + + let result = get_nonce_account(&runtime, nonce_account_address()).await; + + assert_matches!( + result, + Err(GetNonceAccountError::NotAnInitializedNonceAccount(_)) + ); + } + } +} + mod get_recent_block_tests { use super::*; diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index 6b724c9e..a0b0cdcf 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -1,6 +1,6 @@ use crate::{ address::{MINTER_DERIVATION_PATH, account_address, derivation_path}, - constants::RENT_EXEMPTION_THRESHOLD, + constants::{FEE_PER_SIGNATURE, RENT_EXEMPTION_THRESHOLD}, rpc::{BlockHeight, FetchedTransaction}, state::{ DepositBalance, QueuedDeposit, SchnorrPublicKey, State, Sweep, @@ -9,6 +9,7 @@ use crate::{ }, storage::with_event_iter, }; +use base64::{Engine, engine::general_purpose::STANDARD}; use candid::Principal; use cksol_types::DepositSolId; use cksol_types_internal::{Ed25519KeyName, InitArgs, SolanaNetwork}; @@ -17,6 +18,10 @@ use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; use icrc_ledger_types::icrc1::account::Account; use sol_rpc_types::{Lamport, MultiRpcResult}; use solana_address::{Address, address}; +use solana_nonce::{ + state::{Data as NonceData, DurableNonce, State as NonceState}, + versions::Versions as NonceVersions, +}; use solana_transaction_status_client_types::{ EncodedConfirmedTransactionWithStatusMeta, EncodedTransaction, EncodedTransactionWithStatusMeta, TransactionBinaryEncoding, UiLoadedAddresses, @@ -175,6 +180,54 @@ pub fn confirmed_block_at_height(block_height: BlockHeight) -> sol_rpc_types::Co } } +/// A test durable nonce account address, distinct from any deposit address. +pub fn nonce_account_address() -> Address { + Address::from([0x4E; 32]) +} + +/// Returns the nonce value stored by [`nonce_account_info`] for the same `nonce_seed`. +pub fn durable_nonce(nonce_seed: usize) -> solana_hash::Hash { + *DurableNonce::from_blockhash(&seed_hash(nonce_seed)).as_hash() +} + +/// Returns a `getAccountInfo` response for an initialized durable nonce account with +/// the given authority, storing the nonce value [`durable_nonce`] of the same `nonce_seed`. +pub fn nonce_account_info(authority: Address, nonce_seed: usize) -> sol_rpc_types::AccountInfo { + nonce_account_info_in_state(NonceState::Initialized(NonceData::new( + authority, + DurableNonce::from_blockhash(&seed_hash(nonce_seed)), + FEE_PER_SIGNATURE, + ))) +} + +/// Returns a `getAccountInfo` response for a nonce account that has not been initialized. +pub fn uninitialized_nonce_account_info() -> sol_rpc_types::AccountInfo { + nonce_account_info_in_state(NonceState::Uninitialized) +} + +fn nonce_account_info_in_state(state: NonceState) -> sol_rpc_types::AccountInfo { + const SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111"; + let data = bincode::serialize(&NonceVersions::new(state)) + .expect("BUG: serializing a nonce account should succeed"); + sol_rpc_types::AccountInfo { + lamports: 1_447_680, + space: data.len() as u64, + data: sol_rpc_types::AccountData::Binary( + STANDARD.encode(data), + sol_rpc_types::AccountEncoding::Base64, + ), + owner: SYSTEM_PROGRAM_ID.to_string(), + executable: false, + rent_epoch: u64::MAX, + } +} + +fn seed_hash(seed: usize) -> solana_hash::Hash { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&(seed as u64).to_le_bytes()); + solana_hash::Hash::from(bytes) +} + /// Returns an [`Account`] with a deterministic principal derived from `i`. /// The deposit of `account(deposit_id + 1)` with `1_000_000 * (deposit_id + 1)` sweepable /// lamports, so that a sequence of deposits has distinct accounts and amounts, each covering diff --git a/minter/src/withdraw/mod.rs b/minter/src/withdraw/mod.rs index 1e6cf492..b71cdd05 100644 --- a/minter/src/withdraw/mod.rs +++ b/minter/src/withdraw/mod.rs @@ -26,6 +26,7 @@ use crate::{ pub const WITHDRAWAL_PROCESSING_DELAY: Duration = Duration::from_mins(1); +pub mod nonce; mod reserved_account_keys; #[cfg(test)] mod tests; diff --git a/minter/src/withdraw/nonce/mod.rs b/minter/src/withdraw/nonce/mod.rs new file mode 100644 index 00000000..d098cda0 --- /dev/null +++ b/minter/src/withdraw/nonce/mod.rs @@ -0,0 +1,30 @@ +use crate::{ + rpc::{GetNonceAccountError, get_nonce_account}, + runtime::CanisterRuntime, +}; +use solana_address::Address; +use solana_hash::Hash; + +#[cfg(test)] +mod tests; + +/// Reads the given durable nonce account and returns the nonce value it stores. +/// +/// # Panics +/// Panics if the nonce authority of the account is not the minter's main +/// address, since a wrong-authority account in the pool is a serious operator +/// error that must halt the minter loudly. +pub async fn read_verified_nonce( + runtime: &R, + account: Address, + minter_address: Address, +) -> Result { + let nonce_account = get_nonce_account(runtime, account).await?; + if nonce_account.authority != minter_address { + panic!( + "BUG: nonce account {account} has authority {} instead of the minter address {minter_address}", + nonce_account.authority + ); + } + Ok(nonce_account.nonce) +} diff --git a/minter/src/withdraw/nonce/tests.rs b/minter/src/withdraw/nonce/tests.rs new file mode 100644 index 00000000..34bf9267 --- /dev/null +++ b/minter/src/withdraw/nonce/tests.rs @@ -0,0 +1,36 @@ +use crate::{ + test_fixtures::{ + MINTER_ADDRESS, durable_nonce, init_state, nonce_account_address, nonce_account_info, + runtime::TestCanisterRuntime, + }, + withdraw::nonce::read_verified_nonce, +}; +use solana_address::Address; + +type GetAccountInfoResult = sol_rpc_types::MultiRpcResult>; + +#[tokio::test] +async fn should_return_the_nonce_value_of_an_account_with_the_minter_as_authority() { + init_state(); + + let runtime = TestCanisterRuntime::new().add_stub_response(GetAccountInfoResult::Consistent( + Ok(Some(nonce_account_info(MINTER_ADDRESS, 1))), + )); + + let result = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; + + assert_eq!(result, Ok(durable_nonce(1))); +} + +#[tokio::test] +#[should_panic(expected = "BUG: nonce account")] +async fn should_panic_if_the_authority_is_not_the_minter_address() { + init_state(); + let other_authority = Address::from([0x99; 32]); + + let runtime = TestCanisterRuntime::new().add_stub_response(GetAccountInfoResult::Consistent( + Ok(Some(nonce_account_info(other_authority, 1))), + )); + + let _ = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; +} From 1ec98a86629df7783c8977088af30e754825e9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Mon, 5 Oct 2026 15:33:34 +0000 Subject: [PATCH 2/9] fix(minter): require nonce accounts to be system-program accounts Validate the owner and executable flag of a fetched account before decoding its nonce state, so that a foreign account whose data happens to deserialize as a nonce account is rejected with a dedicated error, which the verified read escalates to a trap like an authority mismatch. Co-Authored-By: Claude Fable 5 --- minter/src/rpc/mod.rs | 9 +++++++++ minter/src/rpc/tests.rs | 26 ++++++++++++++++++++++++++ minter/src/withdraw/nonce/mod.rs | 14 ++++++++++---- minter/src/withdraw/nonce/tests.rs | 17 +++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index f7265f78..1fa3e41a 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -18,6 +18,7 @@ use solana_account_decoder_client_types::UiAccount; use solana_address::Address; use solana_hash::Hash; use solana_nonce::{state::State as NonceState, versions::Versions as NonceVersions}; +use solana_sdk_ids::system_program; use solana_signature::Signature; use solana_transaction::{Transaction, versioned::VersionedTransaction}; use solana_transaction_status_client_types::{ @@ -210,6 +211,12 @@ impl TryFrom for NonceAccount { type Error = GetNonceAccountError; fn try_from(account: UiAccount) -> Result { + if account.owner != system_program::ID.to_string() || account.executable { + return Err(GetNonceAccountError::NotOwnedBySystemProgram { + owner: account.owner, + executable: account.executable, + }); + } let data = account.data.decode().ok_or_else(|| { GetNonceAccountError::NotAnInitializedNonceAccount( "undecodable account data".to_string(), @@ -239,6 +246,8 @@ pub enum GetNonceAccountError { InconsistentRpcResults, #[error("Nonce account not found")] AccountNotFound, + #[error("Account owned by {owner} (executable: {executable}) instead of the system program")] + NotOwnedBySystemProgram { owner: String, executable: bool }, #[error("Not an initialized nonce account: {0}")] NotAnInitializedNonceAccount(String), } diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 391a99e2..e37c900d 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -364,6 +364,32 @@ mod get_nonce_account_tests { assert_eq!(result, Err(GetNonceAccountError::AccountNotFound)); } + #[tokio::test] + async fn should_fail_if_account_not_owned_by_the_system_program() { + init_state(); + + let foreign_owner_account = sol_rpc_types::AccountInfo { + owner: MINTER_ADDRESS.to_string(), + ..nonce_account_info(MINTER_ADDRESS, 1) + }; + let executable_account = sol_rpc_types::AccountInfo { + executable: true, + ..nonce_account_info(MINTER_ADDRESS, 1) + }; + + for account in [foreign_owner_account, executable_account] { + let runtime = TestCanisterRuntime::new() + .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some(account)))); + + let result = get_nonce_account(&runtime, nonce_account_address()).await; + + assert_matches!( + result, + Err(GetNonceAccountError::NotOwnedBySystemProgram { .. }) + ); + } + } + #[tokio::test] async fn should_fail_if_account_is_not_an_initialized_nonce_account() { init_state(); diff --git a/minter/src/withdraw/nonce/mod.rs b/minter/src/withdraw/nonce/mod.rs index d098cda0..844069c6 100644 --- a/minter/src/withdraw/nonce/mod.rs +++ b/minter/src/withdraw/nonce/mod.rs @@ -11,15 +11,21 @@ mod tests; /// Reads the given durable nonce account and returns the nonce value it stores. /// /// # Panics -/// Panics if the nonce authority of the account is not the minter's main -/// address, since a wrong-authority account in the pool is a serious operator -/// error that must halt the minter loudly. +/// Panics if the account is not owned by the system program or if its nonce +/// authority is not the minter's main address, since such an account in the +/// pool is a serious operator error that must halt the minter loudly. pub async fn read_verified_nonce( runtime: &R, account: Address, minter_address: Address, ) -> Result { - let nonce_account = get_nonce_account(runtime, account).await?; + let nonce_account = match get_nonce_account(runtime, account).await { + Ok(nonce_account) => nonce_account, + Err(GetNonceAccountError::NotOwnedBySystemProgram { owner, executable }) => panic!( + "BUG: nonce account {account} is owned by {owner} (executable: {executable}) instead of the system program" + ), + Err(error) => return Err(error), + }; if nonce_account.authority != minter_address { panic!( "BUG: nonce account {account} has authority {} instead of the minter address {minter_address}", diff --git a/minter/src/withdraw/nonce/tests.rs b/minter/src/withdraw/nonce/tests.rs index 34bf9267..1e6b815a 100644 --- a/minter/src/withdraw/nonce/tests.rs +++ b/minter/src/withdraw/nonce/tests.rs @@ -22,6 +22,23 @@ async fn should_return_the_nonce_value_of_an_account_with_the_minter_as_authorit assert_eq!(result, Ok(durable_nonce(1))); } +#[tokio::test] +#[should_panic(expected = "BUG: nonce account")] +async fn should_panic_if_the_account_is_not_owned_by_the_system_program() { + init_state(); + + let foreign_owner_account = sol_rpc_types::AccountInfo { + owner: MINTER_ADDRESS.to_string(), + ..nonce_account_info(MINTER_ADDRESS, 1) + }; + + let runtime = TestCanisterRuntime::new().add_stub_response(GetAccountInfoResult::Consistent( + Ok(Some(foreign_owner_account)), + )); + + let _ = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; +} + #[tokio::test] #[should_panic(expected = "BUG: nonce account")] async fn should_panic_if_the_authority_is_not_the_minter_address() { From 3fb2e180e3042472fc7f14309590fabdee56495a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 6 Oct 2026 13:29:09 +0000 Subject: [PATCH 3/9] fix(minter): return an error instead of trapping on a misconfigured nonce account Co-Authored-By: Claude Opus 5.5 --- minter/src/withdraw/nonce/mod.rs | 39 ++++++++++++++++-------------- minter/src/withdraw/nonce/tests.rs | 29 ++++++++++++++++------ 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/minter/src/withdraw/nonce/mod.rs b/minter/src/withdraw/nonce/mod.rs index 844069c6..81a092bf 100644 --- a/minter/src/withdraw/nonce/mod.rs +++ b/minter/src/withdraw/nonce/mod.rs @@ -4,33 +4,36 @@ use crate::{ }; use solana_address::Address; use solana_hash::Hash; +use thiserror::Error; #[cfg(test)] mod tests; -/// Reads the given durable nonce account and returns the nonce value it stores. -/// -/// # Panics -/// Panics if the account is not owned by the system program or if its nonce -/// authority is not the minter's main address, since such an account in the -/// pool is a serious operator error that must halt the minter loudly. +/// Reads the given durable nonce account and returns the nonce value it stores, +/// provided that the account is an initialized nonce account whose nonce +/// authority is the minter's main address. pub async fn read_verified_nonce( runtime: &R, account: Address, minter_address: Address, -) -> Result { - let nonce_account = match get_nonce_account(runtime, account).await { - Ok(nonce_account) => nonce_account, - Err(GetNonceAccountError::NotOwnedBySystemProgram { owner, executable }) => panic!( - "BUG: nonce account {account} is owned by {owner} (executable: {executable}) instead of the system program" - ), - Err(error) => return Err(error), - }; +) -> Result { + let nonce_account = get_nonce_account(runtime, account).await?; if nonce_account.authority != minter_address { - panic!( - "BUG: nonce account {account} has authority {} instead of the minter address {minter_address}", - nonce_account.authority - ); + return Err(ReadNonceError::ForeignAuthority { + authority: nonce_account.authority, + minter_address, + }); } Ok(nonce_account.nonce) } + +#[derive(Debug, PartialEq, Error)] +pub enum ReadNonceError { + #[error(transparent)] + GetNonceAccount(#[from] GetNonceAccountError), + #[error("Nonce authority {authority} instead of the minter address {minter_address}")] + ForeignAuthority { + authority: Address, + minter_address: Address, + }, +} diff --git a/minter/src/withdraw/nonce/tests.rs b/minter/src/withdraw/nonce/tests.rs index 1e6b815a..30ffb45e 100644 --- a/minter/src/withdraw/nonce/tests.rs +++ b/minter/src/withdraw/nonce/tests.rs @@ -1,10 +1,12 @@ use crate::{ + rpc::GetNonceAccountError, test_fixtures::{ MINTER_ADDRESS, durable_nonce, init_state, nonce_account_address, nonce_account_info, runtime::TestCanisterRuntime, }, - withdraw::nonce::read_verified_nonce, + withdraw::nonce::{ReadNonceError, read_verified_nonce}, }; +use assert_matches::assert_matches; use solana_address::Address; type GetAccountInfoResult = sol_rpc_types::MultiRpcResult>; @@ -23,8 +25,7 @@ async fn should_return_the_nonce_value_of_an_account_with_the_minter_as_authorit } #[tokio::test] -#[should_panic(expected = "BUG: nonce account")] -async fn should_panic_if_the_account_is_not_owned_by_the_system_program() { +async fn should_fail_if_the_account_is_not_owned_by_the_system_program() { init_state(); let foreign_owner_account = sol_rpc_types::AccountInfo { @@ -36,12 +37,18 @@ async fn should_panic_if_the_account_is_not_owned_by_the_system_program() { Ok(Some(foreign_owner_account)), )); - let _ = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; + let result = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; + + assert_matches!( + result, + Err(ReadNonceError::GetNonceAccount( + GetNonceAccountError::NotOwnedBySystemProgram { .. } + )) + ); } #[tokio::test] -#[should_panic(expected = "BUG: nonce account")] -async fn should_panic_if_the_authority_is_not_the_minter_address() { +async fn should_fail_if_the_authority_is_not_the_minter_address() { init_state(); let other_authority = Address::from([0x99; 32]); @@ -49,5 +56,13 @@ async fn should_panic_if_the_authority_is_not_the_minter_address() { Ok(Some(nonce_account_info(other_authority, 1))), )); - let _ = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; + let result = read_verified_nonce(&runtime, nonce_account_address(), MINTER_ADDRESS).await; + + assert_eq!( + result, + Err(ReadNonceError::ForeignAuthority { + authority: other_authority, + minter_address: MINTER_ADDRESS, + }) + ); } From 2e90858caa23880fb46a696bd5f0fff557f4aded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 6 Oct 2026 13:29:26 +0000 Subject: [PATCH 4/9] fix(minter): describe the expected nonce account metadata when it does not match Co-Authored-By: Claude Opus 5.5 --- minter/src/rpc/mod.rs | 8 +++++--- minter/src/rpc/tests.rs | 4 ++-- minter/src/withdraw/nonce/tests.rs | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index 1fa3e41a..78bbacc2 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -212,7 +212,7 @@ impl TryFrom for NonceAccount { fn try_from(account: UiAccount) -> Result { if account.owner != system_program::ID.to_string() || account.executable { - return Err(GetNonceAccountError::NotOwnedBySystemProgram { + return Err(GetNonceAccountError::UnexpectedAccountMetadata { owner: account.owner, executable: account.executable, }); @@ -246,8 +246,10 @@ pub enum GetNonceAccountError { InconsistentRpcResults, #[error("Nonce account not found")] AccountNotFound, - #[error("Account owned by {owner} (executable: {executable}) instead of the system program")] - NotOwnedBySystemProgram { owner: String, executable: bool }, + #[error( + "Expected a non-executable account owned by the system program, got owner {owner} (executable: {executable})" + )] + UnexpectedAccountMetadata { owner: String, executable: bool }, #[error("Not an initialized nonce account: {0}")] NotAnInitializedNonceAccount(String), } diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index e37c900d..ba518ae8 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -365,7 +365,7 @@ mod get_nonce_account_tests { } #[tokio::test] - async fn should_fail_if_account_not_owned_by_the_system_program() { + async fn should_fail_if_account_is_not_a_non_executable_system_program_account() { init_state(); let foreign_owner_account = sol_rpc_types::AccountInfo { @@ -385,7 +385,7 @@ mod get_nonce_account_tests { assert_matches!( result, - Err(GetNonceAccountError::NotOwnedBySystemProgram { .. }) + Err(GetNonceAccountError::UnexpectedAccountMetadata { .. }) ); } } diff --git a/minter/src/withdraw/nonce/tests.rs b/minter/src/withdraw/nonce/tests.rs index 30ffb45e..2ea10ec3 100644 --- a/minter/src/withdraw/nonce/tests.rs +++ b/minter/src/withdraw/nonce/tests.rs @@ -25,7 +25,7 @@ async fn should_return_the_nonce_value_of_an_account_with_the_minter_as_authorit } #[tokio::test] -async fn should_fail_if_the_account_is_not_owned_by_the_system_program() { +async fn should_fail_if_the_account_is_not_a_non_executable_system_program_account() { init_state(); let foreign_owner_account = sol_rpc_types::AccountInfo { @@ -42,7 +42,7 @@ async fn should_fail_if_the_account_is_not_owned_by_the_system_program() { assert_matches!( result, Err(ReadNonceError::GetNonceAccount( - GetNonceAccountError::NotOwnedBySystemProgram { .. } + GetNonceAccountError::UnexpectedAccountMetadata { .. } )) ); } From 1f3595beb9ce9ff8e5d271e882bf36337fe49bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 6 Oct 2026 13:29:48 +0000 Subject: [PATCH 5/9] test(minter): reject base64-valid account data that is not a nonce state Co-Authored-By: Claude Opus 5.5 --- minter/src/rpc/tests.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index ba518ae8..78848491 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -394,12 +394,18 @@ mod get_nonce_account_tests { async fn should_fail_if_account_is_not_an_initialized_nonce_account() { init_state(); - let undecodable_account = sol_rpc_types::AccountInfo { - data: AccountData::Binary("not base64!".to_string(), AccountEncoding::Base64), + let account_with_data = |data: &str| sol_rpc_types::AccountInfo { + data: AccountData::Binary(data.to_string(), AccountEncoding::Base64), ..nonce_account_info(MINTER_ADDRESS, 1) }; + let invalid_base64_account = account_with_data("not base64!"); + let invalid_nonce_state_account = account_with_data("AAAA"); - for account in [uninitialized_nonce_account_info(), undecodable_account] { + for account in [ + uninitialized_nonce_account_info(), + invalid_base64_account, + invalid_nonce_state_account, + ] { let runtime = TestCanisterRuntime::new() .add_stub_response(GetAccountInfoResult::Consistent(Ok(Some(account)))); From 8e67b22c2eb6a90fb2dc26c67c78c92b29d922e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 6 Oct 2026 13:30:21 +0000 Subject: [PATCH 6/9] fix(minter): reject legacy nonce accounts Co-Authored-By: Claude Opus 5.5 --- minter/src/rpc/mod.rs | 8 +++++++- minter/src/rpc/tests.rs | 16 +++++++++++++++- minter/src/test_fixtures/mod.rs | 19 +++++++++++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index 78bbacc2..b4349557 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -224,7 +224,11 @@ impl TryFrom for NonceAccount { })?; let versions: NonceVersions = bincode::deserialize(&data) .map_err(|e| GetNonceAccountError::NotAnInitializedNonceAccount(e.to_string()))?; - match versions.state() { + let state = match versions { + NonceVersions::Legacy(_) => return Err(GetNonceAccountError::LegacyNonceAccount), + NonceVersions::Current(state) => state, + }; + match *state { NonceState::Uninitialized => Err(GetNonceAccountError::NotAnInitializedNonceAccount( "the nonce account is uninitialized".to_string(), )), @@ -252,6 +256,8 @@ pub enum GetNonceAccountError { UnexpectedAccountMetadata { owner: String, executable: bool }, #[error("Not an initialized nonce account: {0}")] NotAnInitializedNonceAccount(String), + #[error("Legacy nonce account, which cannot back a durable transaction")] + LegacyNonceAccount, } pub async fn get_recent_block( diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 78848491..9f73525d 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -10,7 +10,8 @@ use crate::{ deposit::{ DEPOSIT_ADDRESS, legacy_deposit_transaction, legacy_deposit_transaction_signature, }, - durable_nonce, fetched, init_state, nonce_account_address, nonce_account_info, + durable_nonce, fetched, init_state, legacy_nonce_account_info, nonce_account_address, + nonce_account_info, runtime::TestCanisterRuntime, uninitialized_nonce_account_info, }, @@ -390,6 +391,19 @@ mod get_nonce_account_tests { } } + #[tokio::test] + async fn should_fail_if_account_is_a_legacy_nonce_account() { + init_state(); + + let runtime = TestCanisterRuntime::new().add_stub_response( + GetAccountInfoResult::Consistent(Ok(Some(legacy_nonce_account_info(MINTER_ADDRESS)))), + ); + + let result = get_nonce_account(&runtime, nonce_account_address()).await; + + assert_eq!(result, Err(GetNonceAccountError::LegacyNonceAccount)); + } + #[tokio::test] async fn should_fail_if_account_is_not_an_initialized_nonce_account() { init_state(); diff --git a/minter/src/test_fixtures/mod.rs b/minter/src/test_fixtures/mod.rs index a0b0cdcf..60a8eb17 100644 --- a/minter/src/test_fixtures/mod.rs +++ b/minter/src/test_fixtures/mod.rs @@ -205,10 +205,25 @@ pub fn uninitialized_nonce_account_info() -> sol_rpc_types::AccountInfo { nonce_account_info_in_state(NonceState::Uninitialized) } +/// Returns a `getAccountInfo` response for an initialized nonce account in the legacy format. +pub fn legacy_nonce_account_info(authority: Address) -> sol_rpc_types::AccountInfo { + nonce_account_info_in_versions(NonceVersions::Legacy(Box::new(NonceState::Initialized( + NonceData::new( + authority, + DurableNonce::from_blockhash(&seed_hash(1)), + FEE_PER_SIGNATURE, + ), + )))) +} + fn nonce_account_info_in_state(state: NonceState) -> sol_rpc_types::AccountInfo { + nonce_account_info_in_versions(NonceVersions::new(state)) +} + +fn nonce_account_info_in_versions(versions: NonceVersions) -> sol_rpc_types::AccountInfo { const SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111"; - let data = bincode::serialize(&NonceVersions::new(state)) - .expect("BUG: serializing a nonce account should succeed"); + let data = + bincode::serialize(&versions).expect("BUG: serializing a nonce account should succeed"); sol_rpc_types::AccountInfo { lamports: 1_447_680, space: data.len() as u64, From 3c9ac4f3a61b97ae598eb83425f397cab6b8e445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 6 Oct 2026 13:43:52 +0000 Subject: [PATCH 7/9] docs: require fresh nonce accounts on reinstallation Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/design.md b/docs/design.md index 5c552116..cb11ee3d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -11,8 +11,9 @@ - [3.2. Converting ckSOL to SOL](#32-converting-cksol-to-sol) - [3.2.1. Durable Nonce Accounts](#321-durable-nonce-accounts) - [3.2.2. Nonce Account Setup](#322-nonce-account-setup) - - [3.2.3. Submitting Withdrawal Requests](#323-submitting-withdrawal-requests) - - [3.2.4. Finalization and Resubmissions](#324-finalization-and-resubmissions) + - [3.2.3. Reinstallation](#323-reinstallation) + - [3.2.4. Submitting Withdrawal Requests](#324-submitting-withdrawal-requests) + - [3.2.5. Finalization and Resubmissions](#325-finalization-and-resubmissions) - [3.3. Fees & Minimum Swap Amounts](#33-fees--minimum-swap-amounts) - [3.3.1. ckSOL Ledger Fees](#331-cksol-ledger-fees) - [3.3.2. ckSOL Minter Fees](#332-cksol-minter-fees) @@ -70,7 +71,7 @@ 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. - [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 transaction has been finalized; a missing status alone never triggers a resubmission, and how it is handled depends on the kind of transaction, as described in [Section 3.2.4](#324-finalization-and-resubmissions). +- [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 transaction has been finalized; a missing status alone never triggers a resubmission, and how it is handled depends on the kind of transaction, as described in [Section 3.2.5](#325-finalization-and-resubmissions). - [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. - [sendTransaction](https://solana.com/docs/rpc/http/sendtransaction): Sends out the provided transaction. It requires the execution of the functions `getSlot` and `getBlock` to obtain a recent block hash, which must be part of the transaction, except for withdrawal transactions, which carry a durable nonce instead (see [Section 3.2.1](#321-durable-nonce-accounts)). @@ -323,7 +324,7 @@ Proposed values for the parameters are provided in this list: A user first obtains their deposit address with `get_deposit_address` and transfers SOL to it, as in the automated flow. The user then asks the ckSOL minter to *sweep* that address. The user does not identify individual Solana transactions: the ckSOL minter reads the balance of the deposit address, moves it to its main account, and mints ckSOL once that sweep is finalized. As a consequence, several transfers that are each below the minimum deposit amount are credited together once their sum exceeds it, and deposits from centralized exchanges, which typically do not show the transaction signature to the user, need nothing but the deposit address. -The manual flow is depicted in the following figure. The sweep reuses the transaction submission flow and the finalization flow described in [Section 3.1.4](#314-consolidation) and [Section 3.2.4](#324-finalization-and-resubmissions). +The manual flow is depicted in the following figure. The sweep reuses the transaction submission flow and the finalization flow described in [Section 3.1.4](#314-consolidation) and [Section 3.2.5](#325-finalization-and-resubmissions). ```mermaid sequenceDiagram @@ -379,7 +380,7 @@ If the balance is below the **minimum deposit amount** defined in [Section 3.3.3 **Sweep.** A timer, running at the same frequency as withdrawal processing, takes up to 10 queued deposits and submits one Solana transaction for them following the transaction submission flow of [Section 3.1.4](#314-consolidation). Each deposit address signs a transfer of its sweepable amount to the main account of the ckSOL minter. The deposit address with the largest sweepable amount is the fee payer; it is listed first in the transaction and its transfer is reduced by the transaction fee of `5000 * k` lamports for `k` signatures. Since the minimum deposit amount is larger than the fee of a full batch (see [Section 3.3.4](#334-parameter-constraints)), the fee payer always has enough funds, and every deposit address is left with the rent exemption threshold plus whatever arrived after the balance check. The deposits are recorded as *swept* together with the transaction signature. No ckSOL is minted yet. -**Finalization.** The sweep transaction is monitored like any other transaction, as described in [Section 3.2.4](#324-finalization-and-resubmissions). Once the transaction is finalized successfully, the deposits it contains are recorded as *finalized*: the SOL has moved to the main account, and the remaining steps only account for it. From this point on, `deposit_status` reports `Finalized` with the sweep signature until the mint lands. +**Finalization.** The sweep transaction is monitored like any other transaction, as described in [Section 3.2.5](#325-finalization-and-resubmissions). Once the transaction is finalized successfully, the deposits it contains are recorded as *finalized*: the SOL has moved to the main account, and the remaining steps only account for it. From this point on, `deposit_status` reports `Finalized` with the sweep signature until the mint lands. 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. @@ -392,7 +393,7 @@ A user can follow the progress with `deposit_status`, which takes a deposit id a Two failure cases exist before the transaction is finalized, and neither is retried 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. Contrary to withdrawals, the sweep is *not* resubmitted. Expiry is determined against the last valid block height persisted with the transaction, as described in [Section 3.2.4](#324-finalization-and-resubmissions), 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. +2. The transaction expires, i.e., its blockhash is no longer valid and it has no on-chain status. Contrary to withdrawals, the sweep is *not* resubmitted. Expiry is determined against the last valid block height persisted with the transaction, as described in [Section 3.2.5](#325-finalization-and-resubmissions), 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. @@ -477,9 +478,24 @@ Deposit sweeps continue to use recent block hashes. The double-pay hazard is spe The nonce accounts are set up **offline** by the operators. The ckSOL minter's main address can be computed before the minter is installed, since it only depends on the canister ID and the subnet's threshold key. Each nonce account is created with the rent exemption minimum of 1,447,680 lamports for its 80 bytes of state and initialized **directly** with the ckSOL minter's main address as the nonce authority: the account is never initialized under an operator's authority and handed over later, and it is never advanced before joining the pool, so the first `AdvanceNonceAccount` instruction ever executed on it comes from the ckSOL minter. This makes the ckSOL minter's value history of the account complete from its first read, which the soundness of the classification in [Section 3.2.1](#321-durable-nonce-accounts) relies on. The ckSOL minter itself never creates, funds, or closes nonce accounts. -The addresses of the pool are passed in the **init arguments**, and further addresses can be added through the **upgrade arguments**; the pool only ever grows, and removing an address is not supported. A removed account would keep the ckSOL minter's main address as its nonce authority while no longer being rejected by the withdrawal destination filter of [Section 3.2.3](#323-submitting-withdrawal-requests), turning it into a destination trap, and an account that ever left and re-joined the pool would break the precondition of [Section 3.2.1](#321-durable-nonce-accounts) that the ckSOL minter's value history of each account is complete. Nobody needs removal, so retiring a mis-configured account is accepted to require a code upgrade. Init and upgrade validation checks that the addresses are well-formed and pairwise distinct. That each address is an initialized nonce account with the ckSOL minter's main address as its authority is verified as part of the verification process of the NNS proposal carrying the init or upgrade arguments. Since the ckSOL minter reads each nonce account with `getAccountInfo` before using it anyway, it additionally asserts that the returned authority is the expected one; a failing assertion indicates a serious operator error. +The addresses of the pool are passed in the **init arguments**, and further addresses can be added through the **upgrade arguments**; the pool only ever grows, and removing an address is not supported. A removed account would keep the ckSOL minter's main address as its nonce authority while no longer being rejected by the withdrawal destination filter of [Section 3.2.4](#324-submitting-withdrawal-requests), turning it into a destination trap, and an account that ever left and re-joined the pool would break the precondition of [Section 3.2.1](#321-durable-nonce-accounts) that the ckSOL minter's value history of each account is complete. Nobody needs removal, so retiring a mis-configured account is accepted to require a code upgrade. Init and upgrade validation checks that the addresses are well-formed and pairwise distinct. That each address is an initialized nonce account with the ckSOL minter's main address as its authority is verified as part of the verification process of the NNS proposal carrying the init or upgrade arguments. Since the ckSOL minter reads each nonce account with `getAccountInfo` before using it anyway, it additionally checks that the account is a non-executable account owned by the system program, holding an initialized nonce in the current format with the ckSOL minter's main address as its authority. A failing check indicates an operator error: it is reported as an error, and the account is skipped for that round. -#### 3.2.3. Submitting Withdrawal Requests +#### 3.2.3. Reinstallation + +The nonce accounts of the pool are bound to one installation of the ckSOL minter. A reinstallation, as opposed to an upgrade, erases the event log and with it the nonce values bound so far, while the main address, which only depends on the canister ID and the threshold key, and therefore the nonce authority of the existing accounts remain the same. Reusing an existing account as is would be unsafe on two counts. First, a withdrawal transaction signed by the previous installation never expires and may still land: a new transaction bound to the same nonce value would then fail silently, and the new installation would mistake the advance for the landing of its own transaction. Second, the previously bound values are no longer known, so a stale read returning one of them could no longer be told apart from an advance (see [Section 3.2.1](#321-durable-nonce-accounts)). + +A reinstallation therefore uses **fresh nonce accounts**, set up as described in [Section 3.2.2](#322-nonce-account-setup). This is the default and requires no further mechanism. + +Existing accounts could nevertheless be reused with the following procedure, which is currently not implemented: + +1. The previous installation is stopped, so that the nonce value of each account no longer changes. +2. The current nonce value of each account, verifiable on chain as part of the NNS proposal verification, is passed in the init arguments together with the account's address. +3. Before first use, the ckSOL minter sends for each account a transaction consisting only of an `AdvanceNonceAccount` instruction, bound to that value. +4. The account becomes usable once a read returns a value other than the one passed in the init arguments. Whether the advance came from this transaction or from a pending transaction of the previous installation, no transaction signed before the reinstallation can land anymore, and the value history of the account starts with the value passed in the init arguments and the newly read one. + +The values bound by the previous installation before the current one remain unknown, so a read returning one of them would still be mistaken for an advance. Such a read would have to be stale by at least the time between stopping the previous installation and the reinstallation, i.e., the duration of the NNS proposal, and be agreed upon by the providers, which is not considered a realistic failure mode. + +#### 3.2.4. Submitting Withdrawal Requests Converting ckSOL back to SOL requires two user actions: The user must approve the ckSOL minter to withdraw from their ckSOL account by calling `icrc2_approve` and then call `withdraw`. Naturally, the user may approve the ckSOL minter to withdraw a large amount from their account so that multiple `withdraw` calls can be performed without the need to create new approvals. @@ -551,7 +567,7 @@ Since transactions are atomic, a single destination that cannot receive lamports Skipping preflight also means that a transaction the main account cannot pay for would land and fail, consuming its nonce and terminally failing the batch. The tracked balance prevents such a transaction from ever being built, and a tracked balance exceeding the actual balance would already be a critical bug, since it means the ckSOL supply is no longer fully backed. In addition, the batch selection keeps the rent exemption threshold back from the available balance, so a withdrawal transaction can never leave the main address with a balance below the rent exemption threshold, which Solana would equally reject at execution. -#### 3.2.4. Finalization and Resubmissions +#### 3.2.5. Finalization and Resubmissions The statuses of (sweep or withdrawal) transactions are checked on a timer by calling the `getSignatureStatuses` endpoint on the SOL RPC canister. The status of any accepted transaction is either `processed`, `confirmed`, or `finalized`. From a035815962f968a91e8762120734ff5615b1a002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 6 Oct 2026 15:44:55 +0000 Subject: [PATCH 8/9] test(minter): type the default nonce account as an address Co-Authored-By: Claude Opus 5.5 --- integration_tests/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 6bd5b79c..7bce5833 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -28,6 +28,7 @@ use pocket_ic::{PocketIcBuilder, RejectResponse, nonblocking::PocketIc}; use serde::de::DeserializeOwned; use sol_rpc_client::SolRpcClient; use sol_rpc_types::{Lamport, RpcAccess}; +use solana_address::address; use solana_transaction::Transaction; use std::{default::Default, env::var, fs, ops::Deref, path::PathBuf, time::Duration, vec}; @@ -128,7 +129,8 @@ impl Setup { pub const DEFAULT_MINIMUM_WITHDRAWAL_AMOUNT: 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: &'static str = "US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx"; + pub const DEFAULT_NONCE_ACCOUNT: solana_address::Address = + address!("US517G5965aydkZ46HS38QLi7UQiSojurfbQfKCELFx"); pub async fn new( make_live: PocketIcMode, From 20d266da7f807aaf2cd0edcf00925030eab32690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 7 Oct 2026 11:49:16 +0000 Subject: [PATCH 9/9] test(minter): cover transport and RPC failures when reading a nonce account Co-Authored-By: Claude Opus 5.5 --- minter/src/rpc/tests.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 9f73525d..b71d377f 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -365,6 +365,37 @@ mod get_nonce_account_tests { assert_eq!(result, Err(GetNonceAccountError::AccountNotFound)); } + #[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), + GetNonceAccountError::IcError(IcError::CallPerformFailed), + ), + ( + TestCanisterRuntime::new() + .add_stub_response(GetAccountInfoResult::Consistent(Err(rpc_error.clone()))), + GetNonceAccountError::RpcError(rpc_error.clone()), + ), + ( + TestCanisterRuntime::new() + .add_stub_response(GetAccountInfoResult::Inconsistent(inconsistent.clone())), + GetNonceAccountError::InconsistentRpcResults, + ), + ] { + let result = get_nonce_account(&runtime, nonce_account_address()).await; + + assert_eq!(result, Err(expected)); + } + } + #[tokio::test] async fn should_fail_if_account_is_not_a_non_executable_system_program_account() { init_state();