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