Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
/target/
.DS_Store
coverage
.env.test
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ 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"
bincode = { version = "2.0.1", features = ["serde"] }

# Error handling
thiserror = "2.0.12"
Expand Down
15 changes: 10 additions & 5 deletions core/src/execution_options/solana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,29 +130,34 @@ impl CommitmentLevel {
#[schema(title = "Solana Transaction Options")]
#[serde(rename_all = "camelCase")]
pub struct SolanaTransactionOptions {
/// List of instructions to execute in this transaction
pub instructions: Vec<SolanaInstructionData>,
/// Transaction input
#[serde(flatten)]
pub input: engine_solana_core::transaction::SolanaTransactionInput,

/// Solana execution options
pub execution_options: SolanaExecutionOptions,
}

/// 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,

/// List of Solana instructions to execute
pub instructions: Vec<SolanaInstructionData>,
/// Transaction input (either instructions or serialized transaction)
#[serde(flatten)]
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<super::WebhookOptions>,
}

Expand Down
1 change: 1 addition & 0 deletions executors/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion executors/src/solana_executor/rpc_cache.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
232 changes: 134 additions & 98 deletions executors/src/solana_executor/worker.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use base64::Engine;
use engine_core::{
credentials::SigningCredential,
error::{EngineError, SolanaRpcErrorToEngineError},
Expand All @@ -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,
Expand Down Expand Up @@ -437,99 +433,50 @@ impl SolanaExecutorJobHandler {
Ok(result)
}

fn get_writable_accounts(
&self,
transaction: &SolanaTransactionOptions,
) -> Vec<Pubkey> {
transaction
.instructions
fn get_writable_accounts(instructions: &[SolanaInstructionData]) -> Vec<Pubkey> {
instructions
.iter()
.flat_map(|i| {
i.accounts
.flat_map(|inst| {
inst.accounts
.iter()
.filter(|a| a.is_writable)
.map(|a| a.pubkey)
})
.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<VersionedTransaction, SolanaExecutorError> {
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<u64, SolanaExecutorError> {
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
}
}
}


Expand Down Expand Up @@ -650,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 (t) = &job_data.transaction.input {
// Deserialize the base64 transaction to check for signatures
if let Ok(tx_bytes) = base64::engine::general_purpose::STANDARD.decode(&t.transaction)
&& let Ok((versioned_tx, _)) = bincode::serde::decode_from_slice::<solana_sdk::transaction::VersionedTransaction, _>(
&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,
Expand Down Expand Up @@ -767,16 +746,73 @@ 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(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, &i.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 = 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,
accounts: vec![],
data: memo_data_base64,
encoding: InstructionDataEncoding::Base64,
});

let solana_tx = SolanaTransaction {
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,
};

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
Expand Down
2 changes: 1 addition & 1 deletion server/src/execution_router/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
1 change: 1 addition & 0 deletions solana-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading