orca_tx_sender/
compute_budget.rs

1use crate::fee_config::{FeeConfig, Percentile, PriorityFeeStrategy};
2use crate::rpc_config::RpcConfig;
3use solana_client::nonblocking::rpc_client::RpcClient;
4use solana_client::rpc_config::RpcSimulateTransactionConfig;
5use solana_compute_budget_interface::ComputeBudgetInstruction;
6use solana_instruction::Instruction;
7use solana_message::AddressLookupTableAccount;
8use solana_message::{v0::Message, VersionedMessage};
9use solana_pubkey::Pubkey;
10use solana_rpc_client_api::response::RpcPrioritizationFee;
11use solana_transaction::versioned::VersionedTransaction;
12
13/// Compute unit limit strategy to apply when building a transaction.
14/// - Dynamic: Estimate compute units by simulating the transaction.
15///            If the simulation fails, the transaction will not build.
16/// - Exact: Directly use the provided compute unit limit up to 1,400,000
17#[derive(Debug, Default)]
18pub enum ComputeUnitLimitStrategy {
19    #[default]
20    Dynamic,
21    Exact(u32),
22}
23
24pub const MAX_COMPUTE_UNIT_LIMIT: u32 = 1_400_000;
25
26pub(crate) fn apply_compute_unit_margin(compute_units: u32, multiplier: f64) -> u32 {
27    ((compute_units as f64 * multiplier) as u32).min(MAX_COMPUTE_UNIT_LIMIT)
28}
29
30pub(crate) fn validate_compute_unit_limit_strategy(
31    strategy: &ComputeUnitLimitStrategy,
32) -> Result<(), String> {
33    if let ComputeUnitLimitStrategy::Exact(units) = strategy {
34        if !(1..=MAX_COMPUTE_UNIT_LIMIT).contains(units) {
35            return Err(format!(
36                "Exact compute unit limit must be between 1 and 1,400,000; received {units}"
37            ));
38        }
39    }
40    Ok(())
41}
42
43/// Compute-unit limit settings used while building a transaction.
44///
45/// Start with [`ComputeConfig::default`] and use [`ComputeConfig::with_unit_limit`]
46/// to override the default dynamic strategy.
47#[non_exhaustive]
48#[derive(Debug, Default)]
49pub struct ComputeConfig {
50    pub unit_limit: ComputeUnitLimitStrategy,
51}
52
53impl ComputeConfig {
54    pub fn with_unit_limit(mut self, unit_limit: ComputeUnitLimitStrategy) -> Self {
55        self.unit_limit = unit_limit;
56        self
57    }
58}
59
60pub(crate) fn format_simulation_error(
61    err: impl std::fmt::Display,
62    logs: Option<Vec<String>>,
63) -> String {
64    let mut message = format!("Transaction simulation failed: {}", err);
65
66    if let Some(logs) = logs {
67        if !logs.is_empty() {
68            message.push_str("\nSimulation logs:\n");
69            message.push_str(&logs.join("\n"));
70        }
71    }
72
73    message
74}
75
76/// Estimate compute units by simulating a transaction
77pub async fn estimate_compute_units(
78    rpc_client: &RpcClient,
79    instructions: &[Instruction],
80    payer: &Pubkey,
81    alts: Option<Vec<AddressLookupTableAccount>>,
82) -> Result<u32, String> {
83    estimate_compute_units_at_commitment(rpc_client, instructions, payer, alts, None, None).await
84}
85
86fn simulation_config(
87    commitment: Option<solana_commitment_config::CommitmentConfig>,
88    min_context_slot: Option<u64>,
89) -> RpcSimulateTransactionConfig {
90    RpcSimulateTransactionConfig {
91        sig_verify: false,
92        replace_recent_blockhash: true,
93        commitment,
94        min_context_slot,
95        ..Default::default()
96    }
97}
98
99pub(crate) async fn estimate_compute_units_at_commitment(
100    rpc_client: &RpcClient,
101    instructions: &[Instruction],
102    payer: &Pubkey,
103    alts: Option<Vec<AddressLookupTableAccount>>,
104    commitment: Option<solana_commitment_config::CommitmentConfig>,
105    min_context_slot: Option<u64>,
106) -> Result<u32, String> {
107    let alt_accounts = alts.unwrap_or_default();
108    let blockhash = rpc_client
109        .get_latest_blockhash()
110        .await
111        .map_err(|e| format!("Failed to get recent blockhash: {}", e))?;
112
113    // Add max compute unit limit instruction so that the simulation does not fail
114    let mut simulation_instructions =
115        vec![ComputeBudgetInstruction::set_compute_unit_limit(1_400_000)];
116    simulation_instructions.extend_from_slice(instructions);
117
118    let message = Message::try_compile(payer, &simulation_instructions, &alt_accounts, blockhash)
119        .map_err(|e| format!("Failed to compile message: {}", e))?;
120
121    let transaction = VersionedTransaction {
122        signatures: vec![
123            solana_signature::Signature::default();
124            message.header.num_required_signatures.into()
125        ],
126        message: VersionedMessage::V0(message),
127    };
128
129    let result = rpc_client
130        .simulate_transaction_with_config(
131            &transaction,
132            simulation_config(commitment, min_context_slot),
133        )
134        .await;
135
136    match result {
137        Ok(simulation_result) => {
138            if let Some(err) = simulation_result.value.err {
139                return Err(format_simulation_error(err, simulation_result.value.logs));
140            }
141            match simulation_result.value.units_consumed {
142                Some(units) => Ok(units as u32),
143                None => Err("Transaction simulation didn't return consumed units".to_string()),
144            }
145        }
146        Err(e) => Err(format!("Transaction simulation failed: {}", e)),
147    }
148}
149
150pub(crate) async fn build_compute_budget_instructions(
151    rpc_client: &RpcClient,
152    instructions: &[Instruction],
153    payer: &Pubkey,
154    address_lookup_tables: Option<Vec<AddressLookupTableAccount>>,
155    compute_config: &ComputeConfig,
156    fee_config: &FeeConfig,
157    rpc_config: &RpcConfig,
158    min_context_slot: Option<u64>,
159) -> Result<Vec<Instruction>, String> {
160    let writable_accounts = get_writable_accounts(instructions);
161
162    let compute_units = match &compute_config.unit_limit {
163        ComputeUnitLimitStrategy::Dynamic => {
164            let estimated_compute_units = estimate_compute_units_at_commitment(
165                rpc_client,
166                instructions,
167                payer,
168                address_lookup_tables,
169                Some(rpc_client.commitment()),
170                min_context_slot,
171            )
172            .await?;
173
174            apply_compute_unit_margin(
175                estimated_compute_units,
176                fee_config.compute_unit_margin_multiplier,
177            )
178        }
179        ComputeUnitLimitStrategy::Exact(units) => {
180            validate_compute_unit_limit_strategy(&compute_config.unit_limit)?;
181            *units
182        }
183    };
184
185    get_compute_budget_instruction(
186        rpc_client,
187        compute_units,
188        payer,
189        rpc_config,
190        fee_config,
191        &writable_accounts,
192    )
193    .await
194}
195
196/// Build compute budget instructions using an already-finalized compute unit limit.
197pub async fn get_compute_budget_instruction(
198    client: &RpcClient,
199    compute_units: u32,
200    _payer: &Pubkey,
201    rpc_config: &RpcConfig,
202    fee_config: &FeeConfig,
203    writable_accounts: &[Pubkey],
204) -> Result<Vec<Instruction>, String> {
205    let mut budget_instructions = Vec::new();
206    budget_instructions.push(ComputeBudgetInstruction::set_compute_unit_limit(
207        compute_units,
208    ));
209
210    match &fee_config.priority_fee {
211        PriorityFeeStrategy::Dynamic {
212            percentile,
213            max_lamports,
214        } => {
215            let fee =
216                calculate_dynamic_priority_fee(client, rpc_config, writable_accounts, *percentile)
217                    .await?;
218            let clamped_fee = std::cmp::min(fee, *max_lamports);
219
220            if clamped_fee > 0 {
221                budget_instructions.push(ComputeBudgetInstruction::set_compute_unit_price(
222                    clamped_fee,
223                ));
224            }
225        }
226        PriorityFeeStrategy::Exact(lamports) => {
227            if *lamports > 0 {
228                budget_instructions
229                    .push(ComputeBudgetInstruction::set_compute_unit_price(*lamports));
230            }
231        }
232        PriorityFeeStrategy::Disabled => {}
233    }
234
235    Ok(budget_instructions)
236}
237
238/// Calculate dynamic priority fee based on recent fees
239pub(crate) async fn calculate_dynamic_priority_fee(
240    client: &RpcClient,
241    rpc_config: &RpcConfig,
242    writable_accounts: &[Pubkey],
243    percentile: Percentile,
244) -> Result<u64, String> {
245    if rpc_config.supports_priority_fee_percentile {
246        get_priority_fee_with_percentile(client, writable_accounts, percentile).await
247    } else {
248        get_priority_fee_legacy(client, writable_accounts, percentile).await
249    }
250}
251
252/// Get priority fee using the getRecentPrioritizationFees endpoint with percentile parameter
253pub(crate) async fn get_priority_fee_with_percentile(
254    client: &RpcClient,
255    writable_accounts: &[Pubkey],
256    percentile: Percentile,
257) -> Result<u64, String> {
258    // This is a direct RPC call using reqwest since the Solana client doesn't support
259    // the percentile parameter yet
260    let rpc_url = client.url();
261
262    let response = reqwest::Client::new()
263        .post(rpc_url)
264        .json(&serde_json::json!({
265            "jsonrpc": "2.0",
266            "id": 1,
267            "method": "getRecentPrioritizationFees",
268            "params": [{
269                "lockedWritableAccounts": writable_accounts.iter().map(|p| p.to_string()).collect::<Vec<String>>(),
270                "percentile": percentile.as_value() * 100
271            }]
272        }))
273        .send()
274        .await
275        .map_err(|e| format!("RPC Error: {}", e))?;
276
277    #[derive(serde::Deserialize)]
278    struct Response {
279        result: RpcPrioritizationFee,
280    }
281
282    response
283        .json::<Response>()
284        .await
285        .map(|resp| resp.result.prioritization_fee)
286        .map_err(|e| format!("Failed to parse prioritization fee response: {}", e))
287}
288
289/// Get priority fee using the legacy getRecentPrioritizationFees endpoint
290pub(crate) async fn get_priority_fee_legacy(
291    client: &RpcClient,
292    writable_accounts: &[Pubkey],
293    percentile: Percentile,
294) -> Result<u64, String> {
295    // This uses the built-in method that returns Vec<RpcPrioritizationFee>
296    let recent_fees = client
297        .get_recent_prioritization_fees(writable_accounts)
298        .await
299        .map_err(|e| format!("RPC Error: {}", e))?;
300
301    // Filter out zero fees and sort
302    let mut non_zero_fees: Vec<u64> = recent_fees
303        .iter()
304        .filter(|fee| fee.prioritization_fee > 0)
305        .map(|fee| fee.prioritization_fee)
306        .collect();
307
308    non_zero_fees.sort_unstable();
309
310    if non_zero_fees.is_empty() {
311        return Ok(0);
312    }
313
314    // Calculate percentile
315    let index = (non_zero_fees.len() as f64 * (percentile.as_value() as f64 / 100.0)) as usize;
316    let index = std::cmp::min(index, non_zero_fees.len() - 1);
317
318    Ok(non_zero_fees[index])
319}
320
321/// Get writable accounts from a list of instructions
322pub fn get_writable_accounts(instructions: &[Instruction]) -> Vec<Pubkey> {
323    let mut writable = std::collections::HashSet::new();
324
325    for ix in instructions {
326        for meta in &ix.accounts {
327            if meta.is_writable {
328                writable.insert(meta.pubkey);
329            }
330        }
331    }
332
333    writable.into_iter().collect()
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use solana_commitment_config::CommitmentConfig;
340
341    #[test]
342    fn simulation_config_preserves_commitment_and_min_context_slot() {
343        let config = simulation_config(Some(CommitmentConfig::confirmed()), Some(123));
344
345        assert_eq!(config.commitment, Some(CommitmentConfig::confirmed()));
346        assert_eq!(config.min_context_slot, Some(123));
347        assert!(!config.sig_verify);
348        assert!(config.replace_recent_blockhash);
349    }
350
351    #[test]
352    fn builder_sets_compute_unit_limit() {
353        let config = ComputeConfig::default().with_unit_limit(ComputeUnitLimitStrategy::Exact(321));
354        assert!(matches!(
355            config.unit_limit,
356            ComputeUnitLimitStrategy::Exact(321)
357        ));
358    }
359
360    #[test]
361    fn dynamic_compute_unit_limit_applies_margin_and_clamps() {
362        assert_eq!(apply_compute_unit_margin(100_000, 1.1), 110_000);
363        assert_eq!(apply_compute_unit_margin(1_350_000, 1.1), 1_400_000);
364    }
365
366    #[tokio::test]
367    async fn exact_compute_unit_limit_is_used_unchanged() {
368        let rpc_client = RpcClient::new("http://127.0.0.1:1".to_string());
369        let compute_config =
370            ComputeConfig::default().with_unit_limit(ComputeUnitLimitStrategy::Exact(200_000));
371        let fee_config = FeeConfig::default();
372        let rpc_config = RpcConfig::default();
373
374        let instructions = build_compute_budget_instructions(
375            &rpc_client,
376            &[],
377            &Pubkey::default(),
378            None,
379            &compute_config,
380            &fee_config,
381            &rpc_config,
382            None,
383        )
384        .await
385        .unwrap();
386
387        assert_eq!(instructions.len(), 1);
388        assert_eq!(
389            u32::from_le_bytes(instructions[0].data[1..5].try_into().unwrap()),
390            200_000
391        );
392    }
393
394    #[tokio::test]
395    async fn invalid_exact_compute_unit_limits_are_rejected() {
396        let rpc_client = RpcClient::new("http://127.0.0.1:1".to_string());
397        let too_large_config =
398            ComputeConfig::default().with_unit_limit(ComputeUnitLimitStrategy::Exact(1_400_001));
399        let fee_config = FeeConfig::default();
400        let rpc_config = RpcConfig::default();
401        let too_large = build_compute_budget_instructions(
402            &rpc_client,
403            &[],
404            &Pubkey::default(),
405            None,
406            &too_large_config,
407            &fee_config,
408            &rpc_config,
409            None,
410        )
411        .await
412        .unwrap_err();
413        assert!(too_large.contains("between 1 and 1,400,000"));
414
415        let zero_config =
416            ComputeConfig::default().with_unit_limit(ComputeUnitLimitStrategy::Exact(0));
417        let zero = build_compute_budget_instructions(
418            &rpc_client,
419            &[],
420            &Pubkey::default(),
421            None,
422            &zero_config,
423            &fee_config,
424            &rpc_config,
425            None,
426        )
427        .await
428        .unwrap_err();
429        assert!(zero.contains("between 1 and 1,400,000"));
430    }
431}