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    // --- Tip Account / Instruction ---
14    /// Picks a random Jito tip account from known constants.
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    /// Creates a system transfer instruction for the tip payment.
21    pub fn create_tip_instruction_to(
22        payer: &Pubkey,
23        tip_account: &Pubkey,
24        tip_lamports: u64,
25    ) -> Instruction {
26        let mut data = vec![2, 0, 0, 0];
27        data.extend_from_slice(&tip_lamports.to_le_bytes());
28        Instruction {
29            program_id: SYSTEM_PROGRAM_ID,
30            accounts: vec![
31                AccountMeta::new(*payer, true),
32                AccountMeta::new(*tip_account, false),
33            ],
34            data,
35        }
36    }
37
38    // --- Tip Resolution ---
39    /// Fetches current tip floor data and returns computed lamports.
40    pub async fn fetch_tip_floor(
41        client: &Client,
42        tip_floor_url: &str,
43    ) -> Result<(u64, JitoTipFloorResponse), JitoError> {
44        let response = client
45            .get(tip_floor_url)
46            .header("Content-Type", "application/json")
47            .send()
48            .await
49            .map_err(|e| JitoError::TipFloorFetchFailed {
50                reason: e.to_string(),
51            })?;
52
53        if !response.status().is_success() {
54            return Err(JitoError::TipFloorFetchFailed {
55                reason: format!("HTTP {}", response.status()),
56            });
57        }
58
59        let data: Vec<JitoTipFloorResponse> =
60            response
61                .json()
62                .await
63                .map_err(|e| JitoError::TipFloorFetchFailed {
64                    reason: format!("failed to parse response: {e}"),
65                })?;
66
67        let tip_data = data.first().ok_or_else(|| JitoError::TipFloorFetchFailed {
68            reason: "tip_floor returned an empty array".to_string(),
69        })?;
70
71        let tip_in_lamports = Self::compute_tip_floor_lamports(tip_data);
72
73        Ok((tip_in_lamports, tip_data.clone()))
74    }
75
76    /// Resolves effective tip amount for the provided strategy.
77    pub async fn resolve_tip(
78        client: &Client,
79        tip_floor_url: &str,
80        strategy: &TipStrategy,
81    ) -> Result<u64, JitoError> {
82        match strategy {
83            TipStrategy::Fixed(lamports) => Ok(*lamports),
84            TipStrategy::FetchFloor => {
85                let (tip, _) = Self::fetch_tip_floor(client, tip_floor_url).await?;
86                Ok(Self::apply_floor_strategy(tip, strategy))
87            }
88            TipStrategy::FetchFloorWithCap { min, max } => {
89                let (tip, _) = Self::fetch_tip_floor(client, tip_floor_url).await?;
90                Ok(Self::apply_floor_strategy(
91                    tip,
92                    &TipStrategy::FetchFloorWithCap {
93                        min: *min,
94                        max: *max,
95                    },
96                ))
97            }
98        }
99    }
100
101    /// Converts EMA 50th percentile SOL tip value into lamports.
102    fn compute_tip_floor_lamports(tip_data: &JitoTipFloorResponse) -> u64 {
103        let tip_float = (tip_data.ema_landed_tips_50th_percentile * 1e9).ceil();
104        if tip_float.is_sign_negative() || tip_float.is_nan() {
105            0u64
106        } else {
107            tip_float as u64
108        }
109    }
110
111    /// Applies strategy transforms such as min/max clamping.
112    fn apply_floor_strategy(tip: u64, strategy: &TipStrategy) -> u64 {
113        match strategy {
114            TipStrategy::Fixed(lamports) => *lamports,
115            TipStrategy::FetchFloor => tip,
116            TipStrategy::FetchFloorWithCap { min, max } => tip.clamp(*min, *max),
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::constants::{DEFAULT_TIP_LAMPORTS, MAX_TIP_LAMPORTS};
125
126    /// Builds a synthetic tip-floor payload for strategy tests.
127    fn make_tip_floor(ema_50th: f64) -> JitoTipFloorResponse {
128        JitoTipFloorResponse {
129            time: "2024-01-01T00:00:00Z".to_string(),
130            landed_tips_25th_percentile: 0.0,
131            landed_tips_50th_percentile: 0.0,
132            landed_tips_75th_percentile: 0.0,
133            landed_tips_95th_percentile: 0.0,
134            landed_tips_99th_percentile: 0.0,
135            ema_landed_tips_50th_percentile: ema_50th,
136        }
137    }
138
139    /// Verifies random tip account selection always returns known accounts.
140    #[test]
141    fn random_tip_account_is_valid() {
142        for _ in 0..100 {
143            let account = TipHelper::get_random_tip_account();
144            assert!(JITO_TIP_ACCOUNTS.contains(&account));
145        }
146    }
147
148    /// Verifies raw floor strategy does not apply min/max clamping.
149    #[test]
150    fn fetch_floor_does_not_clamp_by_default() {
151        let tip_data = make_tip_floor(20.0);
152        let tip = TipHelper::compute_tip_floor_lamports(&tip_data);
153        assert_eq!(tip, 20_000_000_000);
154    }
155
156    /// Verifies invalid floor values are coerced to zero lamports.
157    #[test]
158    fn fetch_floor_negative_or_nan_returns_zero() {
159        let negative = make_tip_floor(-0.1);
160        let tip = TipHelper::compute_tip_floor_lamports(&negative);
161        assert_eq!(tip, 0);
162
163        let nan = make_tip_floor(f64::NAN);
164        let tip = TipHelper::compute_tip_floor_lamports(&nan);
165        assert_eq!(tip, 0);
166    }
167
168    /// Verifies capped floor strategy applies both min and max bounds.
169    #[test]
170    fn fetch_floor_with_cap_applies_min_max() {
171        let tip = 20_000_000_000;
172        let clamped = TipHelper::apply_floor_strategy(
173            tip,
174            &TipStrategy::FetchFloorWithCap {
175                min: DEFAULT_TIP_LAMPORTS,
176                max: MAX_TIP_LAMPORTS,
177            },
178        );
179        assert_eq!(clamped, MAX_TIP_LAMPORTS);
180
181        let small_tip = 50_000;
182        let clamped = TipHelper::apply_floor_strategy(
183            small_tip,
184            &TipStrategy::FetchFloorWithCap {
185                min: DEFAULT_TIP_LAMPORTS,
186                max: MAX_TIP_LAMPORTS,
187            },
188        );
189        assert_eq!(clamped, DEFAULT_TIP_LAMPORTS);
190    }
191}