From 2f5fdcae0c7f6a00e7ae0ff8fe651157346fa563 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Wed, 11 Feb 2026 22:21:02 -0800 Subject: [PATCH] feat(wasm-solana): add authorize and customTx intent handlers Add support for two new Solana intent types in the WASM transaction builder: - authorize: Decodes a pre-built transaction message (base64/bincode) and wraps it as an unsigned Transaction. Bypasses nonce/memo since the message is already complete. - customTx: Parses solInstructions array (programId, keys, data) into native Solana Instructions. Goes through normal nonce/memo path. Also adds corresponding types (AuthorizeIntent, CustomTxIntent, CustomTxInstruction, CustomTxKey) and unit tests for both handlers. BTC-3025 --- packages/wasm-solana/js/intentBuilder.ts | 38 +++- packages/wasm-solana/src/intent/build.rs | 263 ++++++++++++++++++----- packages/wasm-solana/src/intent/types.rs | 55 ++++- 3 files changed, 302 insertions(+), 54 deletions(-) diff --git a/packages/wasm-solana/js/intentBuilder.ts b/packages/wasm-solana/js/intentBuilder.ts index d03c171..b804dba 100644 --- a/packages/wasm-solana/js/intentBuilder.ts +++ b/packages/wasm-solana/js/intentBuilder.ts @@ -176,6 +176,40 @@ export interface ConsolidateIntent extends BaseIntent { }>; } +/** Authorize intent - pre-built transaction message */ +export interface AuthorizeIntent extends BaseIntent { + intentType: "authorize"; + /** Base64-encoded bincode-serialized Solana Message */ + transactionMessage: string; +} + +/** Custom transaction intent */ +export interface CustomTxIntent extends BaseIntent { + intentType: "customTx"; + /** Custom instructions to include in the transaction */ + solInstructions: CustomTxInstruction[]; +} + +/** A single custom instruction */ +export interface CustomTxInstruction { + /** Program ID (base58) */ + programId: string; + /** Account keys for the instruction */ + keys: CustomTxKey[]; + /** Instruction data (base64) */ + data: string; +} + +/** Account key for a custom instruction */ +export interface CustomTxKey { + /** Account public key (base58) */ + pubkey: string; + /** Whether this account must sign the transaction */ + isSigner: boolean; + /** Whether this account is writable */ + isWritable: boolean; +} + /** Union of all supported intent types */ export type SolanaIntent = | PaymentIntent @@ -186,7 +220,9 @@ export type SolanaIntent = | DelegateIntent | EnableTokenIntent | CloseAtaIntent - | ConsolidateIntent; + | ConsolidateIntent + | AuthorizeIntent + | CustomTxIntent; // ============================================================================= // Main Function diff --git a/packages/wasm-solana/src/intent/build.rs b/packages/wasm-solana/src/intent/build.rs index a176e0c..7508272 100644 --- a/packages/wasm-solana/src/intent/build.rs +++ b/packages/wasm-solana/src/intent/build.rs @@ -10,11 +10,14 @@ use super::types::*; // Solana SDK types use solana_sdk::hash::Hash; -use solana_sdk::instruction::Instruction; +use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::message::Message; use solana_sdk::pubkey::Pubkey; use solana_sdk::transaction::Transaction; +// Base64 decoding +use base64::Engine; + // Instruction builders from existing crates use solana_stake_interface::instruction as stake_ix; use solana_stake_interface::state::{Authorized, Lockup}; @@ -50,6 +53,11 @@ pub fn build_from_intent( .and_then(|v| v.as_str()) .ok_or_else(|| WasmSolanaError::new("Missing intentType in intent"))?; + // Authorize is a special case: the message is pre-built, so we skip nonce/memo + if intent_type == "authorize" { + return build_authorize(intent_json); + } + // Build based on intent type let (instructions, generated_keypairs) = match intent_type { "payment" | "goUnstake" => build_payment(intent_json, params)?, @@ -61,6 +69,7 @@ pub fn build_from_intent( "enableToken" => build_enable_token(intent_json, params)?, "closeAssociatedTokenAccount" => build_close_ata(intent_json, params)?, "consolidate" => build_consolidate(intent_json, params)?, + "customTx" => build_custom_tx(intent_json)?, _ => { return Err(WasmSolanaError::new(&format!( "Unsupported intent type: {}", @@ -88,7 +97,7 @@ pub fn build_from_intent( /// Build a Transaction from instructions and params. fn build_transaction_from_instructions( - mut instructions: Vec, + instructions: Vec, params: &BuildParams, ) -> Result { let fee_payer: Pubkey = params @@ -96,26 +105,26 @@ fn build_transaction_from_instructions( .parse() .map_err(|_| WasmSolanaError::new(&format!("Invalid feePayer: {}", params.fee_payer)))?; - // Handle nonce - let blockhash_str = match ¶ms.nonce { - Nonce::Blockhash { value } => value.clone(), + let (blockhash_str, nonce_instruction) = match ¶ms.nonce { + Nonce::Blockhash { value } => (value.clone(), None), Nonce::Durable { address, authority, value, } => { - // Prepend nonce advance instruction let nonce_pubkey: Pubkey = address.parse().map_err(|_| { WasmSolanaError::new(&format!("Invalid nonce.address: {}", address)) })?; let authority_pubkey: Pubkey = authority.parse().map_err(|_| { WasmSolanaError::new(&format!("Invalid nonce.authority: {}", authority)) })?; - instructions.insert( - 0, - system_ix::advance_nonce_account(&nonce_pubkey, &authority_pubkey), - ); - value.clone() + ( + value.clone(), + Some(system_ix::advance_nonce_account( + &nonce_pubkey, + &authority_pubkey, + )), + ) } }; @@ -123,8 +132,14 @@ fn build_transaction_from_instructions( .parse() .map_err(|_| WasmSolanaError::new(&format!("Invalid blockhash: {}", blockhash_str)))?; - // Create message and transaction - let message = Message::new_with_blockhash(&instructions, Some(&fee_payer), &blockhash); + // Build instruction list: nonce advance first (if durable), then intent instructions + let mut all_instructions = Vec::new(); + if let Some(nonce_ix) = nonce_instruction { + all_instructions.push(nonce_ix); + } + all_instructions.extend(instructions); + + let message = Message::new_with_blockhash(&all_instructions, Some(&fee_payer), &blockhash); let tx = Transaction::new_unsigned(message); Ok(tx) @@ -243,38 +258,39 @@ fn build_jito_stake( use borsh::BorshSerialize; use spl_stake_pool::instruction::StakePoolInstruction; - let stake_pool: Pubkey = config.stake_pool_address.parse().map_err(|_| { - WasmSolanaError::new(&format!( - "Invalid stakePoolAddress: {}", - config.stake_pool_address - )) - })?; - let withdraw_authority: Pubkey = config.withdraw_authority.parse().map_err(|_| { - WasmSolanaError::new(&format!( - "Invalid withdrawAuthority: {}", - config.withdraw_authority - )) - })?; - let reserve_stake: Pubkey = config.reserve_stake.parse().map_err(|_| { - WasmSolanaError::new(&format!("Invalid reserveStake: {}", config.reserve_stake)) - })?; - let destination_pool_account: Pubkey = - config.destination_pool_account.parse().map_err(|_| { - WasmSolanaError::new(&format!( - "Invalid destinationPoolAccount: {}", - config.destination_pool_account - )) - })?; - let manager_fee_account: Pubkey = config.manager_fee_account.parse().map_err(|_| { - WasmSolanaError::new(&format!( - "Invalid managerFeeAccount: {}", - config.manager_fee_account - )) - })?; + let stake_pool: Pubkey = config + .stake_pool_address + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing stakePoolAddress"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid stakePoolAddress"))?; + let withdraw_authority: Pubkey = config + .withdraw_authority + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing withdrawAuthority"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid withdrawAuthority"))?; + let reserve_stake: Pubkey = config + .reserve_stake + .parse() + .map_err(|_| WasmSolanaError::new("Invalid reserveStake"))?; + let destination_pool_account: Pubkey = config + .destination_pool_account + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing destinationPoolAccount"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid destinationPoolAccount"))?; + let manager_fee_account: Pubkey = config + .manager_fee_account + .parse() + .map_err(|_| WasmSolanaError::new("Invalid managerFeeAccount"))?; let referral_pool_account: Pubkey = config .referral_pool_account .as_ref() - .unwrap_or(&config.destination_pool_account) + .or(config.destination_pool_account.as_ref()) + .ok_or_else(|| { + WasmSolanaError::new("Missing referralPoolAccount or destinationPoolAccount") + })? .parse() .map_err(|_| WasmSolanaError::new("Invalid referralPoolAccount"))?; let pool_mint: Pubkey = config @@ -384,7 +400,7 @@ fn build_partial_unstake( &StakeInstruction::Split(amount), vec![ solana_sdk::instruction::AccountMeta::new(*stake_pubkey, false), - solana_sdk::instruction::AccountMeta::new(unstake_pubkey, false), + solana_sdk::instruction::AccountMeta::new(unstake_pubkey, true), solana_sdk::instruction::AccountMeta::new_readonly(*fee_payer, true), ], ), @@ -421,12 +437,12 @@ fn build_jito_unstake( let transfer_authority_pubkey: Pubkey = transfer_authority_address.parse().unwrap(); // Parse config addresses - let stake_pool: Pubkey = config.stake_pool_address.parse().map_err(|_| { - WasmSolanaError::new(&format!( - "Invalid stakePoolAddress: {}", - config.stake_pool_address - )) - })?; + let stake_pool: Pubkey = config + .stake_pool_address + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing stakePoolAddress"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid stakePoolAddress"))?; let validator_list: Pubkey = config .validator_list .as_ref() @@ -435,6 +451,8 @@ fn build_jito_unstake( .map_err(|_| WasmSolanaError::new("Invalid validatorList"))?; let withdraw_authority: Pubkey = config .withdraw_authority + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing withdrawAuthority"))? .parse() .map_err(|_| WasmSolanaError::new("Invalid withdrawAuthority"))?; let validator_stake: Pubkey = validator_address @@ -781,6 +799,91 @@ fn build_consolidate( Ok((instructions, vec![])) } +/// Build an authorize transaction from a pre-built message. +/// +/// The authorize intent contains a `transactionMessage` field with a base64-encoded +/// bincode-serialized Solana Message. We decode and wrap it in a Transaction directly. +/// No nonce advance or memo is added — the message is already complete. +fn build_authorize(intent_json: &serde_json::Value) -> Result { + let intent: AuthorizeIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse authorize intent: {}", e)))?; + + // Decode base64 → bytes + let message_bytes = base64::engine::general_purpose::STANDARD + .decode(&intent.transaction_message) + .map_err(|e| { + WasmSolanaError::new(&format!( + "Failed to decode transactionMessage base64: {}", + e + )) + })?; + + // Deserialize bytes → Message (bincode format, matching @solana/web3.js) + let message: Message = bincode::deserialize(&message_bytes).map_err(|e| { + WasmSolanaError::new(&format!("Failed to deserialize transactionMessage: {}", e)) + })?; + + let transaction = Transaction::new_unsigned(message); + + Ok(IntentBuildResult { + transaction, + generated_keypairs: vec![], + }) +} + +/// Build a custom transaction from explicit instruction data. +/// +/// Reads `solInstructions` from the intent and converts each to a Solana Instruction. +/// Returns through the normal path so nonce advance and memo are added. +fn build_custom_tx( + intent_json: &serde_json::Value, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: CustomTxIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse customTx intent: {}", e)))?; + + let mut instructions = Vec::new(); + + for (i, ix) in intent.sol_instructions.iter().enumerate() { + let program_id: Pubkey = ix.program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid programId at instruction {}: {}", + i, ix.program_id + )) + })?; + + let mut accounts = Vec::new(); + for (j, key) in ix.keys.iter().enumerate() { + let pubkey: Pubkey = key.pubkey.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid pubkey at instruction {} key {}: {}", + i, j, key.pubkey + )) + })?; + if key.is_writable { + if key.is_signer { + accounts.push(AccountMeta::new(pubkey, true)); + } else { + accounts.push(AccountMeta::new(pubkey, false)); + } + } else if key.is_signer { + accounts.push(AccountMeta::new_readonly(pubkey, true)); + } else { + accounts.push(AccountMeta::new_readonly(pubkey, false)); + } + } + + let data = base64::engine::general_purpose::STANDARD + .decode(&ix.data) + .map_err(|e| { + WasmSolanaError::new(&format!("Failed to decode instruction {} data: {}", i, e)) + })?; + + instructions.push(Instruction::new_with_bytes(program_id, &data, accounts)); + } + + Ok((instructions, vec![])) +} + /// Build a memo instruction. fn build_memo(message: &str) -> Instruction { let memo_program: Pubkey = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" @@ -855,4 +958,66 @@ mod tests { let result = build_from_intent(&intent, &test_params()); assert!(result.is_ok(), "Failed: {:?}", result); } + + #[test] + fn test_build_authorize_intent() { + // Build a simple message, serialize it with bincode, then base64 encode + let fee_payer: Pubkey = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB" + .parse() + .unwrap(); + let blockhash: Hash = "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4" + .parse() + .unwrap(); + let to: Pubkey = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" + .parse() + .unwrap(); + + let ix = solana_system_interface::instruction::transfer(&fee_payer, &to, 1_000_000); + let message = Message::new_with_blockhash(&[ix], Some(&fee_payer), &blockhash); + let message_bytes = bincode::serialize(&message).unwrap(); + let message_b64 = base64::engine::general_purpose::STANDARD.encode(&message_bytes); + + let intent = serde_json::json!({ + "intentType": "authorize", + "transactionMessage": message_b64, + }); + + let result = build_from_intent(&intent, &test_params()); + assert!(result.is_ok(), "Failed: {:?}", result); + let result = result.unwrap(); + assert!(result.generated_keypairs.is_empty()); + // Verify the transaction message matches the original + assert_eq!(result.transaction.message, message); + } + + #[test] + fn test_build_custom_tx_intent() { + use base64::Engine; + + let program_id = "11111111111111111111111111111111"; + let fee_payer_str = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB"; + let to_str = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH"; + + // Build transfer instruction data manually (SystemInstruction::Transfer = index 2, then u64 LE) + let mut data = vec![2, 0, 0, 0]; // Transfer discriminant + data.extend_from_slice(&1_000_000u64.to_le_bytes()); + let data_b64 = base64::engine::general_purpose::STANDARD.encode(&data); + + let intent = serde_json::json!({ + "intentType": "customTx", + "solInstructions": [{ + "programId": program_id, + "keys": [ + { "pubkey": fee_payer_str, "isSigner": true, "isWritable": true }, + { "pubkey": to_str, "isSigner": false, "isWritable": true }, + ], + "data": data_b64, + }], + }); + + let result = build_from_intent(&intent, &test_params()); + assert!(result.is_ok(), "Failed: {:?}", result); + let result = result.unwrap(); + assert!(result.generated_keypairs.is_empty()); + } } diff --git a/packages/wasm-solana/src/intent/types.rs b/packages/wasm-solana/src/intent/types.rs index d4bc8f7..e03cc1a 100644 --- a/packages/wasm-solana/src/intent/types.rs +++ b/packages/wasm-solana/src/intent/types.rs @@ -155,14 +155,17 @@ pub struct StakeIntent { pub memo: Option, } -/// Stake pool configuration (for Jito) +/// Stake pool configuration (for Jito and other stake pool programs) #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StakePoolConfig { - pub stake_pool_address: String, - pub withdraw_authority: String, + #[serde(default)] + pub stake_pool_address: Option, + #[serde(default)] + pub withdraw_authority: Option, pub reserve_stake: String, - pub destination_pool_account: String, + #[serde(default)] + pub destination_pool_account: Option, pub manager_fee_account: String, #[serde(default)] pub referral_pool_account: Option, @@ -282,3 +285,47 @@ pub struct ConsolidateIntent { #[serde(default)] pub memo: Option, } + +/// Authorize intent - pre-built transaction message +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizeIntent { + pub intent_type: String, + /// Base64-encoded serialized Solana Message (bincode) + pub transaction_message: String, +} + +/// Custom transaction intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomTxIntent { + pub intent_type: String, + /// Custom instructions to include in the transaction + pub sol_instructions: Vec, + #[serde(default)] + pub memo: Option, +} + +/// A single custom instruction +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomTxInstruction { + /// Program ID (base58) + pub program_id: String, + /// Account keys for the instruction + pub keys: Vec, + /// Instruction data (base64) + pub data: String, +} + +/// Account key for a custom instruction +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomTxKey { + /// Account public key (base58) + pub pubkey: String, + /// Whether this account must sign the transaction + pub is_signer: bool, + /// Whether this account is writable + pub is_writable: bool, +}