diff --git a/.gitignore b/.gitignore index e5c48de..b06384d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .idea/ *.iml *.tsbuildinfo +.cursor/ diff --git a/packages/wasm-solana/Cargo.lock b/packages/wasm-solana/Cargo.lock index 10c9b81..5d65f9d 100644 --- a/packages/wasm-solana/Cargo.lock +++ b/packages/wasm-solana/Cargo.lock @@ -683,8 +683,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.9.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -4165,6 +4167,8 @@ dependencies = [ "base64 0.22.1", "bincode", "borsh 1.6.0", + "getrandom 0.1.16", + "getrandom 0.2.17", "hex", "js-sys", "serde", diff --git a/packages/wasm-solana/Cargo.toml b/packages/wasm-solana/Cargo.toml index c1a0b4e..25d969e 100644 --- a/packages/wasm-solana/Cargo.toml +++ b/packages/wasm-solana/Cargo.toml @@ -30,6 +30,9 @@ solana-keypair = "2.0" solana-signer = "2.0" solana-signature = "3.0" solana-address = "1.0" +# WASM random number generation support (need both 0.1 and 0.2 for all deps) +getrandom_01 = { package = "getrandom", version = "0.1", features = ["wasm-bindgen"] } +getrandom = { version = "0.2", features = ["js"] } # Serialization bincode = "1.3" borsh = "1.5" diff --git a/packages/wasm-solana/bitgo-wasm-solana-0.0.1.tgz b/packages/wasm-solana/bitgo-wasm-solana-0.0.1.tgz index cad565b..2cf0af0 100644 Binary files a/packages/wasm-solana/bitgo-wasm-solana-0.0.1.tgz and b/packages/wasm-solana/bitgo-wasm-solana-0.0.1.tgz differ diff --git a/packages/wasm-solana/eslint.config.js b/packages/wasm-solana/eslint.config.js index a94acbe..4ffa4ee 100644 --- a/packages/wasm-solana/eslint.config.js +++ b/packages/wasm-solana/eslint.config.js @@ -7,7 +7,7 @@ export default tseslint.config( { languageOptions: { parserOptions: { - projectService: true, + project: ["./tsconfig.json", "./tsconfig.test.json"], tsconfigRootDir: import.meta.dirname, }, }, diff --git a/packages/wasm-solana/js/index.ts b/packages/wasm-solana/js/index.ts index 54b3d68..13eec3d 100644 --- a/packages/wasm-solana/js/index.ts +++ b/packages/wasm-solana/js/index.ts @@ -22,6 +22,29 @@ export type { AddressLookupTableData } from "./versioned.js"; // Top-level function exports export { parseTransaction } from "./parser.js"; export { buildTransaction, buildFromVersionedData } from "./builder.js"; +export { buildFromIntent } from "./intentBuilder.js"; + +// Intent builder type exports +export type { + BaseIntent, + PaymentIntent, + StakeIntent, + UnstakeIntent, + ClaimIntent, + DeactivateIntent, + DelegateIntent, + EnableTokenIntent, + CloseAtaIntent, + ConsolidateIntent, + SolanaIntent, + StakePoolConfig, + BuildFromIntentParams, + BuildFromIntentResult, + GeneratedKeypair, + NonceSource, + BlockhashNonce, + DurableNonce, +} from "./intentBuilder.js"; // Program ID constants (from WASM) export { @@ -47,7 +70,7 @@ export type { AccountMeta, Instruction } from "./transaction.js"; export type { TransactionInput, ParsedTransaction, - DurableNonce, + DurableNonce as ParsedDurableNonce, InstructionParams, TransferParams, CreateAccountParams, @@ -74,7 +97,7 @@ export type { // Builder type exports (prefixed to avoid conflict with parser/transaction types) export type { TransactionIntent, - NonceSource, + NonceSource as BuilderNonceSource, BlockhashNonceSource, DurableNonceSource, AddressLookupTable as BuilderAddressLookupTable, diff --git a/packages/wasm-solana/js/intentBuilder.ts b/packages/wasm-solana/js/intentBuilder.ts new file mode 100644 index 0000000..d03c171 --- /dev/null +++ b/packages/wasm-solana/js/intentBuilder.ts @@ -0,0 +1,238 @@ +/** + * High-level intent-based transaction building. + * + * This module provides `buildFromIntent()` which accepts BitGo intent objects + * directly and builds Solana transactions without requiring the caller to + * construct low-level instructions. + * + * The intent → transaction mapping happens entirely in Rust/WASM for simplicity. + * + * Usage: + * ```typescript + * import { buildFromIntent } from '@bitgo/wasm-solana'; + * + * const result = buildFromIntent(intent, { + * feePayer: walletRootAddress, + * nonce: { type: 'blockhash', value: recentBlockhash }, + * }); + * + * // result.transaction - Transaction object + * // result.generatedKeypairs - any keypairs generated (e.g., stake accounts) + * ``` + */ + +import { IntentNamespace, WasmTransaction } from "./wasm/wasm_solana.js"; +import { Transaction } from "./transaction.js"; + +/** Internal type for WASM result - matches what Rust returns */ +interface WasmBuildResult { + transaction: WasmTransaction; + generatedKeypairs: GeneratedKeypair[]; +} + +// ============================================================================= +// Types +// ============================================================================= + +/** Nonce source - blockhash or durable nonce */ +export type NonceSource = BlockhashNonce | DurableNonce; + +export interface BlockhashNonce { + type: "blockhash"; + value: string; +} + +export interface DurableNonce { + type: "durable"; + address: string; + authority: string; + value: string; +} + +/** Parameters for building a transaction from intent */ +export interface BuildFromIntentParams { + /** Fee payer address (wallet root) */ + feePayer: string; + /** Nonce source - blockhash or durable nonce */ + nonce: NonceSource; +} + +/** A keypair generated during transaction building */ +export interface GeneratedKeypair { + /** Purpose of this keypair */ + purpose: "stakeAccount" | "unstakeAccount" | "transferAuthority"; + /** Public address (base58) */ + address: string; + /** Secret key (base58) */ + secretKey: string; +} + +/** Result from building a transaction from intent */ +export interface BuildFromIntentResult { + /** The built transaction */ + transaction: Transaction; + /** Generated keypairs (for stake accounts, etc.) */ + generatedKeypairs: GeneratedKeypair[]; +} + +// ============================================================================= +// Intent Types (for TypeScript users) +// ============================================================================= + +/** Base intent - all intents have intentType */ +export interface BaseIntent { + intentType: string; + memo?: string; +} + +/** Payment intent */ +export interface PaymentIntent extends BaseIntent { + intentType: "payment"; + recipients?: Array<{ + address?: { address: string }; + amount?: { value: bigint; symbol?: string }; + }>; +} + +/** Stake intent */ +export interface StakeIntent extends BaseIntent { + intentType: "stake"; + validatorAddress: string; + amount?: { value: bigint }; + stakingType?: "NATIVE" | "JITO" | "MARINADE"; + stakePoolConfig?: StakePoolConfig; +} + +/** Stake pool configuration (for Jito) */ +export interface StakePoolConfig { + stakePoolAddress: string; + withdrawAuthority: string; + reserveStake: string; + destinationPoolAccount: string; + managerFeeAccount: string; + referralPoolAccount?: string; + poolMint: string; + validatorList?: string; + sourcePoolAccount?: string; +} + +/** Unstake intent */ +export interface UnstakeIntent extends BaseIntent { + intentType: "unstake"; + stakingAddress: string; + validatorAddress?: string; + amount?: { value: bigint }; + remainingStakingAmount?: { value: bigint }; + stakingType?: "NATIVE" | "JITO" | "MARINADE"; + stakePoolConfig?: StakePoolConfig; +} + +/** Claim intent (withdraw from deactivated stake) */ +export interface ClaimIntent extends BaseIntent { + intentType: "claim"; + stakingAddress: string; + amount?: { value: bigint }; +} + +/** Deactivate intent */ +export interface DeactivateIntent extends BaseIntent { + intentType: "deactivate"; + stakingAddress?: string; + stakingAddresses?: string[]; +} + +/** Delegate intent */ +export interface DelegateIntent extends BaseIntent { + intentType: "delegate"; + validatorAddress: string; + stakingAddress?: string; + stakingAddresses?: string[]; +} + +/** Enable token intent (create ATA) */ +export interface EnableTokenIntent extends BaseIntent { + intentType: "enableToken"; + recipientAddress?: string; + tokenAddress?: string; + tokenProgramId?: string; +} + +/** Close ATA intent */ +export interface CloseAtaIntent extends BaseIntent { + intentType: "closeAssociatedTokenAccount"; + tokenAccountAddress?: string; + tokenProgramId?: string; +} + +/** Consolidate intent - transfer from child address to root */ +export interface ConsolidateIntent extends BaseIntent { + intentType: "consolidate"; + /** The child address to consolidate from (sender) */ + receiveAddress: string; + /** Recipients (root address for SOL, ATAs for tokens) */ + recipients?: Array<{ + address?: { address: string }; + amount?: { value: bigint }; + }>; +} + +/** Union of all supported intent types */ +export type SolanaIntent = + | PaymentIntent + | StakeIntent + | UnstakeIntent + | ClaimIntent + | DeactivateIntent + | DelegateIntent + | EnableTokenIntent + | CloseAtaIntent + | ConsolidateIntent; + +// ============================================================================= +// Main Function +// ============================================================================= + +/** + * Build a Solana transaction from a BitGo intent. + * + * This function passes the intent directly to Rust/WASM which handles + * all the intent-to-transaction mapping internally. + * + * @param intent - The BitGo intent (with intentType, etc.) + * @param params - Build parameters (feePayer, nonce) + * @returns Transaction object and any generated keypairs + * + * @example + * ```typescript + * // Payment intent + * const result = buildFromIntent( + * { + * intentType: 'payment', + * recipients: [{ address: { address: recipient }, amount: { value: 1000000n } }] + * }, + * { feePayer: walletRoot, nonce: { type: 'blockhash', value: blockhash } } + * ); + * + * // Native staking - generates a new stake account keypair + * const result = buildFromIntent( + * { + * intentType: 'stake', + * validatorAddress: validator, + * amount: { value: 1000000000n } + * }, + * { feePayer: walletRoot, nonce: { type: 'blockhash', value: blockhash } } + * ); + * // result.generatedKeypairs[0] contains the stake account keypair + * ``` + */ +export function buildFromIntent( + intent: BaseIntent, + params: BuildFromIntentParams, +): BuildFromIntentResult { + const result = IntentNamespace.build_from_intent(intent, params) as WasmBuildResult; + + 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 3ed3e68..25e6c80 100644 --- a/packages/wasm-solana/js/keypair.ts +++ b/packages/wasm-solana/js/keypair.ts @@ -9,6 +9,15 @@ import { WasmKeypair } from "./wasm/wasm_solana.js"; export class Keypair { private constructor(private _wasm: WasmKeypair) {} + /** + * Generate a new random keypair + * @returns A new Keypair instance with randomly generated keys + */ + static generate(): Keypair { + const wasm = WasmKeypair.generate(); + return new Keypair(wasm); + } + /** * Create a keypair from a 32-byte secret key * @param secretKey - The 32-byte Ed25519 secret key diff --git a/packages/wasm-solana/src/builder/build.rs b/packages/wasm-solana/src/builder/build.rs index 3b1fe68..9bc8c70 100644 --- a/packages/wasm-solana/src/builder/build.rs +++ b/packages/wasm-solana/src/builder/build.rs @@ -798,7 +798,7 @@ fn build_instruction(ix: IntentInstruction) -> Result hex::decode(&data).map_err(|e| { WasmSolanaError::new(&format!("Invalid hex data in custom instruction: {}", e)) })?, - "base64" | _ => { + _ => { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(&data) diff --git a/packages/wasm-solana/src/instructions/decode.rs b/packages/wasm-solana/src/instructions/decode.rs index d57c37f..32a5539 100644 --- a/packages/wasm-solana/src/instructions/decode.rs +++ b/packages/wasm-solana/src/instructions/decode.rs @@ -79,7 +79,7 @@ fn decode_system_instruction(ctx: InstructionContext) -> ParsedInstruction { // This is part of CreateNonceAccount flow - parsed as intermediate NonceInitialize // Will be combined with CreateAccount in post-processing // Accounts: [0] nonce, [1] recent_blockhashes_sysvar, [2] rent_sysvar - if ctx.accounts.len() >= 1 { + if !ctx.accounts.is_empty() { ParsedInstruction::NonceInitialize(NonceInitializeParams { nonce_address: ctx.accounts[0].clone(), auth_address: authority.to_string(), @@ -141,7 +141,7 @@ fn decode_stake_instruction(ctx: InstructionContext) -> ParsedInstruction { // This is part of StakingActivate flow - parsed as intermediate StakeInitialize // Will be combined with CreateAccount + DelegateStake in post-processing // Accounts: [0] stake, [1] rent_sysvar - if ctx.accounts.len() >= 1 { + if !ctx.accounts.is_empty() { ParsedInstruction::StakeInitialize(StakeInitializeParams { staking_address: ctx.accounts[0].clone(), staker: authorized.staker.to_string(), diff --git a/packages/wasm-solana/src/intent/build.rs b/packages/wasm-solana/src/intent/build.rs new file mode 100644 index 0000000..c15b578 --- /dev/null +++ b/packages/wasm-solana/src/intent/build.rs @@ -0,0 +1,829 @@ +//! Intent-based transaction building implementation. +//! +//! Builds transactions directly from BitGo intents without an intermediate +//! instruction abstraction. + +use crate::error::WasmSolanaError; +use crate::keypair::{Keypair, KeypairExt}; + +use super::types::*; + +// Solana SDK types +use solana_sdk::hash::Hash; +use solana_sdk::instruction::Instruction; +use solana_sdk::message::Message; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::Transaction; + +// Instruction builders from existing crates +use solana_stake_interface::instruction as stake_ix; +use solana_stake_interface::state::{Authorized, Lockup}; +use solana_system_interface::instruction as system_ix; + +// Constants +const STAKE_ACCOUNT_SPACE: u64 = 200; +const STAKE_ACCOUNT_RENT: u64 = 2282880; // ~0.00228288 SOL + +/// Build a transaction from a BitGo intent. +/// +/// # Arguments +/// * `intent_json` - The full intent as JSON (serde_json::Value) +/// * `params` - Build parameters (feePayer, nonce) +/// +/// # Returns +/// * `IntentBuildResult` with transaction and generated keypairs +pub fn build_from_intent( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result { + // Extract intent type + let intent_type = intent_json + .get("intentType") + .and_then(|v| v.as_str()) + .ok_or_else(|| WasmSolanaError::new("Missing intentType in intent"))?; + + // Build based on intent type + let (instructions, generated_keypairs) = match intent_type { + "payment" | "goUnstake" => build_payment(intent_json, params)?, + "stake" => build_stake(intent_json, params)?, + "unstake" => build_unstake(intent_json, params)?, + "claim" => build_claim(intent_json, params)?, + "deactivate" => build_deactivate(intent_json, params)?, + "delegate" => build_delegate(intent_json, params)?, + "enableToken" => build_enable_token(intent_json, params)?, + "closeAssociatedTokenAccount" => build_close_ata(intent_json, params)?, + "consolidate" => build_consolidate(intent_json, params)?, + _ => { + return Err(WasmSolanaError::new(&format!( + "Unsupported intent type: {}", + intent_type + ))) + } + }; + + // Add memo if present + let mut all_instructions = instructions; + if let Some(memo) = intent_json.get("memo").and_then(|v| v.as_str()) { + if !memo.is_empty() { + all_instructions.push(build_memo(memo)); + } + } + + // Build the transaction + let transaction = build_transaction_from_instructions(all_instructions, params)?; + + Ok(IntentBuildResult { + transaction, + generated_keypairs, + }) +} + +/// Build a Transaction from instructions and params. +fn build_transaction_from_instructions( + mut instructions: Vec, + params: &BuildParams, +) -> Result { + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid feePayer: {}", params.fee_payer)))?; + + // Handle nonce + let blockhash_str = match ¶ms.nonce { + Nonce::Blockhash { value } => value.clone(), + 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() + } + }; + + let blockhash: Hash = blockhash_str + .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); + let tx = Transaction::new_unsigned(message); + + Ok(tx) +} + +// ============================================================================= +// Intent Builders +// ============================================================================= + +fn build_payment( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: PaymentIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse payment intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let mut instructions = Vec::new(); + + for recipient in intent.recipients { + let address = recipient + .address + .as_ref() + .map(|a| &a.address) + .ok_or_else(|| WasmSolanaError::new("Recipient missing address"))?; + let amount = recipient + .amount + .as_ref() + .map(|a| &a.value) + .ok_or_else(|| WasmSolanaError::new("Recipient missing amount"))?; + + let to_pubkey: Pubkey = address.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid recipient address: {}", address)) + })?; + let lamports: u64 = *amount; + + instructions.push(system_ix::transfer(&fee_payer, &to_pubkey, lamports)); + } + + Ok((instructions, vec![])) +} + +fn build_stake( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: StakeIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse stake intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let amount: u64 = intent.amount.as_ref().map(|a| a.value).unwrap_or(0); + + // Check if Jito staking + if intent.staking_type.as_deref() == Some("JITO") { + if let Some(config) = &intent.stake_pool_config { + return build_jito_stake(config, &fee_payer, amount); + } + } + + // Native staking: generate stake account keypair + let stake_keypair = Keypair::new(); + let stake_address = stake_keypair.address(); + let stake_pubkey: Pubkey = stake_address + .parse() + .map_err(|_| WasmSolanaError::new("Failed to generate stake address"))?; + + let validator_pubkey: Pubkey = intent + .validator_address + .parse() + .map_err(|_| WasmSolanaError::new("Invalid validatorAddress"))?; + + let instructions = vec![ + // Create account + system_ix::create_account( + &fee_payer, + &stake_pubkey, + amount + STAKE_ACCOUNT_RENT, + STAKE_ACCOUNT_SPACE, + &solana_stake_interface::program::ID, + ), + // Initialize stake + stake_ix::initialize( + &stake_pubkey, + &Authorized { + staker: fee_payer, + withdrawer: fee_payer, + }, + &Lockup::default(), + ), + // Delegate + stake_ix::delegate_stake(&stake_pubkey, &fee_payer, &validator_pubkey), + ]; + + let generated = vec![GeneratedKeypair { + purpose: "stakeAccount".to_string(), + address: stake_address, + secret_key: solana_sdk::bs58::encode(stake_keypair.secret_key_bytes()).into_string(), + }]; + + Ok((instructions, generated)) +} + +fn build_jito_stake( + config: &StakePoolConfig, + fee_payer: &Pubkey, + amount: u64, +) -> Result<(Vec, Vec), WasmSolanaError> { + 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 referral_pool_account: Pubkey = config + .referral_pool_account + .as_ref() + .unwrap_or(&config.destination_pool_account) + .parse() + .map_err(|_| WasmSolanaError::new("Invalid referralPoolAccount"))?; + let pool_mint: Pubkey = config + .pool_mint + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid poolMint: {}", config.pool_mint)))?; + + // 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( + stake_pool_program, + &data, + vec![ + AccountMeta::new(stake_pool, false), + AccountMeta::new_readonly(withdraw_authority, false), + AccountMeta::new(reserve_stake, false), + AccountMeta::new(*fee_payer, true), + AccountMeta::new(destination_pool_account, false), + AccountMeta::new(manager_fee_account, false), + AccountMeta::new(referral_pool_account, false), + AccountMeta::new(pool_mint, false), + AccountMeta::new_readonly(system_program, false), + AccountMeta::new_readonly(token_program, false), + ], + ); + + Ok((vec![instruction], vec![])) +} + +fn build_unstake( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: UnstakeIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse unstake intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let stake_pubkey: Pubkey = intent + .staking_address + .parse() + .map_err(|_| WasmSolanaError::new("Invalid stakingAddress"))?; + + // Check if Jito unstaking + if intent.staking_type.as_deref() == Some("JITO") { + if let Some(config) = &intent.stake_pool_config { + let amount: u64 = intent.amount.as_ref().map(|a| a.value).unwrap_or(0); + return build_jito_unstake(config, &fee_payer, &intent.validator_address, amount); + } + } + + // Check if partial unstake + let amount = intent.amount.as_ref().map(|a| a.value); + let remaining = intent.remaining_staking_amount.as_ref().map(|a| a.value); + + if let (Some(amt_val), Some(rem_val)) = (amount, remaining) { + if amt_val > 0 && rem_val > 0 { + return build_partial_unstake(&stake_pubkey, &fee_payer, amt_val); + } + } + + // Simple deactivate + let instructions = vec![stake_ix::deactivate_stake(&stake_pubkey, &fee_payer)]; + + Ok((instructions, vec![])) +} + +fn build_partial_unstake( + stake_pubkey: &Pubkey, + fee_payer: &Pubkey, + amount: u64, +) -> Result<(Vec, Vec), WasmSolanaError> { + use solana_stake_interface::instruction::StakeInstruction; + + // Generate new stake account for the split + let unstake_keypair = Keypair::new(); + let unstake_address = unstake_keypair.address(); + let unstake_pubkey: Pubkey = unstake_address + .parse() + .map_err(|_| WasmSolanaError::new("Failed to generate unstake address"))?; + + let instructions = vec![ + // Transfer rent to new account + system_ix::transfer(fee_payer, &unstake_pubkey, STAKE_ACCOUNT_RENT), + // Allocate space + system_ix::allocate(&unstake_pubkey, STAKE_ACCOUNT_SPACE), + // Assign to stake program + system_ix::assign(&unstake_pubkey, &solana_stake_interface::program::ID), + // Split stake + Instruction::new_with_bincode( + solana_stake_interface::program::ID, + &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_readonly(*fee_payer, true), + ], + ), + // Deactivate split portion + stake_ix::deactivate_stake(&unstake_pubkey, fee_payer), + ]; + + let generated = vec![GeneratedKeypair { + purpose: "unstakeAccount".to_string(), + address: unstake_address, + secret_key: solana_sdk::bs58::encode(unstake_keypair.secret_key_bytes()).into_string(), + }]; + + Ok((instructions, generated)) +} + +fn build_jito_unstake( + config: &StakePoolConfig, + fee_payer: &Pubkey, + validator_address: &Option, + amount: u64, +) -> Result<(Vec, Vec), WasmSolanaError> { + use borsh::BorshSerialize; + use spl_stake_pool::instruction::StakePoolInstruction; + + // Generate destination stake account + let unstake_keypair = Keypair::new(); + let unstake_address = unstake_keypair.address(); + let unstake_pubkey: Pubkey = unstake_address.parse().unwrap(); + + // Generate transfer authority + let transfer_authority_keypair = Keypair::new(); + let transfer_authority_address = transfer_authority_keypair.address(); + 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 validator_list: Pubkey = config + .validator_list + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing validatorList"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid validatorList"))?; + let withdraw_authority: Pubkey = config + .withdraw_authority + .parse() + .map_err(|_| WasmSolanaError::new("Invalid withdrawAuthority"))?; + 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 + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing sourcePoolAccount"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid sourcePoolAccount"))?; + let manager_fee_account: Pubkey = config + .manager_fee_account + .parse() + .map_err(|_| WasmSolanaError::new("Invalid managerFeeAccount"))?; + let pool_mint: Pubkey = config + .pool_mint + .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(); + + let stake_pool_program: Pubkey = "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" + .parse() + .unwrap(); + let clock_sysvar: Pubkey = solana_sdk::sysvar::clock::ID; + let token_program: Pubkey = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap(); + + use solana_sdk::instruction::AccountMeta; + let instruction = Instruction::new_with_bytes( + stake_pool_program, + &data, + vec![ + AccountMeta::new(stake_pool, false), + AccountMeta::new(validator_list, false), + AccountMeta::new_readonly(withdraw_authority, false), + AccountMeta::new(validator_stake, false), + AccountMeta::new(unstake_pubkey, false), + AccountMeta::new_readonly(*fee_payer, false), + AccountMeta::new_readonly(transfer_authority_pubkey, true), + AccountMeta::new(source_pool_account, false), + AccountMeta::new(manager_fee_account, false), + AccountMeta::new(pool_mint, false), + AccountMeta::new_readonly(clock_sysvar, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(solana_stake_interface::program::ID, false), + ], + ); + + let generated = vec![ + GeneratedKeypair { + purpose: "unstakeAccount".to_string(), + address: unstake_address, + secret_key: solana_sdk::bs58::encode(unstake_keypair.secret_key_bytes()).into_string(), + }, + GeneratedKeypair { + purpose: "transferAuthority".to_string(), + address: transfer_authority_address, + secret_key: solana_sdk::bs58::encode(transfer_authority_keypair.secret_key_bytes()) + .into_string(), + }, + ]; + + Ok((vec![instruction], generated)) +} + +fn build_claim( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: ClaimIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse claim intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let stake_pubkey: Pubkey = intent + .staking_address + .parse() + .map_err(|_| WasmSolanaError::new("Invalid stakingAddress"))?; + + let amount: u64 = intent.amount.as_ref().map(|a| a.value).unwrap_or(0); + + let instructions = vec![stake_ix::withdraw( + &stake_pubkey, + &fee_payer, + &fee_payer, + amount, + None, + )]; + + Ok((instructions, vec![])) +} + +fn build_deactivate( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: DeactivateIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse deactivate intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + // Get addresses - either single or multiple + let addresses: Vec = intent + .staking_addresses + .unwrap_or_else(|| intent.staking_address.into_iter().collect()); + + let mut instructions = Vec::new(); + for addr in addresses { + let stake_pubkey: Pubkey = addr + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid stakingAddress: {}", addr)))?; + instructions.push(stake_ix::deactivate_stake(&stake_pubkey, &fee_payer)); + } + + Ok((instructions, vec![])) +} + +fn build_delegate( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: DelegateIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse delegate intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let validator_pubkey: Pubkey = intent + .validator_address + .parse() + .map_err(|_| WasmSolanaError::new("Invalid validatorAddress"))?; + + // Get addresses - either single or multiple + let addresses: Vec = intent + .staking_addresses + .unwrap_or_else(|| intent.staking_address.into_iter().collect()); + + let mut instructions = Vec::new(); + for addr in addresses { + let stake_pubkey: Pubkey = addr + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid stakingAddress: {}", addr)))?; + instructions.push(stake_ix::delegate_stake( + &stake_pubkey, + &fee_payer, + &validator_pubkey, + )); + } + + Ok((instructions, vec![])) +} + +fn build_enable_token( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: EnableTokenIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse enableToken intent: {}", e)))?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let owner: Pubkey = intent + .recipient_address + .as_ref() + .map(|a| a.parse()) + .transpose() + .map_err(|_| WasmSolanaError::new("Invalid recipientAddress"))? + .unwrap_or(fee_payer); + + let mint: Pubkey = intent + .token_address + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing tokenAddress"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid tokenAddress"))?; + + let token_program: Pubkey = intent + .token_program_id + .as_ref() + .map(|p| p.parse()) + .transpose() + .map_err(|_| WasmSolanaError::new("Invalid tokenProgramId"))? + .unwrap_or_else(|| { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() + }); + + // Derive ATA address + let ata_program: Pubkey = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap(); + let seeds = &[owner.as_ref(), token_program.as_ref(), mint.as_ref()]; + let (ata, _bump) = Pubkey::find_program_address(seeds, &ata_program); + + let system_program: Pubkey = "11111111111111111111111111111111".parse().unwrap(); + + use solana_sdk::instruction::AccountMeta; + let instruction = Instruction::new_with_bytes( + ata_program, + &[], + vec![ + AccountMeta::new(fee_payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(owner, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(system_program, false), + AccountMeta::new_readonly(token_program, false), + ], + ); + + Ok((vec![instruction], vec![])) +} + +fn build_close_ata( + intent_json: &serde_json::Value, + params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: CloseAtaIntent = serde_json::from_value(intent_json.clone()).map_err(|e| { + WasmSolanaError::new(&format!( + "Failed to parse closeAssociatedTokenAccount intent: {}", + e + )) + })?; + + let fee_payer: Pubkey = params + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new("Invalid feePayer"))?; + + let account: Pubkey = intent + .token_account_address + .as_ref() + .ok_or_else(|| WasmSolanaError::new("Missing tokenAccountAddress"))? + .parse() + .map_err(|_| WasmSolanaError::new("Invalid tokenAccountAddress"))?; + + let token_program: Pubkey = intent + .token_program_id + .as_ref() + .map(|p| p.parse()) + .transpose() + .map_err(|_| WasmSolanaError::new("Invalid tokenProgramId"))? + .unwrap_or_else(|| { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() + }); + + // CloseAccount instruction + use spl_token::instruction::TokenInstruction; + let data = TokenInstruction::CloseAccount.pack(); + + use solana_sdk::instruction::AccountMeta; + let instruction = Instruction::new_with_bytes( + token_program, + &data, + vec![ + AccountMeta::new(account, false), + AccountMeta::new(fee_payer, false), + AccountMeta::new_readonly(fee_payer, true), + ], + ); + + Ok((vec![instruction], vec![])) +} + +fn build_consolidate( + intent_json: &serde_json::Value, + _params: &BuildParams, +) -> Result<(Vec, Vec), WasmSolanaError> { + let intent: ConsolidateIntent = serde_json::from_value(intent_json.clone()) + .map_err(|e| WasmSolanaError::new(&format!("Failed to parse consolidate intent: {}", e)))?; + + // The sender is the child address being consolidated (receiveAddress in the intent) + let sender: Pubkey = intent + .receive_address + .parse() + .map_err(|_| WasmSolanaError::new("Invalid receiveAddress (sender)"))?; + + let mut instructions = Vec::new(); + + for recipient in intent.recipients { + let address = recipient + .address + .as_ref() + .map(|a| &a.address) + .ok_or_else(|| WasmSolanaError::new("Recipient missing address"))?; + let amount = recipient + .amount + .as_ref() + .map(|a| &a.value) + .ok_or_else(|| WasmSolanaError::new("Recipient missing amount"))?; + + let to_pubkey: Pubkey = address.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid recipient address: {}", address)) + })?; + let lamports: u64 = *amount; + + // Transfer from sender (child address), not fee_payer + instructions.push(system_ix::transfer(&sender, &to_pubkey, lamports)); + } + + Ok((instructions, vec![])) +} + +/// Build a memo instruction. +fn build_memo(message: &str) -> Instruction { + let memo_program: Pubkey = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" + .parse() + .unwrap(); + Instruction::new_with_bytes(memo_program, message.as_bytes(), vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_params() -> BuildParams { + BuildParams { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + } + } + + #[test] + fn test_build_payment_intent() { + let intent = serde_json::json!({ + "intentType": "payment", + "recipients": [{ + "address": { "address": "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + "amount": { "value": "1000000" } + }] + }); + + let result = build_from_intent(&intent, &test_params()); + assert!(result.is_ok(), "Failed: {:?}", result); + let result = result.unwrap(); + assert!(result.generated_keypairs.is_empty()); + } + + #[test] + fn test_build_stake_intent() { + let intent = serde_json::json!({ + "intentType": "stake", + "validatorAddress": "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + "amount": { "value": "1000000000" } + }); + + let result = build_from_intent(&intent, &test_params()); + 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"); + } + + #[test] + fn test_build_deactivate_intent() { + let intent = serde_json::json!({ + "intentType": "deactivate", + "stakingAddress": "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" + }); + + let result = build_from_intent(&intent, &test_params()); + assert!(result.is_ok(), "Failed: {:?}", result); + } + + #[test] + fn test_build_claim_intent() { + let intent = serde_json::json!({ + "intentType": "claim", + "stakingAddress": "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH", + "amount": { "value": "1000000000" } + }); + + let result = build_from_intent(&intent, &test_params()); + assert!(result.is_ok(), "Failed: {:?}", result); + } +} diff --git a/packages/wasm-solana/src/intent/mod.rs b/packages/wasm-solana/src/intent/mod.rs new file mode 100644 index 0000000..f274455 --- /dev/null +++ b/packages/wasm-solana/src/intent/mod.rs @@ -0,0 +1,18 @@ +//! Intent-based transaction building. +//! +//! This module provides direct intent → transaction building without +//! an intermediate instruction abstraction. +//! +//! # Usage from TypeScript +//! +//! ```typescript +//! const result = buildFromIntent(intent, { feePayer, nonce }); +//! // result.transaction - serialized transaction bytes +//! // result.generatedKeypairs - any keypairs generated (stake accounts, etc.) +//! ``` + +mod build; +mod types; + +pub use build::build_from_intent; +pub use types::*; diff --git a/packages/wasm-solana/src/intent/types.rs b/packages/wasm-solana/src/intent/types.rs new file mode 100644 index 0000000..f60a6e3 --- /dev/null +++ b/packages/wasm-solana/src/intent/types.rs @@ -0,0 +1,275 @@ +//! Types for intent-based transaction building. +//! +//! These types mirror the BitGo intent structures and are deserialized from JavaScript. + +use serde::{Deserialize, Serialize}; + +/// Build parameters provided by wallet-platform. +/// These are NOT part of the intent but needed to build the transaction. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildParams { + /// Fee payer address (wallet root) + pub fee_payer: String, + /// Nonce configuration + pub nonce: Nonce, +} + +/// Nonce source for the transaction. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Nonce { + /// Recent blockhash (standard transactions) + Blockhash { value: String }, + /// Durable nonce (offline signing) + Durable { + address: String, + authority: String, + value: String, + }, +} + +/// Result from building a transaction from intent. +#[derive(Debug, Clone)] +pub struct IntentBuildResult { + /// The built transaction + pub transaction: solana_sdk::transaction::Transaction, + /// Generated keypairs (for stake accounts, etc.) + pub generated_keypairs: Vec, +} + +/// A keypair generated during transaction building. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedKeypair { + /// Purpose of this keypair + pub purpose: String, + /// Public address (base58) + pub address: String, + /// Secret key (base58) + pub secret_key: String, +} + +// ============================================================================= +// Intent Types (match BitGo public-types shapes) +// ============================================================================= + +/// Base intent - all intents have intentType +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BaseIntent { + pub intent_type: String, + #[serde(default)] + pub memo: Option, +} + +/// Recipient for payment intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Recipient { + pub address: Option, + pub amount: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddressWrapper { + pub address: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AmountWrapper { + /// Amount value - accepts bigint from JS (deserialized as u64) + #[serde(deserialize_with = "deserialize_amount")] + pub value: u64, + #[serde(default)] + pub symbol: Option, +} + +/// Deserialize amount from either string or number (for JS BigInt compatibility) +fn deserialize_amount<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + + struct AmountVisitor; + + impl<'de> Visitor<'de> for AmountVisitor { + type Value = u64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or number representing an amount") + } + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(v) + } + + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + u64::try_from(v).map_err(|_| de::Error::custom("negative amount")) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + v.parse().map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(AmountVisitor) +} + +/// Payment intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentIntent { + pub intent_type: String, + #[serde(default)] + pub recipients: Vec, + #[serde(default)] + pub memo: Option, +} + +/// Stake intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StakeIntent { + pub intent_type: String, + pub validator_address: String, + #[serde(default)] + pub amount: Option, + #[serde(default)] + pub staking_type: Option, + #[serde(default)] + pub stake_pool_config: Option, + #[serde(default)] + pub memo: Option, +} + +/// Stake pool configuration (for Jito) +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StakePoolConfig { + pub stake_pool_address: String, + pub withdraw_authority: String, + pub reserve_stake: String, + pub destination_pool_account: String, + pub manager_fee_account: String, + #[serde(default)] + pub referral_pool_account: Option, + pub pool_mint: String, + #[serde(default)] + pub validator_list: Option, + #[serde(default)] + pub source_pool_account: Option, +} + +/// Unstake intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnstakeIntent { + pub intent_type: String, + pub staking_address: String, + #[serde(default)] + pub validator_address: Option, + #[serde(default)] + pub amount: Option, + #[serde(default)] + pub remaining_staking_amount: Option, + #[serde(default)] + pub staking_type: Option, + #[serde(default)] + pub stake_pool_config: Option, + #[serde(default)] + pub memo: Option, +} + +/// Claim intent (withdraw from deactivated stake) +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimIntent { + pub intent_type: String, + pub staking_address: String, + #[serde(default)] + pub amount: Option, + #[serde(default)] + pub memo: Option, +} + +/// Deactivate intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeactivateIntent { + pub intent_type: String, + #[serde(default)] + pub staking_address: Option, + #[serde(default)] + pub staking_addresses: Option>, + #[serde(default)] + pub memo: Option, +} + +/// Delegate intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegateIntent { + pub intent_type: String, + pub validator_address: String, + #[serde(default)] + pub staking_address: Option, + #[serde(default)] + pub staking_addresses: Option>, + #[serde(default)] + pub memo: Option, +} + +/// Enable token intent (create ATA) +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnableTokenIntent { + pub intent_type: String, + #[serde(default)] + pub recipient_address: Option, + #[serde(default)] + pub token_address: Option, + #[serde(default)] + pub token_program_id: Option, + #[serde(default)] + pub memo: Option, +} + +/// Close ATA intent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseAtaIntent { + pub intent_type: String, + #[serde(default)] + pub token_account_address: Option, + #[serde(default)] + pub token_program_id: Option, + #[serde(default)] + pub memo: Option, +} + +/// Consolidate intent - transfer from child address to root +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsolidateIntent { + pub intent_type: String, + /// The child address to consolidate from (sender) + pub receive_address: String, + /// Recipients (root address for SOL, ATAs for tokens) + #[serde(default)] + pub recipients: Vec, + #[serde(default)] + pub memo: Option, +} diff --git a/packages/wasm-solana/src/lib.rs b/packages/wasm-solana/src/lib.rs index ae25013..35827cd 100644 --- a/packages/wasm-solana/src/lib.rs +++ b/packages/wasm-solana/src/lib.rs @@ -26,6 +26,7 @@ pub mod builder; mod error; mod instructions; +pub mod intent; pub mod keypair; mod parser; pub mod pubkey; @@ -44,6 +45,6 @@ pub use versioned::{ // Re-export WASM types pub use wasm::{ - is_versioned_transaction, BuilderNamespace, ParserNamespace, WasmKeypair, WasmPubkey, - WasmTransaction, WasmVersionedTransaction, + is_versioned_transaction, BuilderNamespace, IntentNamespace, ParserNamespace, WasmKeypair, + WasmPubkey, WasmTransaction, WasmVersionedTransaction, }; diff --git a/packages/wasm-solana/src/wasm/intent.rs b/packages/wasm-solana/src/wasm/intent.rs new file mode 100644 index 0000000..8001e24 --- /dev/null +++ b/packages/wasm-solana/src/wasm/intent.rs @@ -0,0 +1,76 @@ +//! WASM bindings for intent-based transaction building. + +use crate::intent; +use crate::wasm::transaction::WasmTransaction; +use wasm_bindgen::prelude::*; + +/// Namespace for intent-based building operations. +#[wasm_bindgen] +pub struct IntentNamespace; + +#[wasm_bindgen] +impl IntentNamespace { + /// Build a transaction directly from a BitGo intent. + /// + /// This function takes the full intent as-is and builds the transaction + /// without requiring the caller to construct instructions. + /// + /// # Arguments + /// + /// * `intent` - The full BitGo intent object (with intentType, etc.) + /// * `params` - Build parameters: { feePayer, nonce } + /// + /// # Returns + /// + /// An object with: + /// * `transaction` - WasmTransaction object + /// * `generatedKeypairs` - Array of keypairs generated (for stake accounts, etc.) + /// + /// # Example + /// + /// ```javascript + /// const result = IntentNamespace.build_from_intent( + /// { + /// intentType: 'stake', + /// validatorAddress: '...', + /// amount: { value: 1000000000n } + /// }, + /// { + /// feePayer: 'DgT9...', + /// nonce: { type: 'blockhash', value: 'GWaQ...' } + /// } + /// ); + /// // result.transaction - WasmTransaction object + /// // result.generatedKeypairs - [{ purpose, address, secretKey }] + /// ``` + #[wasm_bindgen] + pub fn build_from_intent(intent: JsValue, params: JsValue) -> Result { + // Parse intent as generic JSON + let intent_json: serde_json::Value = serde_wasm_bindgen::from_value(intent) + .map_err(|e| JsValue::from_str(&format!("Failed to parse intent: {}", e)))?; + + // Parse build params + let build_params: intent::BuildParams = serde_wasm_bindgen::from_value(params) + .map_err(|e| JsValue::from_str(&format!("Failed to parse build params: {}", e)))?; + + // Build the transaction + let result = intent::build_from_intent(&intent_json, &build_params) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + // Wrap the Transaction directly (no serialize/deserialize round-trip) + let wasm_tx = WasmTransaction::from_inner(result.transaction); + + // Build result object with WasmTransaction + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &"transaction".into(), &wasm_tx.into()) + .map_err(|_| JsValue::from_str("Failed to set transaction"))?; + + // Serialize generated keypairs + let keypairs = serde_wasm_bindgen::to_value(&result.generated_keypairs) + .map_err(|e| JsValue::from_str(&format!("Failed to serialize keypairs: {}", e)))?; + js_sys::Reflect::set(&obj, &"generatedKeypairs".into(), &keypairs) + .map_err(|_| JsValue::from_str("Failed to set generatedKeypairs"))?; + + Ok(obj.into()) + } +} diff --git a/packages/wasm-solana/src/wasm/keypair.rs b/packages/wasm-solana/src/wasm/keypair.rs index aeb411f..d08bd0d 100644 --- a/packages/wasm-solana/src/wasm/keypair.rs +++ b/packages/wasm-solana/src/wasm/keypair.rs @@ -15,6 +15,14 @@ pub struct WasmKeypair { #[wasm_bindgen] impl WasmKeypair { + /// Generate a new random keypair. + #[wasm_bindgen] + pub fn generate() -> WasmKeypair { + WasmKeypair { + inner: Keypair::new(), + } + } + /// Create a keypair from a 32-byte secret key. #[wasm_bindgen] pub fn from_secret_key(secret_key: &[u8]) -> Result { diff --git a/packages/wasm-solana/src/wasm/mod.rs b/packages/wasm-solana/src/wasm/mod.rs index 46e2ecf..8a1cad7 100644 --- a/packages/wasm-solana/src/wasm/mod.rs +++ b/packages/wasm-solana/src/wasm/mod.rs @@ -1,5 +1,6 @@ mod builder; mod constants; +mod intent; mod keypair; mod parser; mod pubkey; @@ -7,6 +8,7 @@ mod transaction; pub mod try_into_js_value; pub use builder::BuilderNamespace; +pub use intent::IntentNamespace; pub use keypair::WasmKeypair; pub use parser::ParserNamespace; pub use pubkey::WasmPubkey; diff --git a/packages/wasm-solana/src/wasm/transaction.rs b/packages/wasm-solana/src/wasm/transaction.rs index 803e688..b2326fc 100644 --- a/packages/wasm-solana/src/wasm/transaction.rs +++ b/packages/wasm-solana/src/wasm/transaction.rs @@ -176,6 +176,12 @@ impl WasmTransaction { pub fn inner(&self) -> &Transaction { &self.inner } + + /// Create a WasmTransaction from an existing Transaction. + /// Used internally by builders to avoid serialize/deserialize round-trip. + pub(crate) fn from_inner(inner: Transaction) -> Self { + WasmTransaction { inner } + } } // ============================================================================ diff --git a/packages/wasm-solana/test/intentBuilder.ts b/packages/wasm-solana/test/intentBuilder.ts new file mode 100644 index 0000000..fa687f9 --- /dev/null +++ b/packages/wasm-solana/test/intentBuilder.ts @@ -0,0 +1,436 @@ +/** + * Tests for intent-based transaction building. + * + * These tests verify that buildFromIntent correctly builds transactions + * from BitGo intent objects. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ + +import assert from "assert"; +import { buildFromIntent, Transaction, parseTransaction } from "../dist/cjs/js/index.js"; + +describe("buildFromIntent", function () { + // Common test params + const feePayer = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB"; + const blockhash = "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4"; + + describe("payment intent", function () { + it("should build a simple payment transaction", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert(Array.isArray(result.generatedKeypairs), "Should return generatedKeypairs array"); + assert.equal(result.generatedKeypairs.length, 0, "Payment should not generate keypairs"); + }); + + it("should build a multi-recipient payment", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + }, + { + address: { address: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN" }, + amount: { value: 2000000n }, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + // Parse to verify + const parsed = parseTransaction(result.transaction.toBytes()); + + // Should have 2 Transfer instructions + const transfers = parsed.instructionsData.filter((i: any) => i.type === "Transfer"); + assert.equal(transfers.length, 2, "Should have 2 transfer instructions"); + }); + + it("should include memo when provided", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + }, + ], + memo: "Test payment memo", + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + // Parse to verify + const parsed = parseTransaction(result.transaction.toBytes()); + + // Should have Memo instruction + const memos = parsed.instructionsData.filter((i: any) => i.type === "Memo"); + assert.equal(memos.length, 1, "Should have 1 memo instruction"); + assert.equal((memos[0] as any).memo, "Test payment memo", "Memo should match"); + }); + }); + + describe("stake intent (native)", function () { + it("should build a native stake transaction", function () { + const intent = { + intentType: "stake", + validatorAddress: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + amount: { value: 1000000000n }, + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 1, "Should generate 1 keypair"); + assert.equal(result.generatedKeypairs[0].purpose, "stakeAccount", "Should be stakeAccount"); + assert(result.generatedKeypairs[0].address, "Should have address"); + assert(result.generatedKeypairs[0].secretKey, "Should have secretKey"); + }); + + it("should produce valid stake instructions", function () { + const intent = { + intentType: "stake", + validatorAddress: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + amount: { value: 1000000000n }, + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + // Parse to verify + const parsed = parseTransaction(result.transaction.toBytes()); + + // Native staking should have 3 instructions: CreateAccount, StakeInitialize, StakeDelegate + assert(parsed.instructionsData.length >= 3, "Should have at least 3 instructions"); + + const createAccount = parsed.instructionsData.find((i: any) => i.type === "CreateAccount"); + assert(createAccount, "Should have CreateAccount instruction"); + + const stakeInit = parsed.instructionsData.find((i: any) => i.type === "StakeInitialize"); + assert(stakeInit, "Should have StakeInitialize instruction"); + + const stakeDelegate = parsed.instructionsData.find((i: any) => i.type === "StakingDelegate"); + assert(stakeDelegate, "Should have StakingDelegate instruction"); + }); + }); + + describe("deactivate intent", function () { + it("should build a deactivate transaction with single address", function () { + const intent = { + intentType: "deactivate", + stakingAddress: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH", + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + // Verify instruction + const parsed = parseTransaction(result.transaction.toBytes()); + + const deactivate = parsed.instructionsData.find((i: any) => i.type === "StakingDeactivate"); + assert(deactivate, "Should have StakingDeactivate instruction"); + }); + + it("should build a deactivate transaction with multiple addresses", function () { + const intent = { + intentType: "deactivate", + stakingAddresses: [ + "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH", + "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + // Verify instructions + const parsed = parseTransaction(result.transaction.toBytes()); + + const deactivates = parsed.instructionsData.filter( + (i: any) => i.type === "StakingDeactivate", + ); + assert.equal(deactivates.length, 2, "Should have 2 StakingDeactivate instructions"); + }); + }); + + describe("claim intent", function () { + it("should build a claim (withdraw) transaction", function () { + const intent = { + intentType: "claim", + stakingAddress: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH", + amount: { value: 1000000000n }, + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + // Verify instruction + const parsed = parseTransaction(result.transaction.toBytes()); + + const withdraw = parsed.instructionsData.find((i: any) => i.type === "StakingWithdraw"); + assert(withdraw, "Should have StakingWithdraw instruction"); + }); + }); + + describe("delegate intent", function () { + it("should build a delegate transaction", function () { + const intent = { + intentType: "delegate", + validatorAddress: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN", + stakingAddress: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH", + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + // Verify instruction - delegate is a StakingDelegate instruction + const parsed = parseTransaction(result.transaction.toBytes()); + + const delegate = parsed.instructionsData.find((i: any) => i.type === "StakingDelegate"); + assert(delegate, "Should have StakingDelegate instruction"); + }); + }); + + describe("enableToken intent", function () { + it("should build an enableToken transaction", function () { + const intent = { + intentType: "enableToken", + tokenAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mint + recipientAddress: feePayer, + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + // Verify instruction + const parsed = parseTransaction(result.transaction.toBytes()); + + const createAta = parsed.instructionsData.find( + (i: any) => i.type === "CreateAssociatedTokenAccount", + ); + assert(createAta, "Should have CreateAssociatedTokenAccount instruction"); + }); + }); + + describe("closeAssociatedTokenAccount intent", function () { + it("should build a closeAssociatedTokenAccount transaction", function () { + const intent = { + intentType: "closeAssociatedTokenAccount", + tokenAccountAddress: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH", + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + // Verify instruction + const parsed = parseTransaction(result.transaction.toBytes()); + + const closeAta = parsed.instructionsData.find( + (i: any) => i.type === "CloseAssociatedTokenAccount", + ); + assert(closeAta, "Should have CloseAssociatedTokenAccount instruction"); + }); + }); + + describe("consolidate intent", function () { + it("should build a consolidate transaction (transfer from child to root)", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const rootAddress = feePayer; + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, // The child address to consolidate FROM + recipients: [ + { + address: { address: rootAddress }, // Transfer TO root + amount: { value: 50000000n }, // 0.05 SOL + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, // Fee payer is root address + nonce: { type: "blockhash", value: blockhash }, + }); + + assert(result.transaction instanceof Transaction, "Should return Transaction object"); + assert.equal(result.generatedKeypairs.length, 0, "Should not generate keypairs"); + + // Verify instruction + const parsed = parseTransaction(result.transaction.toBytes()); + + // Should have a Transfer instruction where the sender is the child address + const transfer = parsed.instructionsData.find((i: any) => i.type === "Transfer"); + assert(transfer, "Should have Transfer instruction"); + // The transfer should be FROM the child address (receiveAddress), not the fee payer + assert.equal((transfer as any).fromAddress, childAddress, "Sender should be child address"); + assert.equal((transfer as any).toAddress, rootAddress, "Recipient should be root address"); + }); + + it("should build a multi-recipient consolidate (SOL + tokens)", function () { + const childAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const intent = { + intentType: "consolidate", + receiveAddress: childAddress, + recipients: [ + { + address: { address: feePayer }, + amount: { value: 50000000n }, + }, + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, // Token ATA + amount: { value: 1000000n }, + }, + ], + }; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + + const parsed = parseTransaction(result.transaction.toBytes()); + + // Should have 2 Transfer instructions + const transfers = parsed.instructionsData.filter((i: any) => i.type === "Transfer"); + assert.equal(transfers.length, 2, "Should have 2 transfer instructions"); + }); + }); + + describe("durable nonce", function () { + it("should prepend nonce advance for durable nonce", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + }, + ], + }; + + const nonceAddress = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const nonceAuthority = "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB"; + + const result = buildFromIntent(intent, { + feePayer, + nonce: { + type: "durable", + address: nonceAddress, + authority: nonceAuthority, + value: blockhash, + }, + }); + + // Verify instructions + const parsed = parseTransaction(result.transaction.toBytes()); + + // First instruction should be NonceAdvance + assert.equal( + parsed.instructionsData[0].type, + "NonceAdvance", + "First instruction should be NonceAdvance", + ); + }); + }); + + describe("error handling", function () { + it("should reject invalid intent type", function () { + const intent = { + intentType: "invalidType", + }; + + assert.throws(() => { + buildFromIntent(intent, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + }, /Unsupported intent type/); + }); + + it("should reject missing intentType", function () { + const intent = { + recipients: [], + }; + + assert.throws(() => { + buildFromIntent(intent as any, { + feePayer, + nonce: { type: "blockhash", value: blockhash }, + }); + }, /Missing intentType/); + }); + + it("should reject invalid feePayer", function () { + const intent = { + intentType: "payment", + recipients: [ + { + address: { address: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" }, + amount: { value: 1000000n }, + }, + ], + }; + + assert.throws(() => { + buildFromIntent(intent, { + feePayer: "invalid-address", + nonce: { type: "blockhash", value: blockhash }, + }); + }, /Invalid feePayer/); + }); + }); +}); diff --git a/packages/wasm-solana/test/keypair.ts b/packages/wasm-solana/test/keypair.ts index b78c7c6..810b91d 100644 --- a/packages/wasm-solana/test/keypair.ts +++ b/packages/wasm-solana/test/keypair.ts @@ -1,9 +1,16 @@ import * as assert from "assert"; -import { Keypair } from "../js/keypair.js"; +import { Keypair } from "../dist/cjs/js/keypair.js"; describe("Keypair", () => { const testSecretKey = new Uint8Array(32).fill(1); + it("should generate a random keypair", () => { + const keypair = Keypair.generate(); + assert.strictEqual(keypair.publicKey.length, 32); + assert.strictEqual(keypair.secretKey.length, 32); + assert.ok(keypair.getAddress().length > 30, "Address should be base58"); + }); + it("should create keypair from secret key", () => { const keypair = Keypair.fromSecretKey(testSecretKey); diff --git a/packages/wasm-solana/tsconfig.cjs.json b/packages/wasm-solana/tsconfig.cjs.json index f6f2384..390db86 100644 --- a/packages/wasm-solana/tsconfig.cjs.json +++ b/packages/wasm-solana/tsconfig.cjs.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "./dist/cjs" }, - "exclude": ["test/**/*"] + "exclude": ["node_modules", "./js/wasm/**/*", "test/**/*"] } diff --git a/packages/wasm-solana/tsconfig.json b/packages/wasm-solana/tsconfig.json index 97f8a7a..499b65c 100644 --- a/packages/wasm-solana/tsconfig.json +++ b/packages/wasm-solana/tsconfig.json @@ -13,6 +13,6 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": ["./js/**/*.ts", "test/**/*.ts"], - "exclude": ["node_modules", "./js/wasm/**/*"] + "include": ["./js/**/*.ts"], + "exclude": ["node_modules", "./js/wasm/**/*", "test/**/*"] } diff --git a/packages/wasm-solana/tsconfig.test.json b/packages/wasm-solana/tsconfig.test.json new file mode 100644 index 0000000..7ec46d3 --- /dev/null +++ b/packages/wasm-solana/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "mocha"], + "noEmit": true + }, + "include": ["./js/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "./js/wasm/**/*"] +}