Skip to main content

jito_bundle/
tip.rs

1use crate::config::tip_strategy::TipStrategy;
2use crate::constants::{
3    JITO_TIP_ACCOUNTS, SYSTEM_PROGRAM_ID,
4};
5use crate::error::JitoError;
6use crate::types::JitoTipFloorResponse;
7use rand::Rng;
8use reqwest::Client;
9use solana_instruction::{AccountMeta, Instruction};
10use solana_pubkey::Pubkey;
11
12pub struct TipHelper;
13
14impl TipHelper {
15    pub fn get_random_tip_account() -> Pubkey {
16        let index = rand::rng().random_range(0..JITO_TIP_ACCOUNTS.len());
17        JITO_TIP_ACCOUNTS[index]
18    }
19
20    pub fn create_tip_instruction_to(
21        payer: &Pubkey,
22        tip_account: &Pubkey,
23        tip_lamports: u64,
24    ) -> Instruction {
25        let mut data = vec![2, 0, 0, 0];
26        data.extend_from_slice(&tip_lamports.to_le_bytes());
27        Instruction {
28            program_id: SYSTEM_PROGRAM_ID,
29            accounts: vec![
30                AccountMeta::new(*payer, true),
31                AccountMeta::new(*tip_account, false),
32            ],
33            data,
34        }
35    }
36
37    pub async fn fetch_tip_floor(
38        client: &Client,
39        tip_floor_url: &str,
40    ) -> Result<(u64, JitoTipFloorResponse), JitoError> {
41        let response = client
42            .get(tip_floor_url)
43            .header("Content-Type", "application/json")
44            .send()
45            .await
46            .map_err(|e| JitoError::TipFloorFetchFailed {
47                reason: e.to_string(),
48            })?;
49
50        if !response.status().is_success() {
51            return Err(JitoError::TipFloorFetchFailed {
52                reason: format!("HTTP {}", response.status()),
53            });
54        }
55
56        let data: Vec<JitoTipFloorResponse> =
57            response
58                .json()
59                .await
60                .map_err(|e| JitoError::TipFloorFetchFailed {
61                    reason: format!("failed to parse response: {e}"),
62                })?;
63
64        let tip_data = data.first().ok_or_else(|| JitoError::TipFloorFetchFailed {
65            reason: "tip_floor returned an empty array".to_string(),
66        })?;
67
68        let tip_in_lamports = Self::compute_tip_floor_lamports(tip_data);
69
70        Ok((tip_in_lamports, tip_data.clone()))
71    }
72
73    pub async fn resolve_tip(
74        client: &Client,
75        tip_floor_url: &str,
76        strategy: &TipStrategy,
77    ) -> Result<u64, JitoError> {
78        match strategy {
79            TipStrategy::Fixed(lamports) => Ok(*lamports),
80            TipStrategy::FetchFloor => {
81                let (tip, _) = Self::fetch_tip_floor(client, tip_floor_url).await?;
82                Ok(Self::apply_floor_strategy(tip, strategy))
83            }
84            TipStrategy::FetchFloorWithCap { min, max } => {
85                let (tip, _) = Self::fetch_tip_floor(client, tip_floor_url).await?;
86                Ok(Self::apply_floor_strategy(
87                    tip,
88                    &TipStrategy::FetchFloorWithCap { min: *min, max: *max },
89                ))
90            }
91        }
92    }
93
94    fn compute_tip_floor_lamports(tip_data: &JitoTipFloorResponse) -> u64 {
95        let tip_float = (tip_data.ema_landed_tips_50th_percentile * 1e9).ceil();
96        if tip_float.is_sign_negative() || tip_float.is_nan() {
97            0u64
98        } else {
99            tip_float as u64
100        }
101    }
102
103    fn apply_floor_strategy(tip: u64, strategy: &TipStrategy) -> u64 {
104        match strategy {
105            TipStrategy::Fixed(lamports) => *lamports,
106            TipStrategy::FetchFloor => tip,
107            TipStrategy::FetchFloorWithCap { min, max } => tip.clamp(*min, *max),
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::constants::{DEFAULT_TIP_LAMPORTS, MAX_TIP_LAMPORTS};
116
117    fn make_tip_floor(ema_50th: f64) -> JitoTipFloorResponse {
118        JitoTipFloorResponse {
119            time: "2024-01-01T00:00:00Z".to_string(),
120            landed_tips_25th_percentile: 0.0,
121            landed_tips_50th_percentile: 0.0,
122            landed_tips_75th_percentile: 0.0,
123            landed_tips_95th_percentile: 0.0,
124            landed_tips_99th_percentile: 0.0,
125            ema_landed_tips_50th_percentile: ema_50th,
126        }
127    }
128
129    #[test]
130    fn random_tip_account_is_valid() {
131        for _ in 0..100 {
132            let account = TipHelper::get_random_tip_account();
133            assert!(JITO_TIP_ACCOUNTS.contains(&account));
134        }
135    }
136
137    #[test]
138    fn fetch_floor_does_not_clamp_by_default() {
139        let tip_data = make_tip_floor(20.0);
140        let tip = TipHelper::compute_tip_floor_lamports(&tip_data);
141        assert_eq!(tip, 20_000_000_000);
142    }
143
144    #[test]
145    fn fetch_floor_negative_or_nan_returns_zero() {
146        let negative = make_tip_floor(-0.1);
147        let tip = TipHelper::compute_tip_floor_lamports(&negative);
148        assert_eq!(tip, 0);
149
150        let nan = make_tip_floor(f64::NAN);
151        let tip = TipHelper::compute_tip_floor_lamports(&nan);
152        assert_eq!(tip, 0);
153    }
154
155    #[test]
156    fn fetch_floor_with_cap_applies_min_max() {
157        let tip = 20_000_000_000;
158        let clamped = TipHelper::apply_floor_strategy(
159            tip,
160            &TipStrategy::FetchFloorWithCap {
161                min: DEFAULT_TIP_LAMPORTS,
162                max: MAX_TIP_LAMPORTS,
163            },
164        );
165        assert_eq!(clamped, MAX_TIP_LAMPORTS);
166
167        let small_tip = 50_000;
168        let clamped = TipHelper::apply_floor_strategy(
169            small_tip,
170            &TipStrategy::FetchFloorWithCap {
171                min: DEFAULT_TIP_LAMPORTS,
172                max: MAX_TIP_LAMPORTS,
173            },
174        );
175        assert_eq!(clamped, DEFAULT_TIP_LAMPORTS);
176    }
177}