From 4ee3d1d6bf360c77ef3b919b86e598194997830a Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 12 Feb 2026 20:13:03 -0800 Subject: [PATCH] feat(wasm-solana): complete Jito stake/unstake and sign generated keypairs in Rust Jito staking: - Add ATA creation support (createAssociatedTokenAccount field) - Derive withdraw authority PDA, destination/source pool accounts, and referral pool account when not explicitly provided - Use validatorAddress as fallback for stakePoolAddress Jito unstaking: - Build full 4-instruction pattern: Approve, CreateAccount, WithdrawStake, Deactivate (matches legacy SDK behavior) - Build SPL Token Approve instruction manually to avoid spl-token v6/v3 type incompatibility Generated keypair signing: - Sign generated keypairs (stake/unstake accounts) in Rust inside build_from_intent before returning to JS - Signatures survive the wasm-bindgen boundary because WasmTransaction is a heap-allocated Rust object that JS holds a handle to - Add sign_with_secret_key to WasmTransaction and sign to WasmKeypair for use by other callers BTC-3025 --- packages/wasm-solana/js/intentBuilder.ts | 5 + packages/wasm-solana/js/keypair.ts | 9 + packages/wasm-solana/js/transaction.ts | 12 + .../wasm-solana/src/instructions/decode.rs | 17 +- .../src/instructions/try_into_js_value.rs | 37 +++ .../wasm-solana/src/instructions/types.rs | 4 +- packages/wasm-solana/src/intent/build.rs | 302 +++++++++++++----- packages/wasm-solana/src/intent/types.rs | 21 +- packages/wasm-solana/src/wasm/keypair.rs | 11 + packages/wasm-solana/src/wasm/transaction.rs | 21 ++ 10 files changed, 353 insertions(+), 86 deletions(-) diff --git a/packages/wasm-solana/js/intentBuilder.ts b/packages/wasm-solana/js/intentBuilder.ts index b804dba..e927c3d 100644 --- a/packages/wasm-solana/js/intentBuilder.ts +++ b/packages/wasm-solana/js/intentBuilder.ts @@ -267,6 +267,11 @@ export function buildFromIntent( ): BuildFromIntentResult { const result = IntentNamespace.build_from_intent(intent, params) as WasmBuildResult; + // Generated keypair signing happens in Rust (build_from_intent signs + // before returning). Signatures survive the wasm-bindgen boundary + // because WasmTransaction is a heap-allocated Rust object that JS + // holds a handle to — no serialization round-trip. + return { transaction: Transaction.fromWasm(result.transaction), generatedKeypairs: result.generatedKeypairs, diff --git a/packages/wasm-solana/js/keypair.ts b/packages/wasm-solana/js/keypair.ts index 25e6c80..e171424 100644 --- a/packages/wasm-solana/js/keypair.ts +++ b/packages/wasm-solana/js/keypair.ts @@ -69,6 +69,15 @@ export class Keypair { return this._wasm.to_base58(); } + /** + * Sign a message with this keypair + * @param message - The message bytes to sign + * @returns The 64-byte Ed25519 signature + */ + sign(message: Uint8Array): Uint8Array { + return this._wasm.sign(message); + } + /** * Get the underlying WASM instance (internal use only) * @internal diff --git a/packages/wasm-solana/js/transaction.ts b/packages/wasm-solana/js/transaction.ts index 9aeb486..85e835b 100644 --- a/packages/wasm-solana/js/transaction.ts +++ b/packages/wasm-solana/js/transaction.ts @@ -224,6 +224,18 @@ export class Transaction { return idx ?? null; } + /** + * Sign this transaction with a base58-encoded Ed25519 secret key. + * + * Derives the public key from the secret, signs the transaction message, + * and places the signature at the correct signer index. + * + * @param secretKeyBase58 - The Ed25519 secret key (32-byte seed) as base58 + */ + signWithSecretKey(secretKeyBase58: string): void { + this._wasm.sign_with_secret_key(secretKeyBase58); + } + /** * Get the underlying WASM instance (internal use only) * @internal diff --git a/packages/wasm-solana/src/instructions/decode.rs b/packages/wasm-solana/src/instructions/decode.rs index 32a5539..09de240 100644 --- a/packages/wasm-solana/src/instructions/decode.rs +++ b/packages/wasm-solana/src/instructions/decode.rs @@ -1,6 +1,7 @@ //! Instruction decoding using official Solana interface crates. use super::types::*; +use crate::intent::AuthorizeType; use solana_compute_budget_interface::ComputeBudgetInstruction; use solana_stake_interface::instruction::StakeInstruction; use solana_system_interface::instruction::SystemInstruction; @@ -155,8 +156,10 @@ fn decode_stake_instruction(ctx: InstructionContext) -> ParsedInstruction { // Accounts: [0] stake, [1] clock, [2] authority, [3] optional custodian if ctx.accounts.len() >= 3 { let auth_type = match stake_authorize { - solana_stake_interface::state::StakeAuthorize::Staker => "Staker", - solana_stake_interface::state::StakeAuthorize::Withdrawer => "Withdrawer", + solana_stake_interface::state::StakeAuthorize::Staker => AuthorizeType::Staker, + solana_stake_interface::state::StakeAuthorize::Withdrawer => { + AuthorizeType::Withdrawer + } }; let custodian = if ctx.accounts.len() >= 4 { Some(ctx.accounts[3].clone()) @@ -167,7 +170,7 @@ fn decode_stake_instruction(ctx: InstructionContext) -> ParsedInstruction { staking_address: ctx.accounts[0].clone(), old_authorize_address: ctx.accounts[2].clone(), new_authorize_address: new_authority.to_string(), - authorize_type: auth_type.to_string(), + authorize_type: auth_type, custodian_address: custodian, }) } else { @@ -178,8 +181,10 @@ fn decode_stake_instruction(ctx: InstructionContext) -> ParsedInstruction { // Accounts: [0] stake, [1] clock, [2] authority, [3] new_authority (signer), [4] optional custodian if ctx.accounts.len() >= 4 { let auth_type = match stake_authorize { - solana_stake_interface::state::StakeAuthorize::Staker => "Staker", - solana_stake_interface::state::StakeAuthorize::Withdrawer => "Withdrawer", + solana_stake_interface::state::StakeAuthorize::Staker => AuthorizeType::Staker, + solana_stake_interface::state::StakeAuthorize::Withdrawer => { + AuthorizeType::Withdrawer + } }; let custodian = if ctx.accounts.len() >= 5 { Some(ctx.accounts[4].clone()) @@ -190,7 +195,7 @@ fn decode_stake_instruction(ctx: InstructionContext) -> ParsedInstruction { staking_address: ctx.accounts[0].clone(), old_authorize_address: ctx.accounts[2].clone(), new_authorize_address: ctx.accounts[3].clone(), - authorize_type: auth_type.to_string(), + authorize_type: auth_type, custodian_address: custodian, }) } else { diff --git a/packages/wasm-solana/src/instructions/try_into_js_value.rs b/packages/wasm-solana/src/instructions/try_into_js_value.rs index e31bf23..e153418 100644 --- a/packages/wasm-solana/src/instructions/try_into_js_value.rs +++ b/packages/wasm-solana/src/instructions/try_into_js_value.rs @@ -9,6 +9,43 @@ use base64::prelude::*; use wasm_bindgen::JsValue; use super::types::*; +use crate::intent::{AuthorizeType, KeypairPurpose, StakingType}; + +// ============================================================================= +// Enum → JS string conversions +// ============================================================================= + +impl TryIntoJsValue for StakingType { + fn try_to_js_value(&self) -> Result { + let s = match self { + StakingType::Native => "NATIVE", + StakingType::Jito => "JITO", + StakingType::Marinade => "MARINADE", + }; + Ok(JsValue::from_str(s)) + } +} + +impl TryIntoJsValue for AuthorizeType { + fn try_to_js_value(&self) -> Result { + let s = match self { + AuthorizeType::Staker => "Staker", + AuthorizeType::Withdrawer => "Withdrawer", + }; + Ok(JsValue::from_str(s)) + } +} + +impl TryIntoJsValue for KeypairPurpose { + fn try_to_js_value(&self) -> Result { + let s = match self { + KeypairPurpose::StakeAccount => "stakeAccount", + KeypairPurpose::UnstakeAccount => "unstakeAccount", + KeypairPurpose::TransferAuthority => "transferAuthority", + }; + Ok(JsValue::from_str(s)) + } +} // ============================================================================= // System Program Params diff --git a/packages/wasm-solana/src/instructions/types.rs b/packages/wasm-solana/src/instructions/types.rs index 09aa72b..946bb53 100644 --- a/packages/wasm-solana/src/instructions/types.rs +++ b/packages/wasm-solana/src/instructions/types.rs @@ -124,7 +124,7 @@ pub struct StakingActivateParams { pub staking_address: String, pub amount: u64, pub validator: String, - pub staking_type: String, // "NATIVE", "JITO", "MARINADE" + pub staking_type: crate::intent::StakingType, } #[derive(Debug, Clone)] @@ -152,7 +152,7 @@ pub struct StakingAuthorizeParams { pub staking_address: String, pub old_authorize_address: String, pub new_authorize_address: String, - pub authorize_type: String, // "Staker" or "Withdrawer" + pub authorize_type: crate::intent::AuthorizeType, pub custodian_address: Option, } diff --git a/packages/wasm-solana/src/intent/build.rs b/packages/wasm-solana/src/intent/build.rs index 6dfb15a..18b8dde 100644 --- a/packages/wasm-solana/src/intent/build.rs +++ b/packages/wasm-solana/src/intent/build.rs @@ -5,6 +5,7 @@ use crate::error::WasmSolanaError; use crate::keypair::{Keypair, KeypairExt}; +use crate::transaction::TransactionExt; use super::types::*; @@ -87,7 +88,22 @@ pub fn build_from_intent( } // Build the transaction - let transaction = build_transaction_from_instructions(all_instructions, params)?; + let mut transaction = build_transaction_from_instructions(all_instructions, params)?; + + // Sign with generated keypairs that are required signers + for kp in &generated_keypairs { + let secret_bytes: Vec = solana_sdk::bs58::decode(&kp.secret_key) + .into_vec() + .map_err(|e| WasmSolanaError::new(&format!("Failed to decode secret key: {}", e)))?; + let keypair = Keypair::from_secret_key_bytes(&secret_bytes)?; + use solana_signer::Signer; + let address = keypair.address(); + if transaction.signer_index(&address).is_some() { + let msg_bytes = transaction.message.serialize(); + let sig = keypair.sign_message(&msg_bytes); + transaction.add_signature(&address, sig.as_ref())?; + } + } Ok(IntentBuildResult { transaction, @@ -203,7 +219,7 @@ fn build_stake( // Check if Jito staking if intent.staking_type == Some(StakingType::Jito) { if let Some(config) = &intent.stake_pool_config { - return build_jito_stake(config, &fee_payer, amount); + return build_jito_stake(config, &fee_payer, &intent.validator_address, amount); } } @@ -241,7 +257,7 @@ fn build_stake( ]; let generated = vec![GeneratedKeypair { - purpose: "stakeAccount".to_string(), + purpose: KeypairPurpose::StakeAccount, address: stake_address, secret_key: solana_sdk::bs58::encode(stake_keypair.secret_key_bytes()).into_string(), }]; @@ -254,7 +270,7 @@ fn build_stake( system_ix::create_account( &fee_payer, &stake_pubkey, - amount + STAKE_ACCOUNT_RENT, + amount, STAKE_ACCOUNT_SPACE, &solana_stake_interface::program::ID, ), @@ -270,7 +286,7 @@ fn build_stake( ]; let generated = vec![GeneratedKeypair { - purpose: "stakeAccount".to_string(), + purpose: KeypairPurpose::StakeAccount, address: stake_address, secret_key: solana_sdk::bs58::encode(stake_keypair.secret_key_bytes()).into_string(), }]; @@ -281,72 +297,107 @@ fn build_stake( fn build_jito_stake( config: &StakePoolConfig, fee_payer: &Pubkey, + validator_address: &str, amount: u64, ) -> Result<(Vec, Vec), WasmSolanaError> { use borsh::BorshSerialize; use spl_stake_pool::instruction::StakePoolInstruction; + let stake_pool_program: Pubkey = "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" + .parse() + .unwrap(); + let system_program: Pubkey = SYSTEM_PROGRAM_ID.parse().unwrap(); + let token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap(); + let ata_program: Pubkey = SPL_ATA_PROGRAM_ID.parse().unwrap(); + + // For Jito, validatorAddress is the stake pool address let stake_pool: Pubkey = config .stake_pool_address .as_ref() - .ok_or_else(|| WasmSolanaError::new("Missing stakePoolAddress"))? + .map(|s| s.as_str()) + .unwrap_or(validator_address) .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"))?; + + // Derive withdraw authority PDA if not provided + let withdraw_authority: Pubkey = if let Some(wa) = &config.withdraw_authority { + wa.parse() + .map_err(|_| WasmSolanaError::new("Invalid withdrawAuthority"))? + } else { + let (pda, _) = + Pubkey::find_program_address(&[stake_pool.as_ref(), b"withdraw"], &stake_pool_program); + pda + }; + let reserve_stake: Pubkey = config .reserve_stake .as_ref() .ok_or_else(|| WasmSolanaError::new("Missing reserveStake"))? .parse() .map_err(|_| WasmSolanaError::new("Invalid reserveStake"))?; - let destination_pool_account: Pubkey = config - .destination_pool_account + + let pool_mint: Pubkey = config + .pool_mint .as_ref() - .ok_or_else(|| WasmSolanaError::new("Missing destinationPoolAccount"))? + .ok_or_else(|| WasmSolanaError::new("Missing poolMint"))? .parse() - .map_err(|_| WasmSolanaError::new("Invalid destinationPoolAccount"))?; + .map_err(|_| WasmSolanaError::new("Invalid poolMint"))?; + let manager_fee_account: Pubkey = config .manager_fee_account .as_ref() .ok_or_else(|| WasmSolanaError::new("Missing managerFeeAccount"))? .parse() .map_err(|_| WasmSolanaError::new("Invalid managerFeeAccount"))?; - let referral_pool_account: Pubkey = config - .referral_pool_account - .as_ref() - .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 - .pool_mint - .as_ref() - .ok_or_else(|| WasmSolanaError::new("Missing poolMint"))? - .parse() - .map_err(|_| WasmSolanaError::new("Invalid poolMint"))?; + + // Derive destination pool account (user's ATA for pool mint) if not provided + let destination_pool_account: Pubkey = if let Some(dpa) = &config.destination_pool_account { + dpa.parse() + .map_err(|_| WasmSolanaError::new("Invalid destinationPoolAccount"))? + } else { + let seeds = &[ + fee_payer.as_ref(), + token_program.as_ref(), + pool_mint.as_ref(), + ]; + let (ata, _) = Pubkey::find_program_address(seeds, &ata_program); + ata + }; + + // Referral pool account defaults to destination pool account + let referral_pool_account: Pubkey = if let Some(rpa) = &config.referral_pool_account { + rpa.parse() + .map_err(|_| WasmSolanaError::new("Invalid referralPoolAccount"))? + } else { + destination_pool_account + }; // Build instruction data let instruction_data = StakePoolInstruction::DepositSol(amount); let mut data = Vec::new(); instruction_data.serialize(&mut data).unwrap(); - let stake_pool_program: Pubkey = "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" - .parse() - .unwrap(); - let system_program: Pubkey = "11111111111111111111111111111111".parse().unwrap(); - let token_program: Pubkey = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" - .parse() - .unwrap(); - use solana_sdk::instruction::AccountMeta; - let instruction = Instruction::new_with_bytes( + + let mut instructions = Vec::new(); + + // Optionally create ATA for pool mint (JitoSOL) if requested + if config.create_associated_token_account == Some(true) { + instructions.push(Instruction::new_with_bytes( + ata_program, + &[], + vec![ + AccountMeta::new(*fee_payer, true), + AccountMeta::new(destination_pool_account, false), + AccountMeta::new_readonly(*fee_payer, false), + AccountMeta::new_readonly(pool_mint, false), + AccountMeta::new_readonly(system_program, false), + AccountMeta::new_readonly(token_program, false), + ], + )); + } + + instructions.push(Instruction::new_with_bytes( stake_pool_program, &data, vec![ @@ -361,9 +412,9 @@ fn build_jito_stake( AccountMeta::new_readonly(system_program, false), AccountMeta::new_readonly(token_program, false), ], - ); + )); - Ok((vec![instruction], vec![])) + Ok((instructions, vec![])) } fn build_unstake( @@ -452,7 +503,7 @@ fn build_partial_unstake( ]; let generated = vec![GeneratedKeypair { - purpose: "unstakeAccount".to_string(), + purpose: KeypairPurpose::UnstakeAccount, address: unstake_address, secret_key: solana_sdk::bs58::encode(unstake_keypair.secret_key_bytes()).into_string(), }]; @@ -505,6 +556,13 @@ fn build_jito_unstake( use borsh::BorshSerialize; use spl_stake_pool::instruction::StakePoolInstruction; + let stake_pool_program: Pubkey = "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" + .parse() + .unwrap(); + let token_program: Pubkey = SPL_TOKEN_PROGRAM_ID.parse().unwrap(); + let ata_program: Pubkey = SPL_ATA_PROGRAM_ID.parse().unwrap(); + let clock_sysvar: Pubkey = solana_sdk::sysvar::clock::ID; + // Generate destination stake account let unstake_keypair = Keypair::new(); let unstake_address = unstake_keypair.address(); @@ -515,7 +573,7 @@ fn build_jito_unstake( let transfer_authority_address = transfer_authority_keypair.address(); let transfer_authority_pubkey: Pubkey = transfer_authority_address.parse().unwrap(); - // Parse config addresses + // Parse config addresses (with derivation for missing fields) let stake_pool: Pubkey = config .stake_pool_address .as_ref() @@ -528,51 +586,81 @@ fn build_jito_unstake( .ok_or_else(|| WasmSolanaError::new("Missing validatorList"))? .parse() .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"))?; + + // Derive withdraw authority PDA if not provided + let withdraw_authority: Pubkey = if let Some(wa) = &config.withdraw_authority { + wa.parse() + .map_err(|_| WasmSolanaError::new("Invalid withdrawAuthority"))? + } else { + let (pda, _) = + Pubkey::find_program_address(&[stake_pool.as_ref(), b"withdraw"], &stake_pool_program); + pda + }; + let validator_stake: Pubkey = validator_address .as_ref() .ok_or_else(|| WasmSolanaError::new("Missing validatorAddress"))? .parse() .map_err(|_| WasmSolanaError::new("Invalid validatorAddress"))?; - let source_pool_account: Pubkey = config - .source_pool_account + + let pool_mint: Pubkey = config + .pool_mint .as_ref() - .ok_or_else(|| WasmSolanaError::new("Missing sourcePoolAccount"))? + .ok_or_else(|| WasmSolanaError::new("Missing poolMint"))? .parse() - .map_err(|_| WasmSolanaError::new("Invalid sourcePoolAccount"))?; + .map_err(|_| WasmSolanaError::new("Invalid poolMint"))?; + + // Derive source pool account (user's ATA for pool mint) if not provided + let source_pool_account: Pubkey = if let Some(spa) = &config.source_pool_account { + spa.parse() + .map_err(|_| WasmSolanaError::new("Invalid sourcePoolAccount"))? + } else { + let seeds = &[ + fee_payer.as_ref(), + token_program.as_ref(), + pool_mint.as_ref(), + ]; + let (ata, _) = Pubkey::find_program_address(seeds, &ata_program); + ata + }; + let manager_fee_account: Pubkey = config .manager_fee_account .as_ref() .ok_or_else(|| WasmSolanaError::new("Missing managerFeeAccount"))? .parse() .map_err(|_| WasmSolanaError::new("Invalid managerFeeAccount"))?; - let pool_mint: Pubkey = config - .pool_mint - .as_ref() - .ok_or_else(|| WasmSolanaError::new("Missing poolMint"))? - .parse() - .map_err(|_| WasmSolanaError::new("Invalid poolMint"))?; - // Build instruction data - let instruction_data = StakePoolInstruction::WithdrawStake(amount); - let mut data = Vec::new(); - instruction_data.serialize(&mut data).unwrap(); + // 1. Approve: allow transfer_authority to spend pool tokens from user's ATA + // SPL Token Approve instruction (index 4): [4u8] + amount as u64 LE + let mut approve_data = vec![4u8]; + approve_data.extend_from_slice(&amount.to_le_bytes()); + let approve_ix = Instruction::new_with_bytes( + token_program, + &approve_data, + vec![ + AccountMeta::new(source_pool_account, false), + AccountMeta::new_readonly(transfer_authority_pubkey, false), + AccountMeta::new_readonly(*fee_payer, true), + ], + ); - let stake_pool_program: Pubkey = "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" - .parse() - .unwrap(); - let clock_sysvar: Pubkey = solana_sdk::sysvar::clock::ID; - let token_program: Pubkey = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" - .parse() - .unwrap(); + // 2. CreateAccount: create the destination stake account (makes unstake_pubkey a signer) + let create_account_ix = system_ix::create_account( + fee_payer, + &unstake_pubkey, + STAKE_ACCOUNT_RENT, + STAKE_ACCOUNT_SPACE, + &solana_stake_interface::program::ID, + ); + + // 3. WithdrawStake: withdraw from stake pool into the new stake account + let withdraw_data = StakePoolInstruction::WithdrawStake(amount); + let mut data = Vec::new(); + withdraw_data.serialize(&mut data).unwrap(); use solana_sdk::instruction::AccountMeta; - let instruction = Instruction::new_with_bytes( + let withdraw_stake_ix = Instruction::new_with_bytes( stake_pool_program, &data, vec![ @@ -592,21 +680,32 @@ fn build_jito_unstake( ], ); + // 4. Deactivate: deactivate the newly created stake account + let deactivate_ix = stake_ix::deactivate_stake(&unstake_pubkey, fee_payer); + let generated = vec![ GeneratedKeypair { - purpose: "unstakeAccount".to_string(), + purpose: KeypairPurpose::UnstakeAccount, address: unstake_address, secret_key: solana_sdk::bs58::encode(unstake_keypair.secret_key_bytes()).into_string(), }, GeneratedKeypair { - purpose: "transferAuthority".to_string(), + purpose: KeypairPurpose::TransferAuthority, address: transfer_authority_address, secret_key: solana_sdk::bs58::encode(transfer_authority_keypair.secret_key_bytes()) .into_string(), }, ]; - Ok((vec![instruction], generated)) + Ok(( + vec![ + approve_ix, + create_account_ix, + withdraw_stake_ix, + deactivate_ix, + ], + generated, + )) } fn build_claim( @@ -1016,7 +1115,53 @@ mod tests { assert!(result.is_ok(), "Failed: {:?}", result); let result = result.unwrap(); assert_eq!(result.generated_keypairs.len(), 1); - assert_eq!(result.generated_keypairs[0].purpose, "stakeAccount"); + assert_eq!( + result.generated_keypairs[0].purpose, + KeypairPurpose::StakeAccount + ); + } + + #[test] + fn test_stake_with_durable_nonce_structure() { + use crate::transaction::TransactionExt; + + let nonce_authority = Keypair::new(); + let params = BuildParams { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Durable { + address: "27E3MXFvXMUNYeMJeX1pAbERGsJfUbkaZTfgMgpmNN5g".to_string(), + authority: nonce_authority.address(), + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + }; + + let intent = serde_json::json!({ + "intentType": "stake", + "validatorAddress": "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + "amount": { "value": "1000000000" } + }); + + let result = build_from_intent(&intent, ¶ms).unwrap(); + + // Transaction should have 3 required signatures: fee_payer + stake_account + nonce_authority + assert_eq!( + result.transaction.num_signatures(), + 3, + "Durable nonce stake tx should have 3 signature slots" + ); + + // Generated keypair (stake account) should already be signed in Rust + let zero_sig = [0u8; 64]; + let non_zero_count = result + .transaction + .signatures + .iter() + .filter(|s| s.as_ref() != &zero_sig) + .count(); + assert_eq!( + non_zero_count, 1, + "build_from_intent should sign generated keypairs in Rust" + ); } #[test] @@ -1121,7 +1266,10 @@ mod tests { // Should generate a stake account keypair assert_eq!(result.generated_keypairs.len(), 1); - assert_eq!(result.generated_keypairs[0].purpose, "stakeAccount"); + assert_eq!( + result.generated_keypairs[0].purpose, + KeypairPurpose::StakeAccount + ); // Transaction should have 2 instructions (CreateAccount + Initialize) // No Delegate instruction for Marinade diff --git a/packages/wasm-solana/src/intent/types.rs b/packages/wasm-solana/src/intent/types.rs index 3729e4e..c8a36cc 100644 --- a/packages/wasm-solana/src/intent/types.rs +++ b/packages/wasm-solana/src/intent/types.rs @@ -65,12 +65,28 @@ pub struct IntentBuildResult { pub generated_keypairs: Vec, } +/// Purpose of a generated keypair. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum KeypairPurpose { + StakeAccount, + UnstakeAccount, + TransferAuthority, +} + +/// Authorize type for stake account authority changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizeType { + Staker, + Withdrawer, +} + /// A keypair generated during transaction building. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GeneratedKeypair { /// Purpose of this keypair - pub purpose: String, + pub purpose: KeypairPurpose, /// Public address (base58) pub address: String, /// Secret key (base58) @@ -204,6 +220,9 @@ pub struct StakePoolConfig { pub validator_list: Option, #[serde(default)] pub source_pool_account: Option, + /// Whether to create an ATA for the pool mint before depositing (Jito staking) + #[serde(default)] + pub create_associated_token_account: Option, } /// Unstake intent diff --git a/packages/wasm-solana/src/wasm/keypair.rs b/packages/wasm-solana/src/wasm/keypair.rs index d08bd0d..7a24a85 100644 --- a/packages/wasm-solana/src/wasm/keypair.rs +++ b/packages/wasm-solana/src/wasm/keypair.rs @@ -60,6 +60,17 @@ impl WasmKeypair { pub fn to_base58(&self) -> String { self.inner.address() } + + /// Sign a message with this keypair and return the 64-byte Ed25519 signature. + /// + /// @param message - The message bytes to sign + /// @returns The 64-byte signature as a Uint8Array + #[wasm_bindgen] + pub fn sign(&self, message: &[u8]) -> js_sys::Uint8Array { + use solana_signer::Signer; + let sig = self.inner.sign_message(message); + js_sys::Uint8Array::from(sig.as_ref()) + } } impl WasmKeypair { diff --git a/packages/wasm-solana/src/wasm/transaction.rs b/packages/wasm-solana/src/wasm/transaction.rs index 61c14b4..97af423 100644 --- a/packages/wasm-solana/src/wasm/transaction.rs +++ b/packages/wasm-solana/src/wasm/transaction.rs @@ -135,6 +135,27 @@ impl WasmTransaction { self.inner.signer_index(pubkey) } + /// Sign this transaction with a base58-encoded Ed25519 secret key. + /// + /// Derives the public key from the secret, signs the transaction message, + /// and places the signature at the correct signer index. + /// + /// @param secret_key_base58 - The Ed25519 secret key (32-byte seed) as base58 + #[wasm_bindgen] + pub fn sign_with_secret_key(&mut self, secret_key_base58: &str) -> Result<(), WasmSolanaError> { + use crate::keypair::{Keypair, KeypairExt}; + use solana_signer::Signer; + + let secret_bytes: Vec = bs58::decode(secret_key_base58) + .into_vec() + .map_err(|e| WasmSolanaError::new(&format!("Failed to decode secret key: {}", e)))?; + let keypair = Keypair::from_secret_key_bytes(&secret_bytes)?; + let message_bytes = self.inner.message.serialize(); + let signature = keypair.sign_message(&message_bytes); + let address = keypair.address(); + self.inner.add_signature(&address, signature.as_ref()) + } + /// Get all instructions as an array. /// /// Each instruction is a JS object with programId, accounts, and data.