openzeppelin_relayer/domain/relayer/solana/dex/
jupiter_ultra.rs

1//! JupiterUltraDex
2//!
3//! Implements the `DexStrategy` trait to perform Solana token swaps via the
4//! Jupiter Ultra REST API. This module handles:
5//!  1. Fetching an Ultra order from Jupiter.
6//!  2. Decoding and signing the transaction.
7//!  3. Serializing and executing the signed order via Jupiter Ultra.
8//!  4. Returning the swap result as `SwapResult`.
9
10use std::sync::Arc;
11
12use super::{DexStrategy, SwapParams, SwapResult};
13use crate::domain::relayer::RelayerError;
14use crate::models::EncodedSerializedTransaction;
15use crate::services::{
16    JupiterService, JupiterServiceTrait, SolanaSignTrait, SolanaSigner, UltraExecuteRequest,
17    UltraOrderRequest,
18};
19use async_trait::async_trait;
20use log::info;
21use solana_sdk::transaction::VersionedTransaction;
22
23pub struct JupiterUltraDex<S, J>
24where
25    S: SolanaSignTrait + 'static,
26    J: JupiterServiceTrait + 'static,
27{
28    signer: Arc<S>,
29    jupiter_service: Arc<J>,
30}
31
32pub type DefaultJupiterUltraDex = JupiterUltraDex<SolanaSigner, JupiterService>;
33
34impl<S, J> JupiterUltraDex<S, J>
35where
36    S: SolanaSignTrait + 'static,
37    J: JupiterServiceTrait + 'static,
38{
39    pub fn new(signer: Arc<S>, jupiter_service: Arc<J>) -> Self {
40        Self {
41            signer,
42            jupiter_service,
43        }
44    }
45}
46
47#[async_trait]
48impl<S, J> DexStrategy for JupiterUltraDex<S, J>
49where
50    S: SolanaSignTrait + Send + Sync + 'static,
51    J: JupiterServiceTrait + Send + Sync + 'static,
52{
53    async fn execute_swap(&self, params: SwapParams) -> Result<SwapResult, RelayerError> {
54        info!("Executing Jupiter swap using ultra api: {:?}", params);
55
56        let order = self
57            .jupiter_service
58            .get_ultra_order(UltraOrderRequest {
59                input_mint: params.source_mint.clone(),
60                output_mint: params.destination_mint,
61                amount: params.amount,
62                taker: params.owner_address,
63            })
64            .await
65            .map_err(|e| {
66                RelayerError::DexError(format!("Failed to get Jupiter Ultra order: {}", e))
67            })?;
68
69        info!("Received order: {:?}", order);
70
71        let encoded_transaction = order.transaction.ok_or_else(|| {
72            RelayerError::DexError("Failed to get transaction from Jupiter order".to_string())
73        })?;
74
75        let mut swap_tx =
76            VersionedTransaction::try_from(EncodedSerializedTransaction::new(encoded_transaction))
77                .map_err(|e| {
78                    RelayerError::DexError(format!("Failed to decode swap transaction: {}", e))
79                })?;
80
81        let signature = self
82            .signer
83            .sign(&swap_tx.message.serialize())
84            .await
85            .map_err(|e| {
86                RelayerError::DexError(format!("Failed to sign Dex swap transaction: {}", e))
87            })?;
88
89        swap_tx.signatures[0] = signature;
90
91        info!("Execute order transaction");
92        let serialized_transaction =
93            EncodedSerializedTransaction::try_from(&swap_tx).map_err(|e| {
94                RelayerError::DexError(format!("Failed to serialize transaction: {}", e))
95            })?;
96        let response = self
97            .jupiter_service
98            .execute_ultra_order(UltraExecuteRequest {
99                signed_transaction: serialized_transaction.into_inner(),
100                request_id: order.request_id,
101            })
102            .await
103            .map_err(|e| RelayerError::DexError(format!("Failed to execute order: {}", e)))?;
104        info!("Order executed successfully, response: {:?}", response);
105
106        Ok(SwapResult {
107            mint: params.source_mint,
108            source_amount: params.amount,
109            destination_amount: order.out_amount,
110            transaction_signature: response.signature.unwrap_or_default(),
111            error: response.error,
112        })
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::{
120        models::SignerError,
121        services::{
122            MockJupiterServiceTrait, MockSolanaSignTrait, RoutePlan, SwapEvents, SwapInfo,
123            UltraExecuteResponse, UltraOrderResponse,
124        },
125    };
126    use mockall::predicate;
127    use solana_sdk::signature::Signature;
128    use std::str::FromStr;
129
130    fn create_mock_jupiter_service() -> MockJupiterServiceTrait {
131        MockJupiterServiceTrait::new()
132    }
133
134    fn create_mock_solana_signer() -> MockSolanaSignTrait {
135        MockSolanaSignTrait::new()
136    }
137
138    fn create_test_ultra_order_response(
139        input_mint: &str,
140        output_mint: &str,
141        amount: u64,
142        out_amount: u64,
143    ) -> UltraOrderResponse {
144        UltraOrderResponse {
145            input_mint: input_mint.to_string(),
146            output_mint: output_mint.to_string(),
147            in_amount: amount,
148            out_amount,
149            other_amount_threshold: out_amount,
150            price_impact_pct: 0.1,
151            swap_mode: "ExactIn".to_string(),
152            slippage_bps: 50, // 0.5%
153            route_plan: vec![RoutePlan {
154                percent: 100,
155                swap_info: SwapInfo {
156                    amm_key: "test_amm_key".to_string(),
157                    label: "Test".to_string(),
158                    input_mint: input_mint.to_string(),
159                    output_mint: output_mint.to_string(),
160                    in_amount: amount.to_string(),
161                    out_amount: out_amount.to_string(),
162                    fee_amount: "1000".to_string(),
163                    fee_mint: input_mint.to_string(),
164                },
165            }],
166            prioritization_fee_lamports: 5000,
167            transaction: Some("AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAKEZhsMunBegjHhwObzSrJeKhnl3sehIwqA8OCTejBJ/Z+O7sAR2gDS0+R1HXkqqjr0Wo3+auYeJQtq0il4DAumgiiHZpJZ1Uy9xq1yiOta3BcBOI7Dv+jmETs0W7Leny+AsVIwZWPN51bjn3Xk4uSzTFeAEom3HHY/EcBBpOfm7HkzWyukBvmNY5l9pnNxB/lTC52M7jy0Pxg6NhYJ37e1WXRYOFdoHOThs0hoFy/UG3+mVBbkR4sB9ywdKopv6IHO9+wuF/sV/02h9w+AjIBszK2bmCBPIrCZH4mqBdRcBFVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABPS2wOQQj9KmokeOrgrMWdshu07fURwWLPYC0eDAkB+1Jh0UqsxbwO7GNdqHBaH3CjnuNams8L+PIsxs5JAZ16jJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FmsH4P9uc5VDeldVYzceVRhzPQ3SsaI7BOphAAiCnjaBgMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAtD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5ejnStls42Wf0xNRAChL93gEW4UQqPNOSYySLu5vwwX4aQR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkGtJJ5s3DlXjsp517KoA8Lg71wC+tMHoDO9HDeQbotrwUMAAUCwFwVAAwACQOhzhsAAAAAAAoGAAQAIgcQAQEPOxAIAAUGAgQgIg8PDQ8hEg4JExEGARQUFAgQKAgmKgEDFhgXFSUnJCkQIywQIysIHSIqAh8DHhkbGhwLL8EgmzNB1pyBBwMAAAA6AWQAAU9kAQIvAABkAgNAQg8AAAAAAE3WYgAAAAAADwAAEAMEAAABCQMW8exZwhONJLLrrr9eKTOouI7XVrRLBjytPl3cL6rziwS+v7vCBB+8CQctooGHnRbQ3aoExfOLSH0uJhZijTPAKrJbYSJJ5hP1VwRmY2FlBkRkC2JtQsJRwDIR3Tbag/HLEdZxTPfqLWdCCyd0nco65bHdIoy/ByorMycoLzADMiYs".to_string()),
168            request_id: "test-request-id".to_string(),
169        }
170    }
171
172    #[tokio::test]
173    async fn test_execute_swap_success() {
174        // Arrange
175        let source_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC
176        let destination_mint = "So11111111111111111111111111111111111111112"; // SOL
177        let amount = 1000000; // 1 USDC
178        let output_amount = 24860952; // ~0.025 SOL
179        let owner_address = "BFzfNx3UdatqpBX4zzJH9Cp7GQZpwc3Fg1aPgYbSgZyf";
180        let test_signature = Signature::from_str("2jg9xbGLtZRsiJBrDWQnz33JuLjDkiKSZuxZPdjJ3qrJbMeTEerXFAKynkPW63J88nq63cvosDNRsg9VqHtGixvP").unwrap();
181
182        // Create mocks
183        let mut mock_jupiter_service = create_mock_jupiter_service();
184        let mut mock_solana_signer = create_mock_solana_signer();
185
186        let expected_order =
187            create_test_ultra_order_response(source_mint, destination_mint, amount, output_amount);
188
189        // Expected execute response
190        let expected_execute_response = UltraExecuteResponse {
191            signature: Some(test_signature.to_string()),
192            status: "success".to_string(),
193            slot: Some("123456789".to_string()),
194            error: None,
195            code: 0,
196            total_input_amount: Some("1000000".to_string()),
197            total_output_amount: Some("1000000".to_string()),
198            input_amount_result: Some("1000000".to_string()),
199            output_amount_result: Some("1000000".to_string()),
200            swap_events: Some(vec![SwapEvents {
201                input_mint: "mock_input_mint".to_string(),
202                output_mint: "mock_output_mint".to_string(),
203                input_amount: "1000000".to_string(),
204                output_amount: "1000000".to_string(),
205            }]),
206        };
207
208        mock_jupiter_service
209            .expect_get_ultra_order()
210            .with(predicate::function(move |req: &UltraOrderRequest| {
211                req.input_mint == source_mint
212                    && req.output_mint == destination_mint
213                    && req.amount == amount
214                    && req.taker == owner_address
215            }))
216            .times(1)
217            .returning(move |_| {
218                let order = expected_order.clone();
219                Box::pin(async move { Ok(order) })
220            });
221
222        mock_solana_signer
223            .expect_sign()
224            .times(1)
225            .returning(move |_| Box::pin(async move { Ok(test_signature) }));
226
227        mock_jupiter_service
228            .expect_execute_ultra_order()
229            .with(predicate::function(move |req: &UltraExecuteRequest| {
230                req.request_id == "test-request-id"
231            }))
232            .times(1)
233            .returning(move |_| {
234                let response = expected_execute_response.clone();
235                Box::pin(async move { Ok(response) })
236            });
237
238        let dex =
239            JupiterUltraDex::new(Arc::new(mock_solana_signer), Arc::new(mock_jupiter_service));
240
241        let result = dex
242            .execute_swap(SwapParams {
243                owner_address: owner_address.to_string(),
244                source_mint: source_mint.to_string(),
245                destination_mint: destination_mint.to_string(),
246                amount,
247                slippage_percent: 0.5,
248            })
249            .await;
250
251        assert!(
252            result.is_ok(),
253            "Swap should succeed, but got error: {:?}",
254            result.err()
255        );
256
257        let swap_result = result.unwrap();
258        assert_eq!(swap_result.source_amount, amount);
259        assert_eq!(swap_result.destination_amount, output_amount);
260        assert_eq!(
261            swap_result.transaction_signature,
262            test_signature.to_string()
263        );
264    }
265
266    #[tokio::test]
267    async fn test_execute_swap_get_order_error() {
268        let source_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC
269        let destination_mint = "So11111111111111111111111111111111111111112"; // SOL
270        let amount = 1000000; // 1 USDC
271        let owner_address = "BFzfNx3UdatqpBX4zzJH9Cp7GQZpwc3Fg1aPgYbSgZyf";
272
273        let mut mock_jupiter_service = create_mock_jupiter_service();
274        let mock_solana_signer = create_mock_solana_signer();
275
276        mock_jupiter_service
277            .expect_get_ultra_order()
278            .times(1)
279            .returning(move |_| {
280                Box::pin(async move {
281                    Err(crate::services::JupiterServiceError::ApiError {
282                        message: "API error: insufficient liquidity".to_string(),
283                    })
284                })
285            });
286
287        let dex =
288            JupiterUltraDex::new(Arc::new(mock_solana_signer), Arc::new(mock_jupiter_service));
289
290        let result = dex
291            .execute_swap(SwapParams {
292                owner_address: owner_address.to_string(),
293                source_mint: source_mint.to_string(),
294                destination_mint: destination_mint.to_string(),
295                amount,
296                slippage_percent: 0.5,
297            })
298            .await;
299
300        match result {
301            Err(RelayerError::DexError(error_message)) => {
302                assert!(
303                    error_message.contains("Failed to get Jupiter Ultra order")
304                        && error_message.contains("insufficient liquidity"),
305                    "Error message did not contain expected substrings: {}",
306                    error_message
307                );
308            }
309            Err(e) => panic!("Expected DexError but got different error: {:?}", e),
310            Ok(_) => panic!("Expected error but got Ok"),
311        }
312    }
313
314    #[tokio::test]
315    async fn test_execute_swap_missing_transaction() {
316        let source_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC
317        let destination_mint = "So11111111111111111111111111111111111111112"; // SOL
318        let amount = 1000000; // 1 USDC
319        let output_amount = 24860952; // ~0.025 SOL
320        let owner_address = "BFzfNx3UdatqpBX4zzJH9Cp7GQZpwc3Fg1aPgYbSgZyf";
321
322        let mut mock_jupiter_service = create_mock_jupiter_service();
323        let mock_solana_signer = create_mock_solana_signer();
324
325        let mut order_response =
326            create_test_ultra_order_response(source_mint, destination_mint, amount, output_amount);
327        order_response.transaction = None; // Missing transaction
328
329        mock_jupiter_service
330            .expect_get_ultra_order()
331            .times(1)
332            .returning(move |_| {
333                let order = order_response.clone();
334                Box::pin(async move { Ok(order) })
335            });
336
337        let dex =
338            JupiterUltraDex::new(Arc::new(mock_solana_signer), Arc::new(mock_jupiter_service));
339
340        let result = dex
341            .execute_swap(SwapParams {
342                owner_address: owner_address.to_string(),
343                source_mint: source_mint.to_string(),
344                destination_mint: destination_mint.to_string(),
345                amount,
346                slippage_percent: 0.5,
347            })
348            .await;
349
350        match result {
351            Err(RelayerError::DexError(error_message)) => {
352                assert!(
353                    error_message.contains("Failed to get transaction from Jupiter order"),
354                    "Error message did not contain expected substrings: {}",
355                    error_message
356                );
357            }
358            Err(e) => panic!("Expected DexError but got different error: {:?}", e),
359            Ok(_) => panic!("Expected error but got Ok"),
360        }
361    }
362
363    #[tokio::test]
364    async fn test_execute_swap_invalid_transaction_format() {
365        let source_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC
366        let destination_mint = "So11111111111111111111111111111111111111112"; // SOL
367        let amount = 1000000; // 1 USDC
368        let output_amount = 24860952; // ~0.025 SOL
369        let owner_address = "BFzfNx3UdatqpBX4zzJH9Cp7GQZpwc3Fg1aPgYbSgZyf";
370
371        let mut mock_jupiter_service = create_mock_jupiter_service();
372        let mock_solana_signer = create_mock_solana_signer();
373
374        let mut order_response =
375            create_test_ultra_order_response(source_mint, destination_mint, amount, output_amount);
376        order_response.transaction = Some("invalid-transaction-format".to_string()); // Invalid format
377
378        mock_jupiter_service
379            .expect_get_ultra_order()
380            .times(1)
381            .returning(move |_| {
382                let order = order_response.clone();
383                Box::pin(async move { Ok(order) })
384            });
385
386        let dex =
387            JupiterUltraDex::new(Arc::new(mock_solana_signer), Arc::new(mock_jupiter_service));
388
389        let result = dex
390            .execute_swap(SwapParams {
391                owner_address: owner_address.to_string(),
392                source_mint: source_mint.to_string(),
393                destination_mint: destination_mint.to_string(),
394                amount,
395                slippage_percent: 0.5,
396            })
397            .await;
398
399        match result {
400            Err(RelayerError::DexError(error_message)) => {
401                assert!(
402                    error_message.contains("Failed to decode swap transaction"),
403                    "Error message did not contain expected substrings: {}",
404                    error_message
405                );
406            }
407            Err(e) => panic!("Expected DexError but got different error: {:?}", e),
408            Ok(_) => panic!("Expected error but got Ok"),
409        }
410    }
411
412    #[tokio::test]
413    async fn test_execute_swap_signing_error() {
414        let source_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC
415        let destination_mint = "So11111111111111111111111111111111111111112"; // SOL
416        let amount = 1000000; // 1 USDC
417        let output_amount = 24860952; // ~0.025 SOL
418        let owner_address = "BFzfNx3UdatqpBX4zzJH9Cp7GQZpwc3Fg1aPgYbSgZyf";
419
420        let mut mock_jupiter_service = create_mock_jupiter_service();
421        let mut mock_solana_signer = create_mock_solana_signer();
422
423        let expected_order =
424            create_test_ultra_order_response(source_mint, destination_mint, amount, output_amount);
425
426        mock_jupiter_service
427            .expect_get_ultra_order()
428            .times(1)
429            .returning(move |_| {
430                let order = expected_order.clone();
431                Box::pin(async move { Ok(order) })
432            });
433
434        mock_solana_signer
435            .expect_sign()
436            .times(1)
437            .returning(move |_| {
438                Box::pin(async move {
439                    Err(SignerError::SigningError(
440                        "Failed to sign: invalid key".to_string(),
441                    ))
442                })
443            });
444
445        let dex =
446            JupiterUltraDex::new(Arc::new(mock_solana_signer), Arc::new(mock_jupiter_service));
447
448        let result = dex
449            .execute_swap(SwapParams {
450                owner_address: owner_address.to_string(),
451                source_mint: source_mint.to_string(),
452                destination_mint: destination_mint.to_string(),
453                amount,
454                slippage_percent: 0.5,
455            })
456            .await;
457
458        match result {
459            Err(RelayerError::DexError(error_message)) => {
460                assert!(
461                    error_message.contains("Failed to sign Dex swap transaction")
462                        && error_message.contains("Failed to sign: invalid key"),
463                    "Error message did not contain expected substrings: {}",
464                    error_message
465                );
466            }
467            Err(e) => panic!("Expected DexError but got different error: {:?}", e),
468            Ok(_) => panic!("Expected error but got Ok"),
469        }
470    }
471
472    #[tokio::test]
473    async fn test_execute_swap_execution_error() {
474        let source_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC
475        let destination_mint = "So11111111111111111111111111111111111111112"; // SOL
476        let amount = 1000000; // 1 USDC
477        let output_amount = 24860952; // ~0.025 SOL
478        let owner_address = "BFzfNx3UdatqpBX4zzJH9Cp7GQZpwc3Fg1aPgYbSgZyf";
479        let test_signature = Signature::from_str("2jg9xbGLtZRsiJBrDWQnz33JuLjDkiKSZuxZPdjJ3qrJbMeTEerXFAKynkPW63J88nq63cvosDNRsg9VqHtGixvP").unwrap();
480
481        let mut mock_jupiter_service = create_mock_jupiter_service();
482        let mut mock_solana_signer = create_mock_solana_signer();
483
484        let expected_order =
485            create_test_ultra_order_response(source_mint, destination_mint, amount, output_amount);
486
487        mock_jupiter_service
488            .expect_get_ultra_order()
489            .times(1)
490            .returning(move |_| {
491                let order = expected_order.clone();
492                Box::pin(async move { Ok(order) })
493            });
494
495        mock_solana_signer
496            .expect_sign()
497            .times(1)
498            .returning(move |_| Box::pin(async move { Ok(test_signature) }));
499
500        mock_jupiter_service
501            .expect_execute_ultra_order()
502            .times(1)
503            .returning(move |_| {
504                Box::pin(async move {
505                    Err(crate::services::JupiterServiceError::ApiError {
506                        message: "Execution failed: price slippage too high".to_string(),
507                    })
508                })
509            });
510
511        let dex =
512            JupiterUltraDex::new(Arc::new(mock_solana_signer), Arc::new(mock_jupiter_service));
513
514        let result = dex
515            .execute_swap(SwapParams {
516                owner_address: owner_address.to_string(),
517                source_mint: source_mint.to_string(),
518                destination_mint: destination_mint.to_string(),
519                amount,
520                slippage_percent: 0.5,
521            })
522            .await;
523
524        match result {
525            Err(RelayerError::DexError(error_message)) => {
526                assert!(
527                    error_message.contains("Failed to execute order")
528                        && error_message.contains("price slippage too high"),
529                    "Error message did not contain expected substrings: {}",
530                    error_message
531                );
532            }
533            Err(e) => panic!("Expected DexError but got different error: {:?}", e),
534            Ok(_) => panic!("Expected error but got Ok"),
535        }
536    }
537}