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}