Skip to content

Commit a1b60c4

Browse files
authored
support partially signed serialised transactions for solana (#77)
* support partially signed serialised transactions for solana * 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. * fix: use tuple enum syntax to make serde work
1 parent aa11347 commit a1b60c4

File tree

10 files changed

+256
-135
lines changed

10 files changed

+256
-135
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/target/
33
.DS_Store
44
coverage
5+
.env.test

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@ aws-sdk-kms = "1.79.0"
3636
aws-credential-types = "1.2.4"
3737

3838
# Serialization
39-
serde = { version = "1.0.219", features = ["derive"] }
40-
serde_json = "1.0.140"
39+
serde = { version = "1.0.228", features = ["derive"] }
40+
serde_json = "1.0.145"
4141
serde_with = "3.14.0"
4242
serde-bool = "0.1.3"
4343
serde_repr = "0.1.20"
44+
bincode = { version = "2.0.1", features = ["serde"] }
4445

4546
# Error handling
4647
thiserror = "2.0.12"

core/src/execution_options/solana.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,29 +130,34 @@ impl CommitmentLevel {
130130
#[schema(title = "Solana Transaction Options")]
131131
#[serde(rename_all = "camelCase")]
132132
pub struct SolanaTransactionOptions {
133-
/// List of instructions to execute in this transaction
134-
pub instructions: Vec<SolanaInstructionData>,
133+
/// Transaction input
134+
#[serde(flatten)]
135+
pub input: engine_solana_core::transaction::SolanaTransactionInput,
135136

136137
/// Solana execution options
137138
pub execution_options: SolanaExecutionOptions,
138139
}
139140

140141
/// Request to send a Solana transaction
141142
#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)]
142-
#[serde(rename_all = "camelCase")]
143+
// #[serde(rename_all = "camelCase")]
143144
pub struct SendSolanaTransactionRequest {
144145
/// Idempotency key for this transaction (defaults to random UUID)
145146
#[serde(default = "super::default_idempotency_key")]
147+
#[serde(rename = "idempotencyKey")]
146148
pub idempotency_key: String,
147149

148-
/// List of Solana instructions to execute
149-
pub instructions: Vec<SolanaInstructionData>,
150+
/// Transaction input (either instructions or serialized transaction)
151+
#[serde(flatten)]
152+
pub input: engine_solana_core::transaction::SolanaTransactionInput,
150153

151154
/// Solana execution options
155+
#[serde(rename = "executionOptions")]
152156
pub execution_options: SolanaExecutionOptions,
153157

154158
/// Webhook options for transaction status notifications
155159
#[serde(default)]
160+
#[serde(rename = "webhookOptions")]
156161
pub webhook_options: Vec<super::WebhookOptions>,
157162
}
158163

executors/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ edition = "2024"
66
[dependencies]
77
hex = { workspace = true }
88
alloy = { workspace = true, features = ["serde"] }
9+
bincode = { workspace = true, features = ["serde"] }
910
thirdweb-core = { version = "0.1.0", path = "../thirdweb-core" }
1011
hmac = { workspace = true }
1112
reqwest = { workspace = true }

executors/src/solana_executor/rpc_cache.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use engine_core::execution_options::solana::SolanaChainId;
22
use moka::future::Cache;
33
use solana_client::nonblocking::rpc_client::RpcClient;
4-
use std::{sync::Arc, time::Duration};
4+
use std::sync::Arc;
55
use tracing::info;
66

77
/// Cache key for RPC clients

executors/src/solana_executor/worker.rs

Lines changed: 134 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use base64::Engine;
21
use engine_core::{
32
credentials::SigningCredential,
43
error::{EngineError, SolanaRpcErrorToEngineError},
@@ -12,16 +11,13 @@ use solana_client::{
1211
rpc_config::{RpcSendTransactionConfig, RpcTransactionConfig},
1312
};
1413
use solana_commitment_config::{CommitmentConfig, CommitmentLevel};
15-
use solana_sdk::{
16-
hash::Hash,
17-
pubkey::Pubkey,
18-
transaction::VersionedTransaction,
19-
};
14+
use solana_sdk::pubkey::Pubkey;
2015
use solana_transaction_status::{
2116
EncodedTransactionWithStatusMeta, UiTransactionEncoding
2217
};
2318
use spl_memo_interface::instruction::build_memo;
2419
use std::{sync::Arc, time::Duration};
20+
use base64::Engine;
2521
use tracing::{error, info, warn};
2622
use twmq::{
2723
DurableExecution, FailHookData, NackHookData, Queue, SuccessHookData, UserCancellable,
@@ -437,99 +433,50 @@ impl SolanaExecutorJobHandler {
437433
Ok(result)
438434
}
439435

440-
fn get_writable_accounts(
441-
&self,
442-
transaction: &SolanaTransactionOptions,
443-
) -> Vec<Pubkey> {
444-
transaction
445-
.instructions
436+
fn get_writable_accounts(instructions: &[SolanaInstructionData]) -> Vec<Pubkey> {
437+
instructions
446438
.iter()
447-
.flat_map(|i| {
448-
i.accounts
439+
.flat_map(|inst| {
440+
inst.accounts
449441
.iter()
450442
.filter(|a| a.is_writable)
451443
.map(|a| a.pubkey)
452444
})
453445
.collect()
454446
}
455447

456-
/// Compile a Solana transaction with priority fees and memo instruction
457-
/// ALWAYS NACK on error - network operations can be retried
458-
///
459-
/// Adds a memo instruction with the transaction_id to ensure unique signatures
460-
/// even when rapidly resubmitting with the same blockhash
461-
async fn compile_transaction(
448+
async fn get_compute_unit_price(
462449
&self,
463-
transaction: &SolanaTransactionOptions,
450+
priority_fee: &SolanaPriorityFee,
451+
instructions: &[SolanaInstructionData],
464452
rpc_client: &RpcClient,
465-
recent_blockhash: Hash,
466453
chain_id: &str,
467-
transaction_id: &str,
468-
) -> JobResult<VersionedTransaction, SolanaExecutorError> {
469-
let compute_unit_price = if let Some(price_config) = &transaction.execution_options.priority_fee {
470-
let price = match price_config {
471-
SolanaPriorityFee::Auto => {
472-
self.get_percentile_compute_unit_price(
473-
rpc_client,
474-
&self.get_writable_accounts(transaction),
475-
75,
476-
chain_id,
477-
)
478-
.await?
479-
}
480-
SolanaPriorityFee::Manual { micro_lamports_per_unit } => {
481-
*micro_lamports_per_unit
482-
}
483-
SolanaPriorityFee::Percentile { percentile } => {
484-
self.get_percentile_compute_unit_price(
485-
rpc_client,
486-
&self.get_writable_accounts(transaction),
487-
*percentile,
488-
chain_id,
489-
)
490-
.await?
491-
}
492-
};
493-
Some(price)
494-
} else {
495-
None
496-
};
497-
498-
// Add memo instruction with transaction_id for unique signatures
499-
let memo_data = format!("thirdweb-engine:{}", transaction_id);
500-
let memo_ix = build_memo(&spl_memo_interface::v3::id(), memo_data.as_bytes(), &[]);
501-
502-
let mut instructions = transaction.instructions.clone();
503-
let memo_data_base64 = base64::engine::general_purpose::STANDARD.encode(memo_data.as_bytes());
504-
instructions.push(SolanaInstructionData {
505-
program_id: memo_ix.program_id,
506-
accounts: vec![],
507-
data: memo_data_base64,
508-
encoding: InstructionDataEncoding::Base64,
509-
});
510-
511-
let solana_transaction = SolanaTransaction {
512-
instructions,
513-
compute_unit_price,
514-
compute_unit_limit: transaction.execution_options.compute_unit_limit,
515-
recent_blockhash,
516-
};
517-
518-
let versioned_tx = solana_transaction
519-
.to_versioned_transaction(transaction.execution_options.signer_address, recent_blockhash)
520-
.map_err(|e| {
521-
error!(
522-
transaction_id = %transaction_id,
523-
error = %e,
524-
"Failed to build transaction"
525-
);
526-
SolanaExecutorError::TransactionBuildFailed {
527-
inner_error: e.to_string(),
528-
}
529-
.fail()
530-
})?;
454+
) -> JobResult<u64, SolanaExecutorError> {
455+
let writable_accounts = Self::get_writable_accounts(instructions);
531456

532-
Ok(versioned_tx)
457+
match priority_fee {
458+
SolanaPriorityFee::Auto => {
459+
self.get_percentile_compute_unit_price(
460+
rpc_client,
461+
&writable_accounts,
462+
75,
463+
chain_id,
464+
)
465+
.await
466+
}
467+
SolanaPriorityFee::Manual { micro_lamports_per_unit } => {
468+
Ok(*micro_lamports_per_unit)
469+
}
470+
SolanaPriorityFee::Percentile { percentile } => {
471+
self.get_percentile_compute_unit_price(
472+
rpc_client,
473+
&writable_accounts,
474+
*percentile,
475+
chain_id,
476+
)
477+
.await
478+
}
479+
}
533480
}
534481

535482

@@ -650,7 +597,39 @@ impl SolanaExecutorJobHandler {
650597
.nack(Some(CONFIRMATION_RETRY_DELAY), RequeuePosition::Last));
651598
}
652599
Ok(false) => {
653-
// Blockhash expired, need to resubmit
600+
// Blockhash expired
601+
602+
// For serialized transactions with existing signatures, we cannot retry with a new blockhash
603+
// because the signatures will become invalid. Check if there are any non-default signatures.
604+
if let engine_solana_core::transaction::SolanaTransactionInput::Serialized (t) = &job_data.transaction.input {
605+
// Deserialize the base64 transaction to check for signatures
606+
if let Ok(tx_bytes) = base64::engine::general_purpose::STANDARD.decode(&t.transaction)
607+
&& let Ok((versioned_tx, _)) = bincode::serde::decode_from_slice::<solana_sdk::transaction::VersionedTransaction, _>(
608+
&tx_bytes,
609+
bincode::config::standard()
610+
) {
611+
// Check if any signatures are non-default (not all zeros)
612+
let has_signatures = versioned_tx.signatures.iter().any(|sig| {
613+
sig.as_ref() != [0u8; 64]
614+
});
615+
616+
if has_signatures {
617+
error!(
618+
transaction_id = %transaction_id,
619+
signature = %signature,
620+
"Blockhash expired for serialized transaction with existing signatures - cannot retry without invalidating them"
621+
);
622+
let _ = self.storage.delete_attempt(transaction_id).await;
623+
return Err(SolanaExecutorError::TransactionFailed {
624+
reason: "Blockhash expired for serialized transaction with existing signatures. Retrying with a new blockhash would invalidate them.".to_string(),
625+
}
626+
.fail());
627+
}
628+
// If no signatures, we can retry - will be signed during execution
629+
}
630+
}
631+
632+
// For instruction-based transactions or serialized without signatures, we can retry with a new blockhash
654633
warn!(
655634
transaction_id = %transaction_id,
656635
signature = %signature,
@@ -767,16 +746,73 @@ impl SolanaExecutorJobHandler {
767746
.nack(Some(NETWORK_ERROR_RETRY_DELAY), RequeuePosition::Last)
768747
})?;
769748

770-
// Compile and sign transaction
771-
let versioned_tx = self
772-
.compile_transaction(
773-
&job_data.transaction,
774-
rpc_client,
775-
recent_blockhash,
776-
chain_id_str.as_str(),
777-
transaction_id,
778-
)
779-
.await?;
749+
// Build transaction - handle execution options differently for instructions vs serialized
750+
let versioned_tx = match &job_data.transaction.input {
751+
engine_solana_core::transaction::SolanaTransactionInput::Instructions(i) => {
752+
// For instruction-based transactions: calculate priority fees and apply execution options
753+
let compute_unit_price = if let Some(priority_fee) = &job_data.transaction.execution_options.priority_fee {
754+
Some(self.get_compute_unit_price(priority_fee, &i.instructions, rpc_client, chain_id_str.as_str()).await?)
755+
} else {
756+
None
757+
};
758+
759+
// Add memo instruction with transaction_id for unique signatures
760+
// This ensures that even with the same blockhash, each resubmission has a unique signature
761+
let memo_data = format!("thirdweb-engine:{}", transaction_id);
762+
let memo_ix = build_memo(&spl_memo_interface::v3::id(), memo_data.as_bytes(), &[]);
763+
764+
let mut instructions_with_memo = i.instructions.clone();
765+
let memo_data_base64 = base64::engine::general_purpose::STANDARD.encode(memo_data.as_bytes());
766+
instructions_with_memo.push(SolanaInstructionData {
767+
program_id: memo_ix.program_id,
768+
accounts: vec![],
769+
data: memo_data_base64,
770+
encoding: InstructionDataEncoding::Base64,
771+
});
772+
773+
let solana_tx = SolanaTransaction {
774+
input: engine_solana_core::transaction::SolanaTransactionInput::new_with_instructions(instructions_with_memo),
775+
compute_unit_limit: job_data.transaction.execution_options.compute_unit_limit,
776+
compute_unit_price,
777+
};
778+
779+
solana_tx
780+
.to_versioned_transaction(signer_address, recent_blockhash)
781+
.map_err(|e| {
782+
error!(
783+
transaction_id = %transaction_id,
784+
error = %e,
785+
"Failed to build transaction from instructions"
786+
);
787+
SolanaExecutorError::TransactionBuildFailed {
788+
inner_error: e.to_string(),
789+
}
790+
.fail()
791+
})?
792+
}
793+
engine_solana_core::transaction::SolanaTransactionInput::Serialized { .. } => {
794+
// For serialized transactions: ignore execution options to avoid invalidating signatures
795+
let solana_tx = SolanaTransaction {
796+
input: job_data.transaction.input.clone(),
797+
compute_unit_limit: None,
798+
compute_unit_price: None,
799+
};
800+
801+
solana_tx
802+
.to_versioned_transaction(signer_address, recent_blockhash)
803+
.map_err(|e| {
804+
error!(
805+
transaction_id = %transaction_id,
806+
error = %e,
807+
"Failed to deserialize compiled transaction"
808+
);
809+
SolanaExecutorError::TransactionBuildFailed {
810+
inner_error: e.to_string(),
811+
}
812+
.fail()
813+
})?
814+
}
815+
};
780816

781817
let signed_tx = self
782818
.solana_signer

server/src/execution_router/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ impl ExecutionRouter {
514514
let signer_address = request.execution_options.signer_address;
515515

516516
let transaction = SolanaTransactionOptions {
517-
instructions: request.instructions,
517+
input: request.input,
518518
execution_options: request.execution_options,
519519
};
520520

solana-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ thiserror = { workspace = true }
1414
tracing = { workspace = true }
1515
hex = { workspace = true }
1616
base64 = { workspace = true }
17+
bincode = { workspace = true }
1718
utoipa = { workspace = true, features = [
1819
"macros",
1920
"chrono",

0 commit comments

Comments
 (0)