Skip to main content

vaea_flash_sdk/
retry.rs

1/// VAEA Flash — Smart Retry (Rust)
2///
3/// Automatic transaction retry with:
4/// - Blockhash refresh on expiry
5/// - Priority fee escalation on congestion
6/// - Never retries program errors (Custom(...), InstructionError)
7///
8/// Uses standard Solana RPC + optional Jito bundles.
9
10use solana_sdk::{
11    compute_budget::ComputeBudgetInstruction,
12    instruction::Instruction,
13    message::{v0, VersionedMessage},
14    signature::Keypair,
15    signer::Signer,
16    transaction::VersionedTransaction,
17    address_lookup_table::AddressLookupTableAccount,
18    commitment_config::CommitmentConfig,
19};
20use solana_client::nonblocking::rpc_client::RpcClient;
21use crate::jito::{JitoConfig, resolve_block_engine_url, calculate_tip, build_tip_instruction, send_jito_bundle, poll_bundle_status};
22
23// ═══════════════════════════════════════════════════════════
24//  Types
25// ═══════════════════════════════════════════════════════════
26
27/// Reason for retry.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum RetryReason {
30    Expired,
31    Congestion,
32    ProgramError,
33}
34
35/// Retry configuration.
36#[derive(Debug, Clone)]
37pub struct RetryConfig {
38    /// Maximum number of attempts (default: 3)
39    pub max_attempts: u32,
40    /// Retry strategy
41    pub strategy: RetryStrategy,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum RetryStrategy {
46    /// No retry — fail immediately
47    None,
48    /// Smart retry: blockhash refresh + fee escalation
49    Adaptive,
50}
51
52impl Default for RetryConfig {
53    fn default() -> Self {
54        Self {
55            max_attempts: 3,
56            strategy: RetryStrategy::Adaptive,
57        }
58    }
59}
60
61/// Send mode: standard RPC or Jito bundle.
62#[derive(Debug, Clone)]
63pub enum SendMode {
64    Rpc,
65    Jito(JitoConfig),
66}
67
68// ═══════════════════════════════════════════════════════════
69//  Smart Retry
70// ═══════════════════════════════════════════════════════════
71
72/// Send a transaction with smart retry logic.
73///
74/// - Refreshes blockhash on expiry
75/// - Escalates priority fee on congestion (×1.5 per attempt)
76/// - Never retries program errors
77///
78/// # Example
79/// ```rust,no_run
80/// use vaea_flash_sdk::retry::{send_with_retry, RetryConfig, SendMode};
81///
82/// let sig = send_with_retry(
83///     &rpc, &wallet, instructions, &lookup_tables,
84///     RetryConfig::default(), Some(1000), SendMode::Rpc,
85/// ).await?;
86/// ```
87pub async fn send_with_retry(
88    rpc: &RpcClient,
89    wallet: &Keypair,
90    instructions: Vec<Instruction>,
91    lookup_tables: &[AddressLookupTableAccount],
92    config: RetryConfig,
93    initial_priority_micro_lamports: Option<u64>,
94    send_mode: SendMode,
95) -> Result<String, String> {
96    if config.strategy == RetryStrategy::None {
97        return send_once(rpc, wallet, &instructions, lookup_tables,
98            initial_priority_micro_lamports.unwrap_or(0)).await;
99    }
100
101    let mut last_error = String::new();
102    let mut priority = initial_priority_micro_lamports.unwrap_or(1_000);
103
104    for attempt in 1..=config.max_attempts {
105        let result = match &send_mode {
106            SendMode::Rpc => {
107                send_once(rpc, wallet, &instructions, lookup_tables, priority).await
108            }
109            SendMode::Jito(jito_config) => {
110                send_once_via_jito(rpc, wallet, &instructions, lookup_tables, priority, jito_config).await
111            }
112        };
113
114        match result {
115            Ok(sig) => return Ok(sig),
116            Err(err) => {
117                let reason = classify_error(&err);
118                last_error = err;
119
120                // Never retry program errors
121                if reason == RetryReason::ProgramError {
122                    return Err(last_error);
123                }
124                if attempt >= config.max_attempts {
125                    return Err(last_error);
126                }
127
128                // Escalate on congestion
129                if reason == RetryReason::Congestion {
130                    priority = (priority as f64 * 1.5) as u64;
131                    let backoff = 400 * 2u64.pow(attempt - 1);
132                    tokio::time::sleep(std::time::Duration::from_millis(backoff)).await;
133                }
134                // Expired: just rebuild with new blockhash (next iteration)
135            }
136        }
137    }
138
139    Err(last_error)
140}
141
142async fn send_once(
143    rpc: &RpcClient,
144    wallet: &Keypair,
145    instructions: &[Instruction],
146    lookup_tables: &[AddressLookupTableAccount],
147    priority_micro_lamports: u64,
148) -> Result<String, String> {
149    let (blockhash, _last_valid_block_height) = rpc
150        .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
151        .await
152        .map_err(|e| e.to_string())?;
153
154    let mut all_ixs: Vec<Instruction> = Vec::new();
155    if priority_micro_lamports > 0 {
156        all_ixs.push(ComputeBudgetInstruction::set_compute_unit_price(priority_micro_lamports));
157    }
158    all_ixs.extend_from_slice(instructions);
159
160    let msg = v0::Message::try_compile(
161        &wallet.pubkey(), &all_ixs, lookup_tables, blockhash,
162    ).map_err(|e| e.to_string())?;
163
164    let tx = VersionedTransaction::try_new(VersionedMessage::V0(msg), &[wallet])
165        .map_err(|e| e.to_string())?;
166
167    let sig = rpc.send_transaction(&tx)
168        .await
169        .map_err(|e| e.to_string())?;
170
171    rpc.confirm_transaction_with_spinner(&sig, &blockhash, CommitmentConfig::confirmed())
172        .await
173        .map_err(|e| e.to_string())?;
174
175    Ok(sig.to_string())
176}
177
178async fn send_once_via_jito(
179    rpc: &RpcClient,
180    wallet: &Keypair,
181    instructions: &[Instruction],
182    lookup_tables: &[AddressLookupTableAccount],
183    priority_micro_lamports: u64,
184    jito_config: &JitoConfig,
185) -> Result<String, String> {
186    let block_engine_url = resolve_block_engine_url(&jito_config.region);
187    let tip_lamports = calculate_tip(&jito_config.tip, 10_000);
188
189    let mut all_ixs: Vec<Instruction> = Vec::new();
190    if priority_micro_lamports > 0 {
191        all_ixs.push(ComputeBudgetInstruction::set_compute_unit_price(priority_micro_lamports));
192    }
193    all_ixs.extend_from_slice(instructions);
194    all_ixs.push(build_tip_instruction(&wallet.pubkey(), tip_lamports));
195
196    let (blockhash, _) = rpc
197        .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
198        .await
199        .map_err(|e| e.to_string())?;
200
201    let msg = v0::Message::try_compile(
202        &wallet.pubkey(), &all_ixs, lookup_tables, blockhash,
203    ).map_err(|e| e.to_string())?;
204
205    let tx = VersionedTransaction::try_new(VersionedMessage::V0(msg), &[wallet])
206        .map_err(|e| e.to_string())?;
207
208    let bundle_id = send_jito_bundle(&block_engine_url, &[tx]).await?;
209    poll_bundle_status(&block_engine_url, &bundle_id, 30_000).await
210}
211
212fn classify_error(err: &str) -> RetryReason {
213    if err.contains("Blockhash") || err.contains("expired") || err.contains("block height exceeded") {
214        RetryReason::Expired
215    } else if err.contains("InstructionError") || err.contains("Custom(") || err.contains("custom program error") {
216        RetryReason::ProgramError
217    } else {
218        RetryReason::Congestion
219    }
220}