openzeppelin_relayer/domain/transaction/stellar/
utils.rs

1//! Utility functions for Stellar transaction domain logic.
2use crate::models::OperationSpec;
3use crate::models::RelayerError;
4use crate::services::provider::StellarProviderTrait;
5use soroban_rs::xdr;
6use tracing::info;
7
8/// Returns true if any operation needs simulation (contract invocation, creation, or wasm upload).
9pub 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
32/// Detects if an error is due to a bad sequence number.
33/// Returns true if the error message contains indicators of sequence number mismatch.
34pub fn is_bad_sequence_error(error_msg: &str) -> bool {
35    let error_lower = error_msg.to_lowercase();
36    error_lower.contains("txbadseq")
37}
38
39/// Fetches the current sequence number from the blockchain and calculates the next usable sequence.
40/// This is a shared helper that can be used by both stellar_relayer and stellar_transaction.
41///
42/// # Returns
43/// The next usable sequence number (on-chain sequence + 1)
44pub 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    // Fetch account info from chain
57    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; // Extract the i64 value
63    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
73/// Convert a V0 transaction to V1 format for signing.
74/// This is needed because the signature payload for V0 transactions uses V1 format internally.
75pub 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
90/// Create a signature payload for the given envelope type
91pub 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            // For V0, convert to V1 transaction format for signing
98            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
115/// Create signature payload for a transaction directly (for operations-based signing)
116pub 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// ============================================================================
129// Status Check Utility Functions
130// ============================================================================
131
132#[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            // Smallest u64 value that will overflow i64
254            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        /// Helper to create a test transaction with a specific created_at timestamp
296        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); // 30 seconds ago
326                let age = get_age_since_created(&tx).unwrap();
327
328                // Allow for small timing differences (within 1 second)
329                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); // 1 hour ago
335                let age = get_age_since_created(&tx).unwrap();
336
337                // Allow for small timing differences
338                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); // Just now
344                let age = get_age_since_created(&tx).unwrap();
345
346                // Should be very close to 0
347                assert!(age.num_seconds() >= 0 && age.num_seconds() <= 1);
348            }
349
350            #[test]
351            fn test_handles_negative_age_gracefully() {
352                // Create transaction with future timestamp (clock skew scenario)
353                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                // Age should be negative
360                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                // Test with UTC timezone
393                tx.created_at = "2025-01-01T12:00:00Z".to_string();
394                assert!(get_age_since_created(&tx).is_ok());
395
396                // Test with offset timezone
397                tx.created_at = "2025-01-01T12:00:00+00:00".to_string();
398                assert!(get_age_since_created(&tx).is_ok());
399
400                // Test with milliseconds
401                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        // Test create_transaction_signature_payload
415        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        // Test create_signature_payload with V0 envelope
430        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            // Create a simple V0 transaction
455            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            // Convert to V1
466            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
467
468            // Check that conversion worked correctly
469            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            // Check source account conversion
477            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            // Create a V0 transaction with time bounds
488            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            // Convert to V1
504            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
505
506            // Check that time bounds were correctly converted to preconditions
507            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}