orca_tx_sender/
lib.rs

1mod compute_budget;
2mod config;
3mod fee_config;
4mod jito;
5mod rpc_config;
6mod signer;
7
8pub use compute_budget::*;
9pub use config::*;
10pub use fee_config::*;
11pub use jito::*;
12pub use rpc_config::*;
13pub use signer::*;
14
15use solana_client::nonblocking::rpc_client::RpcClient;
16
17/// Build and send a transaction using the supplied configuration
18///
19/// This function:
20/// 1. Builds an unsigned transaction with all necessary instructions
21/// 2. Signs the transaction with all provided signers
22/// 3. Sends the transaction and waits for confirmation
23/// 4. Optionally uses address lookup tables for account compression
24pub async fn build_and_send_transaction_with_config<S: Signer>(
25    instructions: Vec<Instruction>,
26    signers: &[&S],
27    commitment: Option<CommitmentLevel>,
28    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
29    rpc_client: &RpcClient,
30    rpc_config: &RpcConfig,
31    fee_config: &FeeConfig,
32) -> Result<Signature, String> {
33    // Get the payer (first signer)
34    let payer = signers
35        .first()
36        .ok_or_else(|| "At least one signer is required".to_string())?;
37
38    // Build transaction with compute budget and priority fees
39    let mut tx = build_transaction_with_config(
40        instructions,
41        &payer.pubkey(),
42        address_lookup_tables,
43        rpc_client,
44        rpc_config,
45        fee_config,
46    )
47    .await?;
48    // Serialize the message once instead of for each signer
49    let serialized_message = tx.message.serialize();
50    tx.signatures = signers
51        .iter()
52        .map(|signer| signer.sign_message(&serialized_message))
53        .collect();
54    // Send with retry logic
55    send_transaction_with_config(tx, commitment, rpc_client).await
56}
57
58/// Build and send a transaction using the global configuration
59///
60/// This function:
61/// 1. Builds an unsigned transaction with all necessary instructions
62/// 2. Signs the transaction with all provided signers
63/// 3. Sends the transaction and waits for confirmation
64/// 4. Optionally uses address lookup tables for account compression
65pub async fn build_and_send_transaction<S: Signer>(
66    instructions: Vec<Instruction>,
67    signers: &[&S],
68    commitment: Option<CommitmentLevel>,
69    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
70) -> Result<Signature, String> {
71    let config = config::get_global_config()
72        .read()
73        .map_err(|e| format!("Lock error: {}", e))?;
74    let rpc_client = config::get_rpc_client()?;
75    let rpc_config = config
76        .rpc_config
77        .as_ref()
78        .ok_or("RPC config not set".to_string())?;
79    let fee_config = &config.fee_config;
80    build_and_send_transaction_with_config(
81        instructions,
82        signers,
83        commitment,
84        address_lookup_tables,
85        &rpc_client,
86        rpc_config,
87        fee_config,
88    )
89    .await
90}
91
92/// Configuration for building a transaction.
93///
94/// Start with [`BuildTransactionConfig::default`] and use the `with_*`
95/// methods to override individual settings. The struct is non-exhaustive so
96/// future build options can be added without another breaking release.
97///
98/// ```
99/// use orca_tx_sender::BuildTransactionConfig;
100///
101/// let config = BuildTransactionConfig::default().with_min_context_slot(123);
102/// assert_eq!(config.min_context_slot, Some(123));
103/// ```
104#[non_exhaustive]
105#[derive(Debug, Default)]
106pub struct BuildTransactionConfig {
107    pub rpc_config: RpcConfig,
108    pub fee_config: FeeConfig,
109    pub compute_config: ComputeConfig,
110    /// Minimum bank slot to use for dynamic compute-unit simulation.
111    ///
112    /// Use the context slot returned when fetching recently changed state,
113    /// such as an address lookup table, so simulation cannot run against an
114    /// older bank that does not contain that state yet.
115    pub min_context_slot: Option<u64>,
116}
117
118impl BuildTransactionConfig {
119    pub fn with_rpc_config(mut self, rpc_config: RpcConfig) -> Self {
120        self.rpc_config = rpc_config;
121        self
122    }
123
124    pub fn with_fee_config(mut self, fee_config: FeeConfig) -> Self {
125        self.fee_config = fee_config;
126        self
127    }
128
129    pub fn with_compute_config(mut self, compute_config: ComputeConfig) -> Self {
130        self.compute_config = compute_config;
131        self
132    }
133
134    /// Require dynamic compute-unit simulation to use a bank at least as
135    /// recent as `min_context_slot`.
136    pub fn with_min_context_slot(mut self, min_context_slot: u64) -> Self {
137        self.min_context_slot = Some(min_context_slot);
138        self
139    }
140}
141
142/// Build a transaction with compute budget and priority fees from the supplied configuration
143///
144/// This function handles:
145/// 1. Building a transaction message with all instructions
146/// 2. Adding compute budget instructions
147/// 3. Adding any Jito tip instructions
148/// 4. Supporting address lookup tables for account compression
149pub async fn build_transaction_with_config_obj(
150    mut instructions: Vec<Instruction>,
151    payer: &Pubkey,
152    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
153    rpc_client: &RpcClient,
154    config: &BuildTransactionConfig,
155) -> Result<VersionedTransaction, String> {
156    let address_lookup_tables_clone = address_lookup_tables.clone();
157
158    let rpc_config = &config.rpc_config;
159    let fee_config = &config.fee_config;
160    let budget_instructions = compute_budget::build_compute_budget_instructions(
161        rpc_client,
162        &instructions,
163        payer,
164        address_lookup_tables_clone,
165        &config.compute_config,
166        fee_config,
167        rpc_config,
168        config.min_context_slot,
169    )
170    .await?;
171
172    let recent_blockhash = rpc_client
173        .get_latest_blockhash()
174        .await
175        .map_err(|e| format!("RPC Error: {}", e))?;
176
177    for (i, budget_ix) in budget_instructions.into_iter().enumerate() {
178        instructions.insert(i, budget_ix);
179    }
180    // Check if network is mainnet before adding Jito tip
181    if fee_config.jito != JitoFeeStrategy::Disabled {
182        if !rpc_config.is_mainnet() {
183            println!("Warning: Jito tips are only supported on mainnet. Skipping Jito tip.");
184        } else if let Some(jito_tip_ix) = jito::add_jito_tip_instruction(fee_config, payer).await? {
185            instructions.insert(0, jito_tip_ix);
186        }
187    }
188    // Create versioned transaction message based on whether ALTs are provided
189    let message = if let Some(address_lookup_tables_clone) = address_lookup_tables {
190        Message::try_compile(
191            payer,
192            &instructions,
193            &address_lookup_tables_clone,
194            recent_blockhash,
195        )
196        .map_err(|e| format!("Failed to compile message with ALTs: {}", e))?
197    } else {
198        Message::try_compile(payer, &instructions, &[], recent_blockhash)
199            .map_err(|e| format!("Failed to compile message: {}", e))?
200    };
201
202    // Provide the correct number of signatures for the transaction, otherwise (de)serialization can fail
203    Ok(VersionedTransaction {
204        signatures: vec![
205            solana_signature::Signature::default();
206            message.header.num_required_signatures.into()
207        ],
208        message: VersionedMessage::V0(message),
209    })
210}
211
212/// Build a transaction with compute budget and priority fees from the supplied configuration
213///
214/// This function handles:
215/// 1. Building a transaction message with all instructions
216/// 2. Adding compute budget instructions
217/// 3. Adding any Jito tip instructions
218/// 4. Supporting address lookup tables for account compression
219pub async fn build_transaction_with_config(
220    instructions: Vec<Instruction>,
221    payer: &Pubkey,
222    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
223    rpc_client: &RpcClient,
224    rpc_config: &RpcConfig,
225    fee_config: &FeeConfig,
226) -> Result<VersionedTransaction, String> {
227    build_transaction_with_config_obj(
228        instructions,
229        payer,
230        address_lookup_tables,
231        rpc_client,
232        &BuildTransactionConfig::default()
233            .with_rpc_config((*rpc_config).clone())
234            .with_fee_config((*fee_config).clone()),
235    )
236    .await
237}
238
239/// Build a transaction with compute budget and priority fees from the global configuration
240///
241/// This function handles:
242/// 1. Building a transaction message with all instructions
243/// 2. Adding compute budget instructions
244/// 3. Adding any Jito tip instructions
245/// 4. Supporting address lookup tables for account compression
246pub async fn build_transaction(
247    instructions: Vec<Instruction>,
248    payer: &Pubkey,
249    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
250) -> Result<VersionedTransaction, String> {
251    let config = config::get_global_config()
252        .read()
253        .map_err(|e| format!("Lock error: {}", e))?;
254    let rpc_client = config::get_rpc_client()?;
255    let rpc_config = config
256        .rpc_config
257        .as_ref()
258        .ok_or("RPC config not set".to_string())?;
259    let fee_config = &config.fee_config;
260    build_transaction_with_config(
261        instructions,
262        payer,
263        address_lookup_tables,
264        &rpc_client,
265        rpc_config,
266        fee_config,
267    )
268    .await
269}
270
271/// Send a transaction with retry logic using the supplied configuration
272///
273/// This function handles:
274/// 1. Sending the transaction to the network
275/// 2. Implementing retry logic with exponential backoff
276/// 3. Waiting for transaction confirmation
277pub async fn send_transaction_with_config(
278    transaction: VersionedTransaction,
279    commitment: Option<CommitmentLevel>,
280    rpc_client: &RpcClient,
281) -> Result<Signature, String> {
282    let sim_result = rpc_client
283        .simulate_transaction(&transaction)
284        .await
285        .map_err(|e| format!("Transaction simulation failed: {}", e))?;
286
287    if let Some(err) = sim_result.value.err {
288        return Err(compute_budget::format_simulation_error(
289            err,
290            sim_result.value.logs,
291        ));
292    }
293
294    let commitment_level = commitment.unwrap_or(CommitmentLevel::Confirmed);
295    let expiry_time = Instant::now() + Duration::from_millis(90_000);
296    let mut retries = 0;
297    let signature = transaction.signatures[0];
298
299    while Instant::now() < expiry_time {
300        // Check if the transaction has been confirmed
301        let status = rpc_client
302            .get_signature_status_with_commitment(
303                &signature,
304                CommitmentConfig {
305                    commitment: commitment_level,
306                },
307            )
308            .await
309            .map_err(|e| format!("Failed to get signature status: {}", e))?;
310
311        match status {
312            // Transaction confirmed
313            Some(Ok(())) => {
314                return Ok(signature);
315            }
316            // Transaction failed
317            Some(Err(err)) => {
318                return Err(format!("Transaction failed: {}", err));
319            }
320            // Transaction not found or still processing
321            None => {
322                // Try to send the transaction if not found
323                println!("sending {}...", signature);
324                match rpc_client
325                    .send_transaction_with_config(
326                        &transaction,
327                        RpcSendTransactionConfig {
328                            skip_preflight: true,
329                            preflight_commitment: Some(commitment_level),
330                            max_retries: Some(0), // We handle retries ourselves
331                            ..RpcSendTransactionConfig::default()
332                        },
333                    )
334                    .await
335                {
336                    Ok(_) => {
337                        retries += 1;
338                    }
339                    Err(err) => {
340                        println!("Transaction send failed (attempt {}): {}", retries, err);
341                    }
342                }
343            }
344        }
345        // Always wait 1 second between loop iterations
346        sleep(Duration::from_secs(1)).await;
347    }
348
349    println!("Transaction send timeout: {}", signature);
350    Ok(signature)
351}
352
353/// Send a transaction with retry logic using the global configuration
354///
355/// This function handles:
356/// 1. Sending the transaction to the network
357/// 2. Implementing retry logic with exponential backoff
358/// 3. Waiting for transaction confirmation
359pub async fn send_transaction(
360    transaction: VersionedTransaction,
361    commitment: Option<CommitmentLevel>,
362) -> Result<Signature, String> {
363    let rpc_client = config::get_rpc_client()?;
364    send_transaction_with_config(transaction, commitment, &rpc_client).await
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::compute_budget;
371    use solana_keypair::{Keypair, Signer};
372    use solana_system_interface::instruction::transfer;
373
374    #[test]
375    fn test_get_writable_accounts() {
376        let keypair = Keypair::new();
377        let recipient = Keypair::new().pubkey();
378
379        let instructions = vec![transfer(&keypair.pubkey(), &recipient, 1_000_000)];
380
381        let writable_accounts = compute_budget::get_writable_accounts(&instructions);
382        assert_eq!(writable_accounts.len(), 2);
383        assert!(writable_accounts.contains(&keypair.pubkey()));
384        assert!(writable_accounts.contains(&recipient));
385    }
386
387    #[test]
388    fn test_fee_config_default() {
389        let config = FeeConfig::default();
390        assert_eq!(config.compute_unit_margin_multiplier, 1.1);
391        assert_eq!(config.jito_block_engine_url, "https://bundles.jito.wtf");
392    }
393
394    #[test]
395    fn test_build_transaction_config_has_no_minimum_context_slot_by_default() {
396        let config = BuildTransactionConfig::default();
397        assert_eq!(config.min_context_slot, None);
398    }
399
400    #[test]
401    fn test_build_transaction_config_builder_sets_minimum_context_slot() {
402        let config = BuildTransactionConfig::default().with_min_context_slot(123);
403        assert_eq!(config.min_context_slot, Some(123));
404    }
405
406    #[test]
407    fn test_format_simulation_error_includes_logs() {
408        let err = compute_budget::format_simulation_error(
409            "InstructionError",
410            Some(vec![
411                "Program Foo invoke [1]".to_string(),
412                "Program log: failed here".to_string(),
413            ]),
414        );
415
416        assert!(err.contains("Transaction simulation failed: InstructionError"));
417        assert!(err.contains("Simulation logs:"));
418        assert!(err.contains("Program log: failed here"));
419    }
420}