openzeppelin_relayer/models/transaction/
response.rs

1use crate::{
2    models::rpc::{
3        SolanaFeeEstimateResult, SolanaPrepareTransactionResult, StellarFeeEstimateResult,
4        StellarPrepareTransactionResult,
5    },
6    models::{
7        evm::Speed, EvmTransactionDataSignature, NetworkTransactionData, SolanaInstructionSpec,
8        TransactionRepoModel, TransactionStatus, U256,
9    },
10    utils::{
11        deserialize_i64, deserialize_optional_u128, deserialize_optional_u64, serialize_i64,
12        serialize_optional_u128,
13    },
14};
15use serde::{Deserialize, Serialize};
16use utoipa::ToSchema;
17
18#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
19#[serde(untagged)]
20pub enum TransactionResponse {
21    Evm(Box<EvmTransactionResponse>),
22    Solana(Box<SolanaTransactionResponse>),
23    Stellar(Box<StellarTransactionResponse>),
24}
25
26#[derive(Debug, Serialize, Clone, PartialEq, Deserialize, ToSchema)]
27pub struct EvmTransactionResponse {
28    pub id: String,
29    #[schema(nullable = false)]
30    pub hash: Option<String>,
31    pub status: TransactionStatus,
32    pub status_reason: Option<String>,
33    pub created_at: String,
34    #[schema(nullable = false)]
35    pub sent_at: Option<String>,
36    #[schema(nullable = false)]
37    pub confirmed_at: Option<String>,
38    #[serde(
39        serialize_with = "serialize_optional_u128",
40        deserialize_with = "deserialize_optional_u128",
41        default
42    )]
43    #[schema(nullable = false, value_type = String)]
44    pub gas_price: Option<u128>,
45    #[serde(deserialize_with = "deserialize_optional_u64", default)]
46    pub gas_limit: Option<u64>,
47    #[serde(deserialize_with = "deserialize_optional_u64", default)]
48    #[schema(nullable = false)]
49    pub nonce: Option<u64>,
50    #[schema(value_type = String)]
51    pub value: U256,
52    pub from: String,
53    #[schema(nullable = false)]
54    pub to: Option<String>,
55    pub relayer_id: String,
56    #[schema(nullable = false)]
57    pub data: Option<String>,
58    #[serde(
59        serialize_with = "serialize_optional_u128",
60        deserialize_with = "deserialize_optional_u128",
61        default
62    )]
63    #[schema(nullable = false, value_type = String)]
64    pub max_fee_per_gas: Option<u128>,
65    #[serde(
66        serialize_with = "serialize_optional_u128",
67        deserialize_with = "deserialize_optional_u128",
68        default
69    )]
70    #[schema(nullable = false, value_type = String)]
71    pub max_priority_fee_per_gas: Option<u128>,
72    pub signature: Option<EvmTransactionDataSignature>,
73    pub speed: Option<Speed>,
74}
75
76#[derive(Debug, Serialize, Clone, PartialEq, Deserialize, ToSchema)]
77pub struct SolanaTransactionResponse {
78    pub id: String,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    #[schema(nullable = false)]
81    pub signature: Option<String>,
82    pub status: TransactionStatus,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    #[schema(nullable = false)]
85    pub status_reason: Option<String>,
86    pub created_at: String,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    #[schema(nullable = false)]
89    pub sent_at: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    #[schema(nullable = false)]
92    pub confirmed_at: Option<String>,
93    pub transaction: String,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    #[schema(nullable = false)]
96    pub instructions: Option<Vec<SolanaInstructionSpec>>,
97}
98
99#[derive(Debug, Serialize, Clone, PartialEq, Deserialize, ToSchema)]
100pub struct StellarTransactionResponse {
101    pub id: String,
102    #[schema(nullable = false)]
103    pub hash: Option<String>,
104    pub status: TransactionStatus,
105    pub status_reason: Option<String>,
106    pub created_at: String,
107    #[schema(nullable = false)]
108    pub sent_at: Option<String>,
109    #[schema(nullable = false)]
110    pub confirmed_at: Option<String>,
111    pub source_account: String,
112    pub fee: u32,
113    /// Stellar sequence number encoded as a decimal string to preserve precision.
114    #[serde(serialize_with = "serialize_i64", deserialize_with = "deserialize_i64")]
115    #[schema(value_type = String, pattern = "^-?[0-9]+$")]
116    pub sequence_number: i64,
117    pub relayer_id: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    #[schema(nullable = false)]
120    pub transaction_result_xdr: Option<String>,
121}
122
123impl From<TransactionRepoModel> for TransactionResponse {
124    fn from(model: TransactionRepoModel) -> Self {
125        match model.network_data {
126            NetworkTransactionData::Evm(evm_data) => {
127                TransactionResponse::Evm(Box::new(EvmTransactionResponse {
128                    id: model.id,
129                    hash: evm_data.hash,
130                    status: model.status,
131                    status_reason: model.status_reason,
132                    created_at: model.created_at,
133                    sent_at: model.sent_at,
134                    confirmed_at: model.confirmed_at,
135                    gas_price: evm_data.gas_price,
136                    gas_limit: evm_data.gas_limit,
137                    nonce: evm_data.nonce,
138                    value: evm_data.value,
139                    from: evm_data.from,
140                    to: evm_data.to,
141                    relayer_id: model.relayer_id,
142                    data: evm_data.data,
143                    max_fee_per_gas: evm_data.max_fee_per_gas,
144                    max_priority_fee_per_gas: evm_data.max_priority_fee_per_gas,
145                    signature: evm_data.signature,
146                    speed: evm_data.speed,
147                }))
148            }
149            NetworkTransactionData::Solana(solana_data) => {
150                TransactionResponse::Solana(Box::new(SolanaTransactionResponse {
151                    id: model.id,
152                    transaction: solana_data.transaction.unwrap_or_default(),
153                    status: model.status,
154                    status_reason: model.status_reason,
155                    created_at: model.created_at,
156                    sent_at: model.sent_at,
157                    confirmed_at: model.confirmed_at,
158                    signature: solana_data.signature,
159                    instructions: solana_data.instructions,
160                }))
161            }
162            NetworkTransactionData::Stellar(stellar_data) => {
163                TransactionResponse::Stellar(Box::new(StellarTransactionResponse {
164                    id: model.id,
165                    hash: stellar_data.hash,
166                    status: model.status,
167                    status_reason: model.status_reason,
168                    created_at: model.created_at,
169                    sent_at: model.sent_at,
170                    confirmed_at: model.confirmed_at,
171                    source_account: stellar_data.source_account,
172                    fee: stellar_data.fee.unwrap_or(0),
173                    sequence_number: stellar_data.sequence_number.unwrap_or(0),
174                    relayer_id: model.relayer_id,
175                    transaction_result_xdr: stellar_data.transaction_result_xdr,
176                }))
177            }
178        }
179    }
180}
181
182/// Network-agnostic fee estimate response for gasless transactions.
183/// Contains network-specific fee estimate results.
184#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
185#[serde(untagged)]
186#[schema(as = SponsoredTransactionQuoteResponse)]
187pub enum SponsoredTransactionQuoteResponse {
188    /// Solana-specific fee estimate result
189    Solana(SolanaFeeEstimateResult),
190    /// Stellar-specific fee estimate result (classic and Soroban)
191    Stellar(StellarFeeEstimateResult),
192}
193
194/// Network-agnostic prepare transaction response for gasless transactions.
195/// Contains network-specific prepare transaction results.
196#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
197#[serde(untagged)]
198#[schema(as = SponsoredTransactionBuildResponse)]
199pub enum SponsoredTransactionBuildResponse {
200    /// Solana-specific prepare transaction result
201    Solana(SolanaPrepareTransactionResult),
202    /// Stellar-specific prepare transaction result (classic and Soroban)
203    /// For Soroban: includes optional user_auth_entry, expiration_ledger
204    Stellar(StellarPrepareTransactionResult),
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::models::{
211        EvmTransactionData, NetworkType, SolanaTransactionData, StellarTransactionData,
212        TransactionRepoModel,
213    };
214    use chrono::Utc;
215
216    #[test]
217    fn test_from_transaction_repo_model_evm() {
218        let now = Utc::now().to_rfc3339();
219        let model = TransactionRepoModel {
220            id: "tx123".to_string(),
221            status: TransactionStatus::Pending,
222            status_reason: None,
223            created_at: now.clone(),
224            sent_at: Some(now.clone()),
225            confirmed_at: None,
226            relayer_id: "relayer1".to_string(),
227            priced_at: None,
228            hashes: vec![],
229            network_data: NetworkTransactionData::Evm(EvmTransactionData {
230                hash: Some("0xabc123".to_string()),
231                gas_price: Some(20_000_000_000),
232                gas_limit: Some(21000),
233                nonce: Some(5),
234                value: U256::from(1000000000000000000u128), // 1 ETH
235                from: "0xsender".to_string(),
236                to: Some("0xrecipient".to_string()),
237                data: None,
238                chain_id: 1,
239                signature: None,
240                speed: None,
241                max_fee_per_gas: None,
242                max_priority_fee_per_gas: None,
243                raw: None,
244            }),
245            valid_until: None,
246            network_type: NetworkType::Evm,
247            noop_count: None,
248            is_canceled: Some(false),
249            delete_at: None,
250            metadata: None,
251        };
252
253        let response = TransactionResponse::from(model.clone());
254
255        match response {
256            TransactionResponse::Evm(evm) => {
257                assert_eq!(evm.id, model.id);
258                assert_eq!(evm.hash, Some("0xabc123".to_string()));
259                assert_eq!(evm.status, TransactionStatus::Pending);
260                assert_eq!(evm.created_at, now);
261                assert_eq!(evm.sent_at, Some(now.clone()));
262                assert_eq!(evm.confirmed_at, None);
263                assert_eq!(evm.gas_price, Some(20_000_000_000));
264                assert_eq!(evm.gas_limit, Some(21000));
265                assert_eq!(evm.nonce, Some(5));
266                assert_eq!(evm.value, U256::from(1000000000000000000u128));
267                assert_eq!(evm.from, "0xsender");
268                assert_eq!(evm.to, Some("0xrecipient".to_string()));
269                assert_eq!(evm.relayer_id, "relayer1");
270            }
271            _ => panic!("Expected EvmTransactionResponse"),
272        }
273    }
274
275    #[test]
276    fn test_from_transaction_repo_model_solana() {
277        let now = Utc::now().to_rfc3339();
278        let model = TransactionRepoModel {
279            id: "tx456".to_string(),
280            status: TransactionStatus::Confirmed,
281            status_reason: None,
282            created_at: now.clone(),
283            sent_at: Some(now.clone()),
284            confirmed_at: Some(now.clone()),
285            relayer_id: "relayer2".to_string(),
286            priced_at: None,
287            hashes: vec![],
288            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
289                transaction: Some("transaction_123".to_string()),
290                instructions: None,
291                signature: Some("signature_123".to_string()),
292            }),
293            valid_until: None,
294            network_type: NetworkType::Solana,
295            noop_count: None,
296            is_canceled: Some(false),
297            delete_at: None,
298            metadata: None,
299        };
300
301        let response = TransactionResponse::from(model.clone());
302
303        match response {
304            TransactionResponse::Solana(solana) => {
305                assert_eq!(solana.id, model.id);
306                assert_eq!(solana.status, TransactionStatus::Confirmed);
307                assert_eq!(solana.created_at, now);
308                assert_eq!(solana.sent_at, Some(now.clone()));
309                assert_eq!(solana.confirmed_at, Some(now.clone()));
310                assert_eq!(solana.transaction, "transaction_123");
311                assert_eq!(solana.signature, Some("signature_123".to_string()));
312            }
313            _ => panic!("Expected SolanaTransactionResponse"),
314        }
315    }
316
317    #[test]
318    fn test_from_transaction_repo_model_stellar() {
319        let now = Utc::now().to_rfc3339();
320        let model = TransactionRepoModel {
321            id: "tx789".to_string(),
322            status: TransactionStatus::Failed,
323            status_reason: None,
324            created_at: now.clone(),
325            sent_at: Some(now.clone()),
326            confirmed_at: Some(now.clone()),
327            relayer_id: "relayer3".to_string(),
328            priced_at: None,
329            hashes: vec![],
330            network_data: NetworkTransactionData::Stellar(StellarTransactionData {
331                hash: Some("stellar_hash_123".to_string()),
332                source_account: "source_account_id".to_string(),
333                fee: Some(100),
334                sequence_number: Some(12345),
335                transaction_input: crate::models::TransactionInput::Operations(vec![]),
336                network_passphrase: "Test SDF Network ; September 2015".to_string(),
337                memo: None,
338                valid_until: None,
339                signatures: Vec::new(),
340                simulation_transaction_data: None,
341                signed_envelope_xdr: None,
342                transaction_result_xdr: None,
343            }),
344            valid_until: None,
345            network_type: NetworkType::Stellar,
346            noop_count: None,
347            is_canceled: Some(false),
348            delete_at: None,
349            metadata: None,
350        };
351
352        let response = TransactionResponse::from(model.clone());
353
354        match response {
355            TransactionResponse::Stellar(stellar) => {
356                assert_eq!(stellar.id, model.id);
357                assert_eq!(stellar.hash, Some("stellar_hash_123".to_string()));
358                assert_eq!(stellar.status, TransactionStatus::Failed);
359                assert_eq!(stellar.created_at, now);
360                assert_eq!(stellar.sent_at, Some(now.clone()));
361                assert_eq!(stellar.confirmed_at, Some(now.clone()));
362                assert_eq!(stellar.source_account, "source_account_id");
363                assert_eq!(stellar.fee, 100);
364                assert_eq!(stellar.sequence_number, 12345);
365                assert_eq!(stellar.relayer_id, "relayer3");
366            }
367            _ => panic!("Expected StellarTransactionResponse"),
368        }
369    }
370
371    #[test]
372    fn test_stellar_fee_bump_transaction_response() {
373        let now = Utc::now().to_rfc3339();
374        let model = TransactionRepoModel {
375            id: "tx-fee-bump".to_string(),
376            status: TransactionStatus::Confirmed,
377            status_reason: None,
378            created_at: now.clone(),
379            sent_at: Some(now.clone()),
380            confirmed_at: Some(now.clone()),
381            relayer_id: "relayer3".to_string(),
382            priced_at: None,
383            hashes: vec!["fee_bump_hash_456".to_string()],
384            network_data: NetworkTransactionData::Stellar(StellarTransactionData {
385                hash: Some("fee_bump_hash_456".to_string()),
386                source_account: "fee_source_account".to_string(),
387                fee: Some(200),
388                sequence_number: Some(54321),
389                transaction_input: crate::models::TransactionInput::SignedXdr {
390                    xdr: "dummy_xdr".to_string(),
391                    max_fee: 1_000_000,
392                },
393                network_passphrase: "Test SDF Network ; September 2015".to_string(),
394                memo: None,
395                valid_until: None,
396                signatures: Vec::new(),
397                simulation_transaction_data: None,
398                signed_envelope_xdr: None,
399                transaction_result_xdr: None,
400            }),
401            valid_until: None,
402            network_type: NetworkType::Stellar,
403            noop_count: None,
404            is_canceled: Some(false),
405            delete_at: None,
406            metadata: None,
407        };
408
409        let response = TransactionResponse::from(model.clone());
410
411        match response {
412            TransactionResponse::Stellar(stellar) => {
413                assert_eq!(stellar.id, model.id);
414                assert_eq!(stellar.hash, Some("fee_bump_hash_456".to_string()));
415                assert_eq!(stellar.status, TransactionStatus::Confirmed);
416                assert_eq!(stellar.created_at, now);
417                assert_eq!(stellar.sent_at, Some(now.clone()));
418                assert_eq!(stellar.confirmed_at, Some(now.clone()));
419                assert_eq!(stellar.source_account, "fee_source_account");
420                assert_eq!(stellar.fee, 200);
421                assert_eq!(stellar.sequence_number, 54321);
422                assert_eq!(stellar.relayer_id, "relayer3");
423            }
424            _ => panic!("Expected StellarTransactionResponse"),
425        }
426    }
427
428    #[test]
429    fn test_stellar_sequence_number_serializes_as_string() {
430        let response = StellarTransactionResponse {
431            id: "tx123".to_string(),
432            hash: None,
433            status: TransactionStatus::Confirmed,
434            status_reason: None,
435            created_at: "2024-01-01T00:00:00Z".to_string(),
436            sent_at: None,
437            confirmed_at: None,
438            source_account: "source_account_id".to_string(),
439            fee: 100,
440            sequence_number: 643918676885760,
441            relayer_id: "relayer3".to_string(),
442            transaction_result_xdr: None,
443        };
444
445        let json = serde_json::to_string(&response).unwrap();
446        assert!(
447            json.contains(r#""sequence_number":"643918676885760""#),
448            "sequence_number should serialize as a string, got: {json}"
449        );
450
451        let decoded: StellarTransactionResponse = serde_json::from_str(&json).unwrap();
452        assert_eq!(decoded.sequence_number, 643918676885760);
453    }
454
455    #[test]
456    fn test_solana_default_recent_blockhash() {
457        let now = Utc::now().to_rfc3339();
458        let model = TransactionRepoModel {
459            id: "tx456".to_string(),
460            status: TransactionStatus::Pending,
461            status_reason: None,
462            created_at: now.clone(),
463            sent_at: None,
464            confirmed_at: None,
465            relayer_id: "relayer2".to_string(),
466            priced_at: None,
467            hashes: vec![],
468            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
469                transaction: Some("transaction_123".to_string()),
470                instructions: None,
471                signature: None,
472            }),
473            valid_until: None,
474            network_type: NetworkType::Solana,
475            noop_count: None,
476            is_canceled: Some(false),
477            delete_at: None,
478            metadata: None,
479        };
480
481        let response = TransactionResponse::from(model);
482
483        match response {
484            TransactionResponse::Solana(solana) => {
485                assert_eq!(solana.transaction, "transaction_123");
486                assert_eq!(solana.signature, None);
487            }
488            _ => panic!("Expected SolanaTransactionResponse"),
489        }
490    }
491}