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 recent_blockhash = rpc_client
157        .get_latest_blockhash()
158        .await
159        .map_err(|e| format!("RPC Error: {}", e))?;
160
161    let writable_accounts = compute_budget::get_writable_accounts(&instructions);
162
163    let address_lookup_tables_clone = address_lookup_tables.clone();
164
165    let compute_units = match config.compute_config.unit_limit {
166        ComputeUnitLimitStrategy::Dynamic => {
167            compute_budget::estimate_compute_units_at_commitment(
168                rpc_client,
169                &instructions,
170                payer,
171                address_lookup_tables_clone,
172                Some(rpc_client.commitment()),
173                config.min_context_slot,
174            )
175            .await?
176        }
177        ComputeUnitLimitStrategy::Exact(units) => units,
178    };
179
180    let rpc_config = &config.rpc_config;
181    let fee_config = &config.fee_config;
182    let budget_instructions = compute_budget::get_compute_budget_instruction(
183        rpc_client,
184        compute_units,
185        payer,
186        rpc_config,
187        fee_config,
188        &writable_accounts,
189    )
190    .await?;
191    for (i, budget_ix) in budget_instructions.into_iter().enumerate() {
192        instructions.insert(i, budget_ix);
193    }
194    // Check if network is mainnet before adding Jito tip
195    if fee_config.jito != JitoFeeStrategy::Disabled {
196        if !rpc_config.is_mainnet() {
197            println!("Warning: Jito tips are only supported on mainnet. Skipping Jito tip.");
198        } else if let Some(jito_tip_ix) = jito::add_jito_tip_instruction(fee_config, payer).await? {
199            instructions.insert(0, jito_tip_ix);
200        }
201    }
202    // Create versioned transaction message based on whether ALTs are provided
203    let message = if let Some(address_lookup_tables_clone) = address_lookup_tables {
204        Message::try_compile(
205            payer,
206            &instructions,
207            &address_lookup_tables_clone,
208            recent_blockhash,
209        )
210        .map_err(|e| format!("Failed to compile message with ALTs: {}", e))?
211    } else {
212        Message::try_compile(payer, &instructions, &[], recent_blockhash)
213            .map_err(|e| format!("Failed to compile message: {}", e))?
214    };
215
216    // Provide the correct number of signatures for the transaction, otherwise (de)serialization can fail
217    Ok(VersionedTransaction {
218        signatures: vec![
219            solana_signature::Signature::default();
220            message.header.num_required_signatures.into()
221        ],
222        message: VersionedMessage::V0(message),
223    })
224}
225
226/// Build a transaction with compute budget and priority fees from the supplied configuration
227///
228/// This function handles:
229/// 1. Building a transaction message with all instructions
230/// 2. Adding compute budget instructions
231/// 3. Adding any Jito tip instructions
232/// 4. Supporting address lookup tables for account compression
233pub async fn build_transaction_with_config(
234    instructions: Vec<Instruction>,
235    payer: &Pubkey,
236    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
237    rpc_client: &RpcClient,
238    rpc_config: &RpcConfig,
239    fee_config: &FeeConfig,
240) -> Result<VersionedTransaction, String> {
241    build_transaction_with_config_obj(
242        instructions,
243        payer,
244        address_lookup_tables,
245        rpc_client,
246        &BuildTransactionConfig::default()
247            .with_rpc_config((*rpc_config).clone())
248            .with_fee_config((*fee_config).clone()),
249    )
250    .await
251}
252
253/// Build a transaction with compute budget and priority fees from the global configuration
254///
255/// This function handles:
256/// 1. Building a transaction message with all instructions
257/// 2. Adding compute budget instructions
258/// 3. Adding any Jito tip instructions
259/// 4. Supporting address lookup tables for account compression
260pub async fn build_transaction(
261    instructions: Vec<Instruction>,
262    payer: &Pubkey,
263    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
264) -> Result<VersionedTransaction, String> {
265    let config = config::get_global_config()
266        .read()
267        .map_err(|e| format!("Lock error: {}", e))?;
268    let rpc_client = config::get_rpc_client()?;
269    let rpc_config = config
270        .rpc_config
271        .as_ref()
272        .ok_or("RPC config not set".to_string())?;
273    let fee_config = &config.fee_config;
274    build_transaction_with_config(
275        instructions,
276        payer,
277        address_lookup_tables,
278        &rpc_client,
279        rpc_config,
280        fee_config,
281    )
282    .await
283}
284
285/// Send a transaction with retry logic using the supplied configuration
286///
287/// This function handles:
288/// 1. Sending the transaction to the network
289/// 2. Implementing retry logic with exponential backoff
290/// 3. Waiting for transaction confirmation
291pub async fn send_transaction_with_config(
292    transaction: VersionedTransaction,
293    commitment: Option<CommitmentLevel>,
294    rpc_client: &RpcClient,
295) -> Result<Signature, String> {
296    let sim_result = rpc_client
297        .simulate_transaction(&transaction)
298        .await
299        .map_err(|e| format!("Transaction simulation failed: {}", e))?;
300
301    if let Some(err) = sim_result.value.err {
302        return Err(compute_budget::format_simulation_error(
303            err,
304            sim_result.value.logs,
305        ));
306    }
307
308    let commitment_level = commitment.unwrap_or(CommitmentLevel::Confirmed);
309    let expiry_time = Instant::now() + Duration::from_millis(90_000);
310    let mut retries = 0;
311    let signature = transaction.signatures[0];
312
313    while Instant::now() < expiry_time {
314        // Check if the transaction has been confirmed
315        let status = rpc_client
316            .get_signature_status_with_commitment(
317                &signature,
318                CommitmentConfig {
319                    commitment: commitment_level,
320                },
321            )
322            .await
323            .map_err(|e| format!("Failed to get signature status: {}", e))?;
324
325        match status {
326            // Transaction confirmed
327            Some(Ok(())) => {
328                return Ok(signature);
329            }
330            // Transaction failed
331            Some(Err(err)) => {
332                return Err(format!("Transaction failed: {}", err));
333            }
334            // Transaction not found or still processing
335            None => {
336                // Try to send the transaction if not found
337                println!("sending {}...", signature);
338                match rpc_client
339                    .send_transaction_with_config(
340                        &transaction,
341                        RpcSendTransactionConfig {
342                            skip_preflight: true,
343                            preflight_commitment: Some(commitment_level),
344                            max_retries: Some(0), // We handle retries ourselves
345                            ..RpcSendTransactionConfig::default()
346                        },
347                    )
348                    .await
349                {
350                    Ok(_) => {
351                        retries += 1;
352                    }
353                    Err(err) => {
354                        println!("Transaction send failed (attempt {}): {}", retries, err);
355                    }
356                }
357            }
358        }
359        // Always wait 1 second between loop iterations
360        sleep(Duration::from_secs(1)).await;
361    }
362
363    println!("Transaction send timeout: {}", signature);
364    Ok(signature)
365}
366
367/// Send a transaction with retry logic using the global configuration
368///
369/// This function handles:
370/// 1. Sending the transaction to the network
371/// 2. Implementing retry logic with exponential backoff
372/// 3. Waiting for transaction confirmation
373pub async fn send_transaction(
374    transaction: VersionedTransaction,
375    commitment: Option<CommitmentLevel>,
376) -> Result<Signature, String> {
377    let rpc_client = config::get_rpc_client()?;
378    send_transaction_with_config(transaction, commitment, &rpc_client).await
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::compute_budget;
385    use solana_keypair::{Keypair, Signer};
386    use solana_system_interface::instruction::transfer;
387
388    #[test]
389    fn test_get_writable_accounts() {
390        let keypair = Keypair::new();
391        let recipient = Keypair::new().pubkey();
392
393        let instructions = vec![transfer(&keypair.pubkey(), &recipient, 1_000_000)];
394
395        let writable_accounts = compute_budget::get_writable_accounts(&instructions);
396        assert_eq!(writable_accounts.len(), 2);
397        assert!(writable_accounts.contains(&keypair.pubkey()));
398        assert!(writable_accounts.contains(&recipient));
399    }
400
401    #[test]
402    fn test_fee_config_default() {
403        let config = FeeConfig::default();
404        assert_eq!(config.compute_unit_margin_multiplier, 1.1);
405        assert_eq!(config.jito_block_engine_url, "https://bundles.jito.wtf");
406    }
407
408    #[test]
409    fn test_build_transaction_config_has_no_minimum_context_slot_by_default() {
410        let config = BuildTransactionConfig::default();
411        assert_eq!(config.min_context_slot, None);
412    }
413
414    #[test]
415    fn test_build_transaction_config_builder_sets_minimum_context_slot() {
416        let config = BuildTransactionConfig::default().with_min_context_slot(123);
417        assert_eq!(config.min_context_slot, Some(123));
418    }
419
420    #[test]
421    fn test_format_simulation_error_includes_logs() {
422        let err = compute_budget::format_simulation_error(
423            "InstructionError",
424            Some(vec![
425                "Program Foo invoke [1]".to_string(),
426                "Program log: failed here".to_string(),
427            ]),
428        );
429
430        assert!(err.contains("Transaction simulation failed: InstructionError"));
431        assert!(err.contains("Simulation logs:"));
432        assert!(err.contains("Program log: failed here"));
433    }
434}