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