openzeppelin_relayer/models/transaction/stellar/
conversion.rs

1//! Transaction conversion logic for Stellar
2
3use crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE;
4use crate::domain::string_to_muxed_account;
5use crate::models::transaction::repository::StellarTransactionData;
6use crate::models::SignerError;
7use chrono::DateTime;
8use soroban_rs::xdr::{
9    Limits, Memo, Operation, Preconditions, ReadXdr, SequenceNumber, TimeBounds, TimePoint,
10    Transaction, TransactionExt, VecM,
11};
12use std::convert::TryFrom;
13
14pub type DecoratedSignature = soroban_rs::xdr::DecoratedSignature;
15
16#[derive(Debug, Clone)]
17pub struct TimeBoundsSpec {
18    pub min_time: u64,
19    pub max_time: u64,
20}
21
22fn valid_until_to_time_bounds(valid_until: Option<String>) -> Option<TimeBoundsSpec> {
23    valid_until.and_then(|expiry| {
24        if let Ok(expiry_time) = expiry.parse::<u64>() {
25            Some(TimeBoundsSpec {
26                min_time: 0,
27                max_time: expiry_time,
28            })
29        } else if let Ok(dt) = DateTime::parse_from_rfc3339(&expiry) {
30            Some(TimeBoundsSpec {
31                min_time: 0,
32                max_time: dt.timestamp() as u64,
33            })
34        } else {
35            None
36        }
37    })
38}
39
40impl TryFrom<StellarTransactionData> for Transaction {
41    type Error = SignerError;
42
43    fn try_from(data: StellarTransactionData) -> Result<Self, Self::Error> {
44        match &data.transaction_input {
45            crate::models::TransactionInput::Operations(ops) => {
46                // Build transaction from operations
47                let converted_ops: Result<Vec<Operation>, SignerError> = ops
48                    .iter()
49                    .map(|op| Operation::try_from(op.clone()))
50                    .collect();
51                let operations = converted_ops?;
52
53                let operations: VecM<Operation, 100> = operations
54                    .try_into()
55                    .map_err(|_| SignerError::ConversionError("op count > 100".into()))?;
56
57                let time_bounds = valid_until_to_time_bounds(data.valid_until);
58                let cond = match time_bounds {
59                    None => Preconditions::None,
60                    Some(tb) => Preconditions::Time(TimeBounds {
61                        min_time: TimePoint(tb.min_time),
62                        max_time: TimePoint(tb.max_time),
63                    }),
64                };
65
66                let memo = match &data.memo {
67                    Some(memo_spec) => Memo::try_from(memo_spec.clone())?,
68                    None => Memo::None,
69                };
70
71                let fee = data.fee.unwrap_or(STELLAR_DEFAULT_TRANSACTION_FEE);
72                let sequence = data.sequence_number.unwrap_or(0);
73
74                let source_account =
75                    string_to_muxed_account(&data.source_account).map_err(|e| {
76                        SignerError::ConversionError(format!("Invalid source account: {e}"))
77                    })?;
78
79                // Apply transaction extension data from simulation if available
80                let ext = match &data.simulation_transaction_data {
81                    Some(xdr_data) => {
82                        use soroban_rs::xdr::SorobanTransactionData;
83                        match SorobanTransactionData::from_xdr_base64(xdr_data, Limits::none()) {
84                            Ok(tx_data) => {
85                                tracing::info!(
86                                    "Applied transaction extension data from simulation"
87                                );
88                                TransactionExt::V1(tx_data)
89                            }
90                            Err(e) => {
91                                tracing::warn!(
92                                    "Failed to decode transaction data XDR: {}, using V0",
93                                    e
94                                );
95                                TransactionExt::V0
96                            }
97                        }
98                    }
99                    None => TransactionExt::V0,
100                };
101
102                Ok(Transaction {
103                    source_account,
104                    fee,
105                    seq_num: SequenceNumber(sequence),
106                    cond,
107                    memo,
108                    operations,
109                    ext,
110                })
111            }
112            crate::models::TransactionInput::UnsignedXdr(_)
113            | crate::models::TransactionInput::SignedXdr { .. }
114            | crate::models::TransactionInput::SorobanGasAbstraction { .. } => {
115                // XDR inputs should not be converted to Transaction
116                // The signer handles TransactionEnvelope XDR directly
117                Err(SignerError::ConversionError(
118                    "XDR inputs should not be converted to Transaction - use envelope directly"
119                        .into(),
120                ))
121            }
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::models::transaction::stellar::asset::AssetSpec;
130    use crate::models::transaction::stellar::{MemoSpec, OperationSpec};
131
132    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
133
134    #[test]
135    fn test_basic_transaction() {
136        let data = StellarTransactionData {
137            source_account: TEST_PK.to_string(),
138            fee: Some(100),
139            sequence_number: Some(1),
140            memo: Some(MemoSpec::None),
141            valid_until: None,
142            transaction_input: crate::models::TransactionInput::Operations(vec![
143                OperationSpec::Payment {
144                    destination: TEST_PK.to_string(),
145                    amount: 1000,
146                    asset: AssetSpec::Native,
147                },
148            ]),
149            network_passphrase: "Test SDF Network ; September 2015".to_string(),
150            signatures: Vec::new(),
151            hash: None,
152            simulation_transaction_data: None,
153            signed_envelope_xdr: None,
154            transaction_result_xdr: None,
155        };
156
157        let tx = Transaction::try_from(data).unwrap();
158        assert_eq!(tx.fee, 100);
159        assert_eq!(tx.seq_num.0, 1);
160        assert_eq!(tx.operations.len(), 1);
161    }
162
163    #[test]
164    fn test_transaction_with_time_bounds() {
165        let data = StellarTransactionData {
166            source_account: TEST_PK.to_string(),
167            fee: None,
168            sequence_number: None,
169            memo: None,
170            valid_until: Some("1735689600".to_string()),
171            transaction_input: crate::models::TransactionInput::Operations(vec![
172                OperationSpec::Payment {
173                    destination: TEST_PK.to_string(),
174                    amount: 1000,
175                    asset: AssetSpec::Native,
176                },
177            ]),
178            network_passphrase: "Test SDF Network ; September 2015".to_string(),
179            signatures: Vec::new(),
180            hash: None,
181            simulation_transaction_data: None,
182            signed_envelope_xdr: None,
183            transaction_result_xdr: None,
184        };
185
186        let tx = Transaction::try_from(data).unwrap();
187        if let Preconditions::Time(tb) = tx.cond {
188            assert_eq!(tb.max_time.0, 1735689600);
189        } else {
190            panic!("Expected time bounds");
191        }
192    }
193
194    #[test]
195    fn test_valid_until_numeric_string() {
196        let tb = valid_until_to_time_bounds(Some("12345".to_string())).unwrap();
197        assert_eq!(tb.max_time, 12_345);
198        assert_eq!(tb.min_time, 0);
199    }
200
201    #[test]
202    fn test_valid_until_rfc3339_string() {
203        let tb = valid_until_to_time_bounds(Some("2025-01-01T00:00:00Z".to_string())).unwrap();
204        assert_eq!(tb.max_time, 1_735_689_600);
205        assert_eq!(tb.min_time, 0);
206    }
207
208    #[test]
209    fn test_valid_until_invalid_string() {
210        assert!(valid_until_to_time_bounds(Some("not a date".to_string())).is_none());
211    }
212
213    #[test]
214    fn test_valid_until_none() {
215        assert!(valid_until_to_time_bounds(None).is_none());
216    }
217}