From 614fb501848a53e72c5e380da991c945ceac1081 Mon Sep 17 00:00:00 2001 From: Prithvish Date: Thu, 30 Oct 2025 13:46:20 +0530 Subject: [PATCH 1/3] support partially signed serialised transactions for solana --- .gitignore | 1 + Cargo.lock | 1 + Cargo.toml | 1 + core/src/execution_options/solana.rs | 11 +- executors/src/solana_executor/rpc_cache.rs | 2 +- executors/src/solana_executor/worker.rs | 200 +++++++++++---------- server/src/execution_router/mod.rs | 2 +- solana-core/Cargo.toml | 1 + solana-core/src/transaction.rs | 101 ++++++++--- 9 files changed, 196 insertions(+), 124 deletions(-) diff --git a/.gitignore b/.gitignore index 0e2d835..39bf8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /target/ .DS_Store coverage +.env.test \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index eb3789a..c3421e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3006,6 +3006,7 @@ name = "engine-solana-core" version = "0.1.0" dependencies = [ "base64 0.22.1", + "bincode 2.0.1", "hex", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index cffad68..0dbae5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ serde_json = "1.0.140" serde_with = "3.14.0" serde-bool = "0.1.3" serde_repr = "0.1.20" +bincode = { version = "2.0.1", features = ["serde"] } # Error handling thiserror = "2.0.12" diff --git a/core/src/execution_options/solana.rs b/core/src/execution_options/solana.rs index e2aa7d6..81ef722 100644 --- a/core/src/execution_options/solana.rs +++ b/core/src/execution_options/solana.rs @@ -126,12 +126,14 @@ impl CommitmentLevel { } } + #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] #[schema(title = "Solana Transaction Options")] #[serde(rename_all = "camelCase")] pub struct SolanaTransactionOptions { - /// List of instructions to execute in this transaction - pub instructions: Vec, + /// Transaction input + #[serde(flatten)] + pub input: engine_solana_core::transaction::SolanaTransactionInput, /// Solana execution options pub execution_options: SolanaExecutionOptions, @@ -145,8 +147,9 @@ pub struct SendSolanaTransactionRequest { #[serde(default = "super::default_idempotency_key")] pub idempotency_key: String, - /// List of Solana instructions to execute - pub instructions: Vec, + /// Transaction input (either instructions or serialized transaction) + #[serde(flatten)] + pub input: engine_solana_core::transaction::SolanaTransactionInput, /// Solana execution options pub execution_options: SolanaExecutionOptions, diff --git a/executors/src/solana_executor/rpc_cache.rs b/executors/src/solana_executor/rpc_cache.rs index 4375c58..f625a44 100644 --- a/executors/src/solana_executor/rpc_cache.rs +++ b/executors/src/solana_executor/rpc_cache.rs @@ -1,7 +1,7 @@ use engine_core::execution_options::solana::SolanaChainId; use moka::future::Cache; use solana_client::nonblocking::rpc_client::RpcClient; -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use tracing::info; /// Cache key for RPC clients diff --git a/executors/src/solana_executor/worker.rs b/executors/src/solana_executor/worker.rs index 85280eb..b6914cb 100644 --- a/executors/src/solana_executor/worker.rs +++ b/executors/src/solana_executor/worker.rs @@ -1,4 +1,3 @@ -use base64::Engine; use engine_core::{ credentials::SigningCredential, error::{EngineError, SolanaRpcErrorToEngineError}, @@ -12,16 +11,13 @@ use solana_client::{ rpc_config::{RpcSendTransactionConfig, RpcTransactionConfig}, }; use solana_commitment_config::{CommitmentConfig, CommitmentLevel}; -use solana_sdk::{ - hash::Hash, - pubkey::Pubkey, - transaction::VersionedTransaction, -}; +use solana_sdk::pubkey::Pubkey; use solana_transaction_status::{ EncodedTransactionWithStatusMeta, UiTransactionEncoding }; use spl_memo_interface::instruction::build_memo; use std::{sync::Arc, time::Duration}; +use base64::Engine; use tracing::{error, info, warn}; use twmq::{ DurableExecution, FailHookData, NackHookData, Queue, SuccessHookData, UserCancellable, @@ -437,15 +433,11 @@ impl SolanaExecutorJobHandler { Ok(result) } - fn get_writable_accounts( - &self, - transaction: &SolanaTransactionOptions, - ) -> Vec { - transaction - .instructions + fn get_writable_accounts(instructions: &[SolanaInstructionData]) -> Vec { + instructions .iter() - .flat_map(|i| { - i.accounts + .flat_map(|inst| { + inst.accounts .iter() .filter(|a| a.is_writable) .map(|a| a.pubkey) @@ -453,83 +445,38 @@ impl SolanaExecutorJobHandler { .collect() } - /// Compile a Solana transaction with priority fees and memo instruction - /// ALWAYS NACK on error - network operations can be retried - /// - /// Adds a memo instruction with the transaction_id to ensure unique signatures - /// even when rapidly resubmitting with the same blockhash - async fn compile_transaction( + async fn get_compute_unit_price( &self, - transaction: &SolanaTransactionOptions, + priority_fee: &SolanaPriorityFee, + instructions: &[SolanaInstructionData], rpc_client: &RpcClient, - recent_blockhash: Hash, chain_id: &str, - transaction_id: &str, - ) -> JobResult { - let compute_unit_price = if let Some(price_config) = &transaction.execution_options.priority_fee { - let price = match price_config { - SolanaPriorityFee::Auto => { - self.get_percentile_compute_unit_price( - rpc_client, - &self.get_writable_accounts(transaction), - 75, - chain_id, - ) - .await? - } - SolanaPriorityFee::Manual { micro_lamports_per_unit } => { - *micro_lamports_per_unit - } - SolanaPriorityFee::Percentile { percentile } => { - self.get_percentile_compute_unit_price( - rpc_client, - &self.get_writable_accounts(transaction), - *percentile, - chain_id, - ) - .await? - } - }; - Some(price) - } else { - None - }; - - // Add memo instruction with transaction_id for unique signatures - let memo_data = format!("thirdweb-engine:{}", transaction_id); - let memo_ix = build_memo(&spl_memo_interface::v3::id(), memo_data.as_bytes(), &[]); - - let mut instructions = transaction.instructions.clone(); - let memo_data_base64 = base64::engine::general_purpose::STANDARD.encode(memo_data.as_bytes()); - instructions.push(SolanaInstructionData { - program_id: memo_ix.program_id, - accounts: vec![], - data: memo_data_base64, - encoding: InstructionDataEncoding::Base64, - }); - - let solana_transaction = SolanaTransaction { - instructions, - compute_unit_price, - compute_unit_limit: transaction.execution_options.compute_unit_limit, - recent_blockhash, - }; - - let versioned_tx = solana_transaction - .to_versioned_transaction(transaction.execution_options.signer_address, recent_blockhash) - .map_err(|e| { - error!( - transaction_id = %transaction_id, - error = %e, - "Failed to build transaction" - ); - SolanaExecutorError::TransactionBuildFailed { - inner_error: e.to_string(), - } - .fail() - })?; + ) -> JobResult { + let writable_accounts = Self::get_writable_accounts(instructions); - Ok(versioned_tx) + match priority_fee { + SolanaPriorityFee::Auto => { + self.get_percentile_compute_unit_price( + rpc_client, + &writable_accounts, + 75, + chain_id, + ) + .await + } + SolanaPriorityFee::Manual { micro_lamports_per_unit } => { + Ok(*micro_lamports_per_unit) + } + SolanaPriorityFee::Percentile { percentile } => { + self.get_percentile_compute_unit_price( + rpc_client, + &writable_accounts, + *percentile, + chain_id, + ) + .await + } + } } @@ -767,16 +714,75 @@ impl SolanaExecutorJobHandler { .nack(Some(NETWORK_ERROR_RETRY_DELAY), RequeuePosition::Last) })?; - // Compile and sign transaction - let versioned_tx = self - .compile_transaction( - &job_data.transaction, - rpc_client, - recent_blockhash, - chain_id_str.as_str(), - transaction_id, - ) - .await?; + // Build transaction - handle execution options differently for instructions vs serialized + let versioned_tx = match &job_data.transaction.input { + engine_solana_core::transaction::SolanaTransactionInput::Instructions { instructions } => { + // For instruction-based transactions: calculate priority fees and apply execution options + let compute_unit_price = if let Some(priority_fee) = &job_data.transaction.execution_options.priority_fee { + Some(self.get_compute_unit_price(priority_fee, instructions, rpc_client, chain_id_str.as_str()).await?) + } else { + None + }; + + // Add memo instruction with transaction_id for unique signatures + // This ensures that even with the same blockhash, each resubmission has a unique signature + let memo_data = format!("thirdweb-engine:{}", transaction_id); + let memo_ix = build_memo(&spl_memo_interface::v3::id(), memo_data.as_bytes(), &[]); + + let mut instructions_with_memo = instructions.clone(); + let memo_data_base64 = base64::engine::general_purpose::STANDARD.encode(memo_data.as_bytes()); + instructions_with_memo.push(SolanaInstructionData { + program_id: memo_ix.program_id, + accounts: vec![], + data: memo_data_base64, + encoding: InstructionDataEncoding::Base64, + }); + + let solana_tx = SolanaTransaction { + input: engine_solana_core::transaction::SolanaTransactionInput::Instructions { + instructions: instructions_with_memo, + }, + compute_unit_limit: job_data.transaction.execution_options.compute_unit_limit, + compute_unit_price, + }; + + solana_tx + .to_versioned_transaction(signer_address, recent_blockhash) + .map_err(|e| { + error!( + transaction_id = %transaction_id, + error = %e, + "Failed to build transaction from instructions" + ); + SolanaExecutorError::TransactionBuildFailed { + inner_error: e.to_string(), + } + .fail() + })? + } + engine_solana_core::transaction::SolanaTransactionInput::Serialized { .. } => { + // For serialized transactions: ignore execution options to avoid invalidating signatures + let solana_tx = SolanaTransaction { + input: job_data.transaction.input.clone(), + compute_unit_limit: None, + compute_unit_price: None, + }; + + solana_tx + .to_versioned_transaction(signer_address, recent_blockhash) + .map_err(|e| { + error!( + transaction_id = %transaction_id, + error = %e, + "Failed to deserialize compiled transaction" + ); + SolanaExecutorError::TransactionBuildFailed { + inner_error: e.to_string(), + } + .fail() + })? + } + }; let signed_tx = self .solana_signer diff --git a/server/src/execution_router/mod.rs b/server/src/execution_router/mod.rs index 244039a..8443054 100644 --- a/server/src/execution_router/mod.rs +++ b/server/src/execution_router/mod.rs @@ -514,7 +514,7 @@ impl ExecutionRouter { let signer_address = request.execution_options.signer_address; let transaction = SolanaTransactionOptions { - instructions: request.instructions, + input: request.input, execution_options: request.execution_options, }; diff --git a/solana-core/Cargo.toml b/solana-core/Cargo.toml index 0ee9dee..5d03731 100644 --- a/solana-core/Cargo.toml +++ b/solana-core/Cargo.toml @@ -14,6 +14,7 @@ thiserror = { workspace = true } tracing = { workspace = true } hex = { workspace = true } base64 = { workspace = true } +bincode = { workspace = true } utoipa = { workspace = true, features = [ "macros", "chrono", diff --git a/solana-core/src/transaction.rs b/solana-core/src/transaction.rs index 7ba67da..62fb617 100644 --- a/solana-core/src/transaction.rs +++ b/solana-core/src/transaction.rs @@ -9,6 +9,20 @@ use solana_sdk::{ transaction::VersionedTransaction, }; +/// Input for Solana transaction - either build from instructions or use pre-built +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase", untagged)] +pub enum SolanaTransactionInput { + /// Build transaction from instructions + Instructions { + instructions: Vec, + }, + /// Use pre-built serialized VersionedTransaction (base64) + Serialized { + transaction: String, + }, +} + /// Solana instruction data provided by the user /// This is a simplified representation that will be converted to a proper Instruction #[serde_as] @@ -66,24 +80,18 @@ impl SolanaAccountMeta { } /// Complete resolved Solana transaction request -#[serde_as] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SolanaTransaction { - /// List of instructions to execute in this transaction - pub instructions: Vec, - - /// Optional recent blockhash (if not provided, will be fetched) - #[serde_as(as = "DisplayFromStr")] - pub recent_blockhash: solana_sdk::hash::Hash, + /// Transaction input + #[serde(flatten)] + pub input: SolanaTransactionInput, /// Compute budget limit (compute units) - /// If not provided, the transaction will use default compute budget #[serde(skip_serializing_if = "Option::is_none")] pub compute_unit_limit: Option, /// Compute budget price (micro-lamports per compute unit) - /// This is the priority fee - higher values increase transaction priority #[serde(skip_serializing_if = "Option::is_none")] pub compute_unit_price: Option, } @@ -127,35 +135,50 @@ impl SolanaInstructionData { } impl SolanaTransaction { - /// Build a VersionedTransaction from this transaction data + /// Build or deserialize a VersionedTransaction pub fn to_versioned_transaction( &self, payer: Pubkey, recent_blockhash: solana_sdk::hash::Hash, ) -> Result { - let mut instructions: Vec = Vec::new(); + match &self.input { + SolanaTransactionInput::Instructions { instructions } => { + self.build_from_instructions(instructions, payer, recent_blockhash) + } + SolanaTransactionInput::Serialized { transaction } => { + self.deserialize_transaction(transaction, payer) + } + } + } + + /// Build transaction from instructions + fn build_from_instructions( + &self, + instructions: &[SolanaInstructionData], + payer: Pubkey, + recent_blockhash: solana_sdk::hash::Hash, + ) -> Result { + let mut inst_list: Vec = Vec::new(); // Add compute budget instructions if specified if let Some(limit) = self.compute_unit_limit { let compute_limit_ix = ComputeBudgetInstruction::set_compute_unit_limit(limit); - instructions.push(compute_limit_ix); + inst_list.push(compute_limit_ix); } if let Some(price) = self.compute_unit_price { let compute_price_ix = ComputeBudgetInstruction::set_compute_unit_price(price); - instructions.push(compute_price_ix); + inst_list.push(compute_price_ix); } - for inst in self.instructions.iter() { - instructions.push(inst.to_instruction()?); + for inst in instructions.iter() { + inst_list.push(inst.to_instruction()?); } - // Build message based on whether we have lookup tables - // Use legacy v0 message without lookup tables let message = v0::Message::try_compile( &payer, - &instructions, - &[], // No address lookup tables + &inst_list, + &[], recent_blockhash, ) .map_err(|e| SolanaTransactionError::MessageCompilationFailed { @@ -163,16 +186,46 @@ impl SolanaTransaction { })?; let message = VersionedMessage::V0(message); - let num_signatures = message.header().num_required_signatures as usize; let signatures = vec![solana_sdk::signature::Signature::default(); num_signatures]; - // Create unsigned transaction Ok(VersionedTransaction { signatures, message, }) } + + /// Deserialize pre-built transaction and verify fee payer + /// Note: compute_unit_limit and compute_unit_price are ignored for serialized transactions + /// as they would require adding new instructions which would invalidate existing signatures + fn deserialize_transaction( + &self, + tx_base64: &str, + expected_payer: Pubkey, + ) -> Result { + let tx_bytes = Base64Engine.decode(tx_base64) + .map_err(|e| SolanaTransactionError::DeserializationFailed { + error: format!("Invalid base64: {}", e), + })?; + + // Deserialize from binary wire format using bincode + let (transaction, _): (VersionedTransaction, _) = + bincode::serde::decode_from_slice(&tx_bytes, bincode::config::standard()) + .map_err(|e| SolanaTransactionError::DeserializationFailed { + error: format!("Failed to deserialize VersionedTransaction: {}", e), + })?; + + // Verify fee payer + let fee_payer = transaction.message.static_account_keys()[0]; + if fee_payer != expected_payer { + return Err(SolanaTransactionError::FeePayerMismatch { + expected: expected_payer.to_string(), + got: fee_payer.to_string(), + }); + } + + Ok(transaction) + } } #[derive(Debug, thiserror::Error)] @@ -192,4 +245,10 @@ pub enum SolanaTransactionError { #[error("Invalid blockhash: {error}")] InvalidBlockhash { error: String }, + + #[error("Failed to deserialize transaction: {error}")] + DeserializationFailed { error: String }, + + #[error("Fee payer mismatch: expected {expected}, got {got}")] + FeePayerMismatch { expected: String, got: String }, } From c27bd5afafaba269035a84d4718af22753dc2035 Mon Sep 17 00:00:00 2001 From: Prithvish Date: Thu, 30 Oct 2025 14:29:20 +0530 Subject: [PATCH 2/3] Add bincode dependency and enhance Solana transaction handling - Added `bincode` as a dependency in the Cargo.toml for the executors. - Updated the SolanaExecutorJobHandler to handle serialized transactions with existing signatures more robustly, preventing retries that would invalidate signatures when the blockhash expires. --- Cargo.lock | 1 + executors/Cargo.toml | 1 + executors/src/solana_executor/worker.rs | 34 ++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c3421e3..4b0ee3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2970,6 +2970,7 @@ version = "0.1.0" dependencies = [ "alloy", "base64 0.22.1", + "bincode 2.0.1", "chrono", "engine-aa-core", "engine-aa-types", diff --git a/executors/Cargo.toml b/executors/Cargo.toml index 4d957db..5f2475f 100644 --- a/executors/Cargo.toml +++ b/executors/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] hex = { workspace = true } alloy = { workspace = true, features = ["serde"] } +bincode = { workspace = true, features = ["serde"] } thirdweb-core = { version = "0.1.0", path = "../thirdweb-core" } hmac = { workspace = true } reqwest = { workspace = true } diff --git a/executors/src/solana_executor/worker.rs b/executors/src/solana_executor/worker.rs index b6914cb..67fd1db 100644 --- a/executors/src/solana_executor/worker.rs +++ b/executors/src/solana_executor/worker.rs @@ -597,7 +597,39 @@ impl SolanaExecutorJobHandler { .nack(Some(CONFIRMATION_RETRY_DELAY), RequeuePosition::Last)); } Ok(false) => { - // Blockhash expired, need to resubmit + // Blockhash expired + + // For serialized transactions with existing signatures, we cannot retry with a new blockhash + // because the signatures will become invalid. Check if there are any non-default signatures. + if let engine_solana_core::transaction::SolanaTransactionInput::Serialized { transaction } = &job_data.transaction.input { + // Deserialize the base64 transaction to check for signatures + if let Ok(tx_bytes) = base64::engine::general_purpose::STANDARD.decode(transaction) + && let Ok((versioned_tx, _)) = bincode::serde::decode_from_slice::( + &tx_bytes, + bincode::config::standard() + ) { + // Check if any signatures are non-default (not all zeros) + let has_signatures = versioned_tx.signatures.iter().any(|sig| { + sig.as_ref() != [0u8; 64] + }); + + if has_signatures { + error!( + transaction_id = %transaction_id, + signature = %signature, + "Blockhash expired for serialized transaction with existing signatures - cannot retry without invalidating them" + ); + let _ = self.storage.delete_attempt(transaction_id).await; + return Err(SolanaExecutorError::TransactionFailed { + reason: "Blockhash expired for serialized transaction with existing signatures. Retrying with a new blockhash would invalidate them.".to_string(), + } + .fail()); + } + // If no signatures, we can retry - will be signed during execution + } + } + + // For instruction-based transactions or serialized without signatures, we can retry with a new blockhash warn!( transaction_id = %transaction_id, signature = %signature, From 8677c8326760990459b52cd7e2479d252023ecf7 Mon Sep 17 00:00:00 2001 From: Prithvish Date: Fri, 31 Oct 2025 05:52:44 +0530 Subject: [PATCH 3/3] fix: use tuple enum syntax to make serde work --- Cargo.toml | 4 +- core/src/execution_options/solana.rs | 6 ++- executors/src/solana_executor/worker.rs | 14 +++-- solana-core/src/transaction.rs | 71 +++++++++++++++---------- 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0dbae5e..6b14f68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,8 +36,8 @@ aws-sdk-kms = "1.79.0" aws-credential-types = "1.2.4" # Serialization -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" serde_with = "3.14.0" serde-bool = "0.1.3" serde_repr = "0.1.20" diff --git a/core/src/execution_options/solana.rs b/core/src/execution_options/solana.rs index 81ef722..e2fa1d3 100644 --- a/core/src/execution_options/solana.rs +++ b/core/src/execution_options/solana.rs @@ -126,7 +126,6 @@ impl CommitmentLevel { } } - #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] #[schema(title = "Solana Transaction Options")] #[serde(rename_all = "camelCase")] @@ -141,10 +140,11 @@ pub struct SolanaTransactionOptions { /// Request to send a Solana transaction #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] +// #[serde(rename_all = "camelCase")] pub struct SendSolanaTransactionRequest { /// Idempotency key for this transaction (defaults to random UUID) #[serde(default = "super::default_idempotency_key")] + #[serde(rename = "idempotencyKey")] pub idempotency_key: String, /// Transaction input (either instructions or serialized transaction) @@ -152,10 +152,12 @@ pub struct SendSolanaTransactionRequest { pub input: engine_solana_core::transaction::SolanaTransactionInput, /// Solana execution options + #[serde(rename = "executionOptions")] pub execution_options: SolanaExecutionOptions, /// Webhook options for transaction status notifications #[serde(default)] + #[serde(rename = "webhookOptions")] pub webhook_options: Vec, } diff --git a/executors/src/solana_executor/worker.rs b/executors/src/solana_executor/worker.rs index 67fd1db..b58dd77 100644 --- a/executors/src/solana_executor/worker.rs +++ b/executors/src/solana_executor/worker.rs @@ -601,9 +601,9 @@ impl SolanaExecutorJobHandler { // For serialized transactions with existing signatures, we cannot retry with a new blockhash // because the signatures will become invalid. Check if there are any non-default signatures. - if let engine_solana_core::transaction::SolanaTransactionInput::Serialized { transaction } = &job_data.transaction.input { + if let engine_solana_core::transaction::SolanaTransactionInput::Serialized (t) = &job_data.transaction.input { // Deserialize the base64 transaction to check for signatures - if let Ok(tx_bytes) = base64::engine::general_purpose::STANDARD.decode(transaction) + if let Ok(tx_bytes) = base64::engine::general_purpose::STANDARD.decode(&t.transaction) && let Ok((versioned_tx, _)) = bincode::serde::decode_from_slice::( &tx_bytes, bincode::config::standard() @@ -748,10 +748,10 @@ impl SolanaExecutorJobHandler { // Build transaction - handle execution options differently for instructions vs serialized let versioned_tx = match &job_data.transaction.input { - engine_solana_core::transaction::SolanaTransactionInput::Instructions { instructions } => { + engine_solana_core::transaction::SolanaTransactionInput::Instructions(i) => { // For instruction-based transactions: calculate priority fees and apply execution options let compute_unit_price = if let Some(priority_fee) = &job_data.transaction.execution_options.priority_fee { - Some(self.get_compute_unit_price(priority_fee, instructions, rpc_client, chain_id_str.as_str()).await?) + Some(self.get_compute_unit_price(priority_fee, &i.instructions, rpc_client, chain_id_str.as_str()).await?) } else { None }; @@ -761,7 +761,7 @@ impl SolanaExecutorJobHandler { let memo_data = format!("thirdweb-engine:{}", transaction_id); let memo_ix = build_memo(&spl_memo_interface::v3::id(), memo_data.as_bytes(), &[]); - let mut instructions_with_memo = instructions.clone(); + let mut instructions_with_memo = i.instructions.clone(); let memo_data_base64 = base64::engine::general_purpose::STANDARD.encode(memo_data.as_bytes()); instructions_with_memo.push(SolanaInstructionData { program_id: memo_ix.program_id, @@ -771,9 +771,7 @@ impl SolanaExecutorJobHandler { }); let solana_tx = SolanaTransaction { - input: engine_solana_core::transaction::SolanaTransactionInput::Instructions { - instructions: instructions_with_memo, - }, + input: engine_solana_core::transaction::SolanaTransactionInput::new_with_instructions(instructions_with_memo), compute_unit_limit: job_data.transaction.execution_options.compute_unit_limit, compute_unit_price, }; diff --git a/solana-core/src/transaction.rs b/solana-core/src/transaction.rs index 62fb617..5485e9c 100644 --- a/solana-core/src/transaction.rs +++ b/solana-core/src/transaction.rs @@ -11,16 +11,32 @@ use solana_sdk::{ /// Input for Solana transaction - either build from instructions or use pre-built #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase", untagged)] +#[serde(untagged)] pub enum SolanaTransactionInput { /// Build transaction from instructions - Instructions { - instructions: Vec, - }, + Instructions(SolanaTransactionInputInstructions), /// Use pre-built serialized VersionedTransaction (base64) - Serialized { - transaction: String, - }, + Serialized(SolanaTransactionInputSerialized), +} + +impl SolanaTransactionInput { + pub fn new_with_instructions(instructions: Vec) -> Self { + Self::Instructions(SolanaTransactionInputInstructions { instructions }) + } + + pub fn new_with_serialized(transaction: String) -> Self { + Self::Serialized(SolanaTransactionInputSerialized { transaction }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SolanaTransactionInputInstructions { + pub instructions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SolanaTransactionInputSerialized { + pub transaction: String, } /// Solana instruction data provided by the user @@ -142,11 +158,11 @@ impl SolanaTransaction { recent_blockhash: solana_sdk::hash::Hash, ) -> Result { match &self.input { - SolanaTransactionInput::Instructions { instructions } => { - self.build_from_instructions(instructions, payer, recent_blockhash) + SolanaTransactionInput::Instructions(i) => { + self.build_from_instructions(&i.instructions, payer, recent_blockhash) } - SolanaTransactionInput::Serialized { transaction } => { - self.deserialize_transaction(transaction, payer) + SolanaTransactionInput::Serialized(t) => { + self.deserialize_transaction(&t.transaction, payer) } } } @@ -175,15 +191,12 @@ impl SolanaTransaction { inst_list.push(inst.to_instruction()?); } - let message = v0::Message::try_compile( - &payer, - &inst_list, - &[], - recent_blockhash, - ) - .map_err(|e| SolanaTransactionError::MessageCompilationFailed { - error: e.to_string(), - })?; + let message = + v0::Message::try_compile(&payer, &inst_list, &[], recent_blockhash).map_err(|e| { + SolanaTransactionError::MessageCompilationFailed { + error: e.to_string(), + } + })?; let message = VersionedMessage::V0(message); let num_signatures = message.header().num_required_signatures as usize; @@ -203,17 +216,19 @@ impl SolanaTransaction { tx_base64: &str, expected_payer: Pubkey, ) -> Result { - let tx_bytes = Base64Engine.decode(tx_base64) - .map_err(|e| SolanaTransactionError::DeserializationFailed { + let tx_bytes = Base64Engine.decode(tx_base64).map_err(|e| { + SolanaTransactionError::DeserializationFailed { error: format!("Invalid base64: {}", e), - })?; + } + })?; // Deserialize from binary wire format using bincode let (transaction, _): (VersionedTransaction, _) = - bincode::serde::decode_from_slice(&tx_bytes, bincode::config::standard()) - .map_err(|e| SolanaTransactionError::DeserializationFailed { + bincode::serde::decode_from_slice(&tx_bytes, bincode::config::standard()).map_err( + |e| SolanaTransactionError::DeserializationFailed { error: format!("Failed to deserialize VersionedTransaction: {}", e), - })?; + }, + )?; // Verify fee payer let fee_payer = transaction.message.static_account_keys()[0]; @@ -245,10 +260,10 @@ pub enum SolanaTransactionError { #[error("Invalid blockhash: {error}")] InvalidBlockhash { error: String }, - + #[error("Failed to deserialize transaction: {error}")] DeserializationFailed { error: String }, - + #[error("Fee payer mismatch: expected {expected}, got {got}")] FeePayerMismatch { expected: String, got: String }, }