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        };
245
246        let response = TransactionResponse::from(model.clone());
247
248        match response {
249            TransactionResponse::Evm(evm) => {
250                assert_eq!(evm.id, model.id);
251                assert_eq!(evm.hash, Some("0xabc123".to_string()));
252                assert_eq!(evm.status, TransactionStatus::Pending);
253                assert_eq!(evm.created_at, now);
254                assert_eq!(evm.sent_at, Some(now.clone()));
255                assert_eq!(evm.confirmed_at, None);
256                assert_eq!(evm.gas_price, Some(20_000_000_000));
257                assert_eq!(evm.gas_limit, Some(21000));
258                assert_eq!(evm.nonce, Some(5));
259                assert_eq!(evm.value, U256::from(1000000000000000000u128));
260                assert_eq!(evm.from, "0xsender");
261                assert_eq!(evm.to, Some("0xrecipient".to_string()));
262                assert_eq!(evm.relayer_id, "relayer1");
263            }
264            _ => panic!("Expected EvmTransactionResponse"),
265        }
266    }
267
268    #[test]
269    fn test_from_transaction_repo_model_solana() {
270        let now = Utc::now().to_rfc3339();
271        let model = TransactionRepoModel {
272            id: "tx456".to_string(),
273            status: TransactionStatus::Confirmed,
274            status_reason: None,
275            created_at: now.clone(),
276            sent_at: Some(now.clone()),
277            confirmed_at: Some(now.clone()),
278            relayer_id: "relayer2".to_string(),
279            priced_at: None,
280            hashes: vec![],
281            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
282                transaction: Some("transaction_123".to_string()),
283                instructions: None,
284                signature: Some("signature_123".to_string()),
285            }),
286            valid_until: None,
287            network_type: NetworkType::Solana,
288            noop_count: None,
289            is_canceled: Some(false),
290            delete_at: None,
291        };
292
293        let response = TransactionResponse::from(model.clone());
294
295        match response {
296            TransactionResponse::Solana(solana) => {
297                assert_eq!(solana.id, model.id);
298                assert_eq!(solana.status, TransactionStatus::Confirmed);
299                assert_eq!(solana.created_at, now);
300                assert_eq!(solana.sent_at, Some(now.clone()));
301                assert_eq!(solana.confirmed_at, Some(now.clone()));
302                assert_eq!(solana.transaction, "transaction_123");
303                assert_eq!(solana.signature, Some("signature_123".to_string()));
304            }
305            _ => panic!("Expected SolanaTransactionResponse"),
306        }
307    }
308
309    #[test]
310    fn test_from_transaction_repo_model_stellar() {
311        let now = Utc::now().to_rfc3339();
312        let model = TransactionRepoModel {
313            id: "tx789".to_string(),
314            status: TransactionStatus::Failed,
315            status_reason: None,
316            created_at: now.clone(),
317            sent_at: Some(now.clone()),
318            confirmed_at: Some(now.clone()),
319            relayer_id: "relayer3".to_string(),
320            priced_at: None,
321            hashes: vec![],
322            network_data: NetworkTransactionData::Stellar(StellarTransactionData {
323                hash: Some("stellar_hash_123".to_string()),
324                source_account: "source_account_id".to_string(),
325                fee: Some(100),
326                sequence_number: Some(12345),
327                transaction_input: crate::models::TransactionInput::Operations(vec![]),
328                network_passphrase: "Test SDF Network ; September 2015".to_string(),
329                memo: None,
330                valid_until: None,
331                signatures: Vec::new(),
332                simulation_transaction_data: None,
333                signed_envelope_xdr: None,
334                transaction_result_xdr: None,
335            }),
336            valid_until: None,
337            network_type: NetworkType::Stellar,
338            noop_count: None,
339            is_canceled: Some(false),
340            delete_at: None,
341        };
342
343        let response = TransactionResponse::from(model.clone());
344
345        match response {
346            TransactionResponse::Stellar(stellar) => {
347                assert_eq!(stellar.id, model.id);
348                assert_eq!(stellar.hash, Some("stellar_hash_123".to_string()));
349                assert_eq!(stellar.status, TransactionStatus::Failed);
350                assert_eq!(stellar.created_at, now);
351                assert_eq!(stellar.sent_at, Some(now.clone()));
352                assert_eq!(stellar.confirmed_at, Some(now.clone()));
353                assert_eq!(stellar.source_account, "source_account_id");
354                assert_eq!(stellar.fee, 100);
355                assert_eq!(stellar.sequence_number, 12345);
356                assert_eq!(stellar.relayer_id, "relayer3");
357            }
358            _ => panic!("Expected StellarTransactionResponse"),
359        }
360    }
361
362    #[test]
363    fn test_stellar_fee_bump_transaction_response() {
364        let now = Utc::now().to_rfc3339();
365        let model = TransactionRepoModel {
366            id: "tx-fee-bump".to_string(),
367            status: TransactionStatus::Confirmed,
368            status_reason: None,
369            created_at: now.clone(),
370            sent_at: Some(now.clone()),
371            confirmed_at: Some(now.clone()),
372            relayer_id: "relayer3".to_string(),
373            priced_at: None,
374            hashes: vec!["fee_bump_hash_456".to_string()],
375            network_data: NetworkTransactionData::Stellar(StellarTransactionData {
376                hash: Some("fee_bump_hash_456".to_string()),
377                source_account: "fee_source_account".to_string(),
378                fee: Some(200),
379                sequence_number: Some(54321),
380                transaction_input: crate::models::TransactionInput::SignedXdr {
381                    xdr: "dummy_xdr".to_string(),
382                    max_fee: 1_000_000,
383                },
384                network_passphrase: "Test SDF Network ; September 2015".to_string(),
385                memo: None,
386                valid_until: None,
387                signatures: Vec::new(),
388                simulation_transaction_data: None,
389                signed_envelope_xdr: None,
390                transaction_result_xdr: None,
391            }),
392            valid_until: None,
393            network_type: NetworkType::Stellar,
394            noop_count: None,
395            is_canceled: Some(false),
396            delete_at: None,
397        };
398
399        let response = TransactionResponse::from(model.clone());
400
401        match response {
402            TransactionResponse::Stellar(stellar) => {
403                assert_eq!(stellar.id, model.id);
404                assert_eq!(stellar.hash, Some("fee_bump_hash_456".to_string()));
405                assert_eq!(stellar.status, TransactionStatus::Confirmed);
406                assert_eq!(stellar.created_at, now);
407                assert_eq!(stellar.sent_at, Some(now.clone()));
408                assert_eq!(stellar.confirmed_at, Some(now.clone()));
409                assert_eq!(stellar.source_account, "fee_source_account");
410                assert_eq!(stellar.fee, 200);
411                assert_eq!(stellar.sequence_number, 54321);
412                assert_eq!(stellar.relayer_id, "relayer3");
413            }
414            _ => panic!("Expected StellarTransactionResponse"),
415        }
416    }
417
418    #[test]
419    fn test_solana_default_recent_blockhash() {
420        let now = Utc::now().to_rfc3339();
421        let model = TransactionRepoModel {
422            id: "tx456".to_string(),
423            status: TransactionStatus::Pending,
424            status_reason: None,
425            created_at: now.clone(),
426            sent_at: None,
427            confirmed_at: None,
428            relayer_id: "relayer2".to_string(),
429            priced_at: None,
430            hashes: vec![],
431            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
432                transaction: Some("transaction_123".to_string()),
433                instructions: None,
434                signature: None,
435            }),
436            valid_until: None,
437            network_type: NetworkType::Solana,
438            noop_count: None,
439            is_canceled: Some(false),
440            delete_at: None,
441        };
442
443        let response = TransactionResponse::from(model);
444
445        match response {
446            TransactionResponse::Solana(solana) => {
447                assert_eq!(solana.transaction, "transaction_123");
448                assert_eq!(solana.signature, None);
449            }
450            _ => panic!("Expected SolanaTransactionResponse"),
451        }
452    }
453}