Skip to main content

jito_bundle/
tip.rs

1use crate::config::tip_strategy::TipStrategy;
2use crate::constants::{JITO_TIP_ACCOUNTS, SYSTEM_PROGRAM_ID};
3use crate::error::JitoError;
4use crate::types::JitoTipFloorResponse;
5use rand::Rng;
6use reqwest::Client;
7use solana_instruction::{AccountMeta, Instruction};
8use solana_pubkey::Pubkey;
9
10pub struct TipHelper;
11
12impl TipHelper {
13    const LAMPORTS_PER_SOL: f64 = 1_000_000_000.0;
14
15    // --- Tip Account / Instruction ---
16    /// Picks a random Jito tip account from known constants.
17    pub fn get_random_tip_account() -> Pubkey {
18        let index = rand::rng().random_range(0..JITO_TIP_ACCOUNTS.len());
19        JITO_TIP_ACCOUNTS[index]
20    }
21
22    /// Creates a system transfer instruction for the tip payment.
23    pub fn create_tip_instruction_to(
24        payer: &Pubkey,
25        tip_account: &Pubkey,
26        tip_lamports: u64,
27    ) -> Instruction {
28        let mut data = vec![2, 0, 0, 0];
29        data.extend_from_slice(&tip_lamports.to_le_bytes());
30        Instruction {
31            program_id: SYSTEM_PROGRAM_ID,
32            accounts: vec![
33                AccountMeta::new(*payer, true),
34                AccountMeta::new(*tip_account, false),
35            ],
36            data,
37        }
38    }
39
40    // --- Tip Resolution ---
41    /// Fetches current tip floor data and returns computed lamports.
42    pub async fn fetch_tip_floor(
43        client: &Client,
44        tip_floor_url: &str,
45    ) -> Result<(u64, JitoTipFloorResponse), JitoError> {
46        let response = client
47            .get(tip_floor_url)
48            .header("Content-Type", "application/json")
49            .send()
50            .await
51            .map_err(|e| JitoError::TipFloorFetchFailed {
52                reason: e.to_string(),
53            })?;
54
55        if !response.status().is_success() {
56            return Err(JitoError::TipFloorFetchFailed {
57                reason: format!("HTTP {}", response.status()),
58            });
59        }
60
61        let data: Vec<JitoTipFloorResponse> =
62            response
63                .json()
64                .await
65                .map_err(|e| JitoError::TipFloorFetchFailed {
66                    reason: format!("failed to parse response: {e}"),
67                })?;
68
69        let tip_data = data.first().ok_or_else(|| JitoError::TipFloorFetchFailed {
70            reason: "tip_floor returned an empty array".to_string(),
71        })?;
72
73        let tip_in_lamports = Self::compute_tip_floor_lamports(tip_data)?;
74
75        Ok((tip_in_lamports, tip_data.clone()))
76    }
77
78    /// Resolves effective tip amount for the provided strategy.
79    pub async fn resolve_tip(
80        client: &Client,
81        tip_floor_url: &str,
82        strategy: &TipStrategy,
83    ) -> Result<u64, JitoError> {
84        match strategy {
85            TipStrategy::Fixed(lamports) => Ok(*lamports),
86            TipStrategy::FetchFloor => {
87                let (tip, _) = Self::fetch_tip_floor(client, tip_floor_url).await?;
88                Ok(Self::apply_floor_strategy(tip, strategy))
89            }
90            TipStrategy::FetchFloorWithCap { min, max } => {
91                let (tip, _) = Self::fetch_tip_floor(client, tip_floor_url).await?;
92                Ok(Self::apply_floor_strategy(
93                    tip,
94                    &TipStrategy::FetchFloorWithCap {
95                        min: *min,
96                        max: *max,
97                    },
98                ))
99            }
100        }
101    }
102
103    /// Converts EMA 50th percentile SOL tip value into lamports.
104    fn compute_tip_floor_lamports(tip_data: &JitoTipFloorResponse) -> Result<u64, JitoError> {
105        let ema_50th = tip_data.ema_landed_tips_50th_percentile;
106        if !ema_50th.is_finite() {
107            return Err(JitoError::TipFloorFetchFailed {
108                reason: format!("invalid tip floor value (non-finite): {ema_50th}"),
109            });
110        }
111        if ema_50th < 0.0 {
112            return Err(JitoError::TipFloorFetchFailed {
113                reason: format!("invalid tip floor value (negative): {ema_50th}"),
114            });
115        }
116
117        let tip_float = (ema_50th * Self::LAMPORTS_PER_SOL).ceil();
118        if !tip_float.is_finite() || tip_float > u64::MAX as f64 {
119            return Err(JitoError::TipFloorFetchFailed {
120                reason: format!("tip floor value is out of range: {ema_50th} SOL"),
121            });
122        }
123
124        Ok(tip_float as u64)
125    }
126
127    /// Applies strategy transforms such as min/max clamping.
128    fn apply_floor_strategy(tip: u64, strategy: &TipStrategy) -> u64 {
129        match strategy {
130            TipStrategy::Fixed(lamports) => *lamports,
131            TipStrategy::FetchFloor => tip,
132            TipStrategy::FetchFloorWithCap { min, max } => tip.clamp(*min, *max),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::constants::{DEFAULT_TIP_LAMPORTS, MAX_TIP_LAMPORTS};
141
142    /// Builds a synthetic tip-floor payload for strategy tests.
143    fn make_tip_floor(ema_50th: f64) -> JitoTipFloorResponse {
144        JitoTipFloorResponse {
145            time: "2024-01-01T00:00:00Z".to_string(),
146            landed_tips_25th_percentile: 0.0,
147            landed_tips_50th_percentile: 0.0,
148            landed_tips_75th_percentile: 0.0,
149            landed_tips_95th_percentile: 0.0,
150            landed_tips_99th_percentile: 0.0,
151            ema_landed_tips_50th_percentile: ema_50th,
152        }
153    }
154
155    /// Verifies random tip account selection always returns known accounts.
156    #[test]
157    fn random_tip_account_is_valid() {
158        for _ in 0..100 {
159            let account = TipHelper::get_random_tip_account();
160            assert!(JITO_TIP_ACCOUNTS.contains(&account));
161        }
162    }
163
164    /// Verifies raw floor strategy does not apply min/max clamping.
165    #[test]
166    fn fetch_floor_does_not_clamp_by_default() {
167        let tip_data = make_tip_floor(20.0);
168        let tip = TipHelper::compute_tip_floor_lamports(&tip_data);
169        assert!(
170            tip.is_ok(),
171            "expected valid tip floor conversion, got {tip:?}"
172        );
173        assert_eq!(tip.unwrap_or_default(), 20_000_000_000);
174    }
175
176    /// Verifies invalid floor values return typed errors.
177    #[test]
178    fn fetch_floor_negative_or_nan_return_error() {
179        let negative = make_tip_floor(-0.1);
180        assert!(
181            TipHelper::compute_tip_floor_lamports(&negative).is_err(),
182            "expected negative tip floor to be rejected"
183        );
184
185        let nan = make_tip_floor(f64::NAN);
186        assert!(
187            TipHelper::compute_tip_floor_lamports(&nan).is_err(),
188            "expected NaN tip floor to be rejected"
189        );
190    }
191
192    /// Verifies infinite tip floors are rejected.
193    #[test]
194    fn fetch_floor_infinite_returns_error() {
195        let infinite = make_tip_floor(f64::INFINITY);
196        assert!(
197            TipHelper::compute_tip_floor_lamports(&infinite).is_err(),
198            "expected infinite tip floor to be rejected"
199        );
200    }
201
202    /// Verifies capped floor strategy applies both min and max bounds.
203    #[test]
204    fn fetch_floor_with_cap_applies_min_max() {
205        let tip = 20_000_000_000;
206        let clamped = TipHelper::apply_floor_strategy(
207            tip,
208            &TipStrategy::FetchFloorWithCap {
209                min: DEFAULT_TIP_LAMPORTS,
210                max: MAX_TIP_LAMPORTS,
211            },
212        );
213        assert_eq!(clamped, MAX_TIP_LAMPORTS);
214
215        let small_tip = 50_000;
216        let clamped = TipHelper::apply_floor_strategy(
217            small_tip,
218            &TipStrategy::FetchFloorWithCap {
219                min: DEFAULT_TIP_LAMPORTS,
220                max: MAX_TIP_LAMPORTS,
221            },
222        );
223        assert_eq!(clamped, DEFAULT_TIP_LAMPORTS);
224    }
225}