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}
113
114impl From<TransactionRepoModel> for TransactionResponse {
115    fn from(model: TransactionRepoModel) -> Self {
116        match model.network_data {
117            NetworkTransactionData::Evm(evm_data) => {
118                TransactionResponse::Evm(Box::new(EvmTransactionResponse {
119                    id: model.id,
120                    hash: evm_data.hash,
121                    status: model.status,
122                    status_reason: model.status_reason,
123                    created_at: model.created_at,
124                    sent_at: model.sent_at,
125                    confirmed_at: model.confirmed_at,
126                    gas_price: evm_data.gas_price,
127                    gas_limit: evm_data.gas_limit,
128                    nonce: evm_data.nonce,
129                    value: evm_data.value,
130                    from: evm_data.from,
131                    to: evm_data.to,
132                    relayer_id: model.relayer_id,
133                    data: evm_data.data,
134                    max_fee_per_gas: evm_data.max_fee_per_gas,
135                    max_priority_fee_per_gas: evm_data.max_priority_fee_per_gas,
136                    signature: evm_data.signature,
137                    speed: evm_data.speed,
138                }))
139            }
140            NetworkTransactionData::Solana(solana_data) => {
141                TransactionResponse::Solana(Box::new(SolanaTransactionResponse {
142                    id: model.id,
143                    transaction: solana_data.transaction.unwrap_or_default(),
144                    status: model.status,
145                    status_reason: model.status_reason,
146                    created_at: model.created_at,
147                    sent_at: model.sent_at,
148                    confirmed_at: model.confirmed_at,
149                    signature: solana_data.signature,
150                    instructions: solana_data.instructions,
151                }))
152            }
153            NetworkTransactionData::Stellar(stellar_data) => {
154                TransactionResponse::Stellar(Box::new(StellarTransactionResponse {
155                    id: model.id,
156                    hash: stellar_data.hash,
157                    status: model.status,
158                    status_reason: model.status_reason,
159                    created_at: model.created_at,
160                    sent_at: model.sent_at,
161                    confirmed_at: model.confirmed_at,
162                    source_account: stellar_data.source_account,
163                    fee: stellar_data.fee.unwrap_or(0),
164                    sequence_number: stellar_data.sequence_number.unwrap_or(0),
165                    relayer_id: model.relayer_id,
166                }))
167            }
168        }
169    }
170}
171
172/// Network-agnostic fee estimate response for gasless transactions.
173/// Contains network-specific fee estimate results.
174#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
175#[serde(untagged)]
176#[schema(as = SponsoredTransactionQuoteResponse)]
177pub enum SponsoredTransactionQuoteResponse {
178    /// Solana-specific fee estimate result
179    Solana(SolanaFeeEstimateResult),
180    /// Stellar-specific fee estimate result
181    Stellar(StellarFeeEstimateResult),
182}
183
184/// Network-agnostic prepare transaction response for gasless transactions.
185/// Contains network-specific prepare transaction results.
186#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
187#[serde(untagged)]
188#[schema(as = SponsoredTransactionBuildResponse)]
189pub enum SponsoredTransactionBuildResponse {
190    /// Solana-specific prepare transaction result
191    Solana(SolanaPrepareTransactionResult),
192    /// Stellar-specific prepare transaction result
193    Stellar(StellarPrepareTransactionResult),
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::models::{
200        EvmTransactionData, NetworkType, SolanaTransactionData, StellarTransactionData,
201        TransactionRepoModel,
202    };
203    use chrono::Utc;
204
205    #[test]
206    fn test_from_transaction_repo_model_evm() {
207        let now = Utc::now().to_rfc3339();
208        let model = TransactionRepoModel {
209            id: "tx123".to_string(),
210            status: TransactionStatus::Pending,
211            status_reason: None,
212            created_at: now.clone(),
213            sent_at: Some(now.clone()),
214            confirmed_at: None,
215            relayer_id: "relayer1".to_string(),
216            priced_at: None,
217            hashes: vec![],
218            network_data: NetworkTransactionData::Evm(EvmTransactionData {
219                hash: Some("0xabc123".to_string()),
220                gas_price: Some(20_000_000_000),
221                gas_limit: Some(21000),
222                nonce: Some(5),
223                value: U256::from(1000000000000000000u128), // 1 ETH
224                from: "0xsender".to_string(),
225                to: Some("0xrecipient".to_string()),
226                data: None,
227                chain_id: 1,
228                signature: None,
229                speed: None,
230                max_fee_per_gas: None,
231                max_priority_fee_per_gas: None,
232                raw: None,
233            }),
234            valid_until: None,
235            network_type: NetworkType::Evm,
236            noop_count: None,
237            is_canceled: Some(false),
238            delete_at: None,
239        };
240
241        let response = TransactionResponse::from(model.clone());
242
243        match response {
244            TransactionResponse::Evm(evm) => {
245                assert_eq!(evm.id, model.id);
246                assert_eq!(evm.hash, Some("0xabc123".to_string()));
247                assert_eq!(evm.status, TransactionStatus::Pending);
248                assert_eq!(evm.created_at, now);
249                assert_eq!(evm.sent_at, Some(now.clone()));
250                assert_eq!(evm.confirmed_at, None);
251                assert_eq!(evm.gas_price, Some(20_000_000_000));
252                assert_eq!(evm.gas_limit, Some(21000));
253                assert_eq!(evm.nonce, Some(5));
254                assert_eq!(evm.value, U256::from(1000000000000000000u128));
255                assert_eq!(evm.from, "0xsender");
256                assert_eq!(evm.to, Some("0xrecipient".to_string()));
257                assert_eq!(evm.relayer_id, "relayer1");
258            }
259            _ => panic!("Expected EvmTransactionResponse"),
260        }
261    }
262
263    #[test]
264    fn test_from_transaction_repo_model_solana() {
265        let now = Utc::now().to_rfc3339();
266        let model = TransactionRepoModel {
267            id: "tx456".to_string(),
268            status: TransactionStatus::Confirmed,
269            status_reason: None,
270            created_at: now.clone(),
271            sent_at: Some(now.clone()),
272            confirmed_at: Some(now.clone()),
273            relayer_id: "relayer2".to_string(),
274            priced_at: None,
275            hashes: vec![],
276            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
277                transaction: Some("transaction_123".to_string()),
278                instructions: None,
279                signature: Some("signature_123".to_string()),
280            }),
281            valid_until: None,
282            network_type: NetworkType::Solana,
283            noop_count: None,
284            is_canceled: Some(false),
285            delete_at: None,
286        };
287
288        let response = TransactionResponse::from(model.clone());
289
290        match response {
291            TransactionResponse::Solana(solana) => {
292                assert_eq!(solana.id, model.id);
293                assert_eq!(solana.status, TransactionStatus::Confirmed);
294                assert_eq!(solana.created_at, now);
295                assert_eq!(solana.sent_at, Some(now.clone()));
296                assert_eq!(solana.confirmed_at, Some(now.clone()));
297                assert_eq!(solana.transaction, "transaction_123");
298                assert_eq!(solana.signature, Some("signature_123".to_string()));
299            }
300            _ => panic!("Expected SolanaTransactionResponse"),
301        }
302    }
303
304    #[test]
305    fn test_from_transaction_repo_model_stellar() {
306        let now = Utc::now().to_rfc3339();
307        let model = TransactionRepoModel {
308            id: "tx789".to_string(),
309            status: TransactionStatus::Failed,
310            status_reason: None,
311            created_at: now.clone(),
312            sent_at: Some(now.clone()),
313            confirmed_at: Some(now.clone()),
314            relayer_id: "relayer3".to_string(),
315            priced_at: None,
316            hashes: vec![],
317            network_data: NetworkTransactionData::Stellar(StellarTransactionData {
318                hash: Some("stellar_hash_123".to_string()),
319                source_account: "source_account_id".to_string(),
320                fee: Some(100),
321                sequence_number: Some(12345),
322                transaction_input: crate::models::TransactionInput::Operations(vec![]),
323                network_passphrase: "Test SDF Network ; September 2015".to_string(),
324                memo: None,
325                valid_until: None,
326                signatures: Vec::new(),
327                simulation_transaction_data: None,
328                signed_envelope_xdr: None,
329            }),
330            valid_until: None,
331            network_type: NetworkType::Stellar,
332            noop_count: None,
333            is_canceled: Some(false),
334            delete_at: None,
335        };
336
337        let response = TransactionResponse::from(model.clone());
338
339        match response {
340            TransactionResponse::Stellar(stellar) => {
341                assert_eq!(stellar.id, model.id);
342                assert_eq!(stellar.hash, Some("stellar_hash_123".to_string()));
343                assert_eq!(stellar.status, TransactionStatus::Failed);
344                assert_eq!(stellar.created_at, now);
345                assert_eq!(stellar.sent_at, Some(now.clone()));
346                assert_eq!(stellar.confirmed_at, Some(now.clone()));
347                assert_eq!(stellar.source_account, "source_account_id");
348                assert_eq!(stellar.fee, 100);
349                assert_eq!(stellar.sequence_number, 12345);
350                assert_eq!(stellar.relayer_id, "relayer3");
351            }
352            _ => panic!("Expected StellarTransactionResponse"),
353        }
354    }
355
356    #[test]
357    fn test_stellar_fee_bump_transaction_response() {
358        let now = Utc::now().to_rfc3339();
359        let model = TransactionRepoModel {
360            id: "tx-fee-bump".to_string(),
361            status: TransactionStatus::Confirmed,
362            status_reason: None,
363            created_at: now.clone(),
364            sent_at: Some(now.clone()),
365            confirmed_at: Some(now.clone()),
366            relayer_id: "relayer3".to_string(),
367            priced_at: None,
368            hashes: vec!["fee_bump_hash_456".to_string()],
369            network_data: NetworkTransactionData::Stellar(StellarTransactionData {
370                hash: Some("fee_bump_hash_456".to_string()),
371                source_account: "fee_source_account".to_string(),
372                fee: Some(200),
373                sequence_number: Some(54321),
374                transaction_input: crate::models::TransactionInput::SignedXdr {
375                    xdr: "dummy_xdr".to_string(),
376                    max_fee: 1_000_000,
377                },
378                network_passphrase: "Test SDF Network ; September 2015".to_string(),
379                memo: None,
380                valid_until: None,
381                signatures: Vec::new(),
382                simulation_transaction_data: None,
383                signed_envelope_xdr: None,
384            }),
385            valid_until: None,
386            network_type: NetworkType::Stellar,
387            noop_count: None,
388            is_canceled: Some(false),
389            delete_at: None,
390        };
391
392        let response = TransactionResponse::from(model.clone());
393
394        match response {
395            TransactionResponse::Stellar(stellar) => {
396                assert_eq!(stellar.id, model.id);
397                assert_eq!(stellar.hash, Some("fee_bump_hash_456".to_string()));
398                assert_eq!(stellar.status, TransactionStatus::Confirmed);
399                assert_eq!(stellar.created_at, now);
400                assert_eq!(stellar.sent_at, Some(now.clone()));
401                assert_eq!(stellar.confirmed_at, Some(now.clone()));
402                assert_eq!(stellar.source_account, "fee_source_account");
403                assert_eq!(stellar.fee, 200);
404                assert_eq!(stellar.sequence_number, 54321);
405                assert_eq!(stellar.relayer_id, "relayer3");
406            }
407            _ => panic!("Expected StellarTransactionResponse"),
408        }
409    }
410
411    #[test]
412    fn test_solana_default_recent_blockhash() {
413        let now = Utc::now().to_rfc3339();
414        let model = TransactionRepoModel {
415            id: "tx456".to_string(),
416            status: TransactionStatus::Pending,
417            status_reason: None,
418            created_at: now.clone(),
419            sent_at: None,
420            confirmed_at: None,
421            relayer_id: "relayer2".to_string(),
422            priced_at: None,
423            hashes: vec![],
424            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
425                transaction: Some("transaction_123".to_string()),
426                instructions: None,
427                signature: None,
428            }),
429            valid_until: None,
430            network_type: NetworkType::Solana,
431            noop_count: None,
432            is_canceled: Some(false),
433            delete_at: None,
434        };
435
436        let response = TransactionResponse::from(model);
437
438        match response {
439            TransactionResponse::Solana(solana) => {
440                assert_eq!(solana.transaction, "transaction_123");
441                assert_eq!(solana.signature, None);
442            }
443            _ => panic!("Expected SolanaTransactionResponse"),
444        }
445    }
446}