openzeppelin_relayer/services/jupiter/
mod.rs

1//! Jupiter API service module
2//! Jupiter API service is used to get quotes for token swaps
3//! Jupiter is not supported on devnet/testnet, so a mock service is used instead
4//! The mock service returns a quote with the same input and output amount
5use crate::{
6    constants::{JUPITER_BASE_API_URL, WRAPPED_SOL_MINT},
7    utils::field_as_string,
8};
9use async_trait::async_trait;
10#[cfg(test)]
11use mockall::automock;
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16#[derive(Error, Debug)]
17pub enum JupiterServiceError {
18    #[error("HTTP request failed: {0}")]
19    HttpRequestError(#[from] reqwest::Error),
20    #[error("API returned an error: {message}")]
21    ApiError { message: String },
22    #[error("Failed to deserialize response: {0}")]
23    DeserializationError(#[from] serde_json::Error),
24    #[error("An unknown error occurred")]
25    UnknownError,
26}
27
28#[derive(Debug, Serialize)]
29pub struct QuoteRequest {
30    #[serde(rename = "inputMint")]
31    pub input_mint: String,
32    #[serde(rename = "outputMint")]
33    pub output_mint: String,
34    pub amount: u64,
35    #[serde(rename = "slippage")]
36    pub slippage: f32,
37}
38
39#[derive(Debug, Deserialize, Serialize, Clone)]
40#[allow(dead_code)]
41pub struct SwapInfo {
42    #[serde(rename = "ammKey")]
43    pub amm_key: String,
44    pub label: String,
45    #[serde(rename = "inputMint")]
46    pub input_mint: String,
47    #[serde(rename = "outputMint")]
48    pub output_mint: String,
49    #[serde(rename = "inAmount")]
50    pub in_amount: String,
51    #[serde(rename = "outAmount")]
52    pub out_amount: String,
53    #[serde(rename = "feeAmount")]
54    pub fee_amount: String,
55    #[serde(rename = "feeMint")]
56    pub fee_mint: String,
57}
58
59#[derive(Debug, Deserialize, Serialize, Clone)]
60#[allow(dead_code)]
61pub struct RoutePlan {
62    pub percent: u32,
63    #[serde(rename = "swapInfo")]
64    pub swap_info: SwapInfo,
65}
66
67#[derive(Debug, Deserialize, Serialize, Clone)]
68#[allow(dead_code)]
69pub struct QuoteResponse {
70    #[serde(rename = "inputMint")]
71    pub input_mint: String,
72    #[serde(rename = "outputMint")]
73    pub output_mint: String,
74    #[serde(rename = "inAmount")]
75    #[serde(with = "field_as_string")]
76    pub in_amount: u64,
77    #[serde(rename = "outAmount")]
78    #[serde(with = "field_as_string")]
79    pub out_amount: u64,
80    #[serde(rename = "otherAmountThreshold")]
81    #[serde(with = "field_as_string")]
82    pub other_amount_threshold: u64,
83    #[serde(rename = "priceImpactPct")]
84    #[serde(with = "field_as_string")]
85    pub price_impact_pct: f64,
86    #[serde(rename = "swapMode")]
87    pub swap_mode: String,
88    #[serde(rename = "slippageBps")]
89    pub slippage_bps: u32,
90    #[serde(rename = "routePlan")]
91    pub route_plan: Vec<RoutePlan>,
92}
93
94#[derive(Debug, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct PrioritizationFeeLamports {
97    pub priority_level_with_max_lamports: PriorityLevelWitMaxLamports,
98}
99
100#[derive(Debug, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct PriorityLevelWitMaxLamports {
103    pub priority_level: Option<String>,
104    pub max_lamports: Option<u64>,
105}
106
107#[derive(Debug, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct SwapRequest {
110    pub quote_response: QuoteResponse,
111    pub user_public_key: String,
112    pub wrap_and_unwrap_sol: Option<bool>,
113    pub fee_account: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub compute_unit_price_micro_lamports: Option<u64>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub prioritization_fee_lamports: Option<PrioritizationFeeLamports>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub dynamic_compute_unit_limit: Option<bool>,
120}
121
122#[derive(Debug, Deserialize, Serialize, Clone)]
123#[serde(rename_all = "camelCase")]
124pub struct SwapResponse {
125    pub swap_transaction: String, // base64 encoded transaction
126    pub last_valid_block_height: u64,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub prioritization_fee_lamports: Option<u64>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub compute_unit_limit: Option<u64>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub simulation_error: Option<String>,
133}
134
135#[derive(Debug, Deserialize, Serialize, Clone)]
136#[serde(rename_all = "camelCase")]
137pub struct UltraOrderRequest {
138    #[serde(rename = "inputMint")]
139    pub input_mint: String,
140    #[serde(rename = "outputMint")]
141    pub output_mint: String,
142    #[serde(with = "field_as_string")]
143    pub amount: u64,
144    pub taker: String,
145}
146
147#[derive(Debug, Deserialize, Serialize, Clone)]
148#[serde(rename_all = "camelCase")]
149pub struct UltraOrderResponse {
150    #[serde(rename = "inputMint")]
151    pub input_mint: String,
152    #[serde(rename = "outputMint")]
153    pub output_mint: String,
154    #[serde(rename = "inAmount")]
155    #[serde(with = "field_as_string")]
156    pub in_amount: u64,
157    #[serde(rename = "outAmount")]
158    #[serde(with = "field_as_string")]
159    pub out_amount: u64,
160    #[serde(rename = "otherAmountThreshold")]
161    #[serde(with = "field_as_string")]
162    pub other_amount_threshold: u64,
163    #[serde(rename = "priceImpactPct")]
164    #[serde(with = "field_as_string")]
165    pub price_impact_pct: f64,
166    #[serde(rename = "swapMode")]
167    pub swap_mode: String,
168    #[serde(rename = "slippageBps")]
169    pub slippage_bps: u32,
170    #[serde(rename = "routePlan")]
171    pub route_plan: Vec<RoutePlan>,
172    #[serde(rename = "prioritizationFeeLamports")]
173    pub prioritization_fee_lamports: u32,
174    pub transaction: Option<String>,
175    #[serde(rename = "requestId")]
176    pub request_id: String,
177}
178
179#[derive(Debug, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct UltraExecuteRequest {
182    #[serde(rename = "signedTransaction")]
183    pub signed_transaction: String,
184    #[serde(rename = "requestId")]
185    pub request_id: String,
186}
187
188#[derive(Debug, Deserialize, Serialize, Clone)]
189#[allow(dead_code)]
190pub struct SwapEvents {
191    #[serde(rename = "inputMint")]
192    pub input_mint: String,
193    #[serde(rename = "outputMint")]
194    pub output_mint: String,
195    #[serde(rename = "inputAmount")]
196    pub input_amount: String,
197    #[serde(rename = "outputAmount")]
198    pub output_amount: String,
199}
200
201#[derive(Debug, Deserialize, Serialize, Clone)]
202#[serde(rename_all = "camelCase")]
203pub struct UltraExecuteResponse {
204    pub signature: Option<String>,
205    pub status: String,
206    pub slot: Option<String>,
207    pub error: Option<String>,
208    pub code: u32,
209    #[serde(rename = "totalInputAmount")]
210    pub total_input_amount: Option<String>,
211    #[serde(rename = "totalOutputAmount")]
212    pub total_output_amount: Option<String>,
213    #[serde(rename = "inputAmountResult")]
214    pub input_amount_result: Option<String>,
215    #[serde(rename = "outputAmountResult")]
216    pub output_amount_result: Option<String>,
217    #[serde(rename = "swapEvents")]
218    pub swap_events: Option<Vec<SwapEvents>>,
219}
220
221#[async_trait]
222#[cfg_attr(test, automock)]
223pub trait JupiterServiceTrait: Send + Sync {
224    async fn get_quote(&self, request: QuoteRequest) -> Result<QuoteResponse, JupiterServiceError>;
225    async fn get_sol_to_token_quote(
226        &self,
227        input_mint: &str,
228        amount: u64,
229        slippage: f32,
230    ) -> Result<QuoteResponse, JupiterServiceError>;
231    async fn get_swap_transaction(
232        &self,
233        request: SwapRequest,
234    ) -> Result<SwapResponse, JupiterServiceError>;
235    async fn get_ultra_order(
236        &self,
237        request: UltraOrderRequest,
238    ) -> Result<UltraOrderResponse, JupiterServiceError>;
239    async fn execute_ultra_order(
240        &self,
241        request: UltraExecuteRequest,
242    ) -> Result<UltraExecuteResponse, JupiterServiceError>;
243}
244
245pub enum JupiterService {
246    Mainnet(MainnetJupiterService),
247    Mock(MockJupiterService),
248}
249
250pub struct MainnetJupiterService {
251    client: Client,
252    base_url: String,
253}
254
255impl MainnetJupiterService {
256    pub fn new() -> Self {
257        Self {
258            client: Client::new(),
259            base_url: JUPITER_BASE_API_URL.to_string(),
260        }
261    }
262}
263
264impl Default for MainnetJupiterService {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270#[async_trait]
271impl JupiterServiceTrait for MainnetJupiterService {
272    /// Get a quote for a given input and output mint
273    async fn get_quote(&self, request: QuoteRequest) -> Result<QuoteResponse, JupiterServiceError> {
274        let slippage_bps: u32 = request.slippage as u32 * 100;
275        let url = format!("{}/swap/v1/quote", self.base_url);
276
277        let response = self
278            .client
279            .get(&url)
280            .query(&[
281                ("inputMint", request.input_mint),
282                ("outputMint", request.output_mint),
283                ("amount", request.amount.to_string()),
284                ("slippageBps", slippage_bps.to_string()),
285            ])
286            .send()
287            .await?
288            .error_for_status()?;
289
290        let quote: QuoteResponse = response.json().await?;
291        Ok(quote)
292    }
293
294    /// Get a quote for a SOL to a given token
295    async fn get_sol_to_token_quote(
296        &self,
297        output_mint: &str,
298        amount: u64,
299        slippage: f32,
300    ) -> Result<QuoteResponse, JupiterServiceError> {
301        let request = QuoteRequest {
302            input_mint: WRAPPED_SOL_MINT.to_string(),
303            output_mint: output_mint.to_string(),
304            amount,
305            slippage,
306        };
307
308        self.get_quote(request).await
309    }
310
311    async fn get_swap_transaction(
312        &self,
313        request: SwapRequest,
314    ) -> Result<SwapResponse, JupiterServiceError> {
315        let url = format!("{}/swap/v1/swap", self.base_url);
316        let response = self.client.post(&url).json(&request).send().await?;
317
318        if response.status().is_success() {
319            response
320                .json::<SwapResponse>()
321                .await
322                .map_err(JupiterServiceError::from)
323        } else {
324            let error_text = response
325                .text()
326                .await
327                .unwrap_or_else(|_| "Unknown error".to_string());
328            Err(JupiterServiceError::ApiError {
329                message: error_text,
330            })
331        }
332    }
333
334    async fn get_ultra_order(
335        &self,
336        request: UltraOrderRequest,
337    ) -> Result<UltraOrderResponse, JupiterServiceError> {
338        let url = format!("{}/ultra/v1/order", self.base_url);
339
340        let response = self
341            .client
342            .get(&url)
343            .query(&[
344                ("inputMint", request.input_mint),
345                ("outputMint", request.output_mint),
346                ("amount", request.amount.to_string()),
347                ("taker", request.taker),
348            ])
349            .send()
350            .await?
351            .error_for_status()?;
352
353        response.json().await.map_err(JupiterServiceError::from)
354    }
355
356    async fn execute_ultra_order(
357        &self,
358        request: UltraExecuteRequest,
359    ) -> Result<UltraExecuteResponse, JupiterServiceError> {
360        let url = format!("{}/ultra/v1/execute", self.base_url);
361        let response = self.client.post(&url).json(&request).send().await?;
362
363        if response.status().is_success() {
364            response.json().await.map_err(JupiterServiceError::from)
365        } else {
366            let error_text = response
367                .text()
368                .await
369                .unwrap_or_else(|_| "Unknown error".to_string());
370            Err(JupiterServiceError::ApiError {
371                message: error_text,
372            })
373        }
374    }
375}
376
377// Jupiter Dev Service
378// This service is used on testnet/devnets to mock the Jupiter API service
379// due to the lack of a testnet API service
380pub struct MockJupiterService {}
381
382impl MockJupiterService {
383    pub fn new() -> Self {
384        Self {}
385    }
386}
387
388impl Default for MockJupiterService {
389    fn default() -> Self {
390        Self::new()
391    }
392}
393
394#[async_trait]
395impl JupiterServiceTrait for MockJupiterService {
396    async fn get_quote(&self, request: QuoteRequest) -> Result<QuoteResponse, JupiterServiceError> {
397        let quote = QuoteResponse {
398            input_mint: request.input_mint.clone(),
399            output_mint: request.output_mint.clone(),
400            in_amount: request.amount,
401            out_amount: request.amount,
402            other_amount_threshold: 0,
403            price_impact_pct: 0.0,
404            swap_mode: "ExactIn".to_string(),
405            slippage_bps: 0,
406            route_plan: vec![RoutePlan {
407                percent: 100,
408                swap_info: SwapInfo {
409                    amm_key: "mock_amm_key".to_string(),
410                    label: "mock_label".to_string(),
411                    input_mint: request.input_mint.clone(),
412                    output_mint: request.output_mint.to_string(),
413                    in_amount: request.amount.to_string(),
414                    out_amount: request.amount.to_string(),
415                    fee_amount: "0".to_string(),
416                    fee_mint: "mock_fee_mint".to_string(),
417                },
418            }],
419        };
420        Ok(quote)
421    }
422
423    /// Get a quote for a SOL to a given token
424    async fn get_sol_to_token_quote(
425        &self,
426        output_mint: &str,
427        amount: u64,
428        slippage: f32,
429    ) -> Result<QuoteResponse, JupiterServiceError> {
430        let request = QuoteRequest {
431            input_mint: WRAPPED_SOL_MINT.to_string(),
432            output_mint: output_mint.to_string(),
433            amount,
434            slippage,
435        };
436
437        self.get_quote(request).await
438    }
439
440    async fn get_swap_transaction(
441        &self,
442        _request: SwapRequest,
443    ) -> Result<SwapResponse, JupiterServiceError> {
444        // Provide realistic-looking mock data
445        Ok(SwapResponse {
446            swap_transaction: "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...".to_string(),
447            last_valid_block_height: 279632475,
448            prioritization_fee_lamports: Some(9999),
449            compute_unit_limit: Some(388876),
450            simulation_error: None,
451        })
452    }
453
454    async fn get_ultra_order(
455        &self,
456        request: UltraOrderRequest,
457    ) -> Result<UltraOrderResponse, JupiterServiceError> {
458        Ok(UltraOrderResponse {
459            input_mint: request.input_mint.clone(),
460            output_mint: request.output_mint.clone(),
461            in_amount: 10,
462            out_amount: 10,
463            other_amount_threshold: 1,
464            swap_mode: "ExactIn".to_string(),
465            price_impact_pct: 0.0,
466            route_plan: vec![RoutePlan {
467                percent: 100,
468                swap_info: SwapInfo {
469                    amm_key: "mock_amm_key".to_string(),
470                    label: "mock_label".to_string(),
471                    input_mint: request.input_mint,
472                    output_mint: request.output_mint.to_string(),
473                    in_amount: request.amount.to_string(),
474                    out_amount: request.amount.to_string(),
475                    fee_amount: "0".to_string(),
476                    fee_mint: "mock_fee_mint".to_string(),
477                },
478            }],
479            prioritization_fee_lamports: 0,
480            transaction: Some("test_transaction".to_string()),
481            request_id: "mock_request_id".to_string(),
482            slippage_bps: 0,
483        })
484    }
485
486    async fn execute_ultra_order(
487        &self,
488        _request: UltraExecuteRequest,
489    ) -> Result<UltraExecuteResponse, JupiterServiceError> {
490        Ok(UltraExecuteResponse {
491            signature: Some("mock_signature".to_string()),
492            status: "success".to_string(),
493            slot: Some("123456789".to_string()),
494            error: None,
495            code: 0,
496            total_input_amount: Some("1000000".to_string()),
497            total_output_amount: Some("1000000".to_string()),
498            input_amount_result: Some("1000000".to_string()),
499            output_amount_result: Some("1000000".to_string()),
500            swap_events: Some(vec![SwapEvents {
501                input_mint: "mock_input_mint".to_string(),
502                output_mint: "mock_output_mint".to_string(),
503                input_amount: "1000000".to_string(),
504                output_amount: "1000000".to_string(),
505            }]),
506        })
507    }
508}
509
510#[async_trait]
511impl JupiterServiceTrait for JupiterService {
512    async fn get_sol_to_token_quote(
513        &self,
514        output_mint: &str,
515        amount: u64,
516        slippage: f32,
517    ) -> Result<QuoteResponse, JupiterServiceError> {
518        match self {
519            JupiterService::Mock(service) => {
520                service
521                    .get_sol_to_token_quote(output_mint, amount, slippage)
522                    .await
523            }
524            JupiterService::Mainnet(service) => {
525                service
526                    .get_sol_to_token_quote(output_mint, amount, slippage)
527                    .await
528            }
529        }
530    }
531
532    async fn get_quote(&self, request: QuoteRequest) -> Result<QuoteResponse, JupiterServiceError> {
533        match self {
534            JupiterService::Mock(service) => service.get_quote(request).await,
535            JupiterService::Mainnet(service) => service.get_quote(request).await,
536        }
537    }
538
539    async fn get_swap_transaction(
540        &self,
541        request: SwapRequest,
542    ) -> Result<SwapResponse, JupiterServiceError> {
543        match self {
544            JupiterService::Mock(service) => service.get_swap_transaction(request).await,
545            JupiterService::Mainnet(service) => service.get_swap_transaction(request).await,
546        }
547    }
548
549    async fn get_ultra_order(
550        &self,
551        request: UltraOrderRequest,
552    ) -> Result<UltraOrderResponse, JupiterServiceError> {
553        match self {
554            JupiterService::Mock(service) => service.get_ultra_order(request).await,
555            JupiterService::Mainnet(service) => service.get_ultra_order(request).await,
556        }
557    }
558
559    async fn execute_ultra_order(
560        &self,
561        request: UltraExecuteRequest,
562    ) -> Result<UltraExecuteResponse, JupiterServiceError> {
563        match self {
564            JupiterService::Mock(service) => service.execute_ultra_order(request).await,
565            JupiterService::Mainnet(service) => service.execute_ultra_order(request).await,
566        }
567    }
568}
569
570impl JupiterService {
571    pub fn new_from_network(network: &str) -> Self {
572        match network {
573            "devnet" | "testnet" => JupiterService::Mock(MockJupiterService::new()),
574            "mainnet" => JupiterService::Mainnet(MainnetJupiterService::new()),
575            _ => JupiterService::Mainnet(MainnetJupiterService::new()),
576        }
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use wiremock::{
584        matchers::{method, path, query_param},
585        Mock, MockServer, ResponseTemplate,
586    };
587
588    #[tokio::test]
589    async fn test_get_quote() {
590        let service = MainnetJupiterService::new();
591
592        // USDC -> SOL quote request
593        let request = QuoteRequest {
594            input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), // noboost
595            output_mint: "So11111111111111111111111111111111111111112".to_string(), // SOL
596            amount: 1000000,                                                        // 1 USDC
597            slippage: 0.5,                                                          // 0.5%
598        };
599
600        let result = service.get_quote(request).await;
601        assert!(result.is_ok());
602
603        let quote = result.unwrap();
604        assert_eq!(
605            quote.input_mint,
606            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
607        );
608        assert_eq!(
609            quote.output_mint,
610            "So11111111111111111111111111111111111111112"
611        );
612        assert!(quote.out_amount > 0);
613    }
614
615    #[tokio::test]
616    async fn test_get_sol_to_token_quote() {
617        let service = MainnetJupiterService::new();
618
619        let result = service
620            .get_sol_to_token_quote("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", 1000000, 0.5)
621            .await;
622        assert!(result.is_ok());
623
624        let quote = result.unwrap();
625        assert_eq!(
626            quote.input_mint,
627            "So11111111111111111111111111111111111111112"
628        );
629        assert_eq!(
630            quote.output_mint,
631            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
632        );
633        assert!(quote.out_amount > 0);
634    }
635
636    #[tokio::test]
637    async fn test_mock_get_quote() {
638        let service = MainnetJupiterService::new();
639
640        // USDC -> SOL quote request
641        let request = QuoteRequest {
642            input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), // USDC
643            output_mint: "So11111111111111111111111111111111111111112".to_string(), // SOL
644            amount: 1000000,                                                        // 1 USDC
645            slippage: 0.5,                                                          // 0.5%
646        };
647
648        let result = service.get_quote(request).await;
649        assert!(result.is_ok());
650
651        let quote = result.unwrap();
652        assert_eq!(
653            quote.input_mint,
654            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
655        );
656        assert_eq!(
657            quote.output_mint,
658            "So11111111111111111111111111111111111111112"
659        );
660        assert!(quote.out_amount > 0);
661    }
662
663    #[tokio::test]
664    async fn test_get_swap_transaction() {
665        let mock_server = MockServer::start().await;
666
667        let quote = QuoteResponse {
668            input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
669            output_mint: "So11111111111111111111111111111111111111112".to_string(),
670            in_amount: 1000000,
671            out_amount: 24860952,
672            other_amount_threshold: 24362733,
673            price_impact_pct: 0.1,
674            swap_mode: "ExactIn".to_string(),
675            slippage_bps: 50,
676            route_plan: vec![RoutePlan {
677                percent: 100,
678                swap_info: SwapInfo {
679                    amm_key: "test_amm_key".to_string(),
680                    label: "test_label".to_string(),
681                    input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
682                    output_mint: "So11111111111111111111111111111111111111112".to_string(),
683                    in_amount: "1000000".to_string(),
684                    out_amount: "24860952".to_string(),
685                    fee_amount: "1000".to_string(),
686                    fee_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
687                },
688            }],
689        };
690
691        let swap_response = SwapResponse {
692            swap_transaction:
693                "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
694                    .to_string(),
695            last_valid_block_height: 12345678,
696            prioritization_fee_lamports: Some(5000),
697            compute_unit_limit: Some(200000),
698            simulation_error: None,
699        };
700
701        Mock::given(method("POST"))
702            .and(path("/swap/v1/swap"))
703            .respond_with(ResponseTemplate::new(200).set_body_json(&swap_response))
704            .expect(1)
705            .mount(&mock_server)
706            .await;
707
708        let service = MainnetJupiterService {
709            client: Client::new(),
710            base_url: mock_server.uri(),
711        };
712
713        let request = SwapRequest {
714            quote_response: quote,
715            user_public_key: "test_public_key".to_string(),
716            wrap_and_unwrap_sol: Some(true),
717            fee_account: None,
718            compute_unit_price_micro_lamports: None,
719            prioritization_fee_lamports: None,
720            dynamic_compute_unit_limit: Some(true),
721        };
722
723        let result = service.get_swap_transaction(request).await;
724
725        assert!(result.is_ok());
726        let response = result.unwrap();
727        assert_eq!(response.last_valid_block_height, 12345678);
728        assert_eq!(response.prioritization_fee_lamports, Some(5000));
729        assert_eq!(response.compute_unit_limit, Some(200000));
730    }
731
732    #[tokio::test]
733    async fn test_get_ultra_order() {
734        let mock_server = MockServer::start().await;
735
736        let ultra_response = UltraOrderResponse {
737            input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
738            output_mint: "So11111111111111111111111111111111111111112".to_string(),
739            in_amount: 1000000,
740            out_amount: 24860952,
741            other_amount_threshold: 24362733,
742            price_impact_pct: 0.1,
743            swap_mode: "ExactIn".to_string(),
744            slippage_bps: 50,
745            route_plan: vec![RoutePlan {
746                percent: 100,
747                swap_info: SwapInfo {
748                    amm_key: "test_amm_key".to_string(),
749                    label: "test_label".to_string(),
750                    input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
751                    output_mint: "So11111111111111111111111111111111111111112".to_string(),
752                    in_amount: "1000000".to_string(),
753                    out_amount: "24860952".to_string(),
754                    fee_amount: "1000".to_string(),
755                    fee_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
756                },
757            }],
758            prioritization_fee_lamports: 5000,
759            transaction: Some("test_transaction".to_string()),
760            request_id: "test_request_id".to_string(),
761        };
762
763        Mock::given(method("GET"))
764            .and(path("/ultra/v1/order"))
765            .and(query_param(
766                "inputMint",
767                "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
768            ))
769            .and(query_param(
770                "outputMint",
771                "So11111111111111111111111111111111111111112",
772            ))
773            .and(query_param("amount", "1000000"))
774            .and(query_param("taker", "test_taker"))
775            .respond_with(ResponseTemplate::new(200).set_body_json(&ultra_response))
776            .expect(1)
777            .mount(&mock_server)
778            .await;
779        let service = MainnetJupiterService {
780            client: Client::new(),
781            base_url: mock_server.uri(),
782        };
783
784        let request = UltraOrderRequest {
785            input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
786            output_mint: "So11111111111111111111111111111111111111112".to_string(),
787            amount: 1000000,
788            taker: "test_taker".to_string(),
789        };
790
791        let result = service.get_ultra_order(request).await;
792
793        assert!(result.is_ok());
794        let response = result.unwrap();
795        assert_eq!(response.in_amount, 1000000);
796        assert_eq!(response.out_amount, 24860952);
797        assert_eq!(response.request_id, "test_request_id");
798        assert!(response.transaction.is_some());
799    }
800
801    #[tokio::test]
802    async fn test_execute_ultra_order() {
803        let mock_server = MockServer::start().await;
804
805        let execute_response = UltraExecuteResponse {
806            signature: Some("mock_signature".to_string()),
807            status: "success".to_string(),
808            slot: Some("123456789".to_string()),
809            error: None,
810            code: 0,
811            total_input_amount: Some("1000000".to_string()),
812            total_output_amount: Some("1000000".to_string()),
813            input_amount_result: Some("1000000".to_string()),
814            output_amount_result: Some("1000000".to_string()),
815            swap_events: Some(vec![SwapEvents {
816                input_mint: "mock_input_mint".to_string(),
817                output_mint: "mock_output_mint".to_string(),
818                input_amount: "1000000".to_string(),
819                output_amount: "1000000".to_string(),
820            }]),
821        };
822
823        Mock::given(method("POST"))
824            .and(path("/ultra/v1/execute"))
825            .respond_with(ResponseTemplate::new(200).set_body_json(&execute_response))
826            .expect(1)
827            .mount(&mock_server)
828            .await;
829
830        let service = MainnetJupiterService {
831            client: Client::new(),
832            base_url: mock_server.uri(),
833        };
834
835        let request = UltraExecuteRequest {
836            signed_transaction: "signed_transaction_data".to_string(),
837            request_id: "test_request_id".to_string(),
838        };
839
840        let result = service.execute_ultra_order(request).await;
841
842        assert!(result.is_ok());
843        let response = result.unwrap();
844        assert_eq!(response.signature, Some("mock_signature".to_string()));
845    }
846
847    #[tokio::test]
848    async fn test_error_handling_for_api_errors() {
849        let mock_server = MockServer::start().await;
850
851        Mock::given(method("GET"))
852            .and(path("/ultra/v1/order"))
853            .respond_with(ResponseTemplate::new(400).set_body_string("Invalid request"))
854            .expect(1)
855            .mount(&mock_server)
856            .await;
857
858        let service = MainnetJupiterService {
859            client: Client::new(),
860            base_url: mock_server.uri(),
861        };
862
863        let request = UltraOrderRequest {
864            input_mint: "invalid_mint".to_string(),
865            output_mint: "invalid_mint".to_string(),
866            amount: 1000000,
867            taker: "test_taker".to_string(),
868        };
869
870        let result = service.get_ultra_order(request).await;
871
872        assert!(result.is_err());
873        match result {
874            Err(JupiterServiceError::HttpRequestError(err)) => {
875                assert!(err
876                    .to_string()
877                    .contains("HTTP status client error (400 Bad Request)"));
878            }
879            _ => panic!("Expected ApiError but got different error type"),
880        }
881    }
882}