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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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}