openzeppelin_relayer/domain/transaction/stellar/
utils.rs1use crate::models::OperationSpec;
3use crate::models::RelayerError;
4use crate::services::provider::StellarProviderTrait;
5use soroban_rs::xdr;
6use tracing::info;
7
8pub fn needs_simulation(operations: &[OperationSpec]) -> bool {
10 operations.iter().any(|op| {
11 matches!(
12 op,
13 OperationSpec::InvokeContract { .. }
14 | OperationSpec::CreateContract { .. }
15 | OperationSpec::UploadWasm { .. }
16 )
17 })
18}
19
20pub fn next_sequence_u64(seq_num: i64) -> Result<u64, RelayerError> {
21 let next_i64 = seq_num
22 .checked_add(1)
23 .ok_or_else(|| RelayerError::ProviderError("sequence overflow".into()))?;
24 u64::try_from(next_i64)
25 .map_err(|_| RelayerError::ProviderError("sequence overflows u64".into()))
26}
27
28pub fn i64_from_u64(value: u64) -> Result<i64, RelayerError> {
29 i64::try_from(value).map_err(|_| RelayerError::ProviderError("u64→i64 overflow".into()))
30}
31
32pub fn is_bad_sequence_error(error_msg: &str) -> bool {
35 let error_lower = error_msg.to_lowercase();
36 error_lower.contains("txbadseq")
37}
38
39pub async fn fetch_next_sequence_from_chain<P>(
45 provider: &P,
46 relayer_address: &str,
47) -> Result<u64, String>
48where
49 P: StellarProviderTrait,
50{
51 info!(
52 "Fetching sequence from chain for address: {}",
53 relayer_address
54 );
55
56 let account = provider
58 .get_account(relayer_address)
59 .await
60 .map_err(|e| format!("Failed to fetch account from chain: {e}"))?;
61
62 let on_chain_seq = account.seq_num.0; let next_usable = next_sequence_u64(on_chain_seq)
64 .map_err(|e| format!("Failed to calculate next sequence: {e}"))?;
65
66 info!(
67 "Fetched sequence from chain: on-chain={}, next usable={}",
68 on_chain_seq, next_usable
69 );
70 Ok(next_usable)
71}
72
73pub fn convert_v0_to_v1_transaction(v0_tx: &xdr::TransactionV0) -> xdr::Transaction {
76 xdr::Transaction {
77 source_account: xdr::MuxedAccount::Ed25519(v0_tx.source_account_ed25519.clone()),
78 fee: v0_tx.fee,
79 seq_num: v0_tx.seq_num.clone(),
80 cond: match v0_tx.time_bounds.clone() {
81 Some(tb) => xdr::Preconditions::Time(tb),
82 None => xdr::Preconditions::None,
83 },
84 memo: v0_tx.memo.clone(),
85 operations: v0_tx.operations.clone(),
86 ext: xdr::TransactionExt::V0,
87 }
88}
89
90pub fn create_signature_payload(
92 envelope: &xdr::TransactionEnvelope,
93 network_id: &xdr::Hash,
94) -> Result<xdr::TransactionSignaturePayload, RelayerError> {
95 let tagged_transaction = match envelope {
96 xdr::TransactionEnvelope::TxV0(e) => {
97 let v1_tx = convert_v0_to_v1_transaction(&e.tx);
99 xdr::TransactionSignaturePayloadTaggedTransaction::Tx(v1_tx)
100 }
101 xdr::TransactionEnvelope::Tx(e) => {
102 xdr::TransactionSignaturePayloadTaggedTransaction::Tx(e.tx.clone())
103 }
104 xdr::TransactionEnvelope::TxFeeBump(e) => {
105 xdr::TransactionSignaturePayloadTaggedTransaction::TxFeeBump(e.tx.clone())
106 }
107 };
108
109 Ok(xdr::TransactionSignaturePayload {
110 network_id: network_id.clone(),
111 tagged_transaction,
112 })
113}
114
115pub fn create_transaction_signature_payload(
117 transaction: &xdr::Transaction,
118 network_id: &xdr::Hash,
119) -> xdr::TransactionSignaturePayload {
120 xdr::TransactionSignaturePayload {
121 network_id: network_id.clone(),
122 tagged_transaction: xdr::TransactionSignaturePayloadTaggedTransaction::Tx(
123 transaction.clone(),
124 ),
125 }
126}
127
128#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::models::AssetSpec;
136 use crate::models::{AuthSpec, ContractSource, WasmSource};
137
138 const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
139
140 fn payment_op(destination: &str) -> OperationSpec {
141 OperationSpec::Payment {
142 destination: destination.to_string(),
143 amount: 100,
144 asset: AssetSpec::Native,
145 }
146 }
147
148 #[test]
149 fn returns_false_for_only_payment_ops() {
150 let ops = vec![payment_op(TEST_PK)];
151 assert!(!needs_simulation(&ops));
152 }
153
154 #[test]
155 fn returns_true_for_invoke_contract_ops() {
156 let ops = vec![OperationSpec::InvokeContract {
157 contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
158 .to_string(),
159 function_name: "transfer".to_string(),
160 args: vec![],
161 auth: None,
162 }];
163 assert!(needs_simulation(&ops));
164 }
165
166 #[test]
167 fn returns_true_for_upload_wasm_ops() {
168 let ops = vec![OperationSpec::UploadWasm {
169 wasm: WasmSource::Hex {
170 hex: "deadbeef".to_string(),
171 },
172 auth: None,
173 }];
174 assert!(needs_simulation(&ops));
175 }
176
177 #[test]
178 fn returns_true_for_create_contract_ops() {
179 let ops = vec![OperationSpec::CreateContract {
180 source: ContractSource::Address {
181 address: TEST_PK.to_string(),
182 },
183 wasm_hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
184 .to_string(),
185 salt: None,
186 constructor_args: None,
187 auth: None,
188 }];
189 assert!(needs_simulation(&ops));
190 }
191
192 #[test]
193 fn returns_true_for_single_invoke_host_function() {
194 let ops = vec![OperationSpec::InvokeContract {
195 contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
196 .to_string(),
197 function_name: "transfer".to_string(),
198 args: vec![],
199 auth: Some(AuthSpec::SourceAccount),
200 }];
201 assert!(needs_simulation(&ops));
202 }
203
204 #[test]
205 fn returns_false_for_multiple_payment_ops() {
206 let ops = vec![payment_op(TEST_PK), payment_op(TEST_PK)];
207 assert!(!needs_simulation(&ops));
208 }
209
210 mod next_sequence_u64_tests {
211 use super::*;
212
213 #[test]
214 fn test_increment() {
215 assert_eq!(next_sequence_u64(0).unwrap(), 1);
216
217 assert_eq!(next_sequence_u64(12345).unwrap(), 12346);
218 }
219
220 #[test]
221 fn test_error_path_overflow_i64_max() {
222 let result = next_sequence_u64(i64::MAX);
223 assert!(result.is_err());
224 match result.unwrap_err() {
225 RelayerError::ProviderError(msg) => assert_eq!(msg, "sequence overflow"),
226 _ => panic!("Unexpected error type"),
227 }
228 }
229 }
230
231 mod i64_from_u64_tests {
232 use super::*;
233
234 #[test]
235 fn test_happy_path_conversion() {
236 assert_eq!(i64_from_u64(0).unwrap(), 0);
237 assert_eq!(i64_from_u64(12345).unwrap(), 12345);
238 assert_eq!(i64_from_u64(i64::MAX as u64).unwrap(), i64::MAX);
239 }
240
241 #[test]
242 fn test_error_path_overflow_u64_max() {
243 let result = i64_from_u64(u64::MAX);
244 assert!(result.is_err());
245 match result.unwrap_err() {
246 RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
247 _ => panic!("Unexpected error type"),
248 }
249 }
250
251 #[test]
252 fn test_edge_case_just_above_i64_max() {
253 let value = (i64::MAX as u64) + 1;
255 let result = i64_from_u64(value);
256 assert!(result.is_err());
257 match result.unwrap_err() {
258 RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
259 _ => panic!("Unexpected error type"),
260 }
261 }
262 }
263
264 mod is_bad_sequence_error_tests {
265 use super::*;
266
267 #[test]
268 fn test_detects_txbadseq() {
269 assert!(is_bad_sequence_error(
270 "Failed to send transaction: transaction submission failed: TxBadSeq"
271 ));
272 assert!(is_bad_sequence_error("Error: TxBadSeq"));
273 assert!(is_bad_sequence_error("txbadseq"));
274 assert!(is_bad_sequence_error("TXBADSEQ"));
275 }
276
277 #[test]
278 fn test_returns_false_for_other_errors() {
279 assert!(!is_bad_sequence_error("network timeout"));
280 assert!(!is_bad_sequence_error("insufficient balance"));
281 assert!(!is_bad_sequence_error("tx_insufficient_fee"));
282 assert!(!is_bad_sequence_error("bad_auth"));
283 assert!(!is_bad_sequence_error(""));
284 }
285 }
286
287 mod status_check_utils_tests {
288 use crate::models::{
289 NetworkTransactionData, StellarTransactionData, TransactionError, TransactionInput,
290 TransactionRepoModel,
291 };
292 use crate::utils::mocks::mockutils::create_mock_transaction;
293 use chrono::{Duration, Utc};
294
295 fn create_test_tx_with_age(seconds_ago: i64) -> TransactionRepoModel {
297 let created_at = (Utc::now() - Duration::seconds(seconds_ago)).to_rfc3339();
298 let mut tx = create_mock_transaction();
299 tx.id = format!("test-tx-{}", seconds_ago);
300 tx.created_at = created_at;
301 tx.network_data = NetworkTransactionData::Stellar(StellarTransactionData {
302 source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
303 .to_string(),
304 fee: None,
305 sequence_number: None,
306 memo: None,
307 valid_until: None,
308 network_passphrase: "Test SDF Network ; September 2015".to_string(),
309 signatures: vec![],
310 hash: Some("test-hash-12345".to_string()),
311 simulation_transaction_data: None,
312 transaction_input: TransactionInput::Operations(vec![]),
313 signed_envelope_xdr: None,
314 });
315 tx
316 }
317
318 mod get_age_since_created_tests {
319 use crate::domain::transaction::util::get_age_since_created;
320
321 use super::*;
322
323 #[test]
324 fn test_returns_correct_age_for_recent_transaction() {
325 let tx = create_test_tx_with_age(30); let age = get_age_since_created(&tx).unwrap();
327
328 assert!(age.num_seconds() >= 29 && age.num_seconds() <= 31);
330 }
331
332 #[test]
333 fn test_returns_correct_age_for_old_transaction() {
334 let tx = create_test_tx_with_age(3600); let age = get_age_since_created(&tx).unwrap();
336
337 assert!(age.num_seconds() >= 3599 && age.num_seconds() <= 3601);
339 }
340
341 #[test]
342 fn test_returns_zero_age_for_just_created_transaction() {
343 let tx = create_test_tx_with_age(0); let age = get_age_since_created(&tx).unwrap();
345
346 assert!(age.num_seconds() >= 0 && age.num_seconds() <= 1);
348 }
349
350 #[test]
351 fn test_handles_negative_age_gracefully() {
352 let created_at = (Utc::now() + Duration::seconds(10)).to_rfc3339();
354 let mut tx = create_mock_transaction();
355 tx.created_at = created_at;
356
357 let age = get_age_since_created(&tx).unwrap();
358
359 assert!(age.num_seconds() < 0);
361 }
362
363 #[test]
364 fn test_returns_error_for_invalid_created_at() {
365 let mut tx = create_mock_transaction();
366 tx.created_at = "invalid-timestamp".to_string();
367
368 let result = get_age_since_created(&tx);
369 assert!(result.is_err());
370
371 match result.unwrap_err() {
372 TransactionError::UnexpectedError(msg) => {
373 assert!(msg.contains("Invalid created_at timestamp"));
374 }
375 _ => panic!("Expected UnexpectedError"),
376 }
377 }
378
379 #[test]
380 fn test_returns_error_for_empty_created_at() {
381 let mut tx = create_mock_transaction();
382 tx.created_at = "".to_string();
383
384 let result = get_age_since_created(&tx);
385 assert!(result.is_err());
386 }
387
388 #[test]
389 fn test_handles_various_rfc3339_formats() {
390 let mut tx = create_mock_transaction();
391
392 tx.created_at = "2025-01-01T12:00:00Z".to_string();
394 assert!(get_age_since_created(&tx).is_ok());
395
396 tx.created_at = "2025-01-01T12:00:00+00:00".to_string();
398 assert!(get_age_since_created(&tx).is_ok());
399
400 tx.created_at = "2025-01-01T12:00:00.123Z".to_string();
402 assert!(get_age_since_created(&tx).is_ok());
403 }
404 }
405 }
406
407 #[test]
408 fn test_create_signature_payload_functions() {
409 use xdr::{
410 Hash, SequenceNumber, TransactionEnvelope, TransactionV0, TransactionV0Envelope,
411 Uint256,
412 };
413
414 let transaction = xdr::Transaction {
416 source_account: xdr::MuxedAccount::Ed25519(Uint256([1u8; 32])),
417 fee: 100,
418 seq_num: SequenceNumber(123),
419 cond: xdr::Preconditions::None,
420 memo: xdr::Memo::None,
421 operations: vec![].try_into().unwrap(),
422 ext: xdr::TransactionExt::V0,
423 };
424 let network_id = Hash([2u8; 32]);
425
426 let payload = create_transaction_signature_payload(&transaction, &network_id);
427 assert_eq!(payload.network_id, network_id);
428
429 let v0_tx = TransactionV0 {
431 source_account_ed25519: Uint256([1u8; 32]),
432 fee: 100,
433 seq_num: SequenceNumber(123),
434 time_bounds: None,
435 memo: xdr::Memo::None,
436 operations: vec![].try_into().unwrap(),
437 ext: xdr::TransactionV0Ext::V0,
438 };
439 let v0_envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
440 tx: v0_tx,
441 signatures: vec![].try_into().unwrap(),
442 });
443
444 let v0_payload = create_signature_payload(&v0_envelope, &network_id).unwrap();
445 assert_eq!(v0_payload.network_id, network_id);
446 }
447
448 mod convert_v0_to_v1_transaction_tests {
449 use super::*;
450 use xdr::{SequenceNumber, TransactionV0, Uint256};
451
452 #[test]
453 fn test_convert_v0_to_v1_transaction() {
454 let v0_tx = TransactionV0 {
456 source_account_ed25519: Uint256([1u8; 32]),
457 fee: 100,
458 seq_num: SequenceNumber(123),
459 time_bounds: None,
460 memo: xdr::Memo::None,
461 operations: vec![].try_into().unwrap(),
462 ext: xdr::TransactionV0Ext::V0,
463 };
464
465 let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
467
468 assert_eq!(v1_tx.fee, v0_tx.fee);
470 assert_eq!(v1_tx.seq_num, v0_tx.seq_num);
471 assert_eq!(v1_tx.memo, v0_tx.memo);
472 assert_eq!(v1_tx.operations, v0_tx.operations);
473 assert!(matches!(v1_tx.ext, xdr::TransactionExt::V0));
474 assert!(matches!(v1_tx.cond, xdr::Preconditions::None));
475
476 match v1_tx.source_account {
478 xdr::MuxedAccount::Ed25519(addr) => {
479 assert_eq!(addr, v0_tx.source_account_ed25519);
480 }
481 _ => panic!("Expected Ed25519 muxed account"),
482 }
483 }
484
485 #[test]
486 fn test_convert_v0_to_v1_transaction_with_time_bounds() {
487 let time_bounds = xdr::TimeBounds {
489 min_time: xdr::TimePoint(100),
490 max_time: xdr::TimePoint(200),
491 };
492
493 let v0_tx = TransactionV0 {
494 source_account_ed25519: Uint256([2u8; 32]),
495 fee: 200,
496 seq_num: SequenceNumber(456),
497 time_bounds: Some(time_bounds.clone()),
498 memo: xdr::Memo::Text("test".try_into().unwrap()),
499 operations: vec![].try_into().unwrap(),
500 ext: xdr::TransactionV0Ext::V0,
501 };
502
503 let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
505
506 match v1_tx.cond {
508 xdr::Preconditions::Time(tb) => {
509 assert_eq!(tb, time_bounds);
510 }
511 _ => panic!("Expected Time preconditions"),
512 }
513 }
514 }
515}