From 0995778f0368e1d3f78ad5f7f00632c126ebd3ec Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 7 Aug 2026 14:18:15 -0600 Subject: [PATCH 01/17] feat(core): confidential balance read and key derivation cheatcodes surfnet_setTokenAccount can write a confidential balance but nothing could read one back, so a confidential-transfer test had to assert on the credit counter and ciphertext non-emptiness instead of on an amount. surfnet_getConfidentialBalance decrypts the account: the available balance via the AES copy the extension keeps for the owner, the pending balance by decrypting the lo/hi ElGamal ciphertexts and recombining them. surfnet_deriveConfidentialKeys derives the ElGamal and AES keys server-side from the owner's keypair and the token account, so the cheatcodes can be driven with no client-side confidential-transfer crypto at all. --- crates/core/src/rpc/surfnet_cheatcodes.rs | 447 +++++++++++++++++- crates/core/src/types.rs | 128 ++++- .../kit/generated/ConfidentialBalanceKeys.ts | 22 + .../DeriveConfidentialKeysResponse.ts | 23 + .../GetConfidentialBalanceResponse.ts | 24 + .../surfpool-sdk/kit/generated/index.ts | 3 + .../surfpool-sdk/kit/generated/methods.ts | 2 + crates/sdk-node/surfpool-sdk/kit/types/api.ts | 11 + crates/types/src/types.rs | 65 ++- 9 files changed, 717 insertions(+), 8 deletions(-) create mode 100644 crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts create mode 100644 crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts create mode 100644 crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 154057c51..8d7e9ea39 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -20,7 +20,11 @@ use surfpool_types::{ GetStreamedAccountsResponse, GetSurfnetInfoResponse, Idl, OfflineAccountConfig, ResetAccountConfig, RpcProfileResultConfig, Scenario, SimnetCommand, StreamAccountConfig, StreamAccountsEntry, UiKeyedProfileResult, - types::{AccountUpdate, SetSomeAccount, SupplyUpdate, TokenAccountUpdate, UuidOrSignature}, + types::{ + AccountUpdate, ConfidentialBalanceKeys, DeriveConfidentialKeysResponse, + GetConfidentialBalanceResponse, SetSomeAccount, SupplyUpdate, TokenAccountUpdate, + UuidOrSignature, + }, }; use super::{RunloopContext, SurfnetRpcContext}; @@ -33,7 +37,7 @@ use crate::{ surfnet::{GetAccountResult, locker::SvmAccessContext}, types::{ TimeTravelConfig, TokenAccount, build_confidential_token_account_data, - mint_has_transfer_fee_config, + decrypt_confidential_balances, derive_confidential_keys, mint_has_transfer_fee_config, }, }; @@ -1213,6 +1217,133 @@ pub trait SurfnetCheatcodes { fn get_surfnet_info(&self, meta: Self::Metadata) -> Result>; + /// A cheat code to read the decrypted confidential-transfer balances of a Token-2022 token account. + /// + /// This is the read counterpart to `surfnet_setTokenAccount`'s `confidential` field: with both, + /// a confidential-transfer test can set a balance up, act on it, and assert on the result without + /// hand-rolling ElGamal/AES decryption client-side. + /// + /// ## Parameters + /// - `token_account`: The base-58 encoded address of the token account to read. + /// - `keys`: The owner's confidential secrets. At least one is required: + /// - `aesKey` (base58/base64, 16 bytes): decrypts the available balance. + /// - `elgamalSecretKey` (base58/base64, 32 bytes): decrypts the pending balance. This is the + /// ElGamal *secret* key, not the public key stored on the account. + /// + /// `surfnet_deriveConfidentialKeys` returns both in the expected form. + /// + /// ## Returns + /// A `RpcResponse` with the decrypted `available` and `pending` + /// amounts (each `null` when the corresponding key was not supplied), plus the account's + /// `pendingBalanceCreditCounter` — non-zero means an `ApplyPendingBalance` is still needed + /// before the pending amount shows up in `available`. + /// + /// ## Example Request + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "id": 1, + /// "method": "surfnet_getConfidentialBalance", + /// "params": [ + /// "4EXSeLGxVBpAZwq7vm6evLdewpcvE2H56fpqL2pPiLFa", + /// { + /// "aesKey": "", + /// "elgamalSecretKey": "" + /// } + /// ] + /// } + /// ``` + /// + /// ## Example Response + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "result": { + /// "context": { + /// "slot": 123456789, + /// "apiVersion": "2.3.8" + /// }, + /// "value": { + /// "available": 10, + /// "pending": 0, + /// "pendingBalanceCreditCounter": 0 + /// } + /// }, + /// "id": 1 + /// } + /// ``` + /// + /// # Notes + /// Requires the Token-2022 program and an account carrying the confidential-transfer extension. + #[rpc(meta, name = "surfnet_getConfidentialBalance")] + fn get_confidential_balance( + &self, + meta: Self::Metadata, + token_account: String, + keys: ConfidentialBalanceKeys, + ) -> BoxFuture>>; + + /// A cheat code to derive an owner's confidential-transfer keys for a token account. + /// + /// The confidential cheatcodes take an `elgamalPubkey` and an `aesKey`, which a client would + /// normally derive with an external confidential-transfer SDK. Deriving them here lets a test + /// drive the whole confidential suite with no client-side crypto dependency. + /// + /// ## Parameters + /// - `keypair`: The owner's 64-byte Solana keypair, base58 or base64 encoded. + /// - `token_account`: The base-58 encoded address of the token account the keys are for. Keys are + /// derived per token account, so this must be the same address later passed to + /// `surfnet_getConfidentialBalance`. + /// + /// ## Returns + /// A `RpcResponse` with base58 `elgamalPubkey` (for + /// `surfnet_setTokenAccount`), `elgamalSecretKey` (for `surfnet_getConfidentialBalance`), and + /// `aesKey` (for both). + /// + /// ## Example Request + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "id": 1, + /// "method": "surfnet_deriveConfidentialKeys", + /// "params": [ + /// "", + /// "4EXSeLGxVBpAZwq7vm6evLdewpcvE2H56fpqL2pPiLFa" + /// ] + /// } + /// ``` + /// + /// ## Example Response + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "result": { + /// "context": { + /// "slot": 123456789, + /// "apiVersion": "2.3.8" + /// }, + /// "value": { + /// "elgamalPubkey": "", + /// "elgamalSecretKey": "", + /// "aesKey": "" + /// } + /// }, + /// "id": 1 + /// } + /// ``` + /// + /// # Notes + /// The derivation matches what a confidential-transfer client computes from the same keypair, so + /// keys produced here interoperate with keys derived off-chain. The keypair is used only to sign + /// the derivation seeds; it is not stored. + #[rpc(meta, name = "surfnet_deriveConfidentialKeys")] + fn derive_confidential_keys( + &self, + meta: Self::Metadata, + keypair: String, + token_account: String, + ) -> Result>; + /// A "cheat code" method for developers to write program data at a specified offset in Surfpool. /// /// This method allows developers to write large Solana programs by sending data in chunks, @@ -2236,6 +2367,75 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { }) } + fn get_confidential_balance( + &self, + meta: Self::Metadata, + token_account_str: String, + keys: ConfidentialBalanceKeys, + ) -> BoxFuture>> { + let token_account = match verify_pubkey(&token_account_str) { + Ok(res) => res, + Err(e) => return e.into(), + }; + + if keys.aes_key.is_none() && keys.elgamal_secret_key.is_none() { + return Box::pin(future::err(Error::invalid_params( + "at least one of aesKey (available balance) or elgamalSecretKey (pending balance) is required".to_string(), + ))); + } + + let SurfnetRpcContext { + svm_locker, + remote_ctx, + } = match meta.get_rpc_context(CommitmentConfig::confirmed()) { + Ok(res) => res, + Err(e) => return e.into(), + }; + + Box::pin(async move { + let SvmAccessContext { + slot, + inner: account_result, + .. + } = svm_locker + .get_account(&remote_ctx, &token_account, None) + .await?; + svm_locker.write_account_update(account_result.clone()); + + let account = account_result.map_account()?; + if account.owner != spl_token_2022_interface::id() { + return Err(Error::invalid_params(format!( + "{token_account} is not owned by the Token-2022 program (owner: {})", + account.owner + ))); + } + + let balance = decrypt_confidential_balances(&account.data, &keys) + .map_err(Error::invalid_params)?; + + Ok(RpcResponse { + context: RpcResponseContext::new(slot), + value: balance, + }) + }) + } + + fn derive_confidential_keys( + &self, + meta: Self::Metadata, + keypair: String, + token_account_str: String, + ) -> Result> { + let token_account = verify_pubkey(&token_account_str)?; + let svm_locker = meta.get_svm_locker()?; + let keys = + derive_confidential_keys(&keypair, &token_account).map_err(Error::invalid_params)?; + Ok(RpcResponse { + context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()), + value: keys, + }) + } + fn write_program( &self, meta: Self::Metadata, @@ -5091,4 +5291,247 @@ mod tests { "receive-only account's decryptable balance should decrypt to 0" ); } + + /// The whole point of the pair: derive keys, set a confidential balance, read it + /// back — with no client-side crypto anywhere in the test. + #[tokio::test(flavor = "multi_thread")] + async fn test_confidential_balance_round_trip() { + use surfpool_types::types::ConfidentialTransferAccountUpdate; + + let client = TestSetup::new(SurfnetCheatcodesRpc::empty()); + let owner = Keypair::new(); + let mint = Keypair::new(); + let token_program = spl_token_2022_interface::id(); + let token_account = get_associated_token_address_with_program_id( + &owner.pubkey(), + &mint.pubkey(), + &token_program, + ); + + let keys = client + .rpc + .derive_confidential_keys( + Some(client.context.clone()), + bs58::encode(owner.to_bytes()).into_string(), + token_account.to_string(), + ) + .expect("key derivation should succeed") + .value; + + client + .rpc + .set_token_account( + Some(client.context.clone()), + owner.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: keys.elgamal_pubkey.clone(), + aes_key: Some(keys.aes_key.clone()), + amount: Some(10), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("set_token_account should succeed"); + + let result = client + .rpc + .get_confidential_balance( + Some(client.context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(keys.aes_key), + elgamal_secret_key: Some(keys.elgamal_secret_key), + }, + ) + .await + .expect("get_confidential_balance should succeed") + .value; + + let balance = result.available.expect("available balance should decrypt"); + assert_eq!(balance, 10); + assert_eq!( + result.pending, + Some(0), + "a freshly configured account has nothing pending" + ); + assert_eq!(result.pending_balance_credit_counter, 0); + } + + /// The pending balance is split across two ciphertexts to keep each within the + /// u32 discrete-log decode range, so the recombination is only exercised by a + /// value that spans both halves. + #[test] + fn test_confidential_pending_balance_recombines_lo_and_hi() { + use bytemuck::bytes_of; + use spl_token_2022_interface::{ + extension::{ + BaseStateWithExtensionsMut, StateWithExtensionsMut, + confidential_transfer::ConfidentialTransferAccount, + }, + solana_zk_sdk::encryption::{elgamal::ElGamalKeypair, pod::elgamal::PodElGamalPubkey}, + }; + use surfpool_types::types::ConfidentialTransferAccountUpdate; + + let elgamal = ElGamalKeypair::new_rand(); + let aes_key = [3u8; 16]; + let mut account_data = build_confidential_token_account_data( + &spl_token_2022_interface::state::Account { + state: spl_token_2022_interface::state::AccountState::Initialized, + ..Default::default() + }, + false, + &ConfidentialTransferAccountUpdate { + elgamal_pubkey: bs58::encode(bytes_of(&PodElGamalPubkey::from( + elgamal.pubkey_owned(), + ))) + .into_string(), + aes_key: Some(bs58::encode(aes_key).into_string()), + ..Default::default() + }, + ) + .expect("confidential account should build"); + + // 70_000 = 4_464 in the low 16 bits + 1 in the high half. + let (pending_lo, pending_hi) = (4_464u64, 1u64); + let mut state = StateWithExtensionsMut::::unpack( + &mut account_data, + ) + .expect("account should unpack"); + let ext = state + .get_extension_mut::() + .expect("confidential extension should be present"); + ext.pending_balance_lo = elgamal.pubkey().encrypt_u64(pending_lo).into(); + ext.pending_balance_hi = elgamal.pubkey().encrypt_u64(pending_hi).into(); + + let balance = decrypt_confidential_balances( + &account_data, + &ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some( + bs58::encode(<[u8; 32]>::from(elgamal.secret())).into_string(), + ), + }, + ) + .expect("pending balance should decrypt"); + + assert_eq!(balance.pending, Some(70_000)); + assert_eq!( + balance.available, None, + "available should be absent when no aesKey is supplied" + ); + } + + /// Keys are scoped to a token account, so the same wallet gets different keys per + /// account — and the same ones every time for a given account. + #[tokio::test(flavor = "multi_thread")] + async fn test_derive_confidential_keys_are_deterministic_and_account_scoped() { + let client = TestSetup::new(SurfnetCheatcodesRpc::empty()); + let owner = bs58::encode(Keypair::new().to_bytes()).into_string(); + let token_account = Pubkey::new_unique(); + + let derive = |token_account: Pubkey| { + client + .rpc + .derive_confidential_keys( + Some(client.context.clone()), + owner.clone(), + token_account.to_string(), + ) + .expect("key derivation should succeed") + .value + }; + + assert_eq!(derive(token_account), derive(token_account)); + assert_ne!(derive(token_account), derive(Pubkey::new_unique())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_get_confidential_balance_rejects_bad_input() { + use surfpool_types::types::ConfidentialTransferAccountUpdate; + + let client = TestSetup::new(SurfnetCheatcodesRpc::empty()); + let owner = Keypair::new(); + let mint = Keypair::new(); + let token_program = spl_token_2022_interface::id(); + let token_account = get_associated_token_address_with_program_id( + &owner.pubkey(), + &mint.pubkey(), + &token_program, + ); + + let keys = client + .rpc + .derive_confidential_keys( + Some(client.context.clone()), + bs58::encode(owner.to_bytes()).into_string(), + token_account.to_string(), + ) + .expect("key derivation should succeed") + .value; + + client + .rpc + .set_token_account( + Some(client.context.clone()), + owner.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: keys.elgamal_pubkey, + aes_key: Some(keys.aes_key), + amount: Some(10), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("set_token_account should succeed"); + + assert!( + client + .rpc + .get_confidential_balance( + Some(client.context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys::default(), + ) + .await + .is_err(), + "reading with no keys at all should fail rather than return two nulls" + ); + + // A wrong AES key must fail the ciphertext's authentication tag rather than + // silently decrypt to some other number. + let wrong_keys = client + .rpc + .derive_confidential_keys( + Some(client.context.clone()), + bs58::encode(Keypair::new().to_bytes()).into_string(), + token_account.to_string(), + ) + .expect("key derivation should succeed") + .value; + assert!( + client + .rpc + .get_confidential_balance( + Some(client.context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(wrong_keys.aes_key), + elgamal_secret_key: None, + }, + ) + .await + .is_err(), + "the wrong aesKey should fail rather than decrypt" + ); + } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 8634f304d..3487c5de8 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -13,6 +13,7 @@ use solana_account_decoder::{ }; use solana_clock::{Epoch, Slot}; use solana_hash::Hash; +use solana_keypair::Keypair; use solana_message::{ AccountKeys, VersionedMessage, v0::{LoadedAddresses, LoadedMessage, MessageAddressTableLookup}, @@ -41,21 +42,26 @@ use solana_transaction_status::{ use spl_token_2022_interface::{ extension::{ BaseStateWithExtensions, BaseStateWithExtensionsMut, ExtensionType, StateWithExtensions, - StateWithExtensionsMut, confidential_transfer::ConfidentialTransferAccount, + StateWithExtensionsMut, + confidential_transfer::{ConfidentialTransferAccount, PENDING_BALANCE_LO_BIT_LENGTH}, confidential_transfer_fee::ConfidentialTransferFeeAmount, - interest_bearing_mint::InterestBearingConfig, scaled_ui_amount::ScaledUiAmountConfig, + interest_bearing_mint::InterestBearingConfig, + scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::TransferFeeConfig, }, solana_zk_sdk::encryption::{ - auth_encryption::AeKey, - elgamal::ElGamalPubkey, + auth_encryption::{AeCiphertext, AeKey}, + elgamal::{ElGamalCiphertext, ElGamalKeypair, ElGamalPubkey, ElGamalSecretKey}, pod::{ auth_encryption::PodAeCiphertext, elgamal::{PodElGamalCiphertext, PodElGamalPubkey}, }, }, }; -use surfpool_types::types::ConfidentialTransferAccountUpdate; +use surfpool_types::types::{ + ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, DeriveConfidentialKeysResponse, + GetConfidentialBalanceResponse, +}; use txtx_addon_kit::indexmap::IndexMap; use crate::{ @@ -1339,6 +1345,118 @@ pub fn build_confidential_token_account_data( Ok(buffer) } +/// Decrypt the confidential balances held on a Token-2022 token account. +/// +/// Backs the `surfnet_getConfidentialBalance` cheatcode, the read half of the +/// confidential test loop whose write half is `surfnet_setTokenAccount`. Each +/// balance has its own key because the extension stores them differently: +/// - **available** is read from `decryptable_available_balance`, the AES copy the +/// program maintains for the owner. The authoritative `available_balance` is an +/// ElGamal ciphertext over a full u64 and is not recoverable by the u32 +/// discrete-log decode, so the AES copy is the only read path. +/// - **pending** is read from the `lo`/`hi` ElGamal ciphertexts, each within the +/// u32 decode range, and recombined. +pub fn decrypt_confidential_balances( + account_data: &[u8], + keys: &ConfidentialBalanceKeys, +) -> Result { + let state = + StateWithExtensions::::unpack(account_data) + .map_err(|e| format!("not a Token-2022 token account: {e}"))?; + let ext = state + .get_extension::() + .map_err(|e| format!("account has no confidential-transfer extension: {e}"))?; + + let available = match keys.aes_key.as_deref() { + Some(aes_key) => { + let bytes = decode_confidential_key(aes_key, 16).map_err(|e| format!("aesKey: {e}"))?; + let key = AeKey::try_from(bytes.as_slice()) + .map_err(|e| format!("aesKey: invalid AES key ({e})"))?; + let ciphertext = AeCiphertext::try_from(ext.decryptable_available_balance) + .map_err(|e| format!("available balance is not a valid AES ciphertext: {e}"))?; + Some(key.decrypt(&ciphertext).ok_or_else(|| { + "aesKey does not decrypt this account's available balance".to_string() + })?) + } + None => None, + }; + + let pending = match keys.elgamal_secret_key.as_deref() { + Some(secret_key) => { + let bytes = decode_confidential_key(secret_key, 32) + .map_err(|e| format!("elgamalSecretKey: {e}"))?; + let secret = ElGamalSecretKey::try_from(bytes.as_slice()) + .map_err(|e| format!("elgamalSecretKey: invalid ElGamal secret key ({e})"))?; + let lo = decrypt_pending_balance(ext.pending_balance_lo, &secret, "lo")?; + let hi = decrypt_pending_balance(ext.pending_balance_hi, &secret, "hi")?; + Some( + hi.checked_shl(PENDING_BALANCE_LO_BIT_LENGTH) + .and_then(|hi| hi.checked_add(lo)) + .ok_or_else(|| "pending balance overflows u64".to_string())?, + ) + } + None => None, + }; + + Ok(GetConfidentialBalanceResponse { + available, + pending, + pending_balance_credit_counter: ext.pending_balance_credit_counter.into(), + }) +} + +fn decrypt_pending_balance( + ciphertext: PodElGamalCiphertext, + secret: &ElGamalSecretKey, + half: &str, +) -> Result { + let ciphertext = ElGamalCiphertext::try_from(ciphertext) + .map_err(|e| format!("pending balance {half} is not a valid ElGamal ciphertext: {e}"))?; + secret.decrypt_u32(&ciphertext).ok_or_else(|| { + format!("elgamalSecretKey does not decrypt this account's pending balance {half}") + }) +} + +/// The per-token-account public seed the confidential keys are derived over. The +/// signature of this seed — not the wallet's private key, which hardware signers +/// never expose — is what the key derivation is hashed from. +const CONFIDENTIAL_KEY_SEED_PREFIX: &[u8] = b"solana-conf-bal/v1"; + +/// Derive an owner's confidential-transfer keys for a token account. +/// +/// Backs the `surfnet_deriveConfidentialKeys` cheatcode. Doing this server-side is +/// what lets the confidential cheatcodes be used with no client-side crypto +/// dependency at all: the caller hands over the keypair its test already holds and +/// gets back the exact keys a confidential client would have derived, ready to pass +/// to `surfnet_setTokenAccount` and `surfnet_getConfidentialBalance`. +/// +/// The ElGamal and AES keys are derived from signatures over two different messages, +/// so this takes the keypair rather than a single pre-computed signature. +pub fn derive_confidential_keys( + keypair: &str, + token_account: &Pubkey, +) -> Result { + let keypair_bytes = + decode_confidential_key(keypair, 64).map_err(|e| format!("keypair: {e}"))?; + let keypair = Keypair::try_from(keypair_bytes.as_slice()) + .map_err(|e| format!("keypair: invalid Solana keypair ({e})"))?; + + let public_seed = [CONFIDENTIAL_KEY_SEED_PREFIX, token_account.as_ref()].concat(); + let elgamal = ElGamalKeypair::new_from_signer(&keypair, &public_seed) + .map_err(|e| format!("failed to derive ElGamal keypair: {e}"))?; + let aes_key = AeKey::new_from_signer(&keypair, &public_seed) + .map_err(|e| format!("failed to derive AES key: {e}"))?; + + let elgamal_secret_key: [u8; 32] = elgamal.secret().into(); + let aes_key: [u8; 16] = aes_key.into(); + Ok(DeriveConfidentialKeysResponse { + elgamal_pubkey: bs58::encode(bytes_of(&PodElGamalPubkey::from(elgamal.pubkey_owned()))) + .into_string(), + elgamal_secret_key: bs58::encode(elgamal_secret_key).into_string(), + aes_key: bs58::encode(aes_key).into_string(), + }) +} + impl_token_program_packable_serde!( TokenAccount, spl_token_2022_interface::state::Account, diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts new file mode 100644 index 000000000..fa37bcd73 --- /dev/null +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts @@ -0,0 +1,22 @@ +// @generated by ts-rs from the Rust types in crates/types. +// Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The owner's confidential-transfer secrets, passed to + * `surfnet_getConfidentialBalance` so it can decrypt the account. + * + * Each key unlocks a different half of the balance, so they are independently + * optional: a caller holding only one still gets the half it can read. + */ +export type ConfidentialBalanceKeys = { +/** + * The owner's AES key (base58 or base64, 16 bytes). Decrypts the available + * balance. + */ +aesKey?: string, +/** + * The owner's ElGamal *secret* key (base58 or base64, 32 bytes) — not the + * public key stored on the account. Decrypts the pending balance. + */ +elgamalSecretKey?: string, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts new file mode 100644 index 000000000..d041d3d54 --- /dev/null +++ b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts @@ -0,0 +1,23 @@ +// @generated by ts-rs from the Rust types in crates/types. +// Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The confidential-transfer keys derived for a token account, returned by + * `surfnet_deriveConfidentialKeys`. All three are base58-encoded and feed + * directly into the other confidential cheatcodes. + */ +export type DeriveConfidentialKeysResponse = { +/** + * The ElGamal public key — `surfnet_setTokenAccount`'s `elgamalPubkey`. + */ +elgamalPubkey: string, +/** + * The ElGamal secret key — `surfnet_getConfidentialBalance`'s + * `elgamalSecretKey`. + */ +elgamalSecretKey: string, +/** + * The AES key — the `aesKey` of both of the above. + */ +aesKey: string, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts new file mode 100644 index 000000000..a7cac1718 --- /dev/null +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts @@ -0,0 +1,24 @@ +// @generated by ts-rs from the Rust types in crates/types. +// Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The decrypted confidential-transfer balances of a Token-2022 token account, + * returned by `surfnet_getConfidentialBalance`. + */ +export type GetConfidentialBalanceResponse = { +/** + * The available (spendable) balance, or `null` if no `aesKey` was supplied. + */ +available?: number | bigint, +/** + * The pending (credited but not yet applied) balance, or `null` if no + * `elgamalSecretKey` was supplied. + */ +pending?: number | bigint, +/** + * How many confidential credits are sitting in the pending balance. Non-zero + * means an `ApplyPendingBalance` is required before they show up in + * `available`. + */ +pendingBalanceCreditCounter: number | bigint, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/index.ts b/crates/sdk-node/surfpool-sdk/kit/generated/index.ts index dbbe0cd89..e610e03d1 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/index.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/index.ts @@ -4,10 +4,13 @@ export type * from "./AccountAddress.js"; export type * from "./AccountSnapshot.js"; export type * from "./AccountUpdate.js"; export type * from "./CheatcodeControlConfig.js"; +export type * from "./ConfidentialBalanceKeys.js"; export type * from "./ConfidentialTransferAccountUpdate.js"; +export type * from "./DeriveConfidentialKeysResponse.js"; export type * from "./ExportSnapshotConfig.js"; export type * from "./ExportSnapshotFilter.js"; export type * from "./ExportSnapshotScope.js"; +export type * from "./GetConfidentialBalanceResponse.js"; export type * from "./GetStreamedAccountsResponse.js"; export type * from "./GetSurfnetInfoResponse.js"; export type * from "./OfflineAccountConfig.js"; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/methods.ts b/crates/sdk-node/surfpool-sdk/kit/generated/methods.ts index 7f4613f17..a60473781 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/methods.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/methods.ts @@ -2,10 +2,12 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. export const SURFNET_CHEATCODE_METHODS = [ "surfnet_cloneProgramAccount", + "surfnet_deriveConfidentialKeys", "surfnet_disableCheatcode", "surfnet_enableCheatcode", "surfnet_exportSnapshot", "surfnet_getActiveIdl", + "surfnet_getConfidentialBalance", "surfnet_getLocalSignatures", "surfnet_getProfileResultsByTag", "surfnet_getStreamedAccounts", diff --git a/crates/sdk-node/surfpool-sdk/kit/types/api.ts b/crates/sdk-node/surfpool-sdk/kit/types/api.ts index 0e29dbea3..5bb7ecf97 100644 --- a/crates/sdk-node/surfpool-sdk/kit/types/api.ts +++ b/crates/sdk-node/surfpool-sdk/kit/types/api.ts @@ -4,7 +4,10 @@ import type { AccountSnapshot, AccountUpdate, CheatcodeControlConfig, + ConfidentialBalanceKeys, + DeriveConfidentialKeysResponse, ExportSnapshotConfig, + GetConfidentialBalanceResponse, GetStreamedAccountsResponse, GetSurfnetInfoResponse, OfflineAccountConfig, @@ -111,6 +114,12 @@ export type SurfnetSetAccountApi = { export type SurfnetSetTokenAccountApi = { setTokenAccount(owner: Address, mint: Address, update: TokenAccountUpdate, tokenProgram?: Address): null; }; +export type SurfnetGetConfidentialBalanceApi = { + getConfidentialBalance(tokenAccount: Address, keys: ConfidentialBalanceKeys): GetConfidentialBalanceResponse; +}; +export type SurfnetDeriveConfidentialKeysApi = { + deriveConfidentialKeys(keypair: string, tokenAccount: Address): DeriveConfidentialKeysResponse; +}; export type SurfnetResetAccountApi = { resetAccount(pubkey: Address, config?: ResetAccountConfig): null; }; @@ -188,10 +197,12 @@ export type SurfnetGetLocalSignaturesApi = { * (it is re-added on the wire by the request transformer). */ export type SurfnetCheatcodesApi = SurfnetCloneProgramAccountApi & + SurfnetDeriveConfidentialKeysApi & SurfnetDisableCheatcodeApi & SurfnetEnableCheatcodeApi & SurfnetExportSnapshotApi & SurfnetGetActiveIdlApi & + SurfnetGetConfidentialBalanceApi & SurfnetGetLocalSignaturesApi & SurfnetGetProfileResultsByTagApi & SurfnetGetStreamedAccountsApi & diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index ae86afd2d..2ebf73403 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -1224,6 +1224,67 @@ pub struct ConfidentialTransferAccountUpdate { pub maximum_pending_balance_credit_counter: Option, } +/// The owner's confidential-transfer secrets, passed to +/// `surfnet_getConfidentialBalance` so it can decrypt the account. +/// +/// Each key unlocks a different half of the balance, so they are independently +/// optional: a caller holding only one still gets the half it can read. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr( + feature = "ts-bindings", + derive(ts_rs::TS), + ts(export, optional_fields) +)] +pub struct ConfidentialBalanceKeys { + /// The owner's AES key (base58 or base64, 16 bytes). Decrypts the available + /// balance. + pub aes_key: Option, + /// The owner's ElGamal *secret* key (base58 or base64, 32 bytes) — not the + /// public key stored on the account. Decrypts the pending balance. + pub elgamal_secret_key: Option, +} + +/// The decrypted confidential-transfer balances of a Token-2022 token account, +/// returned by `surfnet_getConfidentialBalance`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr( + feature = "ts-bindings", + derive(ts_rs::TS), + ts(export, optional_fields) +)] +pub struct GetConfidentialBalanceResponse { + /// The available (spendable) balance, or `null` if no `aesKey` was supplied. + #[cfg_attr(feature = "ts-bindings", ts(optional, type = "number | bigint"))] + pub available: Option, + /// The pending (credited but not yet applied) balance, or `null` if no + /// `elgamalSecretKey` was supplied. + #[cfg_attr(feature = "ts-bindings", ts(optional, type = "number | bigint"))] + pub pending: Option, + /// How many confidential credits are sitting in the pending balance. Non-zero + /// means an `ApplyPendingBalance` is required before they show up in + /// `available`. + #[cfg_attr(feature = "ts-bindings", ts(type = "number | bigint"))] + pub pending_balance_credit_counter: u64, +} + +/// The confidential-transfer keys derived for a token account, returned by +/// `surfnet_deriveConfidentialKeys`. All three are base58-encoded and feed +/// directly into the other confidential cheatcodes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))] +pub struct DeriveConfidentialKeysResponse { + /// The ElGamal public key — `surfnet_setTokenAccount`'s `elgamalPubkey`. + pub elgamal_pubkey: String, + /// The ElGamal secret key — `surfnet_getConfidentialBalance`'s + /// `elgamalSecretKey`. + pub elgamal_secret_key: String, + /// The AES key — the `aesKey` of both of the above. + pub aes_key: String, +} + // token supply update for set supply method in SVM tricks #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] #[cfg_attr( @@ -1715,12 +1776,14 @@ pub enum CheatcodeFilter { /// `surfpool-core/src/rpc/surfnet_cheatcodes.rs` asserts it matches the /// methods actually registered by the `SurfnetCheatcodes` trait, so adding, /// removing, or renaming a cheatcode without updating this list fails CI. -pub const SURFNET_CHEATCODE_METHODS: [&str; 26] = [ +pub const SURFNET_CHEATCODE_METHODS: [&str; 28] = [ "surfnet_cloneProgramAccount", + "surfnet_deriveConfidentialKeys", "surfnet_disableCheatcode", "surfnet_enableCheatcode", "surfnet_exportSnapshot", "surfnet_getActiveIdl", + "surfnet_getConfidentialBalance", "surfnet_getLocalSignatures", "surfnet_getProfileResultsByTag", "surfnet_getStreamedAccounts", From 274a2416c54fc09a4ebf3df75be8a43e35f516be Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 7 Aug 2026 14:45:18 -0600 Subject: [PATCH 02/17] test(core): cover the wrong-key rejection on the pending balance path A foreign elgamalSecretKey fails to decrypt a non-zero pending balance, but nothing exercised that: the one test holding a non-zero pending balance only ever used the matching key. The round-trip test's pending assertion is relabelled rather than dropped. The setter writes an all-zero pending ciphertext, which is the identity point and decodes to 0 under any key, so that assertion pins the response shape and says nothing about decryption. --- crates/core/src/rpc/surfnet_cheatcodes.rs | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 8d7e9ea39..b534584c8 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -5354,10 +5354,13 @@ mod tests { let balance = result.available.expect("available balance should decrypt"); assert_eq!(balance, 10); + // Shape check, not a decryption check: the setter writes an all-zero pending + // ciphertext, which is the identity point and decodes to 0 under any key. The + // decrypt path is covered by test_confidential_pending_balance_recombines_lo_and_hi. assert_eq!( result.pending, Some(0), - "a freshly configured account has nothing pending" + "pending should be present when an elgamalSecretKey is supplied" ); assert_eq!(result.pending_balance_credit_counter, 0); } @@ -5424,6 +5427,25 @@ mod tests { balance.available, None, "available should be absent when no aesKey is supplied" ); + + // A foreign ElGamal secret key must fail rather than decrypt to some other + // number. This only holds against a non-zero pending balance: the all-zero + // ciphertext a freshly configured account carries is the identity point and + // decodes to 0 under any key. + let foreign = ElGamalKeypair::new_rand(); + assert!( + decrypt_confidential_balances( + &account_data, + &ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some( + bs58::encode(<[u8; 32]>::from(foreign.secret())).into_string(), + ), + }, + ) + .is_err(), + "a foreign elgamalSecretKey should fail rather than decrypt" + ); } /// Keys are scoped to a token account, so the same wallet gets different keys per From e312f4c728fcaaa4d00d49a20417bfcfa1eb6320 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 7 Aug 2026 15:50:04 -0600 Subject: [PATCH 03/17] fix(types): declare the confidential balances nullable, not optional GetConfidentialBalanceResponse carried ts(export, optional_fields), so the generated binding declared available and pending as `field?: T`. Neither field is skipped on serialize, so an unsupplied key comes back as an explicit `"available": null`, not as an absent key. A caller holding one of the two keys - the case the method exists to serve - gets a value the binding says cannot occur. optional_fields is right for the request types next to it, where a client omits what it does not set. It is wrong here. RunbookExecutionStatusReport is the response-side precedent: plain ts(export) with an explicit `bigint | null` on the Option field. pendingBalanceCreditCounter is not an Option and is unaffected. --- .../kit/generated/GetConfidentialBalanceResponse.ts | 4 ++-- crates/types/src/types.rs | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts index a7cac1718..7a123cbc8 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts @@ -10,12 +10,12 @@ export type GetConfidentialBalanceResponse = { /** * The available (spendable) balance, or `null` if no `aesKey` was supplied. */ -available?: number | bigint, +available: number | bigint | null, /** * The pending (credited but not yet applied) balance, or `null` if no * `elgamalSecretKey` was supplied. */ -pending?: number | bigint, +pending: number | bigint | null, /** * How many confidential credits are sitting in the pending balance. Non-zero * means an `ApplyPendingBalance` is required before they show up in diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index 2ebf73403..7984486f1 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -1249,18 +1249,17 @@ pub struct ConfidentialBalanceKeys { /// returned by `surfnet_getConfidentialBalance`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[cfg_attr( - feature = "ts-bindings", - derive(ts_rs::TS), - ts(export, optional_fields) -)] +// Not `optional_fields`: nothing here is skipped on serialize, so a `None` +// balance reaches the client as an explicit `null` key rather than an absent +// one. The bindings have to say nullable, not optional. +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))] pub struct GetConfidentialBalanceResponse { /// The available (spendable) balance, or `null` if no `aesKey` was supplied. - #[cfg_attr(feature = "ts-bindings", ts(optional, type = "number | bigint"))] + #[cfg_attr(feature = "ts-bindings", ts(type = "number | bigint | null"))] pub available: Option, /// The pending (credited but not yet applied) balance, or `null` if no /// `elgamalSecretKey` was supplied. - #[cfg_attr(feature = "ts-bindings", ts(optional, type = "number | bigint"))] + #[cfg_attr(feature = "ts-bindings", ts(type = "number | bigint | null"))] pub pending: Option, /// How many confidential credits are sitting in the pending balance. Non-zero /// means an `ApplyPendingBalance` is required before they show up in From 9a79ccd6f0d865d381795e540d81dafa1c9b350b Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 13 Aug 2026 15:58:12 -0600 Subject: [PATCH 04/17] test(core): round trip derive keys, confidential deposit and balance read The confidential cheatcodes were covered only by unit tests over fabricated account data, so nothing proved the keys surfnet_deriveConfidentialKeys hands out actually open ciphertexts the Token-2022 program writes. The test derives the owner's keys, funds a configured confidential account, runs a real Deposit and ApplyPendingBalance against the deployed Token-2022 program, and asserts on the decrypted pending and available balances plus the public balance the deposit drew from. Deposit and apply are the confidential-balance movements that carry no zero-knowledge proof. A party-to-party Transfer needs proofs the ZK ElGamal proof program verifies and is out of scope here. --- crates/core/src/tests/integration.rs | 286 +++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 03247694c..18f69e19e 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -11114,3 +11114,289 @@ async fn test_request_airdrop_rejects_below_rent_amount() { assert_eq!(err.code, jsonrpc_core::ErrorCode::InvalidParams); assert!(err.message.contains("rent-exempt minimum")); } + +/// Round trip: derive the owner's confidential keys, move tokens into the +/// confidential balance with real Token-2022 instructions, and read the new +/// balance back decrypted. +/// +/// The three legs are `surfnet_deriveConfidentialKeys`, a `Deposit` plus an +/// `ApplyPendingBalance` executed by the Token-2022 program itself, and +/// `surfnet_getConfidentialBalance`. Every assertion is on a decrypted amount, +/// so the test fails if the cheatcodes and the on-chain program disagree about +/// the ciphertexts rather than only when a call errors. +/// +/// The deposit path is the confidential-balance movement that carries no +/// zero-knowledge proof. A party-to-party `Transfer` additionally needs proofs +/// verified by the ZK ElGamal proof program and is not covered here. +#[test_case(TestType::sqlite(); "with on-disk sqlite db")] +#[test_case(TestType::in_memory(); "with in-memory sqlite db")] +#[test_case(TestType::no_db(); "with no db")] +#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { + use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; + use spl_token_2022_interface::{ + extension::confidential_transfer::instruction as confidential_instruction, + solana_zk_sdk::encryption::{ + auth_encryption::AeKey, pod::auth_encryption::PodAeCiphertext, + }, + }; + use surfpool_types::types::{ + ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, + }; + + let rpc_server = SurfnetCheatcodesRpc::empty(); + let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); + let svm_locker = SurfnetSvmLocker::new(svm_instance); + let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); + let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); + let runloop_context = RunloopContext { + id: None, + svm_locker: svm_locker.clone(), + simnet_commands_tx: simnet_cmd_tx, + remote_rpc_client: None, + rpc_config: RpcConfig::default(), + cheatcode_config: CheatcodeConfig::new(), + plugin_commands_tx, + }; + + let token_program = spl_token_2022_interface::id(); + let owner = Keypair::new(); + let mint = Keypair::new(); + let decimals = 2u8; + + svm_locker + .airdrop(&owner.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + + let token_account = get_associated_token_address_with_program_id( + &owner.pubkey(), + &mint.pubkey(), + &token_program, + ); + + // Leg 1: derive the owner's confidential keys for this token account. + let keys = rpc_server + .derive_confidential_keys( + Some(runloop_context.clone()), + bs58::encode(owner.to_bytes()).into_string(), + token_account.to_string(), + ) + .expect("deriveConfidentialKeys should succeed") + .value; + + // A mint carrying the confidential-transfer extension, so the Token-2022 + // program will accept confidential instructions against its accounts. + let mint_len = + spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< + spl_token_2022_interface::state::Mint, + >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) + .unwrap(); + let mint_rent = + svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); + + let setup_instructions = vec![ + system_instruction::create_account( + &owner.pubkey(), + &mint.pubkey(), + mint_rent, + mint_len as u64, + &token_program, + ), + confidential_instruction::initialize_mint( + &token_program, + &mint.pubkey(), + Some(owner.pubkey()), + true, + None, + ) + .unwrap(), + spl_token_2022_interface::instruction::initialize_mint2( + &token_program, + &mint.pubkey(), + &owner.pubkey(), + None, + decimals, + ) + .unwrap(), + ]; + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let setup_message = Message::new_with_blockhash( + &setup_instructions, + Some(&owner.pubkey()), + &recent_blockhash, + ); + let setup_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&owner, &mint]) + .unwrap(); + let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, setup_tx, setup_status_tx, false, true) + .await + .unwrap(); + assert!( + matches!( + setup_status_rx.recv().unwrap(), + TransactionStatusEvent::Success(_) + ), + "mint setup should succeed" + ); + + // Fund the owner's account with public tokens and a configured, approved + // confidential-transfer extension holding a zero balance. + let public_amount = 10_000u64; + rpc_server + .set_token_account( + Some(runloop_context.clone()), + owner.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + amount: Some(public_amount), + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: keys.elgamal_pubkey.clone(), + aes_key: Some(keys.aes_key.clone()), + amount: Some(0), + approved: Some(true), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("setTokenAccount should succeed"); + + // Leg 2: a real confidential-transfer deposit, executed by Token-2022. + let deposit_amount = 4_000u64; + let deposit_ix = confidential_instruction::deposit( + &token_program, + &token_account, + &mint.pubkey(), + deposit_amount, + decimals, + &owner.pubkey(), + &[], + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let deposit_message = + Message::new_with_blockhash(&[deposit_ix], Some(&owner.pubkey()), &recent_blockhash); + let deposit_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(deposit_message), &[&owner]) + .unwrap(); + let (deposit_status_tx, deposit_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, deposit_tx, deposit_status_tx, false, true) + .await + .unwrap(); + let deposit_status = deposit_status_rx.recv().unwrap(); + assert!( + matches!(deposit_status, TransactionStatusEvent::Success(_)), + "confidential deposit should succeed, got {:?}", + deposit_status + ); + + // Leg 3: read the balance back. The deposit credits the pending balance, + // which only the ElGamal secret key can open. + let after_deposit = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(keys.aes_key.clone()), + elgamal_secret_key: Some(keys.elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed") + .value; + assert_eq!( + after_deposit.pending, + Some(deposit_amount), + "the deposited amount should decrypt out of the pending balance" + ); + assert_eq!( + after_deposit.available, + Some(0), + "a deposit credits the pending balance, not the available one" + ); + assert_eq!( + after_deposit.pending_balance_credit_counter, 1, + "the deposit should register one pending credit" + ); + + // The public balance funded the deposit, so it drops by the same amount. + let account = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&token_account).unwrap()) + .expect("token account should exist"); + let state = + StateWithExtensions::::unpack(&account.data) + .expect("token account should unpack with extensions"); + assert_eq!( + state.base.amount, + public_amount - deposit_amount, + "the public balance should fall by the deposited amount" + ); + + // Applying the pending balance moves it into the available balance, which + // the owner reads with the AES key. + let aes_bytes: [u8; 16] = bs58::decode(&keys.aes_key) + .into_vec() + .unwrap() + .try_into() + .unwrap(); + let new_available = PodAeCiphertext::from(AeKey::from(aes_bytes).encrypt(deposit_amount)); + let apply_ix = confidential_instruction::apply_pending_balance( + &token_program, + &token_account, + after_deposit.pending_balance_credit_counter, + &new_available, + &owner.pubkey(), + &[], + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let apply_message = + Message::new_with_blockhash(&[apply_ix], Some(&owner.pubkey()), &recent_blockhash); + let apply_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(apply_message), &[&owner]).unwrap(); + let (apply_status_tx, apply_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, apply_tx, apply_status_tx, false, true) + .await + .unwrap(); + let apply_status = apply_status_rx.recv().unwrap(); + assert!( + matches!(apply_status, TransactionStatusEvent::Success(_)), + "applying the pending balance should succeed, got {:?}", + apply_status + ); + + let after_apply = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(keys.aes_key.clone()), + elgamal_secret_key: Some(keys.elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed") + .value; + assert_eq!( + after_apply.available, + Some(deposit_amount), + "the applied amount should decrypt out of the available balance" + ); + assert_eq!( + after_apply.pending, + Some(0), + "the pending balance should be drained by the apply" + ); + assert_eq!( + after_apply.pending_balance_credit_counter, 0, + "applying the pending balance resets the credit counter" + ); +} From 4606119ca98c78a2bf300cfadf0b9c0d50be343e Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 13 Aug 2026 16:09:16 -0600 Subject: [PATCH 05/17] docs(core): correct the confidential key derivation interop claim surfnet_deriveConfidentialKeys documented itself as matching what a confidential-transfer client derives from the same keypair. It does not, and the note said the opposite of the truth. Two things differ. The seed is prefixed with solana-conf-bal/v1, which the standardised derivation in solana_zk_sdk::encryption::derivation already applies itself as its HKDF salt, so the prefix applies it a second time; reference clients pass the seed unprefixed. And the derivation reachable from the pinned Token-2022 interface crate is the older new_from_signer path, which that SDK flags in-tree as a non-standard KDF and replaces with HKDF-SHA512. Derived both ways and compared: dropping the prefix alone still does not reproduce the standardised keys, so the seed is left as it is rather than made to look standard while producing different keys. The notes now say what is actually true and point at the dependency that would close the gap. --- crates/core/src/rpc/surfnet_cheatcodes.rs | 16 +++++++++++++--- crates/core/src/types.rs | 13 +++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index b534584c8..23a3a5dae 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -1333,9 +1333,19 @@ pub trait SurfnetCheatcodes { /// ``` /// /// # Notes - /// The derivation matches what a confidential-transfer client computes from the same keypair, so - /// keys produced here interoperate with keys derived off-chain. The keypair is used only to sign - /// the derivation seeds; it is not stored. + /// These keys are self-consistent within a surfnet: pair them with `surfnet_setTokenAccount` and + /// `surfnet_getConfidentialBalance` and the confidential cheatcodes round-trip. They do **not** + /// currently reproduce the keys a confidential-transfer client derives for the same keypair, so + /// they will not open the balance of an account that was configured off-chain. + /// + /// Two things differ from the standardised derivation in `solana_zk_sdk::encryption::derivation`. + /// The key derivation function is the older `new_from_signer` path, which that SDK itself flags as + /// non-standard and replaces with HKDF-SHA512. And the public seed here is prefixed with + /// `solana-conf-bal/v1`, which the standardised derivation already applies internally as its HKDF + /// salt. Closing the gap means reaching that derivation, which is not available through the + /// Token-2022 interface crate this build pins. + /// + /// The keypair is used only to sign the derivation seeds; it is not stored. #[rpc(meta, name = "surfnet_deriveConfidentialKeys")] fn derive_confidential_keys( &self, diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 3487c5de8..4e4d45a0e 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1418,8 +1418,17 @@ fn decrypt_pending_balance( } /// The per-token-account public seed the confidential keys are derived over. The -/// signature of this seed — not the wallet's private key, which hardware signers -/// never expose — is what the key derivation is hashed from. +/// signature of this seed, not the wallet's private key, which hardware signers +/// never expose, is what the key derivation is hashed from. +/// +/// This prefix is non-standard and the derived keys are surfnet-local as a result. +/// `solana_zk_sdk::encryption::derivation` applies this same `solana-conf-bal/v1` +/// string itself, as its HKDF salt, so prepending it to the seed applies it twice. +/// Reference clients pass the seed unprefixed: an empty seed for per-wallet keying, +/// or the raw token account address for per-account keying. Dropping the prefix on +/// its own would not make these keys interoperable, because the derivation reachable +/// from the pinned Token-2022 interface crate is also the older, pre-HKDF one; both +/// have to move together. const CONFIDENTIAL_KEY_SEED_PREFIX: &[u8] = b"solana-conf-bal/v1"; /// Derive an owner's confidential-transfer keys for a token account. From 672c946651cc5c8fa50e8fdee9010347010587ef Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 13 Aug 2026 16:19:21 -0600 Subject: [PATCH 06/17] docs(core): state what the deposit round trip test actually proves The doc comment claimed every assertion is on a decrypted amount, so the test fails if the cheatcodes and the program disagree about the ciphertexts. Four of the ten assertions are on decrypted balances; three are on plaintext account fields and three only check that a call succeeded. The second half was wrong in a way that mattered more. A Deposit adds the amount to the commitment and passes the decrypt handle through untouched, so starting from the all-zero pending ciphertext the setter writes, the result opens to the same value under any ElGamal secret key. That assertion pins the response shape and the field plumbing, not the derivation. The available balance round-trips the test's own AES ciphertext, which shows AeKey encrypt and decrypt agree and the cheatcode reads the right field. What binds is the public balance falling by exactly the deposited amount and the pending credit counter moving, both computed by the program. Also records that the account reaches its configured state through surfnet_setTokenAccount rather than an on-chain ConfigureAccount, which is proof-gated and blocked by the same SDK skew as Transfer. Comment only. No assertion and no test logic changed. --- crates/core/src/tests/integration.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 18f69e19e..7e16c8027 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -11121,9 +11121,25 @@ async fn test_request_airdrop_rejects_below_rent_amount() { /// /// The three legs are `surfnet_deriveConfidentialKeys`, a `Deposit` plus an /// `ApplyPendingBalance` executed by the Token-2022 program itself, and -/// `surfnet_getConfidentialBalance`. Every assertion is on a decrypted amount, -/// so the test fails if the cheatcodes and the on-chain program disagree about -/// the ciphertexts rather than only when a call errors. +/// `surfnet_getConfidentialBalance`. Four assertions read decrypted balances +/// back through the cheatcode; the others are on plaintext account fields and +/// on call status. The ones that bind hardest are the plaintext fields the +/// program itself computes: the public token balance falls by exactly the +/// deposited amount, and the pending credit counter goes to 1 and back to 0. +/// A deposit that silently did nothing fails the test on those. The decrypted +/// pending balance pins the response shape and the field plumbing rather than +/// the key, because a `Deposit` adds to the commitment and passes the ElGamal +/// decrypt handle through untouched: starting from a zero balance, the +/// resulting ciphertext opens to the same value under any secret key. The +/// decrypted available balance round-trips this test's own AES ciphertext, so +/// it shows that `AeKey` encryption and decryption agree and that the cheatcode +/// reads the right field. That decryption binds to the ElGamal key at all is +/// covered elsewhere, by the foreign-key rejection in +/// `test_confidential_pending_balance_recombines_lo_and_hi`. +/// +/// The account is put into its configured state by `surfnet_setTokenAccount` +/// rather than by an on-chain `ConfigureAccount`, which is proof-gated and so +/// blocked by the same SDK skew as `Transfer`. /// /// The deposit path is the confidential-balance movement that carries no /// zero-knowledge proof. A party-to-party `Transfer` additionally needs proofs From 4a54c7fae6d8bc32351cb258f6a8fd06d02426f3 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 13 Aug 2026 18:07:49 -0600 Subject: [PATCH 07/17] test(core): bind the confidential pending balance read to the derived key The pending-balance assertion in test_confidential_balance_deposit_round_trip did not depend on the derived ElGamal key. Deposit adds the amount to the commitment and passes the decrypt handle through untouched, so a deposit onto the all-zero pending ciphertext a configured account starts with yields an identity handle -- and such a ciphertext opens to the same value under any secret key. Seed pending_balance_lo with a real encryption under the derived public key before the deposit, so the ciphertext the deposit lands on carries a non-identity handle, then assert both directions: the derived secret key recovers seed plus deposit exactly, and a random ElGamal secret key recovers nothing. Substituting a stranger's key now fails the test. The doc comment is rewritten to match, and now names which assertions do not bind to the ElGamal key rather than implying they do. --- crates/core/src/tests/integration.rs | 135 ++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 23 deletions(-) diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 7e16c8027..d72885c55 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -11121,21 +11121,29 @@ async fn test_request_airdrop_rejects_below_rent_amount() { /// /// The three legs are `surfnet_deriveConfidentialKeys`, a `Deposit` plus an /// `ApplyPendingBalance` executed by the Token-2022 program itself, and -/// `surfnet_getConfidentialBalance`. Four assertions read decrypted balances -/// back through the cheatcode; the others are on plaintext account fields and -/// on call status. The ones that bind hardest are the plaintext fields the -/// program itself computes: the public token balance falls by exactly the -/// deposited amount, and the pending credit counter goes to 1 and back to 0. -/// A deposit that silently did nothing fails the test on those. The decrypted -/// pending balance pins the response shape and the field plumbing rather than -/// the key, because a `Deposit` adds to the commitment and passes the ElGamal -/// decrypt handle through untouched: starting from a zero balance, the -/// resulting ciphertext opens to the same value under any secret key. The -/// decrypted available balance round-trips this test's own AES ciphertext, so -/// it shows that `AeKey` encryption and decryption agree and that the cheatcode -/// reads the right field. That decryption binds to the ElGamal key at all is -/// covered elsewhere, by the foreign-key rejection in -/// `test_confidential_pending_balance_recombines_lo_and_hi`. +/// `surfnet_getConfidentialBalance`. +/// +/// The pending balance is seeded with a real ElGamal encryption under the +/// derived public key before the deposit runs, so the ciphertext the deposit +/// lands on carries a decrypt handle that is not the identity point. Two +/// assertions then bind that read to the derived key: the derived secret key +/// recovers seed plus deposit exactly, and a random ElGamal secret key recovers +/// nothing. Replace the derived key with a stranger's and the test fails. The +/// seeding step is deliberate — deposits onto the all-zero pending ciphertext a +/// freshly configured account carries produce an identity handle, and such a +/// ciphertext opens to the same value under any secret key. On-chain that state +/// arrives via a party-to-party `Transfer`, which is proof-gated and out of +/// reach here, so the test writes it directly. +/// +/// What the rest of the assertions carry: the plaintext fields the program +/// itself computes — the public token balance falls by exactly the deposited +/// amount, and the pending credit counter goes to 1 and back to 0 — catch a +/// deposit that silently did nothing. The decrypted available balance +/// round-trips this test's own AES ciphertext, so it shows that `AeKey` +/// encryption and decryption agree and that the cheatcode reads the right +/// field; it does not bind to the ElGamal key. The post-apply pending read of 0 +/// is a shape check, not a key check: `ApplyPendingBalance` resets the pending +/// ciphertext to all-zero, which decodes to 0 under any key. /// /// The account is put into its configured state by `surfnet_setTokenAccount` /// rather than by an on-chain `ConfigureAccount`, which is proof-gated and so @@ -11152,9 +11160,16 @@ async fn test_request_airdrop_rejects_below_rent_amount() { async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; use spl_token_2022_interface::{ - extension::confidential_transfer::instruction as confidential_instruction, + extension::{ + BaseStateWithExtensionsMut, StateWithExtensionsMut, + confidential_transfer::{ + ConfidentialTransferAccount, instruction as confidential_instruction, + }, + }, solana_zk_sdk::encryption::{ - auth_encryption::AeKey, pod::auth_encryption::PodAeCiphertext, + auth_encryption::AeKey, + elgamal::{ElGamalKeypair, ElGamalPubkey}, + pod::auth_encryption::PodAeCiphertext, }, }; use surfpool_types::types::{ @@ -11283,6 +11298,36 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { .await .expect("setTokenAccount should succeed"); + // Give the pending balance a real ElGamal ciphertext before the deposit + // lands on it. `Deposit` adds the amount to the commitment and passes the + // decrypt handle through untouched, so a deposit onto the all-zero pending + // ciphertext a configured account starts with produces an identity handle, + // and that opens to the same value under any secret key. Encrypting under + // the derived public key first gives the ciphertext a handle only the + // derived secret key cancels. On-chain the same state arrives via a + // party-to-party `Transfer`, which is proof-gated and blocked here by the + // SDK skew, so the account is seeded directly instead. + let seeded_pending = 1_500u64; + let elgamal_pubkey_bytes = bs58::decode(&keys.elgamal_pubkey).into_vec().unwrap(); + let elgamal_pubkey = ElGamalPubkey::try_from(elgamal_pubkey_bytes.as_slice()) + .expect("derived elgamalPubkey should parse"); + let mut seeded_account = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&token_account).unwrap()) + .expect("token account should exist"); + { + let mut state = StateWithExtensionsMut::::unpack( + &mut seeded_account.data, + ) + .expect("token account should unpack with extensions"); + let ext = state + .get_extension_mut::() + .expect("confidential extension should be present"); + ext.pending_balance_lo = elgamal_pubkey.encrypt_u64(seeded_pending).into(); + } + svm_locker + .with_svm_writer(|svm| svm.set_account(&token_account, seeded_account)) + .expect("seeding the pending balance should succeed"); + // Leg 2: a real confidential-transfer deposit, executed by Token-2022. let deposit_amount = 4_000u64; let deposit_ix = confidential_instruction::deposit( @@ -11314,14 +11359,58 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { ); // Leg 3: read the balance back. The deposit credits the pending balance, - // which only the ElGamal secret key can open. + // which only the derived ElGamal secret key can open. + let owner_elgamal_secret_key = keys.elgamal_secret_key.clone(); + let expected_pending = seeded_pending + deposit_amount; + + // The pending balance is bound to the derived key: the owner's secret key + // recovers the seeded amount plus the deposit, and a stranger's recovers + // nothing. + let pending_under_owner = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), + }, + ) + .await + .map(|response| response.value.pending) + .unwrap_or(None); + assert_eq!( + pending_under_owner, + Some(expected_pending), + "the derived ElGamal secret key should recover the seeded pending balance plus the deposit" + ); + + let foreign_elgamal = ElGamalKeypair::new_rand(); + let pending_under_foreign = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some( + bs58::encode(<[u8; 32]>::from(foreign_elgamal.secret())).into_string(), + ), + }, + ) + .await + .map(|response| response.value.pending) + .unwrap_or(None); + assert_eq!( + pending_under_foreign, None, + "a foreign ElGamal secret key must fail to recover the pending balance" + ); + let after_deposit = rpc_server .get_confidential_balance( Some(runloop_context.clone()), token_account.to_string(), ConfidentialBalanceKeys { aes_key: Some(keys.aes_key.clone()), - elgamal_secret_key: Some(keys.elgamal_secret_key.clone()), + elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), }, ) .await @@ -11329,7 +11418,7 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { .value; assert_eq!( after_deposit.pending, - Some(deposit_amount), + Some(expected_pending), "the deposited amount should decrypt out of the pending balance" ); assert_eq!( @@ -11362,7 +11451,7 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { .unwrap() .try_into() .unwrap(); - let new_available = PodAeCiphertext::from(AeKey::from(aes_bytes).encrypt(deposit_amount)); + let new_available = PodAeCiphertext::from(AeKey::from(aes_bytes).encrypt(expected_pending)); let apply_ix = confidential_instruction::apply_pending_balance( &token_program, &token_account, @@ -11395,7 +11484,7 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { token_account.to_string(), ConfidentialBalanceKeys { aes_key: Some(keys.aes_key.clone()), - elgamal_secret_key: Some(keys.elgamal_secret_key.clone()), + elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), }, ) .await @@ -11403,7 +11492,7 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { .value; assert_eq!( after_apply.available, - Some(deposit_amount), + Some(expected_pending), "the applied amount should decrypt out of the available balance" ); assert_eq!( From f70c7897833ea2d828b7dffd078957b36aab19c8 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 14 Aug 2026 15:05:10 -0600 Subject: [PATCH 08/17] build(sdk-node): format every file the kit type generator emits The generated TypeScript was checked in unformatted. formatFiles ran over the per-type files but not over index.ts, and prettier was not a declared dependency of the generator, so whether the output came out formatted depended on what happened to be installed. Declare prettier as a dependency and pass index.ts through formatFiles with the rest. The checked-in output is regenerated, which is where the churn across the generated directory comes from. Fixing this at the generator rather than by hand means it survives the next regeneration. A hand-fix would have looked identical here and then come back on the following change. --- crates/sdk-node/package-lock.json | 19 ++++- crates/sdk-node/package.json | 1 + crates/sdk-node/scripts/generate-kit-types.js | 57 ++++++++++++++ .../kit/generated/AccountAddress.ts | 3 +- .../kit/generated/AccountSnapshot.ts | 23 +++--- .../kit/generated/AccountUpdate.ts | 45 +++++------ .../kit/generated/CheatcodeControlConfig.ts | 2 +- .../kit/generated/ConfidentialBalanceKeys.ts | 23 +++--- .../ConfidentialTransferAccountUpdate.ts | 77 ++++++++++--------- .../DeriveConfidentialKeysResponse.ts | 29 +++---- .../kit/generated/ExportSnapshotConfig.ts | 6 +- .../kit/generated/ExportSnapshotFilter.ts | 28 ++++--- .../kit/generated/ExportSnapshotScope.ts | 2 +- .../GetConfidentialBalanceResponse.ts | 33 ++++---- .../generated/GetStreamedAccountsResponse.ts | 4 +- .../kit/generated/GetSurfnetInfoResponse.ts | 4 +- .../kit/generated/OfflineAccountConfig.ts | 2 +- .../kit/generated/OverrideInstance.ts | 67 ++++++++-------- .../kit/generated/ParsedAccount.ts | 2 +- .../surfpool-sdk/kit/generated/PdaSeed.ts | 11 ++- .../kit/generated/ResetAccountConfig.ts | 2 +- .../kit/generated/RpcProfileResultConfig.ts | 5 +- .../generated/RunbookExecutionStatusReport.ts | 7 +- .../surfpool-sdk/kit/generated/Scenario.ts | 43 ++++++----- .../kit/generated/StreamAccountConfig.ts | 2 +- .../kit/generated/StreamAccountsEntry.ts | 5 +- .../kit/generated/StreamedAccountInfo.ts | 5 +- .../kit/generated/SupplyUpdate.ts | 7 +- .../kit/generated/TokenAccountUpdate.ts | 57 +++++++------- .../surfpool-sdk/kit/generated/UiAccount.ts | 9 ++- .../kit/generated/UiAccountChange.ts | 6 +- .../kit/generated/UiAccountData.ts | 3 +- .../kit/generated/UiAccountEncoding.ts | 3 +- .../kit/generated/UiAccountProfileState.ts | 3 +- .../kit/generated/UiKeyedProfileResult.ts | 8 +- .../kit/generated/UiProfileResult.ts | 7 +- crates/sdk-node/surfpool-sdk/kit/types/api.ts | 2 +- 37 files changed, 382 insertions(+), 230 deletions(-) diff --git a/crates/sdk-node/package-lock.json b/crates/sdk-node/package-lock.json index b57f56a49..3efae877e 100644 --- a/crates/sdk-node/package-lock.json +++ b/crates/sdk-node/package-lock.json @@ -13,6 +13,7 @@ "@solana/kit": "^7.0.0", "@solana/kit-plugin-rpc": "^0.15.0", "@solana/kit-plugin-signer": "^0.13.0", + "prettier": "3.9.6", "typescript": "^5.7.0" }, "engines": { @@ -411,7 +412,6 @@ "integrity": "sha512-ZCeai4LRJQooUmJXvpgMEGFTrCdJnV1ODbDJ8oqFZ+Y4t/9x1baQsFFpruqsdRyeGv2Rr+X6jV7cldVD+hyzRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@solana/accounts": "7.0.0", "@solana/addresses": "7.0.0", @@ -1242,13 +1242,28 @@ "node": ">=22.12.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/crates/sdk-node/package.json b/crates/sdk-node/package.json index f158101e8..1b468c8ca 100644 --- a/crates/sdk-node/package.json +++ b/crates/sdk-node/package.json @@ -74,6 +74,7 @@ "@solana/kit": "^7.0.0", "@solana/kit-plugin-rpc": "^0.15.0", "@solana/kit-plugin-signer": "^0.13.0", + "prettier": "3.9.6", "typescript": "^5.7.0" }, "peerDependencies": { diff --git a/crates/sdk-node/scripts/generate-kit-types.js b/crates/sdk-node/scripts/generate-kit-types.js index cc5433312..5ce6418cb 100644 --- a/crates/sdk-node/scripts/generate-kit-types.js +++ b/crates/sdk-node/scripts/generate-kit-types.js @@ -10,6 +10,7 @@ const fs = require("node:fs"); const path = require("node:path"); const repoRoot = path.resolve(__dirname, "..", "..", ".."); +const packageDir = path.resolve(__dirname, ".."); const generatedDir = path.resolve( __dirname, "..", @@ -18,6 +19,56 @@ const generatedDir = path.resolve( "generated", ); +// ts-rs emits unindented single-line type bodies, so the committed bindings are +// formatted here rather than left for a follow-up pass: the freshness job in +// .github/workflows/sdk_node.yml regenerates this directory and fails on any +// diff, so anything that formats the files *after* generation is reverted by the +// next run. Formatting has to happen inside the generator or not at all. +// +// The formatter runs through `npx ` instead of `require`: that job +// checks out the repo and runs this script with no `setup-node` and no `npm ci`, +// so there is no node_modules to resolve against and a bare require would throw +// MODULE_NOT_FOUND there while working fine locally. `npx` uses the local +// devDependency when it satisfies the spec and fetches it otherwise, so both +// environments format with the same bytes. The version comes from package.json +// so there is one place to bump it, and it is pinned exactly because a range +// would let two machines produce different output and turn the gate red. +const prettierVersion = + JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) + .devDependencies?.prettier ?? null; + +function formatFiles(filePaths) { + if (!prettierVersion) { + throw new Error( + "devDependencies.prettier is missing from crates/sdk-node/package.json; " + + "the generated bindings cannot be formatted deterministically without a pinned version.", + ); + } + if (!/^\d+\.\d+\.\d+$/.test(prettierVersion)) { + throw new Error( + `devDependencies.prettier must be an exact version, got "${prettierVersion}"; ` + + "a range lets different machines format the bindings differently and breaks the freshness gate.", + ); + } + execFileSync( + process.platform === "win32" ? "npx.cmd" : "npx", + [ + "--yes", + `prettier@${prettierVersion}`, + // Pinned flags, not preference: config discovery walks up past the repo + // root, so without these the output would depend on files outside the + // checkout and stop being reproducible in CI. + "--no-config", + "--no-editorconfig", + "--log-level", + "warn", + "--write", + ...filePaths, + ], + { cwd: packageDir, stdio: "inherit" }, + ); +} + const HEADER = `// @generated by ts-rs from the Rust types in crates/types.\n// Do not edit; run \`npm run generate:kit-types\` in crates/sdk-node instead.\n`; fs.rmSync(generatedDir, { recursive: true, force: true }); @@ -59,4 +110,10 @@ const barrel = files .join("\n"); fs.writeFileSync(path.join(generatedDir, "index.ts"), `${HEADER}${barrel}\n`); +// Every file this script emits, index.ts included: a generator that formats part +// of its output leaves the rest to drift. +formatFiles( + [...files, "index.ts"].map((name) => path.join(generatedDir, name)), +); + console.log(`Generated ${files.length} binding files in ${generatedDir}`); diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts b/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts index c6366b04e..bcf4f9c52 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts @@ -7,4 +7,5 @@ import type { PdaSeed } from "./PdaSeed.js"; * Defines how an account address should be determined *Defines how an account address should be determined */ -export type AccountAddress = { "pubkey": string } | { "pda": { programId: string, seeds: Array, } }; +export type AccountAddress = + { pubkey: string } | { pda: { programId: string; seeds: Array } }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts b/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts index e7436e47d..a81260157 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts @@ -3,12 +3,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ParsedAccount } from "./ParsedAccount.js"; -export type AccountSnapshot = { lamports: bigint, owner: string, executable: boolean, rentEpoch: bigint, -/** - * Base64 encoded data - */ -data: string, -/** - * Parsed account data if available - */ -parsedData: ParsedAccount | null, }; +export type AccountSnapshot = { + lamports: bigint; + owner: string; + executable: boolean; + rentEpoch: bigint; + /** + * Base64 encoded data + */ + data: string; + /** + * Parsed account data if available + */ + parsedData: ParsedAccount | null; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts index abd1c0643..31a720b3d 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts @@ -2,25 +2,26 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AccountUpdate = { -/** - * providing this value sets the lamports in the account - */ -lamports?: number | bigint, -/** - * providing this value sets the data held in this account, as a - * hex-encoded string - */ -data?: string, -/** - * providing this value sets the program that owns this account. If executable, the program that loads this account. - */ -owner?: string, -/** - * providing this value sets whether this account's data contains a loaded program (and is now read-only) - */ -executable?: boolean, -/** - * providing this value sets the epoch at which this account will next owe rent - */ -rentEpoch?: number | bigint, }; +export type AccountUpdate = { + /** + * providing this value sets the lamports in the account + */ + lamports?: number | bigint; + /** + * providing this value sets the data held in this account, as a + * hex-encoded string + */ + data?: string; + /** + * providing this value sets the program that owns this account. If executable, the program that loads this account. + */ + owner?: string; + /** + * providing this value sets whether this account's data contains a loaded program (and is now read-only) + */ + executable?: boolean; + /** + * providing this value sets the epoch at which this account will next owe rent + */ + rentEpoch?: number | bigint; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts index 6903e159c..bd3774a48 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CheatcodeControlConfig = { lockout?: boolean, }; +export type CheatcodeControlConfig = { lockout?: boolean }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts index fa37bcd73..c26a98b32 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts @@ -9,14 +9,15 @@ * Each key unlocks a different half of the balance, so they are independently * optional: a caller holding only one still gets the half it can read. */ -export type ConfidentialBalanceKeys = { -/** - * The owner's AES key (base58 or base64, 16 bytes). Decrypts the available - * balance. - */ -aesKey?: string, -/** - * The owner's ElGamal *secret* key (base58 or base64, 32 bytes) — not the - * public key stored on the account. Decrypts the pending balance. - */ -elgamalSecretKey?: string, }; +export type ConfidentialBalanceKeys = { + /** + * The owner's AES key (base58 or base64, 16 bytes). Decrypts the available + * balance. + */ + aesKey?: string; + /** + * The owner's ElGamal *secret* key (base58 or base64, 32 bytes) — not the + * public key stored on the account. Decrypts the pending balance. + */ + elgamalSecretKey?: string; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts index d36d98618..cbf78b89c 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts @@ -10,41 +10,42 @@ * funded) confidential account directly, bypassing the real on-chain * configure / deposit / apply-pending-balance instruction flow. */ -export type ConfidentialTransferAccountUpdate = { -/** - * The owner's ElGamal public key (base58 or base64, 32 bytes). Required — - * the confidential balance is encrypted to this key, and confidential - * payment clients read it off the account to encrypt transfers. - */ -elgamalPubkey: string, -/** - * The owner's AES (authenticated-encryption) secret key (base58 or base64, - * 16 bytes). Required. Produces the `decryptable_available_balance` the - * owner reads to learn its balance — even a zero-balance receive-only - * account needs a valid `encrypt(0)` here (a placeholder would fail - * owner-side balance reads), so this is mandatory for every confidential - * account. Modeled as `Option` only so the field can be validated with a - * clear error message when omitted. - */ -aesKey: string, -/** - * The confidential available balance to set (default 0). - */ -amount?: number | bigint, -/** - * Whether the account is approved for confidential transfers (default true). - */ -approved?: boolean, -/** - * Whether the account accepts incoming confidential credits (default true). - */ -allowConfidentialCredits?: boolean, -/** - * Whether the base account accepts incoming non-confidential credits - * (default true). - */ -allowNonConfidentialCredits?: boolean, -/** - * The maximum pending-balance credit counter (default 65536). - */ -maximumPendingBalanceCreditCounter?: number | bigint, }; +export type ConfidentialTransferAccountUpdate = { + /** + * The owner's ElGamal public key (base58 or base64, 32 bytes). Required — + * the confidential balance is encrypted to this key, and confidential + * payment clients read it off the account to encrypt transfers. + */ + elgamalPubkey: string; + /** + * The owner's AES (authenticated-encryption) secret key (base58 or base64, + * 16 bytes). Required. Produces the `decryptable_available_balance` the + * owner reads to learn its balance — even a zero-balance receive-only + * account needs a valid `encrypt(0)` here (a placeholder would fail + * owner-side balance reads), so this is mandatory for every confidential + * account. Modeled as `Option` only so the field can be validated with a + * clear error message when omitted. + */ + aesKey: string; + /** + * The confidential available balance to set (default 0). + */ + amount?: number | bigint; + /** + * Whether the account is approved for confidential transfers (default true). + */ + approved?: boolean; + /** + * Whether the account accepts incoming confidential credits (default true). + */ + allowConfidentialCredits?: boolean; + /** + * Whether the base account accepts incoming non-confidential credits + * (default true). + */ + allowNonConfidentialCredits?: boolean; + /** + * The maximum pending-balance credit counter (default 65536). + */ + maximumPendingBalanceCreditCounter?: number | bigint; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts index d041d3d54..8dfbfceb6 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts @@ -7,17 +7,18 @@ * `surfnet_deriveConfidentialKeys`. All three are base58-encoded and feed * directly into the other confidential cheatcodes. */ -export type DeriveConfidentialKeysResponse = { -/** - * The ElGamal public key — `surfnet_setTokenAccount`'s `elgamalPubkey`. - */ -elgamalPubkey: string, -/** - * The ElGamal secret key — `surfnet_getConfidentialBalance`'s - * `elgamalSecretKey`. - */ -elgamalSecretKey: string, -/** - * The AES key — the `aesKey` of both of the above. - */ -aesKey: string, }; +export type DeriveConfidentialKeysResponse = { + /** + * The ElGamal public key — `surfnet_setTokenAccount`'s `elgamalPubkey`. + */ + elgamalPubkey: string; + /** + * The ElGamal secret key — `surfnet_getConfidentialBalance`'s + * `elgamalSecretKey`. + */ + elgamalSecretKey: string; + /** + * The AES key — the `aesKey` of both of the above. + */ + aesKey: string; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts index 3dcc54d65..c489372d2 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts @@ -4,4 +4,8 @@ import type { ExportSnapshotFilter } from "./ExportSnapshotFilter.js"; import type { ExportSnapshotScope } from "./ExportSnapshotScope.js"; -export type ExportSnapshotConfig = { includeParsedAccounts?: boolean, filter?: ExportSnapshotFilter, scope: ExportSnapshotScope, }; +export type ExportSnapshotConfig = { + includeParsedAccounts?: boolean; + filter?: ExportSnapshotFilter; + scope: ExportSnapshotScope; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts index 1989718db..ef001866d 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts @@ -2,15 +2,19 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExportSnapshotFilter = { includeProgramAccounts?: boolean, includeAccounts?: Array, excludeAccounts?: Array, -/** - * When true, omit accounts owned by the sysvar program. - */ -excludeSysvars?: boolean, -/** - * When true, omit accounts whose pubkey is a known agave feature gate - * (as defined by the `agave_feature_set::FEATURE_NAMES` set built into - * this surfpool binary). Feature gates added upstream after this version - * will not be excluded. - */ -excludeFeatureGates?: boolean, }; +export type ExportSnapshotFilter = { + includeProgramAccounts?: boolean; + includeAccounts?: Array; + excludeAccounts?: Array; + /** + * When true, omit accounts owned by the sysvar program. + */ + excludeSysvars?: boolean; + /** + * When true, omit accounts whose pubkey is a known agave feature gate + * (as defined by the `agave_feature_set::FEATURE_NAMES` set built into + * this surfpool binary). Feature gates added upstream after this version + * will not be excluded. + */ + excludeFeatureGates?: boolean; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts index eed299bc4..bb45ad762 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExportSnapshotScope = "network" | { "preTransaction": string }; +export type ExportSnapshotScope = "network" | { preTransaction: string }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts index 7a123cbc8..1acadef46 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts @@ -6,19 +6,20 @@ * The decrypted confidential-transfer balances of a Token-2022 token account, * returned by `surfnet_getConfidentialBalance`. */ -export type GetConfidentialBalanceResponse = { -/** - * The available (spendable) balance, or `null` if no `aesKey` was supplied. - */ -available: number | bigint | null, -/** - * The pending (credited but not yet applied) balance, or `null` if no - * `elgamalSecretKey` was supplied. - */ -pending: number | bigint | null, -/** - * How many confidential credits are sitting in the pending balance. Non-zero - * means an `ApplyPendingBalance` is required before they show up in - * `available`. - */ -pendingBalanceCreditCounter: number | bigint, }; +export type GetConfidentialBalanceResponse = { + /** + * The available (spendable) balance, or `null` if no `aesKey` was supplied. + */ + available: number | bigint | null; + /** + * The pending (credited but not yet applied) balance, or `null` if no + * `elgamalSecretKey` was supplied. + */ + pending: number | bigint | null; + /** + * How many confidential credits are sitting in the pending balance. Non-zero + * means an `ApplyPendingBalance` is required before they show up in + * `available`. + */ + pendingBalanceCreditCounter: number | bigint; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts index 70ae28816..80b432895 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts @@ -3,4 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { StreamedAccountInfo } from "./StreamedAccountInfo.js"; -export type GetStreamedAccountsResponse = { accounts: Array, }; +export type GetStreamedAccountsResponse = { + accounts: Array; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts index 812452fc3..15ff91a78 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts @@ -3,4 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { RunbookExecutionStatusReport } from "./RunbookExecutionStatusReport.js"; -export type GetSurfnetInfoResponse = { runbookExecutions: Array, }; +export type GetSurfnetInfoResponse = { + runbookExecutions: Array; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts index c7cf40dac..97e9dd783 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type OfflineAccountConfig = { includeOwnedAccounts?: boolean, }; +export type OfflineAccountConfig = { includeOwnedAccounts?: boolean }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts index 348ea2ae5..84d6a972e 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts @@ -6,36 +6,37 @@ import type { AccountAddress } from "./AccountAddress.js"; /** * A concrete instance of an override template with specific values */ -export type OverrideInstance = { -/** - * Unique identifier for this instance (UUID v4) - */ -id: string, -/** - * Reference to the template being used - MUST match a template id from get_override_templates - */ -templateId: string, -/** - * Values for the template properties as a JSON object (NOT a string) - */ -values: Record, -/** - * Relative slot when this override should be applied (1 = 400ms after registration) - */ -scenarioRelativeSlot: number | bigint, -/** - * Optional human-readable label for this instance - */ -label?: string, -/** - * Whether this override is enabled - */ -enabled: boolean, -/** - * Whether to fetch fresh account data just before transaction execution - */ -fetchBeforeUse?: boolean, -/** - * Account address to override - use pubkey for known addresses or pda for derived addresses - */ -account: AccountAddress, }; +export type OverrideInstance = { + /** + * Unique identifier for this instance (UUID v4) + */ + id: string; + /** + * Reference to the template being used - MUST match a template id from get_override_templates + */ + templateId: string; + /** + * Values for the template properties as a JSON object (NOT a string) + */ + values: Record; + /** + * Relative slot when this override should be applied (1 = 400ms after registration) + */ + scenarioRelativeSlot: number | bigint; + /** + * Optional human-readable label for this instance + */ + label?: string; + /** + * Whether this override is enabled + */ + enabled: boolean; + /** + * Whether to fetch fresh account data just before transaction execution + */ + fetchBeforeUse?: boolean; + /** + * Account address to override - use pubkey for known addresses or pda for derived addresses + */ + account: AccountAddress; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts index f97473322..e9bfd5174 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts @@ -5,4 +5,4 @@ /** * Mirrors [`solana_account_decoder_client_types::ParsedAccount`]. */ -export type ParsedAccount = { program: string, parsed: unknown, space: bigint, }; +export type ParsedAccount = { program: string; parsed: unknown; space: bigint }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts b/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts index 967b73a03..21ecd4439 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts @@ -6,4 +6,13 @@ * Seeds used for PDA derivation *Seeds used for PDA derivation */ -export type PdaSeed = { "pubkey": string } | { "string": string } | { "bytes": Array } | { "propertyRef": string } | { "u16Be": number } | { "u16BeRef": string } | { "u16Le": number } | { "bytes32Ref": string } | { "derivedPda": { programId: string, seeds: Array, } }; +export type PdaSeed = + | { pubkey: string } + | { string: string } + | { bytes: Array } + | { propertyRef: string } + | { u16Be: number } + | { u16BeRef: string } + | { u16Le: number } + | { bytes32Ref: string } + | { derivedPda: { programId: string; seeds: Array } }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts index f676b3122..07d53c0fa 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ResetAccountConfig = { includeOwnedAccounts?: boolean, }; +export type ResetAccountConfig = { includeOwnedAccounts?: boolean }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts index aba0731de..208daedf5 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts @@ -4,4 +4,7 @@ import type { RpcProfileDepth } from "./RpcProfileDepth.js"; import type { UiAccountEncoding } from "./UiAccountEncoding.js"; -export type RpcProfileResultConfig = { encoding?: UiAccountEncoding, depth?: RpcProfileDepth, }; +export type RpcProfileResultConfig = { + encoding?: UiAccountEncoding; + depth?: RpcProfileDepth; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts b/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts index 909dd92a9..734d02903 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts @@ -2,4 +2,9 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RunbookExecutionStatusReport = { startedAt: bigint, completedAt: bigint | null, runbookId: string, errors: Array | null, }; +export type RunbookExecutionStatusReport = { + startedAt: bigint; + completedAt: bigint | null; + runbookId: string; + errors: Array | null; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts b/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts index a32bf5280..e4bb3624e 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts @@ -6,24 +6,25 @@ import type { OverrideInstance } from "./OverrideInstance.js"; /** * A scenario containing a timeline of overrides */ -export type Scenario = { -/** - * Unique identifier for the scenario (UUID v4 format) - */ -id: string, -/** - * Human-readable name - */ -name: string, -/** - * Description of this scenario - */ -description: string, -/** - * List of override instances in this scenario - MUST be an array, NOT a string - */ -overrides: Array, -/** - * Tags for categorization - */ -tags: Array, }; +export type Scenario = { + /** + * Unique identifier for the scenario (UUID v4 format) + */ + id: string; + /** + * Human-readable name + */ + name: string; + /** + * Description of this scenario + */ + description: string; + /** + * List of override instances in this scenario - MUST be an array, NOT a string + */ + overrides: Array; + /** + * Tags for categorization + */ + tags: Array; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts index db66d77a7..684cc383e 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type StreamAccountConfig = { includeOwnedAccounts?: boolean, }; +export type StreamAccountConfig = { includeOwnedAccounts?: boolean }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts index 27eee4b8f..7743c97b6 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts @@ -2,4 +2,7 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type StreamAccountsEntry = { pubkey: string, includeOwnedAccounts?: boolean, }; +export type StreamAccountsEntry = { + pubkey: string; + includeOwnedAccounts?: boolean; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts b/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts index 3a771ee55..914abf3cb 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts @@ -2,4 +2,7 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type StreamedAccountInfo = { pubkey: string, includeOwnedAccounts: boolean, }; +export type StreamedAccountInfo = { + pubkey: string; + includeOwnedAccounts: boolean; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts index 2866fd2ac..45cf6325c 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts @@ -2,4 +2,9 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SupplyUpdate = { total?: number | bigint, circulating?: number | bigint, non_circulating?: number | bigint, non_circulating_accounts?: Array, }; +export type SupplyUpdate = { + total?: number | bigint; + circulating?: number | bigint; + non_circulating?: number | bigint; + non_circulating_accounts?: Array; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts index fbe4b377f..7169015b7 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts @@ -3,31 +3,32 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfidentialTransferAccountUpdate } from "./ConfidentialTransferAccountUpdate.js"; -export type TokenAccountUpdate = { -/** - * providing this value sets the amount of the token in the account data - */ -amount?: number | bigint, -/** - * providing this value sets the delegate of the token account: a base58 - * pubkey, or the literal string "null" to clear the delegate - */ -delegate?: string, -/** - * providing this value sets the state of the token account - */ -state?: string, -/** - * providing this value sets the amount authorized to the delegate - */ -delegatedAmount?: number | bigint, -/** - * providing this value sets the close authority of the token account: a - * base58 pubkey, or the literal string "null" to clear the authority - */ -closeAuthority?: string, -/** - * providing this value configures the Token-2022 confidential-transfer - * extension on the account (Token-2022 only) - */ -confidential?: ConfidentialTransferAccountUpdate, }; +export type TokenAccountUpdate = { + /** + * providing this value sets the amount of the token in the account data + */ + amount?: number | bigint; + /** + * providing this value sets the delegate of the token account: a base58 + * pubkey, or the literal string "null" to clear the delegate + */ + delegate?: string; + /** + * providing this value sets the state of the token account + */ + state?: string; + /** + * providing this value sets the amount authorized to the delegate + */ + delegatedAmount?: number | bigint; + /** + * providing this value sets the close authority of the token account: a + * base58 pubkey, or the literal string "null" to clear the authority + */ + closeAuthority?: string; + /** + * providing this value configures the Token-2022 confidential-transfer + * extension on the account (Token-2022 only) + */ + confidential?: ConfidentialTransferAccountUpdate; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts index 38ce0e5af..499a2e6d5 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts @@ -6,4 +6,11 @@ import type { UiAccountData } from "./UiAccountData.js"; /** * Mirrors [`solana_account_decoder_client_types::UiAccount`]. */ -export type UiAccount = { lamports: bigint, data: UiAccountData, owner: string, executable: boolean, rentEpoch: bigint, space: bigint | null, }; +export type UiAccount = { + lamports: bigint; + data: UiAccountData; + owner: string; + executable: boolean; + rentEpoch: bigint; + space: bigint | null; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts index 16b0caa01..913e2cbb4 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts @@ -6,4 +6,8 @@ import type { UiAccount } from "./UiAccount.js"; /** * Mirrors [`crate::types::UiAccountChange`], with [`UiAccountDef`] payloads. */ -export type UiAccountChange = { "type": "create", "data": UiAccount } | { "type": "update", "data": [UiAccount, UiAccount] } | { "type": "delete", "data": UiAccount } | { "type": "unchanged", "data": UiAccount | null }; +export type UiAccountChange = + | { type: "create"; data: UiAccount } + | { type: "update"; data: [UiAccount, UiAccount] } + | { type: "delete"; data: UiAccount } + | { type: "unchanged"; data: UiAccount | null }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts index 4e8652d57..bb78aaa8b 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts @@ -7,4 +7,5 @@ import type { UiAccountEncoding } from "./UiAccountEncoding.js"; /** * Mirrors [`solana_account_decoder_client_types::UiAccountData`]. */ -export type UiAccountData = string | ParsedAccount | [string, UiAccountEncoding]; +export type UiAccountData = + string | ParsedAccount | [string, UiAccountEncoding]; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts index c96a6313d..ce3dee5e2 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts @@ -5,4 +5,5 @@ /** * Mirrors [`solana_account_decoder_client_types::UiAccountEncoding`]. */ -export type UiAccountEncoding = "binary" | "base58" | "base64" | "jsonParsed" | "base64+zstd"; +export type UiAccountEncoding = + "binary" | "base58" | "base64" | "jsonParsed" | "base64+zstd"; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts index 69b31ff44..37a468033 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts @@ -6,4 +6,5 @@ import type { UiAccountChange } from "./UiAccountChange.js"; /** * Mirrors [`crate::types::UiAccountProfileState`], with [`UiAccountChangeDef`] payloads. */ -export type UiAccountProfileState = { "type": "readonly" } | { "type": "writable", "accountChange": UiAccountChange }; +export type UiAccountProfileState = + { type: "readonly" } | { type: "writable"; accountChange: UiAccountChange }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts index f61993eac..36db5f699 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts @@ -4,4 +4,10 @@ import type { UiAccount } from "./UiAccount.js"; import type { UiProfileResult } from "./UiProfileResult.js"; -export type UiKeyedProfileResult = { slot: bigint, key: string, instructionProfiles?: Array, transactionProfile: UiProfileResult, readonlyAccountStates: { [key in string]: UiAccount }, }; +export type UiKeyedProfileResult = { + slot: bigint; + key: string; + instructionProfiles?: Array; + transactionProfile: UiProfileResult; + readonlyAccountStates: { [key in string]: UiAccount }; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts index 6ea4bcdff..f8b7bf464 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts @@ -3,4 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UiAccountProfileState } from "./UiAccountProfileState.js"; -export type UiProfileResult = { accountStates: { [key in string]: UiAccountProfileState }, computeUnitsConsumed: bigint, logMessages: Array | null, errorMessage: string | null, }; +export type UiProfileResult = { + accountStates: { [key in string]: UiAccountProfileState }; + computeUnitsConsumed: bigint; + logMessages: Array | null; + errorMessage: string | null; +}; diff --git a/crates/sdk-node/surfpool-sdk/kit/types/api.ts b/crates/sdk-node/surfpool-sdk/kit/types/api.ts index 5bb7ecf97..339f39b2d 100644 --- a/crates/sdk-node/surfpool-sdk/kit/types/api.ts +++ b/crates/sdk-node/surfpool-sdk/kit/types/api.ts @@ -118,7 +118,7 @@ export type SurfnetGetConfidentialBalanceApi = { getConfidentialBalance(tokenAccount: Address, keys: ConfidentialBalanceKeys): GetConfidentialBalanceResponse; }; export type SurfnetDeriveConfidentialKeysApi = { - deriveConfidentialKeys(keypair: string, tokenAccount: Address): DeriveConfidentialKeysResponse; + deriveConfidentialKeys(elgamalSignature: string, aeSignature: string): DeriveConfidentialKeysResponse; }; export type SurfnetResetAccountApi = { resetAccount(pubkey: Address, config?: ResetAccountConfig): null; From 9344a59323e1bc05f14df43b7e70f7a7d93003ee Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 14 Aug 2026 15:05:24 -0600 Subject: [PATCH 09/17] feat(core): derive confidential keys from signatures, not a keypair surfnet_deriveConfidentialKeys took the owner keypair as an RPC parameter, which teaches callers to put a signing key on the wire. It now takes the two signatures the standard derivation asks the owner to produce, and derives through ElGamalKeypair::new_from_signature and AeKey::new_from_signature. Two signatures rather than one because the SDK domain-separates the two seed messages. What the caller has to sign is documented on the parameters. The seed prefix is dropped. solana_zk_sdk's derivation applies its own HKDF salt, so prefixing applied one a second time and produced keys no reference client would derive. The seed is token_account.as_ref() now, and a test asserts the derived keys are byte-identical to what new_from_signer produces from the same owner and account. Reject the all-zero signature. ElGamalSecretKey::seed_from_signer and AeKey::seed_from_signer both carry that check and the SDK tests that a null signer errors; the signature path went around it, so a defaulted signature would have derived a usable key from nothing. The derived secrets still come back in the response, which the read cheatcode needs in order to decrypt. That is unchanged, and visible on ConfidentialBalanceKeys. --- Cargo.lock | 2 + Cargo.toml | 2 + crates/core/Cargo.toml | 2 + crates/core/src/rpc/surfnet_cheatcodes.rs | 114 ++++++++----- crates/core/src/types.rs | 187 ++++++++++++++++++---- 5 files changed, 237 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 649aa3715..043833613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12034,6 +12034,8 @@ dependencies = [ "solana-version", "spl-associated-token-account-interface", "spl-token-2022-interface", + "spl-token-confidential-transfer-proof-extraction", + "spl-token-confidential-transfer-proof-generation", "spl-token-interface 2.0.0", "spl-token-metadata-interface", "surfpool-db", diff --git a/Cargo.toml b/Cargo.toml index 127ee3c4f..d22c7cc85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,6 +150,8 @@ solana-transaction-status = { version = "4.1", default-features = false, feature solana-version = { version = "4.0", default-features = false } spl-associated-token-account-interface = { version = "2.0.0", default-features = false } spl-token-2022-interface = { version = "2.0.0", default-features = false } +spl-token-confidential-transfer-proof-extraction = { version = "0.5.1", default-features = false } +spl-token-confidential-transfer-proof-generation = { version = "0.5.1", default-features = false } spl-token-interface = { version = "2.0.0", default-features = false } spl-token-metadata-interface = { version = "0.8.0", default-features = false } tempfile = "3.23.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 36e43e16b..301e7688a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -118,6 +118,8 @@ solana-secp256k1-program = { version = "3.0", default-features = false, features solana-secp256r1-program = "3.0" tempfile = { workspace = true } spl-token-metadata-interface = { workspace = true } +spl-token-confidential-transfer-proof-extraction = { workspace = true } +spl-token-confidential-transfer-proof-generation = { workspace = true } [features] default = ["sqlite"] diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 23a3a5dae..4cb0d7a9f 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -1283,17 +1283,23 @@ pub trait SurfnetCheatcodes { keys: ConfidentialBalanceKeys, ) -> BoxFuture>>; - /// A cheat code to derive an owner's confidential-transfer keys for a token account. + /// A cheat code to derive an owner's confidential-transfer keys from the owner's signatures. /// /// The confidential cheatcodes take an `elgamalPubkey` and an `aesKey`, which a client would /// normally derive with an external confidential-transfer SDK. Deriving them here lets a test /// drive the whole confidential suite with no client-side crypto dependency. /// /// ## Parameters - /// - `keypair`: The owner's 64-byte Solana keypair, base58 or base64 encoded. - /// - `token_account`: The base-58 encoded address of the token account the keys are for. Keys are - /// derived per token account, so this must be the same address later passed to - /// `surfnet_getConfidentialBalance`. + /// - `elgamal_signature`: The owner's 64-byte signature over the bytes `"ElGamalSecretKey"` + /// followed by the token account address, base58 or base64 encoded. + /// - `ae_signature`: The owner's 64-byte signature over the bytes `"AeKey"` followed by the token + /// account address, base58 or base64 encoded. + /// + /// `solana_zk_sdk` derives the two keys from signatures over two different domain-separated + /// messages, so one signature cannot reproduce both: passing the same signature twice still + /// returns a well-formed pair, just not the pair the signer path derives. Signing those two + /// messages over the token account address is what scopes the keys to that account; the caller + /// picks the seed, so per-wallet keying is the same call with a different message signed. /// /// ## Returns /// A `RpcResponse` with base58 `elgamalPubkey` (for @@ -1307,8 +1313,8 @@ pub trait SurfnetCheatcodes { /// "id": 1, /// "method": "surfnet_deriveConfidentialKeys", /// "params": [ - /// "", - /// "4EXSeLGxVBpAZwq7vm6evLdewpcvE2H56fpqL2pPiLFa" + /// "", + /// "" /// ] /// } /// ``` @@ -1333,25 +1339,41 @@ pub trait SurfnetCheatcodes { /// ``` /// /// # Notes - /// These keys are self-consistent within a surfnet: pair them with `surfnet_setTokenAccount` and - /// `surfnet_getConfidentialBalance` and the confidential cheatcodes round-trip. They do **not** - /// currently reproduce the keys a confidential-transfer client derives for the same keypair, so - /// they will not open the balance of an account that was configured off-chain. - /// - /// Two things differ from the standardised derivation in `solana_zk_sdk::encryption::derivation`. - /// The key derivation function is the older `new_from_signer` path, which that SDK itself flags as - /// non-standard and replaces with HKDF-SHA512. And the public seed here is prefixed with - /// `solana-conf-bal/v1`, which the standardised derivation already applies internally as its HKDF - /// salt. Closing the gap means reaching that derivation, which is not available through the - /// Token-2022 interface crate this build pins. - /// - /// The keypair is used only to sign the derivation seeds; it is not stored. + /// The keys come from `ElGamalKeypair::new_from_signature` and `AeKey::new_from_signature` in the + /// `solana_zk_sdk` this build pins. Those are the same two functions `new_from_signer` calls once + /// it has signed the seed messages itself, so signing `"ElGamalSecretKey" || token_account` and + /// `"AeKey" || token_account` here yields byte-identical keys to + /// `ElGamalKeypair::new_from_signer(&owner, token_account.as_ref())` and + /// `AeKey::new_from_signer(&owner, token_account.as_ref())`. An account configured off-chain by a + /// client on that derivation opens with these keys. The one input where the two paths would + /// otherwise part is the all-zero default signature, which `new_from_signer` refuses as key + /// material and `new_from_signature` would hash into a publicly computable key pair; this + /// method rejects it too, so the equivalence holds without exception. + /// + /// That SDK marks the KDF behind these functions as non-standard and intends to replace it, so + /// what these keys interoperate with is the SDK version, not a frozen standard. + /// + /// The owner's *signing* key never crosses the wire: the caller signs the two seed messages + /// locally and sends only the signatures, so a hardware signer, which never exposes its signing + /// key, can drive this. The signatures are used only as key material and are not stored. + /// + /// What that does and does not buy is worth stating plainly. Because the ElGamal secret is a + /// hash of the signature, `elgamalSignature` is exactly as sensitive as the `elgamalSecretKey` + /// it derives — anyone who sees it recomputes that key offline. What changed is that the + /// material on the wire no longer confers transaction-signing power, only confidential-balance + /// decryption; it is not that nothing sensitive is transported. + /// + /// The derived `elgamalSecretKey` and `aesKey` do travel back in the response, and + /// `surfnet_getConfidentialBalance` takes them back as inputs. That is the point of the + /// cheatcode — it is what removes the client-side crypto dependency — but it does mean the + /// confidential keys are transported and are only as private as the RPC connection. This is a + /// simnet testing convenience, not a key-management pattern to carry to a live cluster. #[rpc(meta, name = "surfnet_deriveConfidentialKeys")] fn derive_confidential_keys( &self, meta: Self::Metadata, - keypair: String, - token_account: String, + elgamal_signature: String, + ae_signature: String, ) -> Result>; /// A "cheat code" method for developers to write program data at a specified offset in Surfpool. @@ -2433,13 +2455,12 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { fn derive_confidential_keys( &self, meta: Self::Metadata, - keypair: String, - token_account_str: String, + elgamal_signature: String, + ae_signature: String, ) -> Result> { - let token_account = verify_pubkey(&token_account_str)?; let svm_locker = meta.get_svm_locker()?; - let keys = - derive_confidential_keys(&keypair, &token_account).map_err(Error::invalid_params)?; + let keys = derive_confidential_keys(&elgamal_signature, &ae_signature) + .map_err(Error::invalid_params)?; Ok(RpcResponse { context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()), value: keys, @@ -2593,7 +2614,10 @@ mod tests { }; use super::*; - use crate::{rpc::surfnet_cheatcodes::SurfnetCheatcodesRpc, tests::helpers::TestSetup}; + use crate::{ + rpc::surfnet_cheatcodes::SurfnetCheatcodesRpc, tests::helpers::TestSetup, + types::confidential_key_signatures, + }; /// Guards the canonical cheatcode method manifest in `surfpool-types` /// against drift: adding, removing, or renaming a method on the @@ -5303,7 +5327,10 @@ mod tests { } /// The whole point of the pair: derive keys, set a confidential balance, read it - /// back — with no client-side crypto anywhere in the test. + /// back. The only thing the test computes locally is the two ed25519 signatures a + /// wallet would produce; the ElGamal and AES keys are derived by + /// `surfnet_deriveConfidentialKeys`, and the balance is encrypted by + /// `surfnet_setTokenAccount` and decrypted by `surfnet_getConfidentialBalance`. #[tokio::test(flavor = "multi_thread")] async fn test_confidential_balance_round_trip() { use surfpool_types::types::ConfidentialTransferAccountUpdate; @@ -5318,12 +5345,13 @@ mod tests { &token_program, ); + let (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); let keys = client .rpc .derive_confidential_keys( Some(client.context.clone()), - bs58::encode(owner.to_bytes()).into_string(), - token_account.to_string(), + elgamal_signature, + ae_signature, ) .expect("key derivation should succeed") .value; @@ -5458,21 +5486,24 @@ mod tests { ); } - /// Keys are scoped to a token account, so the same wallet gets different keys per - /// account — and the same ones every time for a given account. + /// Signing the seed messages over a token account address is what scopes the keys + /// to that account: the same wallet gets different keys per account, and the same + /// ones every time for a given account. #[tokio::test(flavor = "multi_thread")] async fn test_derive_confidential_keys_are_deterministic_and_account_scoped() { let client = TestSetup::new(SurfnetCheatcodesRpc::empty()); - let owner = bs58::encode(Keypair::new().to_bytes()).into_string(); + let owner = Keypair::new(); let token_account = Pubkey::new_unique(); let derive = |token_account: Pubkey| { + let (elgamal_signature, ae_signature) = + confidential_key_signatures(&owner, &token_account); client .rpc .derive_confidential_keys( Some(client.context.clone()), - owner.clone(), - token_account.to_string(), + elgamal_signature, + ae_signature, ) .expect("key derivation should succeed") .value @@ -5496,12 +5527,13 @@ mod tests { &token_program, ); + let (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); let keys = client .rpc .derive_confidential_keys( Some(client.context.clone()), - bs58::encode(owner.to_bytes()).into_string(), - token_account.to_string(), + elgamal_signature, + ae_signature, ) .expect("key derivation should succeed") .value; @@ -5541,12 +5573,14 @@ mod tests { // A wrong AES key must fail the ciphertext's authentication tag rather than // silently decrypt to some other number. + let (foreign_elgamal_signature, foreign_ae_signature) = + confidential_key_signatures(&Keypair::new(), &token_account); let wrong_keys = client .rpc .derive_confidential_keys( Some(client.context.clone()), - bs58::encode(Keypair::new().to_bytes()).into_string(), - token_account.to_string(), + foreign_elgamal_signature, + foreign_ae_signature, ) .expect("key derivation should succeed") .value; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 4e4d45a0e..400406b9d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -13,7 +13,6 @@ use solana_account_decoder::{ }; use solana_clock::{Epoch, Slot}; use solana_hash::Hash; -use solana_keypair::Keypair; use solana_message::{ AccountKeys, VersionedMessage, v0::{LoadedAddresses, LoadedMessage, MessageAddressTableLookup}, @@ -21,6 +20,7 @@ use solana_message::{ use solana_program_option::COption; use solana_program_pack::Pack; use solana_pubkey::Pubkey; +use solana_signature::{SIGNATURE_BYTES, Signature}; use solana_transaction::{ sanitized::SanitizedTransaction, versioned::{TransactionVersion, VersionedTransaction}, @@ -1237,6 +1237,22 @@ fn decode_confidential_key(input: &str, expected_len: usize) -> Result, Ok(bytes) } +/// Decode one of the owner signatures the confidential key derivation is hashed +/// from, accepting the same base58-or-base64 encodings as the key parameters. +fn parse_confidential_key_signature(input: &str) -> Result { + let bytes = decode_confidential_key(input, SIGNATURE_BYTES)?; + let signature = Signature::try_from(bytes.as_slice()) + .map_err(|_| format!("expected a {SIGNATURE_BYTES}-byte Solana signature"))?; + // Some `Signer` implementations return the all-zero default signature instead of + // signing. `new_from_signer` rejects it as key material; `new_from_signature`, which + // this derivation calls, hashes whatever it is handed and would return a fixed key + // pair anyone can compute. Reject it here so both paths agree on this input. + if signature == Signature::default() { + return Err("expected a real signature, got the all-zero default".to_string()); + } + Ok(signature) +} + /// Build the raw account data for a Token-2022 token account that carries the /// confidential-transfer extension (and, when `mint_has_transfer_fee` is set, /// the companion confidential-transfer fee-amount extension). @@ -1351,7 +1367,8 @@ pub fn build_confidential_token_account_data( /// confidential test loop whose write half is `surfnet_setTokenAccount`. Each /// balance has its own key because the extension stores them differently: /// - **available** is read from `decryptable_available_balance`, the AES copy the -/// program maintains for the owner. The authoritative `available_balance` is an +/// owner supplies and the program stores verbatim — the program holds no AES key, +/// so it never computes this field. The authoritative `available_balance` is an /// ElGamal ciphertext over a full u64 and is not recoverable by the u32 /// discrete-log decode, so the AES copy is the only read path. /// - **pending** is read from the `lo`/`hi` ElGamal ciphertexts, each within the @@ -1417,43 +1434,35 @@ fn decrypt_pending_balance( }) } -/// The per-token-account public seed the confidential keys are derived over. The -/// signature of this seed, not the wallet's private key, which hardware signers -/// never expose, is what the key derivation is hashed from. -/// -/// This prefix is non-standard and the derived keys are surfnet-local as a result. -/// `solana_zk_sdk::encryption::derivation` applies this same `solana-conf-bal/v1` -/// string itself, as its HKDF salt, so prepending it to the seed applies it twice. -/// Reference clients pass the seed unprefixed: an empty seed for per-wallet keying, -/// or the raw token account address for per-account keying. Dropping the prefix on -/// its own would not make these keys interoperable, because the derivation reachable -/// from the pinned Token-2022 interface crate is also the older, pre-HKDF one; both -/// have to move together. -const CONFIDENTIAL_KEY_SEED_PREFIX: &[u8] = b"solana-conf-bal/v1"; - -/// Derive an owner's confidential-transfer keys for a token account. +/// Derive an owner's confidential-transfer keys from the owner's signatures. /// /// Backs the `surfnet_deriveConfidentialKeys` cheatcode. Doing this server-side is /// what lets the confidential cheatcodes be used with no client-side crypto -/// dependency at all: the caller hands over the keypair its test already holds and -/// gets back the exact keys a confidential client would have derived, ready to pass +/// dependency at all: the caller signs the two seed messages its confidential +/// client would sign, hands over the signatures, and gets back keys ready to pass /// to `surfnet_setTokenAccount` and `surfnet_getConfidentialBalance`. /// -/// The ElGamal and AES keys are derived from signatures over two different messages, -/// so this takes the keypair rather than a single pre-computed signature. +/// The derivation semantics are documented once, on the RPC method that exposes this: +/// see `surfnet_deriveConfidentialKeys` on +/// [`crate::rpc::surfnet_cheatcodes::SurfnetCheatcodes`] for why the two seed messages are +/// domain-separated and take one signature each, why the result is byte-identical to +/// `ElGamalKeypair::new_from_signer` / `AeKey::new_from_signer`, and what does and does not +/// cross the wire. +/// +/// `derive_confidential_keys` itself imposes no seed: the caller owns what the keys are +/// scoped to, because the caller owns what it signed. pub fn derive_confidential_keys( - keypair: &str, - token_account: &Pubkey, + elgamal_signature: &str, + ae_signature: &str, ) -> Result { - let keypair_bytes = - decode_confidential_key(keypair, 64).map_err(|e| format!("keypair: {e}"))?; - let keypair = Keypair::try_from(keypair_bytes.as_slice()) - .map_err(|e| format!("keypair: invalid Solana keypair ({e})"))?; + let elgamal_signature = parse_confidential_key_signature(elgamal_signature) + .map_err(|e| format!("elgamalSignature: {e}"))?; + let ae_signature = + parse_confidential_key_signature(ae_signature).map_err(|e| format!("aeSignature: {e}"))?; - let public_seed = [CONFIDENTIAL_KEY_SEED_PREFIX, token_account.as_ref()].concat(); - let elgamal = ElGamalKeypair::new_from_signer(&keypair, &public_seed) + let elgamal = ElGamalKeypair::new_from_signature(&elgamal_signature) .map_err(|e| format!("failed to derive ElGamal keypair: {e}"))?; - let aes_key = AeKey::new_from_signer(&keypair, &public_seed) + let aes_key = AeKey::new_from_signature(&ae_signature) .map_err(|e| format!("failed to derive AES key: {e}"))?; let elgamal_secret_key: [u8; 32] = elgamal.secret().into(); @@ -1466,6 +1475,124 @@ pub fn derive_confidential_keys( }) } +/// Sign the two seed messages `derive_confidential_keys` expects, scoping the keys +/// to `token_account`. +/// +/// This is the client-side half of the cheatcode: it mirrors, in the open, what +/// `ElGamalKeypair::new_from_signer` and `AeKey::new_from_signer` sign internally. +#[cfg(test)] +pub(crate) fn confidential_key_signatures( + owner: &solana_keypair::Keypair, + token_account: &Pubkey, +) -> (String, String) { + use solana_signer::Signer; + + let sign = |domain: &[u8]| { + owner + .sign_message(&[domain, token_account.as_ref()].concat()) + .to_string() + }; + (sign(b"ElGamalSecretKey"), sign(b"AeKey")) +} + +#[cfg(test)] +mod confidential_key_derivation_tests { + use solana_keypair::Keypair; + + use super::*; + + /// The whole point of moving from a keypair to signatures is that the keys do + /// not change. Derive both ways over the same owner and token account and + /// compare: `new_from_signer` signs `"ElGamalSecretKey" || seed` and + /// `"AeKey" || seed` itself and then calls the very `new_from_signature` + /// functions the cheatcode now calls, so the two paths must agree byte for byte. + #[test] + fn signatures_reproduce_the_keys_the_signer_path_derived() { + let owner = Keypair::new(); + let token_account = Pubkey::new_unique(); + + let (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); + let from_signatures = derive_confidential_keys(&elgamal_signature, &ae_signature) + .expect("deriving from signatures should succeed"); + + // The reference path, with the standard per-account seed and no prefix. + let seed = token_account.as_ref(); + let elgamal = ElGamalKeypair::new_from_signer(&owner, seed).unwrap(); + let aes_key = AeKey::new_from_signer(&owner, seed).unwrap(); + let elgamal_secret: [u8; 32] = elgamal.secret().into(); + let aes_key_bytes: [u8; 16] = aes_key.into(); + + assert_eq!( + from_signatures.elgamal_pubkey, + bs58::encode(bytes_of(&PodElGamalPubkey::from(elgamal.pubkey_owned()))).into_string(), + ); + assert_eq!( + from_signatures.elgamal_secret_key, + bs58::encode(elgamal_secret).into_string(), + ); + assert_eq!( + from_signatures.aes_key, + bs58::encode(aes_key_bytes).into_string(), + ); + } + + /// The two seed messages are domain-separated, so reusing one signature for + /// both keys does not reproduce the signer path. This is why the cheatcode + /// takes two signatures rather than one. + #[test] + fn one_signature_cannot_stand_in_for_both() { + let owner = Keypair::new(); + let token_account = Pubkey::new_unique(); + + let (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); + assert_ne!(elgamal_signature, ae_signature); + + let reused = derive_confidential_keys(&elgamal_signature, &elgamal_signature).unwrap(); + let correct = derive_confidential_keys(&elgamal_signature, &ae_signature).unwrap(); + + assert_eq!(reused.elgamal_pubkey, correct.elgamal_pubkey); + assert_ne!(reused.aes_key, correct.aes_key); + } + + /// `new_from_signature` hashes the all-zero default signature happily, so + /// without an explicit check the cheatcode would hand back a fixed key pair + /// anyone can compute. `new_from_signer`, the path this replaced, rejects it. + /// Assert the rejection on both parameters and that the error names which one. + #[test] + fn the_default_signature_is_rejected_on_both_parameters() { + let owner = Keypair::new(); + let token_account = Pubkey::new_unique(); + let (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); + let default_signature = Signature::default().to_string(); + + // Why the check has to live here: the SDK functions this calls accept it. + assert!(ElGamalKeypair::new_from_signature(&Signature::default()).is_ok()); + assert!(AeKey::new_from_signature(&Signature::default()).is_ok()); + + let error = derive_confidential_keys(&default_signature, &ae_signature).unwrap_err(); + assert!(error.starts_with("elgamalSignature:"), "got: {error}"); + + let error = derive_confidential_keys(&elgamal_signature, &default_signature).unwrap_err(); + assert!(error.starts_with("aeSignature:"), "got: {error}"); + } + + /// A 32-byte pubkey is valid base58 but is not a signature, and the error has + /// to name which of the two parameters was wrong. + #[test] + fn a_malformed_signature_names_its_parameter() { + let owner = Keypair::new(); + let token_account = Pubkey::new_unique(); + let (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); + let not_a_signature = token_account.to_string(); + + let error = derive_confidential_keys(¬_a_signature, &ae_signature).unwrap_err(); + assert!(error.starts_with("elgamalSignature:"), "got: {error}"); + + let error = derive_confidential_keys(&elgamal_signature, ¬_a_signature).unwrap_err(); + assert!(error.starts_with("aeSignature:"), "got: {error}"); + } +} + impl_token_program_packable_serde!( TokenAccount, spl_token_2022_interface::state::Account, From 4e35cafff969e6575e45e5d3d760132bbf294b13 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 14 Aug 2026 15:06:13 -0600 Subject: [PATCH 10/17] test(core): cover the confidential balance cheatcodes end to end Three round trips through a running surfnet: a deposit, a plaintext transfer_checked followed by a deposit, and a confidential Transfer. The confidential Transfer test is ignored and does not run. Proof generation here resolves solana-zk-sdk 4.0.0 through spl-token-2022-interface and spl-pod, while the proof program the runtime verifies against is 5.0.1, and transcript construction moved layer between those majors. The instruction builds and Token-2022 accepts it; the proof program rejects it with SigmaProof(Equality, AlgebraicRelation). Nothing published aligns the two sides, so the test ships with that reason recorded rather than deleted or weakened into passing. The two that run assert on decrypted balances, and their doc comments name the assertions that do not bind to the derived keys rather than implying they all do. A deposit passes the decrypt handle through untouched, so a deposit onto an all-zero pending ciphertext opens to the same value under any secret key; the pending balance is seeded with a real encryption first so that assertion binds. --- crates/core/src/tests/integration.rs | 849 +++++++++++++++++++++++++-- 1 file changed, 812 insertions(+), 37 deletions(-) diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index d72885c55..769e4b209 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -11115,43 +11115,37 @@ async fn test_request_airdrop_rejects_below_rent_amount() { assert!(err.message.contains("rent-exempt minimum")); } -/// Round trip: derive the owner's confidential keys, move tokens into the -/// confidential balance with real Token-2022 instructions, and read the new -/// balance back decrypted. +/// Round trip: derive the owner's confidential keys with +/// `surfnet_deriveConfidentialKeys`, move tokens into the confidential balance +/// with a real `Deposit` plus `ApplyPendingBalance`, and read the new balance +/// back with `surfnet_getConfidentialBalance`. /// -/// The three legs are `surfnet_deriveConfidentialKeys`, a `Deposit` plus an -/// `ApplyPendingBalance` executed by the Token-2022 program itself, and -/// `surfnet_getConfidentialBalance`. -/// -/// The pending balance is seeded with a real ElGamal encryption under the -/// derived public key before the deposit runs, so the ciphertext the deposit -/// lands on carries a decrypt handle that is not the identity point. Two -/// assertions then bind that read to the derived key: the derived secret key -/// recovers seed plus deposit exactly, and a random ElGamal secret key recovers -/// nothing. Replace the derived key with a stranger's and the test fails. The -/// seeding step is deliberate — deposits onto the all-zero pending ciphertext a -/// freshly configured account carries produce an identity handle, and such a -/// ciphertext opens to the same value under any secret key. On-chain that state -/// arrives via a party-to-party `Transfer`, which is proof-gated and out of -/// reach here, so the test writes it directly. +/// The pending balance is seeded with a real ElGamal encryption under the derived +/// public key first, so the ciphertext the deposit lands on carries a decrypt +/// handle that is not the identity point — deposits onto the all-zero pending +/// ciphertext a freshly configured account carries produce an identity handle, +/// and such a ciphertext opens to the same value under any secret key. On chain +/// that state arrives via a party-to-party `Transfer`, which is proof-gated and +/// out of reach here, so the test writes it directly. Two assertions then bind +/// the read to the derived key: the derived secret key recovers seed plus deposit +/// exactly, and a random ElGamal secret key recovers nothing. /// -/// What the rest of the assertions carry: the plaintext fields the program -/// itself computes — the public token balance falls by exactly the deposited -/// amount, and the pending credit counter goes to 1 and back to 0 — catch a -/// deposit that silently did nothing. The decrypted available balance -/// round-trips this test's own AES ciphertext, so it shows that `AeKey` -/// encryption and decryption agree and that the cheatcode reads the right -/// field; it does not bind to the ElGamal key. The post-apply pending read of 0 -/// is a shape check, not a key check: `ApplyPendingBalance` resets the pending -/// ciphertext to all-zero, which decodes to 0 under any key. +/// The remaining assertions are weaker, and labelled as such. The plaintext +/// fields the program itself computes — public token balance down by exactly the +/// deposited amount, pending credit counter to 1 and back to 0 — only catch a +/// deposit that silently did nothing. The decrypted available balance round-trips +/// this test's own AES ciphertext: it shows that `AeKey` encryption and +/// decryption agree and that the cheatcode reads the right field, but it does not +/// bind to the ElGamal key. The post-apply pending read of 0 is a shape check, +/// not a key check — `ApplyPendingBalance` resets that ciphertext to all-zero, +/// which decodes to 0 under any key. /// -/// The account is put into its configured state by `surfnet_setTokenAccount` -/// rather than by an on-chain `ConfigureAccount`, which is proof-gated and so -/// blocked by the same SDK skew as `Transfer`. -/// -/// The deposit path is the confidential-balance movement that carries no -/// zero-knowledge proof. A party-to-party `Transfer` additionally needs proofs -/// verified by the ZK ElGamal proof program and is not covered here. +/// The account reaches its configured state via `surfnet_setTokenAccount` rather +/// than an on-chain `ConfigureAccount`, which is proof-gated; nothing here +/// establishes whether that instruction would succeed under this harness. +/// `Deposit` is the confidential-balance movement that carries no zero-knowledge +/// proof; a party-to-party `Transfer` additionally needs proofs verified by the +/// ZK ElGamal proof program and is not covered here. #[test_case(TestType::sqlite(); "with on-disk sqlite db")] #[test_case(TestType::in_memory(); "with in-memory sqlite db")] #[test_case(TestType::no_db(); "with no db")] @@ -11207,12 +11201,15 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { &token_program, ); - // Leg 1: derive the owner's confidential keys for this token account. + // Leg 1: derive the owner's confidential keys from signatures scoped to this + // token account, which are the two messages a confidential client signs. + let (elgamal_signature, ae_signature) = + crate::types::confidential_key_signatures(&owner, &token_account); let keys = rpc_server .derive_confidential_keys( Some(runloop_context.clone()), - bs58::encode(owner.to_bytes()).into_string(), - token_account.to_string(), + elgamal_signature, + ae_signature, ) .expect("deriveConfidentialKeys should succeed") .value; @@ -11505,3 +11502,781 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { "applying the pending balance resets the credit counter" ); } + +/// **This test is `#[ignore]`d and does not run — nothing described below is +/// asserted by the suite.** See the `# Why this is ignored` section. +/// +/// The round trip the confidential cheatcodes exist to support, end to end and +/// between two parties: derive both owners' keys with +/// `surfnet_deriveConfidentialKeys`, move value from one to the other with a +/// real Token-2022 `Transfer`, and read the result on both sides with +/// `surfnet_getConfidentialBalance`. +/// +/// Unlike a `Deposit`, which only moves an owner's own public tokens into their +/// own encrypted pending balance, a `Transfer` moves value between two +/// accounts entirely under encryption and is gated on three zero-knowledge +/// proofs verified by the ZK ElGamal proof program: a ciphertext-commitment +/// equality proof, a batched grouped-ciphertext validity proof, and a batched +/// range proof. This test builds all three and submits them alongside the +/// transfer. +/// +/// What binds the assertions to the derived keys: the amount that lands on the +/// recipient is decrypted out of the recipient's pending balance with the +/// recipient's ElGamal secret key. That pending balance is written by the +/// program from the transfer's ciphertext, under the recipient's ElGamal +/// public key, so nothing but the matching secret key opens it, and a +/// stranger's key is asserted to recover nothing. The sender's remaining +/// available balance is decrypted with the sender's AES key, which shows the +/// cheatcode reads the right field but does not bind to the derivation, the +/// same caveat the deposit round trip records. +/// +/// # Why this is ignored +/// +/// This does not pass today, and the reason is a version skew in the dependency +/// graph rather than anything about the cheatcodes. Two different +/// `solana-zk-sdk` majors are linked at once: +/// +/// - proof *generation* here resolves to `solana-zk-sdk` 4.0.0, pinned through +/// `spl-token-2022-interface` -> `spl-pod`; +/// - the proof *verifier* the runtime actually runs is +/// `solana-zk-elgamal-proof-program` 4.1.2, pulled in by `litesvm` -> +/// `solana-builtins`, which is built against `solana-zk-sdk` 5.0.1. +/// +/// Between those two versions the Fiat-Shamir transcript of the +/// ciphertext-commitment equality proof changed. 5.0.1 hashes the public +/// context (ElGamal pubkey, ciphertext, commitment) into the transcript inside +/// both `CiphertextCommitmentEqualityProof::new` and `::verify`; 4.0.0 hashes +/// none of it *there*, and documents that the caller is responsible. In 4.0.0 +/// the caller does do it, one layer up in +/// `CiphertextCommitmentEqualityProofContext::new_transcript`, which also seeds +/// the transcript with `Transcript::new` where 5.0.1 uses +/// `Transcript::new_zk_elgamal_transcript`. So both versions absorb the same +/// context, but at different layers and from a different starting state — +/// worth being precise about, because "add the missing hash to 4.0.0" is not +/// the fix it sounds like. Prover and verifier therefore derive different +/// challenge scalars, and the program rejects the proof with +/// `proof verification failed: SigmaProof(Equality, AlgebraicRelation)`. +/// +/// Everything in this section was observed by running the test locally; because +/// it is ignored, nothing in the suite asserts any of it. In particular, no test +/// in this change establishes that a confidential `Transfer` completes — the +/// assertions that would show it never execute. Treat that path as uncovered, +/// not as working. +/// +/// Un-ignore this once proof generation and the runtime's proof program agree on +/// a `solana-zk-sdk` major — either `litesvm` linking a 4.x proof program, or +/// `spl-token-2022-interface` moving to the 5.x proof stack. +#[test_case(TestType::no_db(); "with no db")] +#[tokio::test(flavor = "multi_thread")] +#[ignore = "see the '# Why this is ignored' section of the doc comment above"] +async fn test_confidential_balance_transfer_round_trip(test_type: TestType) { + use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; + use spl_token_2022_interface::{ + extension::confidential_transfer::{ + ConfidentialTransferAccount, instruction as confidential_instruction, + }, + solana_zk_sdk::encryption::{ + auth_encryption::{AeCiphertext, AeKey}, + elgamal::{ElGamalCiphertext, ElGamalKeypair, ElGamalPubkey, ElGamalSecretKey}, + }, + }; + use spl_token_confidential_transfer_proof_extraction::instruction::ProofLocation; + use spl_token_confidential_transfer_proof_generation::transfer::transfer_split_proof_data; + use surfpool_types::types::{ + ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, + }; + + let rpc_server = SurfnetCheatcodesRpc::empty(); + let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); + let svm_locker = SurfnetSvmLocker::new(svm_instance); + let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); + let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); + let runloop_context = RunloopContext { + id: None, + svm_locker: svm_locker.clone(), + simnet_commands_tx: simnet_cmd_tx, + remote_rpc_client: None, + rpc_config: RpcConfig::default(), + cheatcode_config: CheatcodeConfig::new(), + plugin_commands_tx, + }; + + let token_program = spl_token_2022_interface::id(); + let sender = Keypair::new(); + let recipient = Keypair::new(); + let mint = Keypair::new(); + let decimals = 2u8; + + svm_locker + .airdrop(&sender.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + svm_locker + .airdrop(&recipient.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + + let source_account = get_associated_token_address_with_program_id( + &sender.pubkey(), + &mint.pubkey(), + &token_program, + ); + let destination_account = get_associated_token_address_with_program_id( + &recipient.pubkey(), + &mint.pubkey(), + &token_program, + ); + + // Leg 1: derive both parties' confidential keys through the cheatcode, from + // signatures scoped to each party's own token account. + let (sender_elgamal_signature, sender_ae_signature) = + crate::types::confidential_key_signatures(&sender, &source_account); + let sender_keys = rpc_server + .derive_confidential_keys( + Some(runloop_context.clone()), + sender_elgamal_signature, + sender_ae_signature, + ) + .expect("deriveConfidentialKeys should succeed for the sender") + .value; + + let (recipient_elgamal_signature, recipient_ae_signature) = + crate::types::confidential_key_signatures(&recipient, &destination_account); + let recipient_keys = rpc_server + .derive_confidential_keys( + Some(runloop_context.clone()), + recipient_elgamal_signature, + recipient_ae_signature, + ) + .expect("deriveConfidentialKeys should succeed for the recipient") + .value; + + // A mint carrying the confidential-transfer extension. + let mint_len = + spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< + spl_token_2022_interface::state::Mint, + >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) + .unwrap(); + let mint_rent = + svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); + + let setup_instructions = vec![ + system_instruction::create_account( + &sender.pubkey(), + &mint.pubkey(), + mint_rent, + mint_len as u64, + &token_program, + ), + confidential_instruction::initialize_mint( + &token_program, + &mint.pubkey(), + Some(sender.pubkey()), + true, + None, + ) + .unwrap(), + spl_token_2022_interface::instruction::initialize_mint2( + &token_program, + &mint.pubkey(), + &sender.pubkey(), + None, + decimals, + ) + .unwrap(), + ]; + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let setup_message = Message::new_with_blockhash( + &setup_instructions, + Some(&sender.pubkey()), + &recent_blockhash, + ); + let setup_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&sender, &mint]) + .unwrap(); + let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, setup_tx, setup_status_tx, false, true) + .await + .unwrap(); + assert!( + matches!( + setup_status_rx.recv().unwrap(), + TransactionStatusEvent::Success(_) + ), + "mint setup should succeed" + ); + + // Configure both confidential accounts. The sender starts with a real + // available balance; the recipient starts empty and receive-only. + let sender_start = 5_000u64; + rpc_server + .set_token_account( + Some(runloop_context.clone()), + sender.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + amount: Some(0), + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: sender_keys.elgamal_pubkey.clone(), + aes_key: Some(sender_keys.aes_key.clone()), + amount: Some(sender_start), + approved: Some(true), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("setTokenAccount should succeed for the sender"); + + rpc_server + .set_token_account( + Some(runloop_context.clone()), + recipient.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + amount: Some(0), + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: recipient_keys.elgamal_pubkey.clone(), + aes_key: Some(recipient_keys.aes_key.clone()), + amount: Some(0), + approved: Some(true), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("setTokenAccount should succeed for the recipient"); + + // Rebuild the sender's keys as SDK objects; the proof generation needs the + // ElGamal keypair and AES key that the cheatcode just handed back. + let sender_elgamal_secret = ElGamalSecretKey::try_from( + bs58::decode(&sender_keys.elgamal_secret_key) + .into_vec() + .unwrap() + .as_slice(), + ) + .expect("derived sender elgamalSecretKey should parse"); + let sender_elgamal_keypair = ElGamalKeypair::new(sender_elgamal_secret); + let sender_ae_bytes: [u8; 16] = bs58::decode(&sender_keys.aes_key) + .into_vec() + .unwrap() + .try_into() + .unwrap(); + let sender_ae_key = AeKey::from(sender_ae_bytes); + let recipient_elgamal_pubkey = ElGamalPubkey::try_from( + bs58::decode(&recipient_keys.elgamal_pubkey) + .into_vec() + .unwrap() + .as_slice(), + ) + .expect("derived recipient elgamalPubkey should parse"); + + // Read the sender's on-account ciphertexts back out; the proofs are built + // against exactly the state the program will do its homomorphic math on. + let (current_available_balance, current_decryptable_available_balance) = { + let account = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&source_account).unwrap()) + .expect("source token account should exist"); + let state = + StateWithExtensions::::unpack(&account.data) + .expect("source account should unpack with extensions"); + let ext = state + .get_extension::() + .expect("confidential extension should be present"); + ( + ElGamalCiphertext::try_from(ext.available_balance) + .expect("available balance should be a valid ElGamal ciphertext"), + AeCiphertext::try_from(ext.decryptable_available_balance) + .expect("decryptable available balance should be a valid AES ciphertext"), + ) + }; + + // Leg 2: build the three zero-knowledge proofs a real transfer requires and + // submit them with the transfer itself. + let transfer_amount = 1_250u64; + let proof_data = transfer_split_proof_data( + ¤t_available_balance, + ¤t_decryptable_available_balance, + transfer_amount, + &sender_elgamal_keypair, + &sender_ae_key, + &recipient_elgamal_pubkey, + None, + ) + .expect("transfer proof generation should succeed"); + + let new_source_decryptable_available_balance = + sender_ae_key.encrypt(sender_start - transfer_amount).into(); + + let transfer_instructions = confidential_instruction::transfer( + &token_program, + &source_account, + &mint.pubkey(), + &destination_account, + &new_source_decryptable_available_balance, + &proof_data + .ciphertext_validity_proof_data_with_ciphertext + .ciphertext_lo, + &proof_data + .ciphertext_validity_proof_data_with_ciphertext + .ciphertext_hi, + &sender.pubkey(), + &[], + ProofLocation::InstructionOffset(1.try_into().unwrap(), &proof_data.equality_proof_data), + ProofLocation::InstructionOffset( + 2.try_into().unwrap(), + &proof_data + .ciphertext_validity_proof_data_with_ciphertext + .proof_data, + ), + ProofLocation::InstructionOffset(3.try_into().unwrap(), &proof_data.range_proof_data), + ) + .expect("building the transfer instructions should succeed"); + + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let transfer_message = Message::new_with_blockhash( + &transfer_instructions, + Some(&sender.pubkey()), + &recent_blockhash, + ); + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(transfer_message), &[&sender]) + .unwrap(); + let (transfer_status_tx, transfer_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, transfer_tx, transfer_status_tx, false, true) + .await + .unwrap(); + let transfer_status = transfer_status_rx.recv().unwrap(); + assert!( + matches!(transfer_status, TransactionStatusEvent::Success(_)), + "the confidential transfer should succeed, got {:?}", + transfer_status + ); + + // Leg 3: read both sides back through the cheatcode. + // + // The sender's remaining available balance is the AES-decryptable field the + // transfer rewrote. + let sender_after = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + source_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(sender_keys.aes_key.clone()), + elgamal_secret_key: Some(sender_keys.elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed for the sender") + .value; + assert_eq!( + sender_after.available, + Some(sender_start - transfer_amount), + "the sender's available balance should fall by exactly the transferred amount" + ); + + // The recipient's side is the real proof that value moved under encryption: + // the program wrote the transfer ciphertext into the recipient's pending + // balance under the recipient's ElGamal public key. + let recipient_after = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + destination_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(recipient_keys.aes_key.clone()), + elgamal_secret_key: Some(recipient_keys.elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed for the recipient") + .value; + assert_eq!( + recipient_after.pending, + Some(transfer_amount), + "the transferred amount should decrypt out of the recipient's pending balance" + ); + assert_eq!( + recipient_after.pending_balance_credit_counter, 1, + "the transfer should register exactly one pending credit on the recipient" + ); + + // A stranger's ElGamal key must not open the recipient's pending balance. + let foreign_elgamal = ElGamalKeypair::new_rand(); + let pending_under_foreign = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + destination_account.to_string(), + ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some( + bs58::encode(<[u8; 32]>::from(foreign_elgamal.secret())).into_string(), + ), + }, + ) + .await + .map(|response| response.value.pending) + .unwrap_or(None); + assert_eq!( + pending_under_foreign, None, + "a foreign ElGamal secret key must not open the recipient's pending balance" + ); +} + +/// The closest thing to a two-party confidential round trip this simnet can +/// execute: derive both owners' keys with `surfnet_deriveConfidentialKeys`, move +/// tokens with a plaintext Token-2022 transfer, have the recipient credit them +/// to their confidential balance with a real `Deposit` and `ApplyPendingBalance`, +/// and read the result back with `surfnet_getConfidentialBalance`. The value +/// crosses between the owners in the clear and only becomes confidential on the +/// recipient's side; a confidential `Transfer` moves it entirely under encryption +/// and is gated on proofs this build cannot verify — see +/// `test_confidential_balance_transfer_round_trip`. +/// +/// Two `surfnet_setTokenAccount` calls stage both accounts into a configured +/// confidential state, standing in for the proof-gated on-chain +/// `ConfigureAccount`; the transfer, `Deposit` and `ApplyPendingBalance` after it +/// are executed by the Token-2022 program. The assertions establish that the +/// cheatcode derives distinct keys for two owners and that the recipient's +/// derived AES key opens the recipient's available balance while the sender's +/// does not. That read round-trips this test's own `AeKey::encrypt`, because +/// `ApplyPendingBalance` stores the ciphertext its caller hands it — the same +/// caveat the sibling deposit test carries. +#[test_case(TestType::sqlite(); "with on-disk sqlite db")] +#[test_case(TestType::in_memory(); "with in-memory sqlite db")] +#[test_case(TestType::no_db(); "with no db")] +#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_confidential_balance_plaintext_transfer_then_deposit(test_type: TestType) { + use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; + use spl_token_2022_interface::{ + extension::confidential_transfer::instruction as confidential_instruction, + solana_zk_sdk::encryption::{ + auth_encryption::AeKey, pod::auth_encryption::PodAeCiphertext, + }, + }; + use surfpool_types::types::{ + ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, + }; + + let rpc_server = SurfnetCheatcodesRpc::empty(); + let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); + let svm_locker = SurfnetSvmLocker::new(svm_instance); + let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); + let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); + let runloop_context = RunloopContext { + id: None, + svm_locker: svm_locker.clone(), + simnet_commands_tx: simnet_cmd_tx, + remote_rpc_client: None, + rpc_config: RpcConfig::default(), + cheatcode_config: CheatcodeConfig::new(), + plugin_commands_tx, + }; + + let token_program = spl_token_2022_interface::id(); + let sender = Keypair::new(); + let recipient = Keypair::new(); + let mint = Keypair::new(); + let decimals = 2u8; + + svm_locker + .airdrop(&sender.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + svm_locker + .airdrop(&recipient.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + + let source_account = get_associated_token_address_with_program_id( + &sender.pubkey(), + &mint.pubkey(), + &token_program, + ); + let destination_account = get_associated_token_address_with_program_id( + &recipient.pubkey(), + &mint.pubkey(), + &token_program, + ); + + // Leg 1: derive both owners' confidential keys through the cheatcode, each + // from signatures scoped to that owner's own token account. + let (sender_elgamal_signature, sender_ae_signature) = + crate::types::confidential_key_signatures(&sender, &source_account); + let sender_keys = rpc_server + .derive_confidential_keys( + Some(runloop_context.clone()), + sender_elgamal_signature, + sender_ae_signature, + ) + .expect("deriveConfidentialKeys should succeed for the sender") + .value; + + let (recipient_elgamal_signature, recipient_ae_signature) = + crate::types::confidential_key_signatures(&recipient, &destination_account); + let recipient_keys = rpc_server + .derive_confidential_keys( + Some(runloop_context.clone()), + recipient_elgamal_signature, + recipient_ae_signature, + ) + .expect("deriveConfidentialKeys should succeed for the recipient") + .value; + assert_ne!( + sender_keys.elgamal_pubkey, recipient_keys.elgamal_pubkey, + "two owners must not derive the same confidential keys" + ); + + // A mint carrying the confidential-transfer extension. + let mint_len = + spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< + spl_token_2022_interface::state::Mint, + >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) + .unwrap(); + let mint_rent = + svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); + + let setup_instructions = vec![ + system_instruction::create_account( + &sender.pubkey(), + &mint.pubkey(), + mint_rent, + mint_len as u64, + &token_program, + ), + confidential_instruction::initialize_mint( + &token_program, + &mint.pubkey(), + Some(sender.pubkey()), + true, + None, + ) + .unwrap(), + spl_token_2022_interface::instruction::initialize_mint2( + &token_program, + &mint.pubkey(), + &sender.pubkey(), + None, + decimals, + ) + .unwrap(), + ]; + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let setup_message = Message::new_with_blockhash( + &setup_instructions, + Some(&sender.pubkey()), + &recent_blockhash, + ); + let setup_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&sender, &mint]) + .unwrap(); + let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, setup_tx, setup_status_tx, false, true) + .await + .unwrap(); + assert!( + matches!( + setup_status_rx.recv().unwrap(), + TransactionStatusEvent::Success(_) + ), + "mint setup should succeed" + ); + + // Both owners get a configured confidential account. The sender holds the + // public tokens to begin with; the recipient starts empty on both sides. + let sender_start = 9_000u64; + for (owner, keys, amount) in [ + (&sender, &sender_keys, sender_start), + (&recipient, &recipient_keys, 0), + ] { + rpc_server + .set_token_account( + Some(runloop_context.clone()), + owner.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + amount: Some(amount), + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: keys.elgamal_pubkey.clone(), + aes_key: Some(keys.aes_key.clone()), + amount: Some(0), + approved: Some(true), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("setTokenAccount should succeed"); + } + + // Leg 2: a real party-to-party token transfer, executed by Token-2022. + let moved_amount = 2_500u64; + let transfer_ix = spl_token_2022_interface::instruction::transfer_checked( + &token_program, + &source_account, + &mint.pubkey(), + &destination_account, + &sender.pubkey(), + &[], + moved_amount, + decimals, + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let transfer_message = + Message::new_with_blockhash(&[transfer_ix], Some(&sender.pubkey()), &recent_blockhash); + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(transfer_message), &[&sender]) + .unwrap(); + let (transfer_status_tx, transfer_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, transfer_tx, transfer_status_tx, false, true) + .await + .unwrap(); + let transfer_status = transfer_status_rx.recv().unwrap(); + assert!( + matches!(transfer_status, TransactionStatusEvent::Success(_)), + "the party-to-party token transfer should succeed, got {:?}", + transfer_status + ); + + // Leg 3: the recipient credits what they received to their own confidential + // balance, then applies it so it lands in the available balance. + let deposit_ix = confidential_instruction::deposit( + &token_program, + &destination_account, + &mint.pubkey(), + moved_amount, + decimals, + &recipient.pubkey(), + &[], + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let deposit_message = + Message::new_with_blockhash(&[deposit_ix], Some(&recipient.pubkey()), &recent_blockhash); + let deposit_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(deposit_message), &[&recipient]) + .unwrap(); + let (deposit_status_tx, deposit_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, deposit_tx, deposit_status_tx, false, true) + .await + .unwrap(); + let deposit_status = deposit_status_rx.recv().unwrap(); + assert!( + matches!(deposit_status, TransactionStatusEvent::Success(_)), + "the recipient's confidential deposit should succeed, got {:?}", + deposit_status + ); + + let recipient_ae_bytes: [u8; 16] = bs58::decode(&recipient_keys.aes_key) + .into_vec() + .unwrap() + .try_into() + .unwrap(); + let new_available = + PodAeCiphertext::from(AeKey::from(recipient_ae_bytes).encrypt(moved_amount)); + let apply_ix = confidential_instruction::apply_pending_balance( + &token_program, + &destination_account, + 1, + &new_available, + &recipient.pubkey(), + &[], + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let apply_message = + Message::new_with_blockhash(&[apply_ix], Some(&recipient.pubkey()), &recent_blockhash); + let apply_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(apply_message), &[&recipient]) + .unwrap(); + let (apply_status_tx, apply_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, apply_tx, apply_status_tx, false, true) + .await + .unwrap(); + let apply_status = apply_status_rx.recv().unwrap(); + assert!( + matches!(apply_status, TransactionStatusEvent::Success(_)), + "applying the recipient's pending balance should succeed, got {:?}", + apply_status + ); + + // Leg 4: read the recipient's confidential balance back through the + // cheatcode, under the keys the cheatcode derived for the recipient. + let recipient_balance = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + destination_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(recipient_keys.aes_key.clone()), + elgamal_secret_key: Some(recipient_keys.elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed for the recipient") + .value; + assert_eq!( + recipient_balance.available, + Some(moved_amount), + "the recipient's confidential available balance should hold what was transferred" + ); + + // The recipient's own AES key opens that balance; the sender's does not. + // AES-GCM-SIV authenticates, so a wrong key fails rather than decoding to + // some other number. + let available_under_sender_key = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + destination_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(sender_keys.aes_key.clone()), + elgamal_secret_key: None, + }, + ) + .await + .map(|response| response.value.available) + .unwrap_or(None); + assert_eq!( + available_under_sender_key, None, + "the sender's AES key must not open the recipient's confidential balance" + ); + + // The public ledger agrees: the tokens left the sender and are now held + // confidentially by the recipient rather than sitting in the clear. + let source_state = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&source_account).unwrap()) + .expect("source token account should exist"); + let source_state = + StateWithExtensions::::unpack(&source_state.data) + .expect("source account should unpack with extensions"); + assert_eq!( + source_state.base.amount, + sender_start - moved_amount, + "the sender's public balance should fall by the transferred amount" + ); + + let destination_state = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&destination_account).unwrap()) + .expect("destination token account should exist"); + let destination_state = + StateWithExtensions::::unpack( + &destination_state.data, + ) + .expect("destination account should unpack with extensions"); + assert_eq!( + destination_state.base.amount, 0, + "the recipient's public balance should be empty once deposited confidentially" + ); +} From 9eba2d8aa228a6313ab95c1eba5407ff023cddfd Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Fri, 14 Aug 2026 15:37:50 -0600 Subject: [PATCH 11/17] docs: drop the em-dashes from the doc comments this change adds Seventeen sites, rewritten rather than character-swapped, so the prose reads the same in a terminal, a diff and a generated .d.ts. The kit bindings are regenerated to match, since these doc comments are their source. Upstream's own em-dashes are left as they are, including the two that reach ConfidentialTransferAccountUpdate.ts from doc comments already on main. --- crates/core/src/rpc/surfnet_cheatcodes.rs | 6 +++--- crates/core/src/tests/integration.rs | 20 +++++++++---------- crates/core/src/types.rs | 2 +- .../kit/generated/ConfidentialBalanceKeys.ts | 2 +- .../DeriveConfidentialKeysResponse.ts | 6 +++--- crates/types/src/types.rs | 8 ++++---- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 4cb0d7a9f..fb2886ea4 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -1235,7 +1235,7 @@ pub trait SurfnetCheatcodes { /// ## Returns /// A `RpcResponse` with the decrypted `available` and `pending` /// amounts (each `null` when the corresponding key was not supplied), plus the account's - /// `pendingBalanceCreditCounter` — non-zero means an `ApplyPendingBalance` is still needed + /// `pendingBalanceCreditCounter`: non-zero means an `ApplyPendingBalance` is still needed /// before the pending amount shows up in `available`. /// /// ## Example Request @@ -1359,13 +1359,13 @@ pub trait SurfnetCheatcodes { /// /// What that does and does not buy is worth stating plainly. Because the ElGamal secret is a /// hash of the signature, `elgamalSignature` is exactly as sensitive as the `elgamalSecretKey` - /// it derives — anyone who sees it recomputes that key offline. What changed is that the + /// it derives: anyone who sees it recomputes that key offline. What changed is that the /// material on the wire no longer confers transaction-signing power, only confidential-balance /// decryption; it is not that nothing sensitive is transported. /// /// The derived `elgamalSecretKey` and `aesKey` do travel back in the response, and /// `surfnet_getConfidentialBalance` takes them back as inputs. That is the point of the - /// cheatcode — it is what removes the client-side crypto dependency — but it does mean the + /// cheatcode; it is what removes the client-side crypto dependency. It does mean the /// confidential keys are transported and are only as private as the RPC connection. This is a /// simnet testing convenience, not a key-management pattern to carry to a live cluster. #[rpc(meta, name = "surfnet_deriveConfidentialKeys")] diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 769e4b209..eb253b1ad 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -11122,7 +11122,7 @@ async fn test_request_airdrop_rejects_below_rent_amount() { /// /// The pending balance is seeded with a real ElGamal encryption under the derived /// public key first, so the ciphertext the deposit lands on carries a decrypt -/// handle that is not the identity point — deposits onto the all-zero pending +/// handle that is not the identity point. Deposits onto the all-zero pending /// ciphertext a freshly configured account carries produce an identity handle, /// and such a ciphertext opens to the same value under any secret key. On chain /// that state arrives via a party-to-party `Transfer`, which is proof-gated and @@ -11131,13 +11131,13 @@ async fn test_request_airdrop_rejects_below_rent_amount() { /// exactly, and a random ElGamal secret key recovers nothing. /// /// The remaining assertions are weaker, and labelled as such. The plaintext -/// fields the program itself computes — public token balance down by exactly the -/// deposited amount, pending credit counter to 1 and back to 0 — only catch a +/// fields the program itself computes (public token balance down by exactly the +/// deposited amount, pending credit counter to 1 and back to 0) only catch a /// deposit that silently did nothing. The decrypted available balance round-trips /// this test's own AES ciphertext: it shows that `AeKey` encryption and /// decryption agree and that the cheatcode reads the right field, but it does not /// bind to the ElGamal key. The post-apply pending read of 0 is a shape check, -/// not a key check — `ApplyPendingBalance` resets that ciphertext to all-zero, +/// not a key check: `ApplyPendingBalance` resets that ciphertext to all-zero, /// which decodes to 0 under any key. /// /// The account reaches its configured state via `surfnet_setTokenAccount` rather @@ -11503,7 +11503,7 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { ); } -/// **This test is `#[ignore]`d and does not run — nothing described below is +/// **This test is `#[ignore]`d and does not run. Nothing described below is /// asserted by the suite.** See the `# Why this is ignored` section. /// /// The round trip the confidential cheatcodes exist to support, end to end and @@ -11551,7 +11551,7 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { /// `CiphertextCommitmentEqualityProofContext::new_transcript`, which also seeds /// the transcript with `Transcript::new` where 5.0.1 uses /// `Transcript::new_zk_elgamal_transcript`. So both versions absorb the same -/// context, but at different layers and from a different starting state — +/// context, but at different layers and from a different starting state, which is /// worth being precise about, because "add the missing hash to 4.0.0" is not /// the fix it sounds like. Prover and verifier therefore derive different /// challenge scalars, and the program rejects the proof with @@ -11559,12 +11559,12 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { /// /// Everything in this section was observed by running the test locally; because /// it is ignored, nothing in the suite asserts any of it. In particular, no test -/// in this change establishes that a confidential `Transfer` completes — the +/// in this change establishes that a confidential `Transfer` completes: the /// assertions that would show it never execute. Treat that path as uncovered, /// not as working. /// /// Un-ignore this once proof generation and the runtime's proof program agree on -/// a `solana-zk-sdk` major — either `litesvm` linking a 4.x proof program, or +/// a `solana-zk-sdk` major, either `litesvm` linking a 4.x proof program, or /// `spl-token-2022-interface` moving to the 5.x proof stack. #[test_case(TestType::no_db(); "with no db")] #[tokio::test(flavor = "multi_thread")] @@ -11935,7 +11935,7 @@ async fn test_confidential_balance_transfer_round_trip(test_type: TestType) { /// and read the result back with `surfnet_getConfidentialBalance`. The value /// crosses between the owners in the clear and only becomes confidential on the /// recipient's side; a confidential `Transfer` moves it entirely under encryption -/// and is gated on proofs this build cannot verify — see +/// and is gated on proofs this build cannot verify. See /// `test_confidential_balance_transfer_round_trip`. /// /// Two `surfnet_setTokenAccount` calls stage both accounts into a configured @@ -11945,7 +11945,7 @@ async fn test_confidential_balance_transfer_round_trip(test_type: TestType) { /// cheatcode derives distinct keys for two owners and that the recipient's /// derived AES key opens the recipient's available balance while the sender's /// does not. That read round-trips this test's own `AeKey::encrypt`, because -/// `ApplyPendingBalance` stores the ciphertext its caller hands it — the same +/// `ApplyPendingBalance` stores the ciphertext its caller hands it, the same /// caveat the sibling deposit test carries. #[test_case(TestType::sqlite(); "with on-disk sqlite db")] #[test_case(TestType::in_memory(); "with in-memory sqlite db")] diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 400406b9d..5ff9a4ba8 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1367,7 +1367,7 @@ pub fn build_confidential_token_account_data( /// confidential test loop whose write half is `surfnet_setTokenAccount`. Each /// balance has its own key because the extension stores them differently: /// - **available** is read from `decryptable_available_balance`, the AES copy the -/// owner supplies and the program stores verbatim — the program holds no AES key, +/// owner supplies and the program stores verbatim. The program holds no AES key, /// so it never computes this field. The authoritative `available_balance` is an /// ElGamal ciphertext over a full u64 and is not recoverable by the u32 /// discrete-log decode, so the AES copy is the only read path. diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts index c26a98b32..65eaf9735 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts @@ -16,7 +16,7 @@ export type ConfidentialBalanceKeys = { */ aesKey?: string; /** - * The owner's ElGamal *secret* key (base58 or base64, 32 bytes) — not the + * The owner's ElGamal *secret* key (base58 or base64, 32 bytes), not the * public key stored on the account. Decrypts the pending balance. */ elgamalSecretKey?: string; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts index 8dfbfceb6..0a7e76fc3 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts @@ -9,16 +9,16 @@ */ export type DeriveConfidentialKeysResponse = { /** - * The ElGamal public key — `surfnet_setTokenAccount`'s `elgamalPubkey`. + * The ElGamal public key: `surfnet_setTokenAccount`'s `elgamalPubkey`. */ elgamalPubkey: string; /** - * The ElGamal secret key — `surfnet_getConfidentialBalance`'s + * The ElGamal secret key: `surfnet_getConfidentialBalance`'s * `elgamalSecretKey`. */ elgamalSecretKey: string; /** - * The AES key — the `aesKey` of both of the above. + * The AES key: the `aesKey` of both of the above. */ aesKey: string; }; diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index 7984486f1..261fb1a74 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -1240,7 +1240,7 @@ pub struct ConfidentialBalanceKeys { /// The owner's AES key (base58 or base64, 16 bytes). Decrypts the available /// balance. pub aes_key: Option, - /// The owner's ElGamal *secret* key (base58 or base64, 32 bytes) — not the + /// The owner's ElGamal *secret* key (base58 or base64, 32 bytes), not the /// public key stored on the account. Decrypts the pending balance. pub elgamal_secret_key: Option, } @@ -1275,12 +1275,12 @@ pub struct GetConfidentialBalanceResponse { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))] pub struct DeriveConfidentialKeysResponse { - /// The ElGamal public key — `surfnet_setTokenAccount`'s `elgamalPubkey`. + /// The ElGamal public key: `surfnet_setTokenAccount`'s `elgamalPubkey`. pub elgamal_pubkey: String, - /// The ElGamal secret key — `surfnet_getConfidentialBalance`'s + /// The ElGamal secret key: `surfnet_getConfidentialBalance`'s /// `elgamalSecretKey`. pub elgamal_secret_key: String, - /// The AES key — the `aesKey` of both of the above. + /// The AES key: the `aesKey` of both of the above. pub aes_key: String, } From 65d60aa2714f065df53942b9e5f866dfa2eb542e Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 20 Aug 2026 09:38:05 -0600 Subject: [PATCH 12/17] test(core): cut the ignored confidential transfer test and the plaintext duplicate test_confidential_balance_transfer_round_trip was #[ignore]d from the day it landed: a party-to-party confidential Transfer needs proofs the ZK ElGamal proof program has to verify, and this harness cannot. A test that never runs asserts nothing, so it goes rather than sit in the file explaining why it is disabled. test_confidential_balance_plaintext_transfer_then_deposit moved value in the clear and then deposited it, which is the deposit round trip the sibling test already covers, with a second owner added. It duplicated that coverage rather than extending it. --- crates/core/src/tests/integration.rs | 778 --------------------------- 1 file changed, 778 deletions(-) diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index eb253b1ad..00578e4b2 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -11502,781 +11502,3 @@ async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { "applying the pending balance resets the credit counter" ); } - -/// **This test is `#[ignore]`d and does not run. Nothing described below is -/// asserted by the suite.** See the `# Why this is ignored` section. -/// -/// The round trip the confidential cheatcodes exist to support, end to end and -/// between two parties: derive both owners' keys with -/// `surfnet_deriveConfidentialKeys`, move value from one to the other with a -/// real Token-2022 `Transfer`, and read the result on both sides with -/// `surfnet_getConfidentialBalance`. -/// -/// Unlike a `Deposit`, which only moves an owner's own public tokens into their -/// own encrypted pending balance, a `Transfer` moves value between two -/// accounts entirely under encryption and is gated on three zero-knowledge -/// proofs verified by the ZK ElGamal proof program: a ciphertext-commitment -/// equality proof, a batched grouped-ciphertext validity proof, and a batched -/// range proof. This test builds all three and submits them alongside the -/// transfer. -/// -/// What binds the assertions to the derived keys: the amount that lands on the -/// recipient is decrypted out of the recipient's pending balance with the -/// recipient's ElGamal secret key. That pending balance is written by the -/// program from the transfer's ciphertext, under the recipient's ElGamal -/// public key, so nothing but the matching secret key opens it, and a -/// stranger's key is asserted to recover nothing. The sender's remaining -/// available balance is decrypted with the sender's AES key, which shows the -/// cheatcode reads the right field but does not bind to the derivation, the -/// same caveat the deposit round trip records. -/// -/// # Why this is ignored -/// -/// This does not pass today, and the reason is a version skew in the dependency -/// graph rather than anything about the cheatcodes. Two different -/// `solana-zk-sdk` majors are linked at once: -/// -/// - proof *generation* here resolves to `solana-zk-sdk` 4.0.0, pinned through -/// `spl-token-2022-interface` -> `spl-pod`; -/// - the proof *verifier* the runtime actually runs is -/// `solana-zk-elgamal-proof-program` 4.1.2, pulled in by `litesvm` -> -/// `solana-builtins`, which is built against `solana-zk-sdk` 5.0.1. -/// -/// Between those two versions the Fiat-Shamir transcript of the -/// ciphertext-commitment equality proof changed. 5.0.1 hashes the public -/// context (ElGamal pubkey, ciphertext, commitment) into the transcript inside -/// both `CiphertextCommitmentEqualityProof::new` and `::verify`; 4.0.0 hashes -/// none of it *there*, and documents that the caller is responsible. In 4.0.0 -/// the caller does do it, one layer up in -/// `CiphertextCommitmentEqualityProofContext::new_transcript`, which also seeds -/// the transcript with `Transcript::new` where 5.0.1 uses -/// `Transcript::new_zk_elgamal_transcript`. So both versions absorb the same -/// context, but at different layers and from a different starting state, which is -/// worth being precise about, because "add the missing hash to 4.0.0" is not -/// the fix it sounds like. Prover and verifier therefore derive different -/// challenge scalars, and the program rejects the proof with -/// `proof verification failed: SigmaProof(Equality, AlgebraicRelation)`. -/// -/// Everything in this section was observed by running the test locally; because -/// it is ignored, nothing in the suite asserts any of it. In particular, no test -/// in this change establishes that a confidential `Transfer` completes: the -/// assertions that would show it never execute. Treat that path as uncovered, -/// not as working. -/// -/// Un-ignore this once proof generation and the runtime's proof program agree on -/// a `solana-zk-sdk` major, either `litesvm` linking a 4.x proof program, or -/// `spl-token-2022-interface` moving to the 5.x proof stack. -#[test_case(TestType::no_db(); "with no db")] -#[tokio::test(flavor = "multi_thread")] -#[ignore = "see the '# Why this is ignored' section of the doc comment above"] -async fn test_confidential_balance_transfer_round_trip(test_type: TestType) { - use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; - use spl_token_2022_interface::{ - extension::confidential_transfer::{ - ConfidentialTransferAccount, instruction as confidential_instruction, - }, - solana_zk_sdk::encryption::{ - auth_encryption::{AeCiphertext, AeKey}, - elgamal::{ElGamalCiphertext, ElGamalKeypair, ElGamalPubkey, ElGamalSecretKey}, - }, - }; - use spl_token_confidential_transfer_proof_extraction::instruction::ProofLocation; - use spl_token_confidential_transfer_proof_generation::transfer::transfer_split_proof_data; - use surfpool_types::types::{ - ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, - }; - - let rpc_server = SurfnetCheatcodesRpc::empty(); - let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); - let svm_locker = SurfnetSvmLocker::new(svm_instance); - let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); - let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); - let runloop_context = RunloopContext { - id: None, - svm_locker: svm_locker.clone(), - simnet_commands_tx: simnet_cmd_tx, - remote_rpc_client: None, - rpc_config: RpcConfig::default(), - cheatcode_config: CheatcodeConfig::new(), - plugin_commands_tx, - }; - - let token_program = spl_token_2022_interface::id(); - let sender = Keypair::new(); - let recipient = Keypair::new(); - let mint = Keypair::new(); - let decimals = 2u8; - - svm_locker - .airdrop(&sender.pubkey(), 10 * LAMPORTS_PER_SOL) - .unwrap() - .unwrap(); - svm_locker - .airdrop(&recipient.pubkey(), 10 * LAMPORTS_PER_SOL) - .unwrap() - .unwrap(); - - let source_account = get_associated_token_address_with_program_id( - &sender.pubkey(), - &mint.pubkey(), - &token_program, - ); - let destination_account = get_associated_token_address_with_program_id( - &recipient.pubkey(), - &mint.pubkey(), - &token_program, - ); - - // Leg 1: derive both parties' confidential keys through the cheatcode, from - // signatures scoped to each party's own token account. - let (sender_elgamal_signature, sender_ae_signature) = - crate::types::confidential_key_signatures(&sender, &source_account); - let sender_keys = rpc_server - .derive_confidential_keys( - Some(runloop_context.clone()), - sender_elgamal_signature, - sender_ae_signature, - ) - .expect("deriveConfidentialKeys should succeed for the sender") - .value; - - let (recipient_elgamal_signature, recipient_ae_signature) = - crate::types::confidential_key_signatures(&recipient, &destination_account); - let recipient_keys = rpc_server - .derive_confidential_keys( - Some(runloop_context.clone()), - recipient_elgamal_signature, - recipient_ae_signature, - ) - .expect("deriveConfidentialKeys should succeed for the recipient") - .value; - - // A mint carrying the confidential-transfer extension. - let mint_len = - spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< - spl_token_2022_interface::state::Mint, - >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) - .unwrap(); - let mint_rent = - svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); - - let setup_instructions = vec![ - system_instruction::create_account( - &sender.pubkey(), - &mint.pubkey(), - mint_rent, - mint_len as u64, - &token_program, - ), - confidential_instruction::initialize_mint( - &token_program, - &mint.pubkey(), - Some(sender.pubkey()), - true, - None, - ) - .unwrap(), - spl_token_2022_interface::instruction::initialize_mint2( - &token_program, - &mint.pubkey(), - &sender.pubkey(), - None, - decimals, - ) - .unwrap(), - ]; - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let setup_message = Message::new_with_blockhash( - &setup_instructions, - Some(&sender.pubkey()), - &recent_blockhash, - ); - let setup_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&sender, &mint]) - .unwrap(); - let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, setup_tx, setup_status_tx, false, true) - .await - .unwrap(); - assert!( - matches!( - setup_status_rx.recv().unwrap(), - TransactionStatusEvent::Success(_) - ), - "mint setup should succeed" - ); - - // Configure both confidential accounts. The sender starts with a real - // available balance; the recipient starts empty and receive-only. - let sender_start = 5_000u64; - rpc_server - .set_token_account( - Some(runloop_context.clone()), - sender.pubkey().to_string(), - mint.pubkey().to_string(), - TokenAccountUpdate { - amount: Some(0), - confidential: Some(ConfidentialTransferAccountUpdate { - elgamal_pubkey: sender_keys.elgamal_pubkey.clone(), - aes_key: Some(sender_keys.aes_key.clone()), - amount: Some(sender_start), - approved: Some(true), - ..Default::default() - }), - ..Default::default() - }, - Some(token_program.to_string()), - ) - .await - .expect("setTokenAccount should succeed for the sender"); - - rpc_server - .set_token_account( - Some(runloop_context.clone()), - recipient.pubkey().to_string(), - mint.pubkey().to_string(), - TokenAccountUpdate { - amount: Some(0), - confidential: Some(ConfidentialTransferAccountUpdate { - elgamal_pubkey: recipient_keys.elgamal_pubkey.clone(), - aes_key: Some(recipient_keys.aes_key.clone()), - amount: Some(0), - approved: Some(true), - ..Default::default() - }), - ..Default::default() - }, - Some(token_program.to_string()), - ) - .await - .expect("setTokenAccount should succeed for the recipient"); - - // Rebuild the sender's keys as SDK objects; the proof generation needs the - // ElGamal keypair and AES key that the cheatcode just handed back. - let sender_elgamal_secret = ElGamalSecretKey::try_from( - bs58::decode(&sender_keys.elgamal_secret_key) - .into_vec() - .unwrap() - .as_slice(), - ) - .expect("derived sender elgamalSecretKey should parse"); - let sender_elgamal_keypair = ElGamalKeypair::new(sender_elgamal_secret); - let sender_ae_bytes: [u8; 16] = bs58::decode(&sender_keys.aes_key) - .into_vec() - .unwrap() - .try_into() - .unwrap(); - let sender_ae_key = AeKey::from(sender_ae_bytes); - let recipient_elgamal_pubkey = ElGamalPubkey::try_from( - bs58::decode(&recipient_keys.elgamal_pubkey) - .into_vec() - .unwrap() - .as_slice(), - ) - .expect("derived recipient elgamalPubkey should parse"); - - // Read the sender's on-account ciphertexts back out; the proofs are built - // against exactly the state the program will do its homomorphic math on. - let (current_available_balance, current_decryptable_available_balance) = { - let account = svm_locker - .with_svm_reader(|svm| svm.inner.get_account(&source_account).unwrap()) - .expect("source token account should exist"); - let state = - StateWithExtensions::::unpack(&account.data) - .expect("source account should unpack with extensions"); - let ext = state - .get_extension::() - .expect("confidential extension should be present"); - ( - ElGamalCiphertext::try_from(ext.available_balance) - .expect("available balance should be a valid ElGamal ciphertext"), - AeCiphertext::try_from(ext.decryptable_available_balance) - .expect("decryptable available balance should be a valid AES ciphertext"), - ) - }; - - // Leg 2: build the three zero-knowledge proofs a real transfer requires and - // submit them with the transfer itself. - let transfer_amount = 1_250u64; - let proof_data = transfer_split_proof_data( - ¤t_available_balance, - ¤t_decryptable_available_balance, - transfer_amount, - &sender_elgamal_keypair, - &sender_ae_key, - &recipient_elgamal_pubkey, - None, - ) - .expect("transfer proof generation should succeed"); - - let new_source_decryptable_available_balance = - sender_ae_key.encrypt(sender_start - transfer_amount).into(); - - let transfer_instructions = confidential_instruction::transfer( - &token_program, - &source_account, - &mint.pubkey(), - &destination_account, - &new_source_decryptable_available_balance, - &proof_data - .ciphertext_validity_proof_data_with_ciphertext - .ciphertext_lo, - &proof_data - .ciphertext_validity_proof_data_with_ciphertext - .ciphertext_hi, - &sender.pubkey(), - &[], - ProofLocation::InstructionOffset(1.try_into().unwrap(), &proof_data.equality_proof_data), - ProofLocation::InstructionOffset( - 2.try_into().unwrap(), - &proof_data - .ciphertext_validity_proof_data_with_ciphertext - .proof_data, - ), - ProofLocation::InstructionOffset(3.try_into().unwrap(), &proof_data.range_proof_data), - ) - .expect("building the transfer instructions should succeed"); - - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let transfer_message = Message::new_with_blockhash( - &transfer_instructions, - Some(&sender.pubkey()), - &recent_blockhash, - ); - let transfer_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(transfer_message), &[&sender]) - .unwrap(); - let (transfer_status_tx, transfer_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, transfer_tx, transfer_status_tx, false, true) - .await - .unwrap(); - let transfer_status = transfer_status_rx.recv().unwrap(); - assert!( - matches!(transfer_status, TransactionStatusEvent::Success(_)), - "the confidential transfer should succeed, got {:?}", - transfer_status - ); - - // Leg 3: read both sides back through the cheatcode. - // - // The sender's remaining available balance is the AES-decryptable field the - // transfer rewrote. - let sender_after = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - source_account.to_string(), - ConfidentialBalanceKeys { - aes_key: Some(sender_keys.aes_key.clone()), - elgamal_secret_key: Some(sender_keys.elgamal_secret_key.clone()), - }, - ) - .await - .expect("getConfidentialBalance should succeed for the sender") - .value; - assert_eq!( - sender_after.available, - Some(sender_start - transfer_amount), - "the sender's available balance should fall by exactly the transferred amount" - ); - - // The recipient's side is the real proof that value moved under encryption: - // the program wrote the transfer ciphertext into the recipient's pending - // balance under the recipient's ElGamal public key. - let recipient_after = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - destination_account.to_string(), - ConfidentialBalanceKeys { - aes_key: Some(recipient_keys.aes_key.clone()), - elgamal_secret_key: Some(recipient_keys.elgamal_secret_key.clone()), - }, - ) - .await - .expect("getConfidentialBalance should succeed for the recipient") - .value; - assert_eq!( - recipient_after.pending, - Some(transfer_amount), - "the transferred amount should decrypt out of the recipient's pending balance" - ); - assert_eq!( - recipient_after.pending_balance_credit_counter, 1, - "the transfer should register exactly one pending credit on the recipient" - ); - - // A stranger's ElGamal key must not open the recipient's pending balance. - let foreign_elgamal = ElGamalKeypair::new_rand(); - let pending_under_foreign = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - destination_account.to_string(), - ConfidentialBalanceKeys { - aes_key: None, - elgamal_secret_key: Some( - bs58::encode(<[u8; 32]>::from(foreign_elgamal.secret())).into_string(), - ), - }, - ) - .await - .map(|response| response.value.pending) - .unwrap_or(None); - assert_eq!( - pending_under_foreign, None, - "a foreign ElGamal secret key must not open the recipient's pending balance" - ); -} - -/// The closest thing to a two-party confidential round trip this simnet can -/// execute: derive both owners' keys with `surfnet_deriveConfidentialKeys`, move -/// tokens with a plaintext Token-2022 transfer, have the recipient credit them -/// to their confidential balance with a real `Deposit` and `ApplyPendingBalance`, -/// and read the result back with `surfnet_getConfidentialBalance`. The value -/// crosses between the owners in the clear and only becomes confidential on the -/// recipient's side; a confidential `Transfer` moves it entirely under encryption -/// and is gated on proofs this build cannot verify. See -/// `test_confidential_balance_transfer_round_trip`. -/// -/// Two `surfnet_setTokenAccount` calls stage both accounts into a configured -/// confidential state, standing in for the proof-gated on-chain -/// `ConfigureAccount`; the transfer, `Deposit` and `ApplyPendingBalance` after it -/// are executed by the Token-2022 program. The assertions establish that the -/// cheatcode derives distinct keys for two owners and that the recipient's -/// derived AES key opens the recipient's available balance while the sender's -/// does not. That read round-trips this test's own `AeKey::encrypt`, because -/// `ApplyPendingBalance` stores the ciphertext its caller hands it, the same -/// caveat the sibling deposit test carries. -#[test_case(TestType::sqlite(); "with on-disk sqlite db")] -#[test_case(TestType::in_memory(); "with in-memory sqlite db")] -#[test_case(TestType::no_db(); "with no db")] -#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] -#[tokio::test(flavor = "multi_thread")] -async fn test_confidential_balance_plaintext_transfer_then_deposit(test_type: TestType) { - use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; - use spl_token_2022_interface::{ - extension::confidential_transfer::instruction as confidential_instruction, - solana_zk_sdk::encryption::{ - auth_encryption::AeKey, pod::auth_encryption::PodAeCiphertext, - }, - }; - use surfpool_types::types::{ - ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, - }; - - let rpc_server = SurfnetCheatcodesRpc::empty(); - let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); - let svm_locker = SurfnetSvmLocker::new(svm_instance); - let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); - let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); - let runloop_context = RunloopContext { - id: None, - svm_locker: svm_locker.clone(), - simnet_commands_tx: simnet_cmd_tx, - remote_rpc_client: None, - rpc_config: RpcConfig::default(), - cheatcode_config: CheatcodeConfig::new(), - plugin_commands_tx, - }; - - let token_program = spl_token_2022_interface::id(); - let sender = Keypair::new(); - let recipient = Keypair::new(); - let mint = Keypair::new(); - let decimals = 2u8; - - svm_locker - .airdrop(&sender.pubkey(), 10 * LAMPORTS_PER_SOL) - .unwrap() - .unwrap(); - svm_locker - .airdrop(&recipient.pubkey(), 10 * LAMPORTS_PER_SOL) - .unwrap() - .unwrap(); - - let source_account = get_associated_token_address_with_program_id( - &sender.pubkey(), - &mint.pubkey(), - &token_program, - ); - let destination_account = get_associated_token_address_with_program_id( - &recipient.pubkey(), - &mint.pubkey(), - &token_program, - ); - - // Leg 1: derive both owners' confidential keys through the cheatcode, each - // from signatures scoped to that owner's own token account. - let (sender_elgamal_signature, sender_ae_signature) = - crate::types::confidential_key_signatures(&sender, &source_account); - let sender_keys = rpc_server - .derive_confidential_keys( - Some(runloop_context.clone()), - sender_elgamal_signature, - sender_ae_signature, - ) - .expect("deriveConfidentialKeys should succeed for the sender") - .value; - - let (recipient_elgamal_signature, recipient_ae_signature) = - crate::types::confidential_key_signatures(&recipient, &destination_account); - let recipient_keys = rpc_server - .derive_confidential_keys( - Some(runloop_context.clone()), - recipient_elgamal_signature, - recipient_ae_signature, - ) - .expect("deriveConfidentialKeys should succeed for the recipient") - .value; - assert_ne!( - sender_keys.elgamal_pubkey, recipient_keys.elgamal_pubkey, - "two owners must not derive the same confidential keys" - ); - - // A mint carrying the confidential-transfer extension. - let mint_len = - spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< - spl_token_2022_interface::state::Mint, - >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) - .unwrap(); - let mint_rent = - svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); - - let setup_instructions = vec![ - system_instruction::create_account( - &sender.pubkey(), - &mint.pubkey(), - mint_rent, - mint_len as u64, - &token_program, - ), - confidential_instruction::initialize_mint( - &token_program, - &mint.pubkey(), - Some(sender.pubkey()), - true, - None, - ) - .unwrap(), - spl_token_2022_interface::instruction::initialize_mint2( - &token_program, - &mint.pubkey(), - &sender.pubkey(), - None, - decimals, - ) - .unwrap(), - ]; - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let setup_message = Message::new_with_blockhash( - &setup_instructions, - Some(&sender.pubkey()), - &recent_blockhash, - ); - let setup_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&sender, &mint]) - .unwrap(); - let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, setup_tx, setup_status_tx, false, true) - .await - .unwrap(); - assert!( - matches!( - setup_status_rx.recv().unwrap(), - TransactionStatusEvent::Success(_) - ), - "mint setup should succeed" - ); - - // Both owners get a configured confidential account. The sender holds the - // public tokens to begin with; the recipient starts empty on both sides. - let sender_start = 9_000u64; - for (owner, keys, amount) in [ - (&sender, &sender_keys, sender_start), - (&recipient, &recipient_keys, 0), - ] { - rpc_server - .set_token_account( - Some(runloop_context.clone()), - owner.pubkey().to_string(), - mint.pubkey().to_string(), - TokenAccountUpdate { - amount: Some(amount), - confidential: Some(ConfidentialTransferAccountUpdate { - elgamal_pubkey: keys.elgamal_pubkey.clone(), - aes_key: Some(keys.aes_key.clone()), - amount: Some(0), - approved: Some(true), - ..Default::default() - }), - ..Default::default() - }, - Some(token_program.to_string()), - ) - .await - .expect("setTokenAccount should succeed"); - } - - // Leg 2: a real party-to-party token transfer, executed by Token-2022. - let moved_amount = 2_500u64; - let transfer_ix = spl_token_2022_interface::instruction::transfer_checked( - &token_program, - &source_account, - &mint.pubkey(), - &destination_account, - &sender.pubkey(), - &[], - moved_amount, - decimals, - ) - .unwrap(); - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let transfer_message = - Message::new_with_blockhash(&[transfer_ix], Some(&sender.pubkey()), &recent_blockhash); - let transfer_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(transfer_message), &[&sender]) - .unwrap(); - let (transfer_status_tx, transfer_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, transfer_tx, transfer_status_tx, false, true) - .await - .unwrap(); - let transfer_status = transfer_status_rx.recv().unwrap(); - assert!( - matches!(transfer_status, TransactionStatusEvent::Success(_)), - "the party-to-party token transfer should succeed, got {:?}", - transfer_status - ); - - // Leg 3: the recipient credits what they received to their own confidential - // balance, then applies it so it lands in the available balance. - let deposit_ix = confidential_instruction::deposit( - &token_program, - &destination_account, - &mint.pubkey(), - moved_amount, - decimals, - &recipient.pubkey(), - &[], - ) - .unwrap(); - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let deposit_message = - Message::new_with_blockhash(&[deposit_ix], Some(&recipient.pubkey()), &recent_blockhash); - let deposit_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(deposit_message), &[&recipient]) - .unwrap(); - let (deposit_status_tx, deposit_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, deposit_tx, deposit_status_tx, false, true) - .await - .unwrap(); - let deposit_status = deposit_status_rx.recv().unwrap(); - assert!( - matches!(deposit_status, TransactionStatusEvent::Success(_)), - "the recipient's confidential deposit should succeed, got {:?}", - deposit_status - ); - - let recipient_ae_bytes: [u8; 16] = bs58::decode(&recipient_keys.aes_key) - .into_vec() - .unwrap() - .try_into() - .unwrap(); - let new_available = - PodAeCiphertext::from(AeKey::from(recipient_ae_bytes).encrypt(moved_amount)); - let apply_ix = confidential_instruction::apply_pending_balance( - &token_program, - &destination_account, - 1, - &new_available, - &recipient.pubkey(), - &[], - ) - .unwrap(); - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let apply_message = - Message::new_with_blockhash(&[apply_ix], Some(&recipient.pubkey()), &recent_blockhash); - let apply_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(apply_message), &[&recipient]) - .unwrap(); - let (apply_status_tx, apply_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, apply_tx, apply_status_tx, false, true) - .await - .unwrap(); - let apply_status = apply_status_rx.recv().unwrap(); - assert!( - matches!(apply_status, TransactionStatusEvent::Success(_)), - "applying the recipient's pending balance should succeed, got {:?}", - apply_status - ); - - // Leg 4: read the recipient's confidential balance back through the - // cheatcode, under the keys the cheatcode derived for the recipient. - let recipient_balance = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - destination_account.to_string(), - ConfidentialBalanceKeys { - aes_key: Some(recipient_keys.aes_key.clone()), - elgamal_secret_key: Some(recipient_keys.elgamal_secret_key.clone()), - }, - ) - .await - .expect("getConfidentialBalance should succeed for the recipient") - .value; - assert_eq!( - recipient_balance.available, - Some(moved_amount), - "the recipient's confidential available balance should hold what was transferred" - ); - - // The recipient's own AES key opens that balance; the sender's does not. - // AES-GCM-SIV authenticates, so a wrong key fails rather than decoding to - // some other number. - let available_under_sender_key = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - destination_account.to_string(), - ConfidentialBalanceKeys { - aes_key: Some(sender_keys.aes_key.clone()), - elgamal_secret_key: None, - }, - ) - .await - .map(|response| response.value.available) - .unwrap_or(None); - assert_eq!( - available_under_sender_key, None, - "the sender's AES key must not open the recipient's confidential balance" - ); - - // The public ledger agrees: the tokens left the sender and are now held - // confidentially by the recipient rather than sitting in the clear. - let source_state = svm_locker - .with_svm_reader(|svm| svm.inner.get_account(&source_account).unwrap()) - .expect("source token account should exist"); - let source_state = - StateWithExtensions::::unpack(&source_state.data) - .expect("source account should unpack with extensions"); - assert_eq!( - source_state.base.amount, - sender_start - moved_amount, - "the sender's public balance should fall by the transferred amount" - ); - - let destination_state = svm_locker - .with_svm_reader(|svm| svm.inner.get_account(&destination_account).unwrap()) - .expect("destination token account should exist"); - let destination_state = - StateWithExtensions::::unpack( - &destination_state.data, - ) - .expect("destination account should unpack with extensions"); - assert_eq!( - destination_state.base.amount, 0, - "the recipient's public balance should be empty once deposited confidentially" - ); -} From 075813e608018759b185534dfb2ff21dc2dba89c Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 20 Aug 2026 09:38:10 -0600 Subject: [PATCH 13/17] build(core): drop the confidential-transfer proof crates the cut test needed spl-token-confidential-transfer-proof-extraction and spl-token-confidential-transfer-proof-generation were pulled in for the ignored transfer test and used nowhere else. With that test gone they are two dependencies the tree carries for no reason. --- Cargo.lock | 2 -- Cargo.toml | 2 -- crates/core/Cargo.toml | 2 -- 3 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 043833613..649aa3715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12034,8 +12034,6 @@ dependencies = [ "solana-version", "spl-associated-token-account-interface", "spl-token-2022-interface", - "spl-token-confidential-transfer-proof-extraction", - "spl-token-confidential-transfer-proof-generation", "spl-token-interface 2.0.0", "spl-token-metadata-interface", "surfpool-db", diff --git a/Cargo.toml b/Cargo.toml index d22c7cc85..127ee3c4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,8 +150,6 @@ solana-transaction-status = { version = "4.1", default-features = false, feature solana-version = { version = "4.0", default-features = false } spl-associated-token-account-interface = { version = "2.0.0", default-features = false } spl-token-2022-interface = { version = "2.0.0", default-features = false } -spl-token-confidential-transfer-proof-extraction = { version = "0.5.1", default-features = false } -spl-token-confidential-transfer-proof-generation = { version = "0.5.1", default-features = false } spl-token-interface = { version = "2.0.0", default-features = false } spl-token-metadata-interface = { version = "0.8.0", default-features = false } tempfile = "3.23.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 301e7688a..36e43e16b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -118,8 +118,6 @@ solana-secp256k1-program = { version = "3.0", default-features = false, features solana-secp256r1-program = "3.0" tempfile = { workspace = true } spl-token-metadata-interface = { workspace = true } -spl-token-confidential-transfer-proof-extraction = { workspace = true } -spl-token-confidential-transfer-proof-generation = { workspace = true } [features] default = ["sqlite"] From f868a9de4b190ade4f6d16d31c95bb4c54458eee Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 20 Aug 2026 09:38:17 -0600 Subject: [PATCH 14/17] test(core): move the confidential deposit test beside the other token-2022 tests It was parked at the end of the file, a long way from test_token2022_metadata_realloc and the rest of the token-2022 suite it belongs to. Move it up next to them; no assertion changes. Its doc comment is also cut back. The long explanation of why the pending balance is seeded with a real ElGamal ciphertext now lives at the seeding site, where a reader hits it in context, and the paragraph contrasting Deposit with a proof-gated Transfer described a test that no longer exists. --- crates/core/src/tests/integration.rs | 1108 +++++++++++++------------- 1 file changed, 547 insertions(+), 561 deletions(-) diff --git a/crates/core/src/tests/integration.rs b/crates/core/src/tests/integration.rs index 00578e4b2..3050832b6 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -10378,215 +10378,589 @@ async fn test_token2022_metadata_realloc(test_type: TestType) { println!("✓ Regression test #530: Token-2022 metadata CPI realloc works correctly"); } -async fn test_duplicate_transaction_rejected(test_type: TestType) { +/// Round trip: derive the keys, deposit into the confidential balance, read it back. +/// +/// The pending balance is seeded with a real ElGamal ciphertext before the deposit +/// lands on it; without that step the assertions below would hold under any secret +/// key. The mechanism is documented at the seeding site. +/// +/// The remaining assertions are weaker, and labelled as such. The plaintext +/// fields the program itself computes (public token balance down by exactly the +/// deposited amount, pending credit counter to 1 and back to 0) only catch a +/// deposit that silently did nothing. The decrypted available balance round-trips +/// this test's own AES ciphertext: it shows that `AeKey` encryption and +/// decryption agree and that the cheatcode reads the right field, but it does not +/// bind to the ElGamal key. The post-apply pending read of 0 is a shape check, +/// not a key check: `ApplyPendingBalance` resets that ciphertext to all-zero, +/// which decodes to 0 under any key. +/// +/// The account reaches its configured state via `surfnet_setTokenAccount` rather +/// than an on-chain `ConfigureAccount`, which is proof-gated; nothing here +/// establishes whether that instruction would succeed under this harness. +#[test_case(TestType::sqlite(); "with on-disk sqlite db")] +#[test_case(TestType::in_memory(); "with in-memory sqlite db")] +#[test_case(TestType::no_db(); "with no db")] +#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { + use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; + use spl_token_2022_interface::{ + extension::{ + BaseStateWithExtensionsMut, StateWithExtensionsMut, + confidential_transfer::{ + ConfidentialTransferAccount, instruction as confidential_instruction, + }, + }, + solana_zk_sdk::encryption::{ + auth_encryption::AeKey, + elgamal::{ElGamalKeypair, ElGamalPubkey}, + pod::auth_encryption::PodAeCiphertext, + }, + }; + use surfpool_types::types::{ + ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, + }; + + let rpc_server = SurfnetCheatcodesRpc::empty(); let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); let svm_locker = SurfnetSvmLocker::new(svm_instance); + let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); + let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); + let runloop_context = RunloopContext { + id: None, + svm_locker: svm_locker.clone(), + simnet_commands_tx: simnet_cmd_tx, + remote_rpc_client: None, + rpc_config: RpcConfig::default(), + cheatcode_config: CheatcodeConfig::new(), + plugin_commands_tx, + }; - let payer = Keypair::new(); - let recipient = Pubkey::new_unique(); - let lamports_to_send = 1_000_000; + let token_program = spl_token_2022_interface::id(); + let owner = Keypair::new(); + let mint = Keypair::new(); + let decimals = 2u8; - // Airdrop SOL to payer svm_locker - .with_svm_writer(|svm| svm.airdrop(&payer.pubkey(), lamports_to_send * 10)) + .airdrop(&owner.pubkey(), 10 * LAMPORTS_PER_SOL) .unwrap() .unwrap(); - // Build a transfer transaction - let instruction = transfer(&payer.pubkey(), &recipient, lamports_to_send); - let latest_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let message = - Message::new_with_blockhash(&[instruction], Some(&payer.pubkey()), &latest_blockhash); - let transaction = - VersionedTransaction::try_new(VersionedMessage::Legacy(message), &[&payer]).unwrap(); - - // First submission should succeed - let (status_tx, status_rx) = crossbeam_unbounded(); - svm_locker - .process_transaction(&None, transaction.clone(), status_tx, false, true) - .await - .unwrap(); - - match status_rx.recv() { - Ok(TransactionStatusEvent::Success(_)) => { - println!("First transaction processed successfully"); - } - other => { - panic!("Expected first transaction to succeed, got: {:?}", other); - } - } + let token_account = get_associated_token_address_with_program_id( + &owner.pubkey(), + &mint.pubkey(), + &token_program, + ); - // Second submission of the same transaction should be rejected - let (status_tx2, status_rx2) = crossbeam_unbounded(); - let result = svm_locker - .process_transaction(&None, transaction.clone(), status_tx2, false, true) - .await; + // Leg 1: derive the owner's confidential keys from signatures scoped to this + // token account, which are the two messages a confidential client signs. + let (elgamal_signature, ae_signature) = + crate::types::confidential_key_signatures(&owner, &token_account); + let keys = rpc_server + .derive_confidential_keys( + Some(runloop_context.clone()), + elgamal_signature, + ae_signature, + ) + .expect("deriveConfidentialKeys should succeed") + .value; - assert!(result.is_err(), "Duplicate transaction should be rejected"); + // A mint carrying the confidential-transfer extension, so the Token-2022 + // program will accept confidential instructions against its accounts. + let mint_len = + spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< + spl_token_2022_interface::state::Mint, + >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) + .unwrap(); + let mint_rent = + svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("already been processed"), - "Error should mention 'already been processed', got: {err_msg}" + let setup_instructions = vec![ + system_instruction::create_account( + &owner.pubkey(), + &mint.pubkey(), + mint_rent, + mint_len as u64, + &token_program, + ), + confidential_instruction::initialize_mint( + &token_program, + &mint.pubkey(), + Some(owner.pubkey()), + true, + None, + ) + .unwrap(), + spl_token_2022_interface::instruction::initialize_mint2( + &token_program, + &mint.pubkey(), + &owner.pubkey(), + None, + decimals, + ) + .unwrap(), + ]; + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let setup_message = Message::new_with_blockhash( + &setup_instructions, + Some(&owner.pubkey()), + &recent_blockhash, ); - - // Status channel should receive a VerificationFailure - match status_rx2.recv() { - Ok(TransactionStatusEvent::VerificationFailure(msg)) => { - assert!( - msg.contains("already been processed"), - "VerificationFailure should mention 'already been processed', got: {msg}" - ); - println!("Duplicate correctly rejected with VerificationFailure"); - } - other => { - panic!( - "Expected VerificationFailure on status channel, got: {:?}", - other - ); - } - } - - // Verify the original transaction data is still stored correctly - let sig = transaction.signatures[0].to_string(); - let stored = svm_locker.with_svm_reader(|svm| svm.transactions.get(&sig)); + let setup_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&owner, &mint]) + .unwrap(); + let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, setup_tx, setup_status_tx, false, true) + .await + .unwrap(); assert!( matches!( - stored, - Ok(Some(crate::types::SurfnetTransactionStatus::Processed(_))) + setup_status_rx.recv().unwrap(), + TransactionStatusEvent::Success(_) ), - "Original transaction should still be stored as Processed" + "mint setup should succeed" ); - println!("Duplicate transaction rejection test passed!"); -} - -// ============================================================================ -// AccountLoadedTwice: V0 transactions with duplicate accounts in static keys + ALT -// ============================================================================ -// When a V0 transaction has an account in both its static keys and an Address -// Lookup Table, Agave rejects pre-execution with AccountLoadedTwice (0 CU). -// Surfpool must match this behavior. -#[test_case(TestType::sqlite(); "with on-disk sqlite db")] -#[test_case(TestType::in_memory(); "with in-memory sqlite db")] -#[test_case(TestType::no_db(); "with no db")] -#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] -#[tokio::test(flavor = "multi_thread")] -async fn test_account_loaded_twice_rejected(test_type: TestType) { - let simnet = boot_simnet(BlockProductionMode::Clock, Some(400), test_type) - .expect("the simnet should boot"); - let svm_locker = simnet.locker.clone(); + let public_amount = 10_000u64; + rpc_server + .set_token_account( + Some(runloop_context.clone()), + owner.pubkey().to_string(), + mint.pubkey().to_string(), + TokenAccountUpdate { + amount: Some(public_amount), + confidential: Some(ConfidentialTransferAccountUpdate { + elgamal_pubkey: keys.elgamal_pubkey.clone(), + aes_key: Some(keys.aes_key.clone()), + amount: Some(0), + approved: Some(true), + ..Default::default() + }), + ..Default::default() + }, + Some(token_program.to_string()), + ) + .await + .expect("setTokenAccount should succeed"); - let payer = Keypair::new(); + // Give the pending balance a real ElGamal ciphertext before the deposit + // lands on it. `Deposit` adds the amount to the commitment and passes the + // decrypt handle through untouched, so a deposit onto the all-zero pending + // ciphertext a configured account starts with produces an identity handle, + // and that opens to the same value under any secret key. Encrypting under + // the derived public key first gives the ciphertext a handle only the + // derived secret key cancels. On-chain the same state arrives via a + // party-to-party `Transfer`, which is proof-gated and blocked here by the + // SDK skew, so the account is seeded directly instead. + let seeded_pending = 1_500u64; + let elgamal_pubkey_bytes = bs58::decode(&keys.elgamal_pubkey).into_vec().unwrap(); + let elgamal_pubkey = ElGamalPubkey::try_from(elgamal_pubkey_bytes.as_slice()) + .expect("derived elgamalPubkey should parse"); + let mut seeded_account = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&token_account).unwrap()) + .expect("token account should exist"); + { + let mut state = StateWithExtensionsMut::::unpack( + &mut seeded_account.data, + ) + .expect("token account should unpack with extensions"); + let ext = state + .get_extension_mut::() + .expect("confidential extension should be present"); + ext.pending_balance_lo = elgamal_pubkey.encrypt_u64(seeded_pending).into(); + } svm_locker - .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL) - .unwrap() - .unwrap(); - - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - - // Create an ALT that contains system_program (11111...111) - let alt_key = Pubkey::new_unique(); - let system_program_id = system_program::id(); + .with_svm_writer(|svm| svm.set_account(&token_account, seeded_account)) + .expect("seeding the pending balance should succeed"); - let alt_account_data = AddressLookupTable { - meta: LookupTableMeta { - authority: Some(payer.pubkey()), - ..Default::default() - }, - addresses: vec![system_program_id].into(), - }; + // Leg 2: a real confidential-transfer deposit, executed by Token-2022. + let deposit_amount = 4_000u64; + let deposit_ix = confidential_instruction::deposit( + &token_program, + &token_account, + &mint.pubkey(), + deposit_amount, + decimals, + &owner.pubkey(), + &[], + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let deposit_message = + Message::new_with_blockhash(&[deposit_ix], Some(&owner.pubkey()), &recent_blockhash); + let deposit_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(deposit_message), &[&owner]) + .unwrap(); + let (deposit_status_tx, deposit_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, deposit_tx, deposit_status_tx, false, true) + .await + .unwrap(); + let deposit_status = deposit_status_rx.recv().unwrap(); + assert!( + matches!(deposit_status, TransactionStatusEvent::Success(_)), + "confidential deposit should succeed, got {:?}", + deposit_status + ); - svm_locker.with_svm_writer(|svm| { - let alt_data = alt_account_data.serialize_for_tests().unwrap(); - let alt_account = Account { - lamports: 1_000_000, - data: alt_data, - owner: solana_address_lookup_table_interface::program::id(), - executable: false, - rent_epoch: 0, - }; - svm.set_account(&alt_key, alt_account).unwrap(); - }); + // Leg 3: read the balance back. The deposit credits the pending balance, + // which only the derived ElGamal secret key can open. + let owner_elgamal_secret_key = keys.elgamal_secret_key.clone(); + let expected_pending = seeded_pending + deposit_amount; - // Build a V0 message that has system_program in BOTH static keys AND the ALT. - // try_compile would deduplicate, so we build the message manually. - let recipient = Pubkey::new_unique(); - let v0_message = v0::Message { - header: MessageHeader { - num_required_signatures: 1, - num_readonly_signed_accounts: 0, - // system_program is readonly unsigned - num_readonly_unsigned_accounts: 1, - }, - // Static keys: [payer (signer+writable), recipient (writable), system_program (readonly)] - account_keys: vec![payer.pubkey(), recipient, system_program_id], - recent_blockhash, - // A simple transfer instruction: program_id_index=2 (system_program), - // accounts=[0 (payer), 1 (recipient)] - instructions: vec![solana_message::compiled_instruction::CompiledInstruction { - program_id_index: 2, - accounts: vec![0, 1], - data: { - // system_instruction::transfer encodes as: [2,0,0,0] + 8-byte LE amount - let mut data = vec![2, 0, 0, 0]; - data.extend_from_slice(&100u64.to_le_bytes()); - data + // The pending balance is bound to the derived key: the owner's secret key + // recovers the seeded amount plus the deposit, and a stranger's recovers + // nothing. + let pending_under_owner = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), }, - }], - // ALT lookup that also loads system_program (index 0 in the ALT) as readonly - address_table_lookups: vec![MessageAddressTableLookup { - account_key: alt_key, - writable_indexes: vec![], - readonly_indexes: vec![0], // system_program is at index 0 in the ALT - }], - }; + ) + .await + .map(|response| response.value.pending) + .unwrap_or(None); + assert_eq!( + pending_under_owner, + Some(expected_pending), + "the derived ElGamal secret key should recover the seeded pending balance plus the deposit" + ); - let tx = VersionedTransaction::try_new(VersionedMessage::V0(v0_message), &[&payer]) - .expect("Failed to create transaction"); + let foreign_elgamal = ElGamalKeypair::new_rand(); + let pending_under_foreign = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: None, + elgamal_secret_key: Some( + bs58::encode(<[u8; 32]>::from(foreign_elgamal.secret())).into_string(), + ), + }, + ) + .await + .map(|response| response.value.pending) + .unwrap_or(None); + assert_eq!( + pending_under_foreign, None, + "a foreign ElGamal secret key must fail to recover the pending balance" + ); - let (status_tx, _status_rx) = crossbeam_channel::unbounded(); - let result = svm_locker - .process_transaction(&None, tx, status_tx, false, true) - .await; + let after_deposit = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(keys.aes_key.clone()), + elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed") + .value; + assert_eq!( + after_deposit.pending, + Some(expected_pending), + "the deposited amount should decrypt out of the pending balance" + ); + assert_eq!( + after_deposit.available, + Some(0), + "a deposit credits the pending balance, not the available one" + ); + assert_eq!( + after_deposit.pending_balance_credit_counter, 1, + "the deposit should register one pending credit" + ); - assert!( - result.is_err(), - "Transaction with duplicate account should be rejected, got: {:?}", - result + // The public balance funded the deposit, so it drops by the same amount. + let account = svm_locker + .with_svm_reader(|svm| svm.inner.get_account(&token_account).unwrap()) + .expect("token account should exist"); + let state = + StateWithExtensions::::unpack(&account.data) + .expect("token account should unpack with extensions"); + assert_eq!( + state.base.amount, + public_amount - deposit_amount, + "the public balance should fall by the deposited amount" ); - let err_string = result.unwrap_err().to_string(); + + // Applying the pending balance moves it into the available balance, which + // the owner reads with the AES key. + let aes_bytes: [u8; 16] = bs58::decode(&keys.aes_key) + .into_vec() + .unwrap() + .try_into() + .unwrap(); + let new_available = PodAeCiphertext::from(AeKey::from(aes_bytes).encrypt(expected_pending)); + let apply_ix = confidential_instruction::apply_pending_balance( + &token_program, + &token_account, + after_deposit.pending_balance_credit_counter, + &new_available, + &owner.pubkey(), + &[], + ) + .unwrap(); + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let apply_message = + Message::new_with_blockhash(&[apply_ix], Some(&owner.pubkey()), &recent_blockhash); + let apply_tx = + VersionedTransaction::try_new(VersionedMessage::Legacy(apply_message), &[&owner]).unwrap(); + let (apply_status_tx, apply_status_rx) = crossbeam_channel::unbounded(); + svm_locker + .process_transaction(&None, apply_tx, apply_status_tx, false, true) + .await + .unwrap(); + let apply_status = apply_status_rx.recv().unwrap(); assert!( - err_string.contains("Account loaded twice"), - "Expected AccountLoadedTwice error, got: {}", - err_string + matches!(apply_status, TransactionStatusEvent::Success(_)), + "applying the pending balance should succeed, got {:?}", + apply_status ); - println!("AccountLoadedTwice rejection test passed!"); + let after_apply = rpc_server + .get_confidential_balance( + Some(runloop_context.clone()), + token_account.to_string(), + ConfidentialBalanceKeys { + aes_key: Some(keys.aes_key.clone()), + elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), + }, + ) + .await + .expect("getConfidentialBalance should succeed") + .value; + assert_eq!( + after_apply.available, + Some(expected_pending), + "the applied amount should decrypt out of the available balance" + ); + assert_eq!( + after_apply.pending, + Some(0), + "the pending balance should be drained by the apply" + ); + assert_eq!( + after_apply.pending_balance_credit_counter, 0, + "applying the pending balance resets the credit counter" + ); } -// ============================================================================ -// Regression #684: finalized slot must be recent enough for ALT creation -// ============================================================================ -// Address lookup table creation validates the provided recent_slot against the -// SlotHashes sysvar. Client code commonly passes getSlot(finalized), which -// should remain inside SlotHashes' 512-entry retention window. -#[cfg_attr(feature = "ignore_tests_ci", ignore = "flaky CI tests")] -#[test_case(TestType::sqlite(); "with on-disk sqlite db")] -#[test_case(TestType::in_memory(); "with in-memory sqlite db")] -#[test_case(TestType::no_db(); "with no db")] -#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] -#[tokio::test(flavor = "multi_thread")] -async fn test_create_lookup_table_with_finalized_slot(test_type: TestType) { - use solana_slot_hashes::SlotHashes; - +async fn test_duplicate_transaction_rejected(test_type: TestType) { let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); let svm_locker = SurfnetSvmLocker::new(svm_instance); let payer = Keypair::new(); - svm_locker - .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL) - .unwrap() + let recipient = Pubkey::new_unique(); + let lamports_to_send = 1_000_000; + + // Airdrop SOL to payer + svm_locker + .with_svm_writer(|svm| svm.airdrop(&payer.pubkey(), lamports_to_send * 10)) + .unwrap() + .unwrap(); + + // Build a transfer transaction + let instruction = transfer(&payer.pubkey(), &recipient, lamports_to_send); + let latest_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + let message = + Message::new_with_blockhash(&[instruction], Some(&payer.pubkey()), &latest_blockhash); + let transaction = + VersionedTransaction::try_new(VersionedMessage::Legacy(message), &[&payer]).unwrap(); + + // First submission should succeed + let (status_tx, status_rx) = crossbeam_unbounded(); + svm_locker + .process_transaction(&None, transaction.clone(), status_tx, false, true) + .await + .unwrap(); + + match status_rx.recv() { + Ok(TransactionStatusEvent::Success(_)) => { + println!("First transaction processed successfully"); + } + other => { + panic!("Expected first transaction to succeed, got: {:?}", other); + } + } + + // Second submission of the same transaction should be rejected + let (status_tx2, status_rx2) = crossbeam_unbounded(); + let result = svm_locker + .process_transaction(&None, transaction.clone(), status_tx2, false, true) + .await; + + assert!(result.is_err(), "Duplicate transaction should be rejected"); + + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("already been processed"), + "Error should mention 'already been processed', got: {err_msg}" + ); + + // Status channel should receive a VerificationFailure + match status_rx2.recv() { + Ok(TransactionStatusEvent::VerificationFailure(msg)) => { + assert!( + msg.contains("already been processed"), + "VerificationFailure should mention 'already been processed', got: {msg}" + ); + println!("Duplicate correctly rejected with VerificationFailure"); + } + other => { + panic!( + "Expected VerificationFailure on status channel, got: {:?}", + other + ); + } + } + + // Verify the original transaction data is still stored correctly + let sig = transaction.signatures[0].to_string(); + let stored = svm_locker.with_svm_reader(|svm| svm.transactions.get(&sig)); + assert!( + matches!( + stored, + Ok(Some(crate::types::SurfnetTransactionStatus::Processed(_))) + ), + "Original transaction should still be stored as Processed" + ); + + println!("Duplicate transaction rejection test passed!"); +} + +// ============================================================================ +// AccountLoadedTwice: V0 transactions with duplicate accounts in static keys + ALT +// ============================================================================ +// When a V0 transaction has an account in both its static keys and an Address +// Lookup Table, Agave rejects pre-execution with AccountLoadedTwice (0 CU). +// Surfpool must match this behavior. +#[test_case(TestType::sqlite(); "with on-disk sqlite db")] +#[test_case(TestType::in_memory(); "with in-memory sqlite db")] +#[test_case(TestType::no_db(); "with no db")] +#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_account_loaded_twice_rejected(test_type: TestType) { + let simnet = boot_simnet(BlockProductionMode::Clock, Some(400), test_type) + .expect("the simnet should boot"); + let svm_locker = simnet.locker.clone(); + + let payer = Keypair::new(); + svm_locker + .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL) + .unwrap() + .unwrap(); + + let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); + + // Create an ALT that contains system_program (11111...111) + let alt_key = Pubkey::new_unique(); + let system_program_id = system_program::id(); + + let alt_account_data = AddressLookupTable { + meta: LookupTableMeta { + authority: Some(payer.pubkey()), + ..Default::default() + }, + addresses: vec![system_program_id].into(), + }; + + svm_locker.with_svm_writer(|svm| { + let alt_data = alt_account_data.serialize_for_tests().unwrap(); + let alt_account = Account { + lamports: 1_000_000, + data: alt_data, + owner: solana_address_lookup_table_interface::program::id(), + executable: false, + rent_epoch: 0, + }; + svm.set_account(&alt_key, alt_account).unwrap(); + }); + + // Build a V0 message that has system_program in BOTH static keys AND the ALT. + // try_compile would deduplicate, so we build the message manually. + let recipient = Pubkey::new_unique(); + let v0_message = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + // system_program is readonly unsigned + num_readonly_unsigned_accounts: 1, + }, + // Static keys: [payer (signer+writable), recipient (writable), system_program (readonly)] + account_keys: vec![payer.pubkey(), recipient, system_program_id], + recent_blockhash, + // A simple transfer instruction: program_id_index=2 (system_program), + // accounts=[0 (payer), 1 (recipient)] + instructions: vec![solana_message::compiled_instruction::CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data: { + // system_instruction::transfer encodes as: [2,0,0,0] + 8-byte LE amount + let mut data = vec![2, 0, 0, 0]; + data.extend_from_slice(&100u64.to_le_bytes()); + data + }, + }], + // ALT lookup that also loads system_program (index 0 in the ALT) as readonly + address_table_lookups: vec![MessageAddressTableLookup { + account_key: alt_key, + writable_indexes: vec![], + readonly_indexes: vec![0], // system_program is at index 0 in the ALT + }], + }; + + let tx = VersionedTransaction::try_new(VersionedMessage::V0(v0_message), &[&payer]) + .expect("Failed to create transaction"); + + let (status_tx, _status_rx) = crossbeam_channel::unbounded(); + let result = svm_locker + .process_transaction(&None, tx, status_tx, false, true) + .await; + + assert!( + result.is_err(), + "Transaction with duplicate account should be rejected, got: {:?}", + result + ); + let err_string = result.unwrap_err().to_string(); + assert!( + err_string.contains("Account loaded twice"), + "Expected AccountLoadedTwice error, got: {}", + err_string + ); + + println!("AccountLoadedTwice rejection test passed!"); +} + +// ============================================================================ +// Regression #684: finalized slot must be recent enough for ALT creation +// ============================================================================ +// Address lookup table creation validates the provided recent_slot against the +// SlotHashes sysvar. Client code commonly passes getSlot(finalized), which +// should remain inside SlotHashes' 512-entry retention window. +#[cfg_attr(feature = "ignore_tests_ci", ignore = "flaky CI tests")] +#[test_case(TestType::sqlite(); "with on-disk sqlite db")] +#[test_case(TestType::in_memory(); "with in-memory sqlite db")] +#[test_case(TestType::no_db(); "with no db")] +#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_create_lookup_table_with_finalized_slot(test_type: TestType) { + use solana_slot_hashes::SlotHashes; + + let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); + let svm_locker = SurfnetSvmLocker::new(svm_instance); + + let payer = Keypair::new(); + svm_locker + .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL) + .unwrap() .unwrap(); while svm_locker.get_latest_absolute_slot() < 40 { @@ -11114,391 +11488,3 @@ async fn test_request_airdrop_rejects_below_rent_amount() { assert_eq!(err.code, jsonrpc_core::ErrorCode::InvalidParams); assert!(err.message.contains("rent-exempt minimum")); } - -/// Round trip: derive the owner's confidential keys with -/// `surfnet_deriveConfidentialKeys`, move tokens into the confidential balance -/// with a real `Deposit` plus `ApplyPendingBalance`, and read the new balance -/// back with `surfnet_getConfidentialBalance`. -/// -/// The pending balance is seeded with a real ElGamal encryption under the derived -/// public key first, so the ciphertext the deposit lands on carries a decrypt -/// handle that is not the identity point. Deposits onto the all-zero pending -/// ciphertext a freshly configured account carries produce an identity handle, -/// and such a ciphertext opens to the same value under any secret key. On chain -/// that state arrives via a party-to-party `Transfer`, which is proof-gated and -/// out of reach here, so the test writes it directly. Two assertions then bind -/// the read to the derived key: the derived secret key recovers seed plus deposit -/// exactly, and a random ElGamal secret key recovers nothing. -/// -/// The remaining assertions are weaker, and labelled as such. The plaintext -/// fields the program itself computes (public token balance down by exactly the -/// deposited amount, pending credit counter to 1 and back to 0) only catch a -/// deposit that silently did nothing. The decrypted available balance round-trips -/// this test's own AES ciphertext: it shows that `AeKey` encryption and -/// decryption agree and that the cheatcode reads the right field, but it does not -/// bind to the ElGamal key. The post-apply pending read of 0 is a shape check, -/// not a key check: `ApplyPendingBalance` resets that ciphertext to all-zero, -/// which decodes to 0 under any key. -/// -/// The account reaches its configured state via `surfnet_setTokenAccount` rather -/// than an on-chain `ConfigureAccount`, which is proof-gated; nothing here -/// establishes whether that instruction would succeed under this harness. -/// `Deposit` is the confidential-balance movement that carries no zero-knowledge -/// proof; a party-to-party `Transfer` additionally needs proofs verified by the -/// ZK ElGamal proof program and is not covered here. -#[test_case(TestType::sqlite(); "with on-disk sqlite db")] -#[test_case(TestType::in_memory(); "with in-memory sqlite db")] -#[test_case(TestType::no_db(); "with no db")] -#[cfg_attr(feature = "postgres", test_case(TestType::postgres(); "with postgres db"))] -#[tokio::test(flavor = "multi_thread")] -async fn test_confidential_balance_deposit_round_trip(test_type: TestType) { - use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; - use spl_token_2022_interface::{ - extension::{ - BaseStateWithExtensionsMut, StateWithExtensionsMut, - confidential_transfer::{ - ConfidentialTransferAccount, instruction as confidential_instruction, - }, - }, - solana_zk_sdk::encryption::{ - auth_encryption::AeKey, - elgamal::{ElGamalKeypair, ElGamalPubkey}, - pod::auth_encryption::PodAeCiphertext, - }, - }; - use surfpool_types::types::{ - ConfidentialBalanceKeys, ConfidentialTransferAccountUpdate, TokenAccountUpdate, - }; - - let rpc_server = SurfnetCheatcodesRpc::empty(); - let (svm_instance, _simnet_events_rx, _geyser_events_rx) = test_type.initialize_svm(); - let svm_locker = SurfnetSvmLocker::new(svm_instance); - let (simnet_cmd_tx, _simnet_cmd_rx) = crossbeam_unbounded::(); - let (plugin_commands_tx, _plugin_commands_rx) = crossbeam_channel::unbounded::(); - let runloop_context = RunloopContext { - id: None, - svm_locker: svm_locker.clone(), - simnet_commands_tx: simnet_cmd_tx, - remote_rpc_client: None, - rpc_config: RpcConfig::default(), - cheatcode_config: CheatcodeConfig::new(), - plugin_commands_tx, - }; - - let token_program = spl_token_2022_interface::id(); - let owner = Keypair::new(); - let mint = Keypair::new(); - let decimals = 2u8; - - svm_locker - .airdrop(&owner.pubkey(), 10 * LAMPORTS_PER_SOL) - .unwrap() - .unwrap(); - - let token_account = get_associated_token_address_with_program_id( - &owner.pubkey(), - &mint.pubkey(), - &token_program, - ); - - // Leg 1: derive the owner's confidential keys from signatures scoped to this - // token account, which are the two messages a confidential client signs. - let (elgamal_signature, ae_signature) = - crate::types::confidential_key_signatures(&owner, &token_account); - let keys = rpc_server - .derive_confidential_keys( - Some(runloop_context.clone()), - elgamal_signature, - ae_signature, - ) - .expect("deriveConfidentialKeys should succeed") - .value; - - // A mint carrying the confidential-transfer extension, so the Token-2022 - // program will accept confidential instructions against its accounts. - let mint_len = - spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::< - spl_token_2022_interface::state::Mint, - >(&[spl_token_2022_interface::extension::ExtensionType::ConfidentialTransferMint]) - .unwrap(); - let mint_rent = - svm_locker.with_svm_reader(|svm| svm.inner.minimum_balance_for_rent_exemption(mint_len)); - - let setup_instructions = vec![ - system_instruction::create_account( - &owner.pubkey(), - &mint.pubkey(), - mint_rent, - mint_len as u64, - &token_program, - ), - confidential_instruction::initialize_mint( - &token_program, - &mint.pubkey(), - Some(owner.pubkey()), - true, - None, - ) - .unwrap(), - spl_token_2022_interface::instruction::initialize_mint2( - &token_program, - &mint.pubkey(), - &owner.pubkey(), - None, - decimals, - ) - .unwrap(), - ]; - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let setup_message = Message::new_with_blockhash( - &setup_instructions, - Some(&owner.pubkey()), - &recent_blockhash, - ); - let setup_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(setup_message), &[&owner, &mint]) - .unwrap(); - let (setup_status_tx, setup_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, setup_tx, setup_status_tx, false, true) - .await - .unwrap(); - assert!( - matches!( - setup_status_rx.recv().unwrap(), - TransactionStatusEvent::Success(_) - ), - "mint setup should succeed" - ); - - // Fund the owner's account with public tokens and a configured, approved - // confidential-transfer extension holding a zero balance. - let public_amount = 10_000u64; - rpc_server - .set_token_account( - Some(runloop_context.clone()), - owner.pubkey().to_string(), - mint.pubkey().to_string(), - TokenAccountUpdate { - amount: Some(public_amount), - confidential: Some(ConfidentialTransferAccountUpdate { - elgamal_pubkey: keys.elgamal_pubkey.clone(), - aes_key: Some(keys.aes_key.clone()), - amount: Some(0), - approved: Some(true), - ..Default::default() - }), - ..Default::default() - }, - Some(token_program.to_string()), - ) - .await - .expect("setTokenAccount should succeed"); - - // Give the pending balance a real ElGamal ciphertext before the deposit - // lands on it. `Deposit` adds the amount to the commitment and passes the - // decrypt handle through untouched, so a deposit onto the all-zero pending - // ciphertext a configured account starts with produces an identity handle, - // and that opens to the same value under any secret key. Encrypting under - // the derived public key first gives the ciphertext a handle only the - // derived secret key cancels. On-chain the same state arrives via a - // party-to-party `Transfer`, which is proof-gated and blocked here by the - // SDK skew, so the account is seeded directly instead. - let seeded_pending = 1_500u64; - let elgamal_pubkey_bytes = bs58::decode(&keys.elgamal_pubkey).into_vec().unwrap(); - let elgamal_pubkey = ElGamalPubkey::try_from(elgamal_pubkey_bytes.as_slice()) - .expect("derived elgamalPubkey should parse"); - let mut seeded_account = svm_locker - .with_svm_reader(|svm| svm.inner.get_account(&token_account).unwrap()) - .expect("token account should exist"); - { - let mut state = StateWithExtensionsMut::::unpack( - &mut seeded_account.data, - ) - .expect("token account should unpack with extensions"); - let ext = state - .get_extension_mut::() - .expect("confidential extension should be present"); - ext.pending_balance_lo = elgamal_pubkey.encrypt_u64(seeded_pending).into(); - } - svm_locker - .with_svm_writer(|svm| svm.set_account(&token_account, seeded_account)) - .expect("seeding the pending balance should succeed"); - - // Leg 2: a real confidential-transfer deposit, executed by Token-2022. - let deposit_amount = 4_000u64; - let deposit_ix = confidential_instruction::deposit( - &token_program, - &token_account, - &mint.pubkey(), - deposit_amount, - decimals, - &owner.pubkey(), - &[], - ) - .unwrap(); - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let deposit_message = - Message::new_with_blockhash(&[deposit_ix], Some(&owner.pubkey()), &recent_blockhash); - let deposit_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(deposit_message), &[&owner]) - .unwrap(); - let (deposit_status_tx, deposit_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, deposit_tx, deposit_status_tx, false, true) - .await - .unwrap(); - let deposit_status = deposit_status_rx.recv().unwrap(); - assert!( - matches!(deposit_status, TransactionStatusEvent::Success(_)), - "confidential deposit should succeed, got {:?}", - deposit_status - ); - - // Leg 3: read the balance back. The deposit credits the pending balance, - // which only the derived ElGamal secret key can open. - let owner_elgamal_secret_key = keys.elgamal_secret_key.clone(); - let expected_pending = seeded_pending + deposit_amount; - - // The pending balance is bound to the derived key: the owner's secret key - // recovers the seeded amount plus the deposit, and a stranger's recovers - // nothing. - let pending_under_owner = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - token_account.to_string(), - ConfidentialBalanceKeys { - aes_key: None, - elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), - }, - ) - .await - .map(|response| response.value.pending) - .unwrap_or(None); - assert_eq!( - pending_under_owner, - Some(expected_pending), - "the derived ElGamal secret key should recover the seeded pending balance plus the deposit" - ); - - let foreign_elgamal = ElGamalKeypair::new_rand(); - let pending_under_foreign = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - token_account.to_string(), - ConfidentialBalanceKeys { - aes_key: None, - elgamal_secret_key: Some( - bs58::encode(<[u8; 32]>::from(foreign_elgamal.secret())).into_string(), - ), - }, - ) - .await - .map(|response| response.value.pending) - .unwrap_or(None); - assert_eq!( - pending_under_foreign, None, - "a foreign ElGamal secret key must fail to recover the pending balance" - ); - - let after_deposit = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - token_account.to_string(), - ConfidentialBalanceKeys { - aes_key: Some(keys.aes_key.clone()), - elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), - }, - ) - .await - .expect("getConfidentialBalance should succeed") - .value; - assert_eq!( - after_deposit.pending, - Some(expected_pending), - "the deposited amount should decrypt out of the pending balance" - ); - assert_eq!( - after_deposit.available, - Some(0), - "a deposit credits the pending balance, not the available one" - ); - assert_eq!( - after_deposit.pending_balance_credit_counter, 1, - "the deposit should register one pending credit" - ); - - // The public balance funded the deposit, so it drops by the same amount. - let account = svm_locker - .with_svm_reader(|svm| svm.inner.get_account(&token_account).unwrap()) - .expect("token account should exist"); - let state = - StateWithExtensions::::unpack(&account.data) - .expect("token account should unpack with extensions"); - assert_eq!( - state.base.amount, - public_amount - deposit_amount, - "the public balance should fall by the deposited amount" - ); - - // Applying the pending balance moves it into the available balance, which - // the owner reads with the AES key. - let aes_bytes: [u8; 16] = bs58::decode(&keys.aes_key) - .into_vec() - .unwrap() - .try_into() - .unwrap(); - let new_available = PodAeCiphertext::from(AeKey::from(aes_bytes).encrypt(expected_pending)); - let apply_ix = confidential_instruction::apply_pending_balance( - &token_program, - &token_account, - after_deposit.pending_balance_credit_counter, - &new_available, - &owner.pubkey(), - &[], - ) - .unwrap(); - let recent_blockhash = svm_locker.with_svm_reader(|svm| svm.latest_blockhash()); - let apply_message = - Message::new_with_blockhash(&[apply_ix], Some(&owner.pubkey()), &recent_blockhash); - let apply_tx = - VersionedTransaction::try_new(VersionedMessage::Legacy(apply_message), &[&owner]).unwrap(); - let (apply_status_tx, apply_status_rx) = crossbeam_channel::unbounded(); - svm_locker - .process_transaction(&None, apply_tx, apply_status_tx, false, true) - .await - .unwrap(); - let apply_status = apply_status_rx.recv().unwrap(); - assert!( - matches!(apply_status, TransactionStatusEvent::Success(_)), - "applying the pending balance should succeed, got {:?}", - apply_status - ); - - let after_apply = rpc_server - .get_confidential_balance( - Some(runloop_context.clone()), - token_account.to_string(), - ConfidentialBalanceKeys { - aes_key: Some(keys.aes_key.clone()), - elgamal_secret_key: Some(owner_elgamal_secret_key.clone()), - }, - ) - .await - .expect("getConfidentialBalance should succeed") - .value; - assert_eq!( - after_apply.available, - Some(expected_pending), - "the applied amount should decrypt out of the available balance" - ); - assert_eq!( - after_apply.pending, - Some(0), - "the pending balance should be drained by the apply" - ); - assert_eq!( - after_apply.pending_balance_credit_counter, 0, - "applying the pending balance resets the credit counter" - ); -} From 0e1df8b90ad3df7c9aed1219469f704e50fc8a5e Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 20 Aug 2026 09:38:23 -0600 Subject: [PATCH 15/17] docs(core): distil the confidential key derivation doc comment derive_confidential_keys carried two paragraphs restating what the RPC method above it already documents, and a third selling the no-client-side-crypto property. Say what the function does and point at the one place the derivation semantics are written down. --- crates/core/src/types.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 5ff9a4ba8..6c485672e 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1436,18 +1436,12 @@ fn decrypt_pending_balance( /// Derive an owner's confidential-transfer keys from the owner's signatures. /// -/// Backs the `surfnet_deriveConfidentialKeys` cheatcode. Doing this server-side is -/// what lets the confidential cheatcodes be used with no client-side crypto -/// dependency at all: the caller signs the two seed messages its confidential -/// client would sign, hands over the signatures, and gets back keys ready to pass -/// to `surfnet_setTokenAccount` and `surfnet_getConfidentialBalance`. +/// Backs the `surfnet_deriveConfidentialKeys` cheatcode: the caller signs the two seed +/// messages and gets back keys ready to pass to the other confidential cheatcodes. /// -/// The derivation semantics are documented once, on the RPC method that exposes this: -/// see `surfnet_deriveConfidentialKeys` on -/// [`crate::rpc::surfnet_cheatcodes::SurfnetCheatcodes`] for why the two seed messages are -/// domain-separated and take one signature each, why the result is byte-identical to -/// `ElGamalKeypair::new_from_signer` / `AeKey::new_from_signer`, and what does and does not -/// cross the wire. +/// The derivation semantics — domain separation, the `new_from_signer` equivalence, and +/// what does and does not cross the wire — are documented on `surfnet_deriveConfidentialKeys` +/// in [`crate::rpc::surfnet_cheatcodes::SurfnetCheatcodes`]. /// /// `derive_confidential_keys` itself imposes no seed: the caller owns what the keys are /// scoped to, because the caller owns what it signed. From b1731300b14addeb498b29aacba8c5f1e9ee7250 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 20 Aug 2026 09:38:23 -0600 Subject: [PATCH 16/17] fix(types): declare the confidential response amounts bigint, not number GetConfidentialBalanceResponse overrode its ts-rs bindings to "number | bigint", which forces every caller to narrow before doing arithmetic. Dropping the override lets ts-rs map u64 to bigint, which is what the other response types in this crate already emit. --- crates/types/src/types.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index 261fb1a74..d1250ba30 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -1255,16 +1255,13 @@ pub struct ConfidentialBalanceKeys { #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))] pub struct GetConfidentialBalanceResponse { /// The available (spendable) balance, or `null` if no `aesKey` was supplied. - #[cfg_attr(feature = "ts-bindings", ts(type = "number | bigint | null"))] pub available: Option, /// The pending (credited but not yet applied) balance, or `null` if no /// `elgamalSecretKey` was supplied. - #[cfg_attr(feature = "ts-bindings", ts(type = "number | bigint | null"))] pub pending: Option, /// How many confidential credits are sitting in the pending balance. Non-zero /// means an `ApplyPendingBalance` is required before they show up in /// `available`. - #[cfg_attr(feature = "ts-bindings", ts(type = "number | bigint"))] pub pending_balance_credit_counter: u64, } From 0e7f3bccddd25cdc273c3ccd0b565ddbb39a58ba Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 20 Aug 2026 09:38:29 -0600 Subject: [PATCH 17/17] build(sdk-node): drop the kit-types formatter and regenerate the bindings generate-kit-types.js was made to shell out to prettier over everything ts-rs emits. That reformatted thirty-odd binding files this change never touched, so the diff read as a rewrite of the generated directory when three files were actually new. Take the formatter back out and let the generator's own output stand, as it did before. The bindings are regenerated on top of that, which is also where the previous commit's bigint change surfaces. --- crates/sdk-node/package-lock.json | 19 +---- crates/sdk-node/package.json | 1 - crates/sdk-node/scripts/generate-kit-types.js | 57 -------------- .../kit/generated/AccountAddress.ts | 3 +- .../kit/generated/AccountSnapshot.ts | 23 +++--- .../kit/generated/AccountUpdate.ts | 45 ++++++----- .../kit/generated/CheatcodeControlConfig.ts | 2 +- .../kit/generated/ConfidentialBalanceKeys.ts | 23 +++--- .../ConfidentialTransferAccountUpdate.ts | 77 +++++++++---------- .../DeriveConfidentialKeysResponse.ts | 29 ++++--- .../kit/generated/ExportSnapshotConfig.ts | 6 +- .../kit/generated/ExportSnapshotFilter.ts | 28 +++---- .../kit/generated/ExportSnapshotScope.ts | 2 +- .../GetConfidentialBalanceResponse.ts | 33 ++++---- .../generated/GetStreamedAccountsResponse.ts | 4 +- .../kit/generated/GetSurfnetInfoResponse.ts | 4 +- .../kit/generated/OfflineAccountConfig.ts | 2 +- .../kit/generated/OverrideInstance.ts | 67 ++++++++-------- .../kit/generated/ParsedAccount.ts | 2 +- .../surfpool-sdk/kit/generated/PdaSeed.ts | 11 +-- .../kit/generated/ResetAccountConfig.ts | 2 +- .../kit/generated/RpcProfileResultConfig.ts | 5 +- .../generated/RunbookExecutionStatusReport.ts | 7 +- .../surfpool-sdk/kit/generated/Scenario.ts | 43 +++++------ .../kit/generated/StreamAccountConfig.ts | 2 +- .../kit/generated/StreamAccountsEntry.ts | 5 +- .../kit/generated/StreamedAccountInfo.ts | 5 +- .../kit/generated/SupplyUpdate.ts | 7 +- .../kit/generated/TokenAccountUpdate.ts | 57 +++++++------- .../surfpool-sdk/kit/generated/UiAccount.ts | 9 +-- .../kit/generated/UiAccountChange.ts | 6 +- .../kit/generated/UiAccountData.ts | 3 +- .../kit/generated/UiAccountEncoding.ts | 3 +- .../kit/generated/UiAccountProfileState.ts | 3 +- .../kit/generated/UiKeyedProfileResult.ts | 8 +- .../kit/generated/UiProfileResult.ts | 7 +- 36 files changed, 229 insertions(+), 381 deletions(-) diff --git a/crates/sdk-node/package-lock.json b/crates/sdk-node/package-lock.json index 3efae877e..b57f56a49 100644 --- a/crates/sdk-node/package-lock.json +++ b/crates/sdk-node/package-lock.json @@ -13,7 +13,6 @@ "@solana/kit": "^7.0.0", "@solana/kit-plugin-rpc": "^0.15.0", "@solana/kit-plugin-signer": "^0.13.0", - "prettier": "3.9.6", "typescript": "^5.7.0" }, "engines": { @@ -412,6 +411,7 @@ "integrity": "sha512-ZCeai4LRJQooUmJXvpgMEGFTrCdJnV1ODbDJ8oqFZ+Y4t/9x1baQsFFpruqsdRyeGv2Rr+X6jV7cldVD+hyzRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@solana/accounts": "7.0.0", "@solana/addresses": "7.0.0", @@ -1242,28 +1242,13 @@ "node": ">=22.12.0" } }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/crates/sdk-node/package.json b/crates/sdk-node/package.json index 1b468c8ca..f158101e8 100644 --- a/crates/sdk-node/package.json +++ b/crates/sdk-node/package.json @@ -74,7 +74,6 @@ "@solana/kit": "^7.0.0", "@solana/kit-plugin-rpc": "^0.15.0", "@solana/kit-plugin-signer": "^0.13.0", - "prettier": "3.9.6", "typescript": "^5.7.0" }, "peerDependencies": { diff --git a/crates/sdk-node/scripts/generate-kit-types.js b/crates/sdk-node/scripts/generate-kit-types.js index 5ce6418cb..cc5433312 100644 --- a/crates/sdk-node/scripts/generate-kit-types.js +++ b/crates/sdk-node/scripts/generate-kit-types.js @@ -10,7 +10,6 @@ const fs = require("node:fs"); const path = require("node:path"); const repoRoot = path.resolve(__dirname, "..", "..", ".."); -const packageDir = path.resolve(__dirname, ".."); const generatedDir = path.resolve( __dirname, "..", @@ -19,56 +18,6 @@ const generatedDir = path.resolve( "generated", ); -// ts-rs emits unindented single-line type bodies, so the committed bindings are -// formatted here rather than left for a follow-up pass: the freshness job in -// .github/workflows/sdk_node.yml regenerates this directory and fails on any -// diff, so anything that formats the files *after* generation is reverted by the -// next run. Formatting has to happen inside the generator or not at all. -// -// The formatter runs through `npx ` instead of `require`: that job -// checks out the repo and runs this script with no `setup-node` and no `npm ci`, -// so there is no node_modules to resolve against and a bare require would throw -// MODULE_NOT_FOUND there while working fine locally. `npx` uses the local -// devDependency when it satisfies the spec and fetches it otherwise, so both -// environments format with the same bytes. The version comes from package.json -// so there is one place to bump it, and it is pinned exactly because a range -// would let two machines produce different output and turn the gate red. -const prettierVersion = - JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) - .devDependencies?.prettier ?? null; - -function formatFiles(filePaths) { - if (!prettierVersion) { - throw new Error( - "devDependencies.prettier is missing from crates/sdk-node/package.json; " + - "the generated bindings cannot be formatted deterministically without a pinned version.", - ); - } - if (!/^\d+\.\d+\.\d+$/.test(prettierVersion)) { - throw new Error( - `devDependencies.prettier must be an exact version, got "${prettierVersion}"; ` + - "a range lets different machines format the bindings differently and breaks the freshness gate.", - ); - } - execFileSync( - process.platform === "win32" ? "npx.cmd" : "npx", - [ - "--yes", - `prettier@${prettierVersion}`, - // Pinned flags, not preference: config discovery walks up past the repo - // root, so without these the output would depend on files outside the - // checkout and stop being reproducible in CI. - "--no-config", - "--no-editorconfig", - "--log-level", - "warn", - "--write", - ...filePaths, - ], - { cwd: packageDir, stdio: "inherit" }, - ); -} - const HEADER = `// @generated by ts-rs from the Rust types in crates/types.\n// Do not edit; run \`npm run generate:kit-types\` in crates/sdk-node instead.\n`; fs.rmSync(generatedDir, { recursive: true, force: true }); @@ -110,10 +59,4 @@ const barrel = files .join("\n"); fs.writeFileSync(path.join(generatedDir, "index.ts"), `${HEADER}${barrel}\n`); -// Every file this script emits, index.ts included: a generator that formats part -// of its output leaves the rest to drift. -formatFiles( - [...files, "index.ts"].map((name) => path.join(generatedDir, name)), -); - console.log(`Generated ${files.length} binding files in ${generatedDir}`); diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts b/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts index bcf4f9c52..c6366b04e 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/AccountAddress.ts @@ -7,5 +7,4 @@ import type { PdaSeed } from "./PdaSeed.js"; * Defines how an account address should be determined *Defines how an account address should be determined */ -export type AccountAddress = - { pubkey: string } | { pda: { programId: string; seeds: Array } }; +export type AccountAddress = { "pubkey": string } | { "pda": { programId: string, seeds: Array, } }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts b/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts index a81260157..e7436e47d 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/AccountSnapshot.ts @@ -3,17 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ParsedAccount } from "./ParsedAccount.js"; -export type AccountSnapshot = { - lamports: bigint; - owner: string; - executable: boolean; - rentEpoch: bigint; - /** - * Base64 encoded data - */ - data: string; - /** - * Parsed account data if available - */ - parsedData: ParsedAccount | null; -}; +export type AccountSnapshot = { lamports: bigint, owner: string, executable: boolean, rentEpoch: bigint, +/** + * Base64 encoded data + */ +data: string, +/** + * Parsed account data if available + */ +parsedData: ParsedAccount | null, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts index 31a720b3d..abd1c0643 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/AccountUpdate.ts @@ -2,26 +2,25 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AccountUpdate = { - /** - * providing this value sets the lamports in the account - */ - lamports?: number | bigint; - /** - * providing this value sets the data held in this account, as a - * hex-encoded string - */ - data?: string; - /** - * providing this value sets the program that owns this account. If executable, the program that loads this account. - */ - owner?: string; - /** - * providing this value sets whether this account's data contains a loaded program (and is now read-only) - */ - executable?: boolean; - /** - * providing this value sets the epoch at which this account will next owe rent - */ - rentEpoch?: number | bigint; -}; +export type AccountUpdate = { +/** + * providing this value sets the lamports in the account + */ +lamports?: number | bigint, +/** + * providing this value sets the data held in this account, as a + * hex-encoded string + */ +data?: string, +/** + * providing this value sets the program that owns this account. If executable, the program that loads this account. + */ +owner?: string, +/** + * providing this value sets whether this account's data contains a loaded program (and is now read-only) + */ +executable?: boolean, +/** + * providing this value sets the epoch at which this account will next owe rent + */ +rentEpoch?: number | bigint, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts index bd3774a48..6903e159c 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/CheatcodeControlConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CheatcodeControlConfig = { lockout?: boolean }; +export type CheatcodeControlConfig = { lockout?: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts index 65eaf9735..be0acaee7 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialBalanceKeys.ts @@ -9,15 +9,14 @@ * Each key unlocks a different half of the balance, so they are independently * optional: a caller holding only one still gets the half it can read. */ -export type ConfidentialBalanceKeys = { - /** - * The owner's AES key (base58 or base64, 16 bytes). Decrypts the available - * balance. - */ - aesKey?: string; - /** - * The owner's ElGamal *secret* key (base58 or base64, 32 bytes), not the - * public key stored on the account. Decrypts the pending balance. - */ - elgamalSecretKey?: string; -}; +export type ConfidentialBalanceKeys = { +/** + * The owner's AES key (base58 or base64, 16 bytes). Decrypts the available + * balance. + */ +aesKey?: string, +/** + * The owner's ElGamal *secret* key (base58 or base64, 32 bytes), not the + * public key stored on the account. Decrypts the pending balance. + */ +elgamalSecretKey?: string, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts index cbf78b89c..d36d98618 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ConfidentialTransferAccountUpdate.ts @@ -10,42 +10,41 @@ * funded) confidential account directly, bypassing the real on-chain * configure / deposit / apply-pending-balance instruction flow. */ -export type ConfidentialTransferAccountUpdate = { - /** - * The owner's ElGamal public key (base58 or base64, 32 bytes). Required — - * the confidential balance is encrypted to this key, and confidential - * payment clients read it off the account to encrypt transfers. - */ - elgamalPubkey: string; - /** - * The owner's AES (authenticated-encryption) secret key (base58 or base64, - * 16 bytes). Required. Produces the `decryptable_available_balance` the - * owner reads to learn its balance — even a zero-balance receive-only - * account needs a valid `encrypt(0)` here (a placeholder would fail - * owner-side balance reads), so this is mandatory for every confidential - * account. Modeled as `Option` only so the field can be validated with a - * clear error message when omitted. - */ - aesKey: string; - /** - * The confidential available balance to set (default 0). - */ - amount?: number | bigint; - /** - * Whether the account is approved for confidential transfers (default true). - */ - approved?: boolean; - /** - * Whether the account accepts incoming confidential credits (default true). - */ - allowConfidentialCredits?: boolean; - /** - * Whether the base account accepts incoming non-confidential credits - * (default true). - */ - allowNonConfidentialCredits?: boolean; - /** - * The maximum pending-balance credit counter (default 65536). - */ - maximumPendingBalanceCreditCounter?: number | bigint; -}; +export type ConfidentialTransferAccountUpdate = { +/** + * The owner's ElGamal public key (base58 or base64, 32 bytes). Required — + * the confidential balance is encrypted to this key, and confidential + * payment clients read it off the account to encrypt transfers. + */ +elgamalPubkey: string, +/** + * The owner's AES (authenticated-encryption) secret key (base58 or base64, + * 16 bytes). Required. Produces the `decryptable_available_balance` the + * owner reads to learn its balance — even a zero-balance receive-only + * account needs a valid `encrypt(0)` here (a placeholder would fail + * owner-side balance reads), so this is mandatory for every confidential + * account. Modeled as `Option` only so the field can be validated with a + * clear error message when omitted. + */ +aesKey: string, +/** + * The confidential available balance to set (default 0). + */ +amount?: number | bigint, +/** + * Whether the account is approved for confidential transfers (default true). + */ +approved?: boolean, +/** + * Whether the account accepts incoming confidential credits (default true). + */ +allowConfidentialCredits?: boolean, +/** + * Whether the base account accepts incoming non-confidential credits + * (default true). + */ +allowNonConfidentialCredits?: boolean, +/** + * The maximum pending-balance credit counter (default 65536). + */ +maximumPendingBalanceCreditCounter?: number | bigint, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts index 0a7e76fc3..9eb541d1c 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/DeriveConfidentialKeysResponse.ts @@ -7,18 +7,17 @@ * `surfnet_deriveConfidentialKeys`. All three are base58-encoded and feed * directly into the other confidential cheatcodes. */ -export type DeriveConfidentialKeysResponse = { - /** - * The ElGamal public key: `surfnet_setTokenAccount`'s `elgamalPubkey`. - */ - elgamalPubkey: string; - /** - * The ElGamal secret key: `surfnet_getConfidentialBalance`'s - * `elgamalSecretKey`. - */ - elgamalSecretKey: string; - /** - * The AES key: the `aesKey` of both of the above. - */ - aesKey: string; -}; +export type DeriveConfidentialKeysResponse = { +/** + * The ElGamal public key: `surfnet_setTokenAccount`'s `elgamalPubkey`. + */ +elgamalPubkey: string, +/** + * The ElGamal secret key: `surfnet_getConfidentialBalance`'s + * `elgamalSecretKey`. + */ +elgamalSecretKey: string, +/** + * The AES key: the `aesKey` of both of the above. + */ +aesKey: string, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts index c489372d2..3dcc54d65 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotConfig.ts @@ -4,8 +4,4 @@ import type { ExportSnapshotFilter } from "./ExportSnapshotFilter.js"; import type { ExportSnapshotScope } from "./ExportSnapshotScope.js"; -export type ExportSnapshotConfig = { - includeParsedAccounts?: boolean; - filter?: ExportSnapshotFilter; - scope: ExportSnapshotScope; -}; +export type ExportSnapshotConfig = { includeParsedAccounts?: boolean, filter?: ExportSnapshotFilter, scope: ExportSnapshotScope, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts index ef001866d..1989718db 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotFilter.ts @@ -2,19 +2,15 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExportSnapshotFilter = { - includeProgramAccounts?: boolean; - includeAccounts?: Array; - excludeAccounts?: Array; - /** - * When true, omit accounts owned by the sysvar program. - */ - excludeSysvars?: boolean; - /** - * When true, omit accounts whose pubkey is a known agave feature gate - * (as defined by the `agave_feature_set::FEATURE_NAMES` set built into - * this surfpool binary). Feature gates added upstream after this version - * will not be excluded. - */ - excludeFeatureGates?: boolean; -}; +export type ExportSnapshotFilter = { includeProgramAccounts?: boolean, includeAccounts?: Array, excludeAccounts?: Array, +/** + * When true, omit accounts owned by the sysvar program. + */ +excludeSysvars?: boolean, +/** + * When true, omit accounts whose pubkey is a known agave feature gate + * (as defined by the `agave_feature_set::FEATURE_NAMES` set built into + * this surfpool binary). Feature gates added upstream after this version + * will not be excluded. + */ +excludeFeatureGates?: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts index bb45ad762..eed299bc4 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ExportSnapshotScope.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExportSnapshotScope = "network" | { preTransaction: string }; +export type ExportSnapshotScope = "network" | { "preTransaction": string }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts index 1acadef46..efa5cc6f3 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetConfidentialBalanceResponse.ts @@ -6,20 +6,19 @@ * The decrypted confidential-transfer balances of a Token-2022 token account, * returned by `surfnet_getConfidentialBalance`. */ -export type GetConfidentialBalanceResponse = { - /** - * The available (spendable) balance, or `null` if no `aesKey` was supplied. - */ - available: number | bigint | null; - /** - * The pending (credited but not yet applied) balance, or `null` if no - * `elgamalSecretKey` was supplied. - */ - pending: number | bigint | null; - /** - * How many confidential credits are sitting in the pending balance. Non-zero - * means an `ApplyPendingBalance` is required before they show up in - * `available`. - */ - pendingBalanceCreditCounter: number | bigint; -}; +export type GetConfidentialBalanceResponse = { +/** + * The available (spendable) balance, or `null` if no `aesKey` was supplied. + */ +available: bigint | null, +/** + * The pending (credited but not yet applied) balance, or `null` if no + * `elgamalSecretKey` was supplied. + */ +pending: bigint | null, +/** + * How many confidential credits are sitting in the pending balance. Non-zero + * means an `ApplyPendingBalance` is required before they show up in + * `available`. + */ +pendingBalanceCreditCounter: bigint, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts index 80b432895..70ae28816 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetStreamedAccountsResponse.ts @@ -3,6 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { StreamedAccountInfo } from "./StreamedAccountInfo.js"; -export type GetStreamedAccountsResponse = { - accounts: Array; -}; +export type GetStreamedAccountsResponse = { accounts: Array, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts b/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts index 15ff91a78..812452fc3 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/GetSurfnetInfoResponse.ts @@ -3,6 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { RunbookExecutionStatusReport } from "./RunbookExecutionStatusReport.js"; -export type GetSurfnetInfoResponse = { - runbookExecutions: Array; -}; +export type GetSurfnetInfoResponse = { runbookExecutions: Array, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts index 97e9dd783..c7cf40dac 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OfflineAccountConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type OfflineAccountConfig = { includeOwnedAccounts?: boolean }; +export type OfflineAccountConfig = { includeOwnedAccounts?: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts index 84d6a972e..348ea2ae5 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts @@ -6,37 +6,36 @@ import type { AccountAddress } from "./AccountAddress.js"; /** * A concrete instance of an override template with specific values */ -export type OverrideInstance = { - /** - * Unique identifier for this instance (UUID v4) - */ - id: string; - /** - * Reference to the template being used - MUST match a template id from get_override_templates - */ - templateId: string; - /** - * Values for the template properties as a JSON object (NOT a string) - */ - values: Record; - /** - * Relative slot when this override should be applied (1 = 400ms after registration) - */ - scenarioRelativeSlot: number | bigint; - /** - * Optional human-readable label for this instance - */ - label?: string; - /** - * Whether this override is enabled - */ - enabled: boolean; - /** - * Whether to fetch fresh account data just before transaction execution - */ - fetchBeforeUse?: boolean; - /** - * Account address to override - use pubkey for known addresses or pda for derived addresses - */ - account: AccountAddress; -}; +export type OverrideInstance = { +/** + * Unique identifier for this instance (UUID v4) + */ +id: string, +/** + * Reference to the template being used - MUST match a template id from get_override_templates + */ +templateId: string, +/** + * Values for the template properties as a JSON object (NOT a string) + */ +values: Record, +/** + * Relative slot when this override should be applied (1 = 400ms after registration) + */ +scenarioRelativeSlot: number | bigint, +/** + * Optional human-readable label for this instance + */ +label?: string, +/** + * Whether this override is enabled + */ +enabled: boolean, +/** + * Whether to fetch fresh account data just before transaction execution + */ +fetchBeforeUse?: boolean, +/** + * Account address to override - use pubkey for known addresses or pda for derived addresses + */ +account: AccountAddress, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts index e9bfd5174..f97473322 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ParsedAccount.ts @@ -5,4 +5,4 @@ /** * Mirrors [`solana_account_decoder_client_types::ParsedAccount`]. */ -export type ParsedAccount = { program: string; parsed: unknown; space: bigint }; +export type ParsedAccount = { program: string, parsed: unknown, space: bigint, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts b/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts index 21ecd4439..967b73a03 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/PdaSeed.ts @@ -6,13 +6,4 @@ * Seeds used for PDA derivation *Seeds used for PDA derivation */ -export type PdaSeed = - | { pubkey: string } - | { string: string } - | { bytes: Array } - | { propertyRef: string } - | { u16Be: number } - | { u16BeRef: string } - | { u16Le: number } - | { bytes32Ref: string } - | { derivedPda: { programId: string; seeds: Array } }; +export type PdaSeed = { "pubkey": string } | { "string": string } | { "bytes": Array } | { "propertyRef": string } | { "u16Be": number } | { "u16BeRef": string } | { "u16Le": number } | { "bytes32Ref": string } | { "derivedPda": { programId: string, seeds: Array, } }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts index 07d53c0fa..f676b3122 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/ResetAccountConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ResetAccountConfig = { includeOwnedAccounts?: boolean }; +export type ResetAccountConfig = { includeOwnedAccounts?: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts index 208daedf5..aba0731de 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/RpcProfileResultConfig.ts @@ -4,7 +4,4 @@ import type { RpcProfileDepth } from "./RpcProfileDepth.js"; import type { UiAccountEncoding } from "./UiAccountEncoding.js"; -export type RpcProfileResultConfig = { - encoding?: UiAccountEncoding; - depth?: RpcProfileDepth; -}; +export type RpcProfileResultConfig = { encoding?: UiAccountEncoding, depth?: RpcProfileDepth, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts b/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts index 734d02903..909dd92a9 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/RunbookExecutionStatusReport.ts @@ -2,9 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RunbookExecutionStatusReport = { - startedAt: bigint; - completedAt: bigint | null; - runbookId: string; - errors: Array | null; -}; +export type RunbookExecutionStatusReport = { startedAt: bigint, completedAt: bigint | null, runbookId: string, errors: Array | null, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts b/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts index e4bb3624e..a32bf5280 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/Scenario.ts @@ -6,25 +6,24 @@ import type { OverrideInstance } from "./OverrideInstance.js"; /** * A scenario containing a timeline of overrides */ -export type Scenario = { - /** - * Unique identifier for the scenario (UUID v4 format) - */ - id: string; - /** - * Human-readable name - */ - name: string; - /** - * Description of this scenario - */ - description: string; - /** - * List of override instances in this scenario - MUST be an array, NOT a string - */ - overrides: Array; - /** - * Tags for categorization - */ - tags: Array; -}; +export type Scenario = { +/** + * Unique identifier for the scenario (UUID v4 format) + */ +id: string, +/** + * Human-readable name + */ +name: string, +/** + * Description of this scenario + */ +description: string, +/** + * List of override instances in this scenario - MUST be an array, NOT a string + */ +overrides: Array, +/** + * Tags for categorization + */ +tags: Array, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts index 684cc383e..db66d77a7 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountConfig.ts @@ -2,4 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type StreamAccountConfig = { includeOwnedAccounts?: boolean }; +export type StreamAccountConfig = { includeOwnedAccounts?: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts index 7743c97b6..27eee4b8f 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/StreamAccountsEntry.ts @@ -2,7 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type StreamAccountsEntry = { - pubkey: string; - includeOwnedAccounts?: boolean; -}; +export type StreamAccountsEntry = { pubkey: string, includeOwnedAccounts?: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts b/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts index 914abf3cb..3a771ee55 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/StreamedAccountInfo.ts @@ -2,7 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type StreamedAccountInfo = { - pubkey: string; - includeOwnedAccounts: boolean; -}; +export type StreamedAccountInfo = { pubkey: string, includeOwnedAccounts: boolean, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts index 45cf6325c..2866fd2ac 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/SupplyUpdate.ts @@ -2,9 +2,4 @@ // Do not edit; run `npm run generate:kit-types` in crates/sdk-node instead. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SupplyUpdate = { - total?: number | bigint; - circulating?: number | bigint; - non_circulating?: number | bigint; - non_circulating_accounts?: Array; -}; +export type SupplyUpdate = { total?: number | bigint, circulating?: number | bigint, non_circulating?: number | bigint, non_circulating_accounts?: Array, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts b/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts index 7169015b7..fbe4b377f 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/TokenAccountUpdate.ts @@ -3,32 +3,31 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfidentialTransferAccountUpdate } from "./ConfidentialTransferAccountUpdate.js"; -export type TokenAccountUpdate = { - /** - * providing this value sets the amount of the token in the account data - */ - amount?: number | bigint; - /** - * providing this value sets the delegate of the token account: a base58 - * pubkey, or the literal string "null" to clear the delegate - */ - delegate?: string; - /** - * providing this value sets the state of the token account - */ - state?: string; - /** - * providing this value sets the amount authorized to the delegate - */ - delegatedAmount?: number | bigint; - /** - * providing this value sets the close authority of the token account: a - * base58 pubkey, or the literal string "null" to clear the authority - */ - closeAuthority?: string; - /** - * providing this value configures the Token-2022 confidential-transfer - * extension on the account (Token-2022 only) - */ - confidential?: ConfidentialTransferAccountUpdate; -}; +export type TokenAccountUpdate = { +/** + * providing this value sets the amount of the token in the account data + */ +amount?: number | bigint, +/** + * providing this value sets the delegate of the token account: a base58 + * pubkey, or the literal string "null" to clear the delegate + */ +delegate?: string, +/** + * providing this value sets the state of the token account + */ +state?: string, +/** + * providing this value sets the amount authorized to the delegate + */ +delegatedAmount?: number | bigint, +/** + * providing this value sets the close authority of the token account: a + * base58 pubkey, or the literal string "null" to clear the authority + */ +closeAuthority?: string, +/** + * providing this value configures the Token-2022 confidential-transfer + * extension on the account (Token-2022 only) + */ +confidential?: ConfidentialTransferAccountUpdate, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts index 499a2e6d5..38ce0e5af 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccount.ts @@ -6,11 +6,4 @@ import type { UiAccountData } from "./UiAccountData.js"; /** * Mirrors [`solana_account_decoder_client_types::UiAccount`]. */ -export type UiAccount = { - lamports: bigint; - data: UiAccountData; - owner: string; - executable: boolean; - rentEpoch: bigint; - space: bigint | null; -}; +export type UiAccount = { lamports: bigint, data: UiAccountData, owner: string, executable: boolean, rentEpoch: bigint, space: bigint | null, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts index 913e2cbb4..16b0caa01 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountChange.ts @@ -6,8 +6,4 @@ import type { UiAccount } from "./UiAccount.js"; /** * Mirrors [`crate::types::UiAccountChange`], with [`UiAccountDef`] payloads. */ -export type UiAccountChange = - | { type: "create"; data: UiAccount } - | { type: "update"; data: [UiAccount, UiAccount] } - | { type: "delete"; data: UiAccount } - | { type: "unchanged"; data: UiAccount | null }; +export type UiAccountChange = { "type": "create", "data": UiAccount } | { "type": "update", "data": [UiAccount, UiAccount] } | { "type": "delete", "data": UiAccount } | { "type": "unchanged", "data": UiAccount | null }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts index bb78aaa8b..4e8652d57 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountData.ts @@ -7,5 +7,4 @@ import type { UiAccountEncoding } from "./UiAccountEncoding.js"; /** * Mirrors [`solana_account_decoder_client_types::UiAccountData`]. */ -export type UiAccountData = - string | ParsedAccount | [string, UiAccountEncoding]; +export type UiAccountData = string | ParsedAccount | [string, UiAccountEncoding]; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts index ce3dee5e2..c96a6313d 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountEncoding.ts @@ -5,5 +5,4 @@ /** * Mirrors [`solana_account_decoder_client_types::UiAccountEncoding`]. */ -export type UiAccountEncoding = - "binary" | "base58" | "base64" | "jsonParsed" | "base64+zstd"; +export type UiAccountEncoding = "binary" | "base58" | "base64" | "jsonParsed" | "base64+zstd"; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts index 37a468033..69b31ff44 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiAccountProfileState.ts @@ -6,5 +6,4 @@ import type { UiAccountChange } from "./UiAccountChange.js"; /** * Mirrors [`crate::types::UiAccountProfileState`], with [`UiAccountChangeDef`] payloads. */ -export type UiAccountProfileState = - { type: "readonly" } | { type: "writable"; accountChange: UiAccountChange }; +export type UiAccountProfileState = { "type": "readonly" } | { "type": "writable", "accountChange": UiAccountChange }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts index 36db5f699..f61993eac 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiKeyedProfileResult.ts @@ -4,10 +4,4 @@ import type { UiAccount } from "./UiAccount.js"; import type { UiProfileResult } from "./UiProfileResult.js"; -export type UiKeyedProfileResult = { - slot: bigint; - key: string; - instructionProfiles?: Array; - transactionProfile: UiProfileResult; - readonlyAccountStates: { [key in string]: UiAccount }; -}; +export type UiKeyedProfileResult = { slot: bigint, key: string, instructionProfiles?: Array, transactionProfile: UiProfileResult, readonlyAccountStates: { [key in string]: UiAccount }, }; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts b/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts index f8b7bf464..6ea4bcdff 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/UiProfileResult.ts @@ -3,9 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UiAccountProfileState } from "./UiAccountProfileState.js"; -export type UiProfileResult = { - accountStates: { [key in string]: UiAccountProfileState }; - computeUnitsConsumed: bigint; - logMessages: Array | null; - errorMessage: string | null; -}; +export type UiProfileResult = { accountStates: { [key in string]: UiAccountProfileState }, computeUnitsConsumed: bigint, logMessages: Array | null, errorMessage: string | null, };