openzeppelin_relayer/models/transaction/stellar/
conversion.rs1use 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 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 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 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}