diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 154057c51..fb2886ea4 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,165 @@ 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 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 + /// - `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 + /// `surfnet_setTokenAccount`), `elgamalSecretKey` (for `surfnet_getConfidentialBalance`), and + /// `aesKey` (for both). + /// + /// ## Example Request + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "id": 1, + /// "method": "surfnet_deriveConfidentialKeys", + /// "params": [ + /// "", + /// "" + /// ] + /// } + /// ``` + /// + /// ## Example Response + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "result": { + /// "context": { + /// "slot": 123456789, + /// "apiVersion": "2.3.8" + /// }, + /// "value": { + /// "elgamalPubkey": "", + /// "elgamalSecretKey": "", + /// "aesKey": "" + /// } + /// }, + /// "id": 1 + /// } + /// ``` + /// + /// # Notes + /// 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. 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, + elgamal_signature: String, + ae_signature: 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 +2399,74 @@ 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, + elgamal_signature: String, + ae_signature: String, + ) -> Result> { + let svm_locker = meta.get_svm_locker()?; + 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, + }) + } + fn write_program( &self, meta: Self::Metadata, @@ -2383,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 @@ -5091,4 +5325,279 @@ 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. 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; + + 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 (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); + let keys = client + .rpc + .derive_confidential_keys( + Some(client.context.clone()), + elgamal_signature, + ae_signature, + ) + .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); + // 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), + "pending should be present when an elgamalSecretKey is supplied" + ); + 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" + ); + + // 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" + ); + } + + /// 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 = 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()), + elgamal_signature, + ae_signature, + ) + .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 (elgamal_signature, ae_signature) = confidential_key_signatures(&owner, &token_account); + let keys = client + .rpc + .derive_confidential_keys( + Some(client.context.clone()), + elgamal_signature, + ae_signature, + ) + .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 (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()), + foreign_elgamal_signature, + foreign_ae_signature, + ) + .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/tests/integration.rs b/crates/core/src/tests/integration.rs index 03247694c..3050832b6 100644 --- a/crates/core/src/tests/integration.rs +++ b/crates/core/src/tests/integration.rs @@ -10378,6 +10378,380 @@ async fn test_token2022_metadata_realloc(test_type: TestType) { println!("✓ Regression test #530: Token-2022 metadata CPI realloc works correctly"); } +/// 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 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" + ); + + 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" + ); +} + 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); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 8634f304d..6c485672e 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -20,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}, @@ -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::{ @@ -1231,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). @@ -1339,6 +1361,232 @@ 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 +/// 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 +/// 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}") + }) +} + +/// Derive an owner's confidential-transfer keys from the owner's signatures. +/// +/// 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 — 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. +pub fn derive_confidential_keys( + elgamal_signature: &str, + ae_signature: &str, +) -> Result { + 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 elgamal = ElGamalKeypair::new_from_signature(&elgamal_signature) + .map_err(|e| format!("failed to derive ElGamal keypair: {e}"))?; + 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(); + 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(), + }) +} + +/// 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, 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..be0acaee7 --- /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..9eb541d1c --- /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..efa5cc6f3 --- /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: 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/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..339f39b2d 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(elgamalSignature: string, aeSignature: string): 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..d1250ba30 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -1224,6 +1224,63 @@ 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")] +// 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. + pub available: Option, + /// The pending (credited but not yet applied) balance, or `null` if no + /// `elgamalSecretKey` was supplied. + 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`. + 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 +1772,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",