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::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#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::models::AssetSpec;
132    use crate::models::{AuthSpec, ContractSource, WasmSource};
133
134    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
135
136    fn payment_op(destination: &str) -> OperationSpec {
137        OperationSpec::Payment {
138            destination: destination.to_string(),
139            amount: 100,
140            asset: AssetSpec::Native,
141        }
142    }
143
144    #[test]
145    fn returns_false_for_only_payment_ops() {
146        let ops = vec![payment_op(TEST_PK)];
147        assert!(!needs_simulation(&ops));
148    }
149
150    #[test]
151    fn returns_true_for_invoke_contract_ops() {
152        let ops = vec![OperationSpec::InvokeContract {
153            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
154                .to_string(),
155            function_name: "transfer".to_string(),
156            args: vec![],
157            auth: None,
158        }];
159        assert!(needs_simulation(&ops));
160    }
161
162    #[test]
163    fn returns_true_for_upload_wasm_ops() {
164        let ops = vec![OperationSpec::UploadWasm {
165            wasm: WasmSource::Hex {
166                hex: "deadbeef".to_string(),
167            },
168            auth: None,
169        }];
170        assert!(needs_simulation(&ops));
171    }
172
173    #[test]
174    fn returns_true_for_create_contract_ops() {
175        let ops = vec![OperationSpec::CreateContract {
176            source: ContractSource::Address {
177                address: TEST_PK.to_string(),
178            },
179            wasm_hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
180                .to_string(),
181            salt: None,
182            constructor_args: None,
183            auth: None,
184        }];
185        assert!(needs_simulation(&ops));
186    }
187
188    #[test]
189    fn returns_true_for_single_invoke_host_function() {
190        let ops = vec![OperationSpec::InvokeContract {
191            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
192                .to_string(),
193            function_name: "transfer".to_string(),
194            args: vec![],
195            auth: Some(AuthSpec::SourceAccount),
196        }];
197        assert!(needs_simulation(&ops));
198    }
199
200    #[test]
201    fn returns_false_for_multiple_payment_ops() {
202        let ops = vec![payment_op(TEST_PK), payment_op(TEST_PK)];
203        assert!(!needs_simulation(&ops));
204    }
205
206    mod next_sequence_u64_tests {
207        use super::*;
208
209        #[test]
210        fn test_increment() {
211            assert_eq!(next_sequence_u64(0).unwrap(), 1);
212
213            assert_eq!(next_sequence_u64(12345).unwrap(), 12346);
214        }
215
216        #[test]
217        fn test_error_path_overflow_i64_max() {
218            let result = next_sequence_u64(i64::MAX);
219            assert!(result.is_err());
220            match result.unwrap_err() {
221                RelayerError::ProviderError(msg) => assert_eq!(msg, "sequence overflow"),
222                _ => panic!("Unexpected error type"),
223            }
224        }
225    }
226
227    mod i64_from_u64_tests {
228        use super::*;
229
230        #[test]
231        fn test_happy_path_conversion() {
232            assert_eq!(i64_from_u64(0).unwrap(), 0);
233            assert_eq!(i64_from_u64(12345).unwrap(), 12345);
234            assert_eq!(i64_from_u64(i64::MAX as u64).unwrap(), i64::MAX);
235        }
236
237        #[test]
238        fn test_error_path_overflow_u64_max() {
239            let result = i64_from_u64(u64::MAX);
240            assert!(result.is_err());
241            match result.unwrap_err() {
242                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
243                _ => panic!("Unexpected error type"),
244            }
245        }
246
247        #[test]
248        fn test_edge_case_just_above_i64_max() {
249            // Smallest u64 value that will overflow i64
250            let value = (i64::MAX as u64) + 1;
251            let result = i64_from_u64(value);
252            assert!(result.is_err());
253            match result.unwrap_err() {
254                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
255                _ => panic!("Unexpected error type"),
256            }
257        }
258    }
259
260    mod is_bad_sequence_error_tests {
261        use super::*;
262
263        #[test]
264        fn test_detects_txbadseq() {
265            assert!(is_bad_sequence_error(
266                "Failed to send transaction: transaction submission failed: TxBadSeq"
267            ));
268            assert!(is_bad_sequence_error("Error: TxBadSeq"));
269            assert!(is_bad_sequence_error("txbadseq"));
270            assert!(is_bad_sequence_error("TXBADSEQ"));
271        }
272
273        #[test]
274        fn test_returns_false_for_other_errors() {
275            assert!(!is_bad_sequence_error("network timeout"));
276            assert!(!is_bad_sequence_error("insufficient balance"));
277            assert!(!is_bad_sequence_error("tx_insufficient_fee"));
278            assert!(!is_bad_sequence_error("bad_auth"));
279            assert!(!is_bad_sequence_error(""));
280        }
281    }
282
283    #[test]
284    fn test_create_signature_payload_functions() {
285        use xdr::{
286            Hash, SequenceNumber, TransactionEnvelope, TransactionV0, TransactionV0Envelope,
287            Uint256,
288        };
289
290        // Test create_transaction_signature_payload
291        let transaction = xdr::Transaction {
292            source_account: xdr::MuxedAccount::Ed25519(Uint256([1u8; 32])),
293            fee: 100,
294            seq_num: SequenceNumber(123),
295            cond: xdr::Preconditions::None,
296            memo: xdr::Memo::None,
297            operations: vec![].try_into().unwrap(),
298            ext: xdr::TransactionExt::V0,
299        };
300        let network_id = Hash([2u8; 32]);
301
302        let payload = create_transaction_signature_payload(&transaction, &network_id);
303        assert_eq!(payload.network_id, network_id);
304
305        // Test create_signature_payload with V0 envelope
306        let v0_tx = TransactionV0 {
307            source_account_ed25519: Uint256([1u8; 32]),
308            fee: 100,
309            seq_num: SequenceNumber(123),
310            time_bounds: None,
311            memo: xdr::Memo::None,
312            operations: vec![].try_into().unwrap(),
313            ext: xdr::TransactionV0Ext::V0,
314        };
315        let v0_envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
316            tx: v0_tx,
317            signatures: vec![].try_into().unwrap(),
318        });
319
320        let v0_payload = create_signature_payload(&v0_envelope, &network_id).unwrap();
321        assert_eq!(v0_payload.network_id, network_id);
322    }
323
324    mod convert_v0_to_v1_transaction_tests {
325        use super::*;
326        use xdr::{SequenceNumber, TransactionV0, Uint256};
327
328        #[test]
329        fn test_convert_v0_to_v1_transaction() {
330            // Create a simple V0 transaction
331            let v0_tx = TransactionV0 {
332                source_account_ed25519: Uint256([1u8; 32]),
333                fee: 100,
334                seq_num: SequenceNumber(123),
335                time_bounds: None,
336                memo: xdr::Memo::None,
337                operations: vec![].try_into().unwrap(),
338                ext: xdr::TransactionV0Ext::V0,
339            };
340
341            // Convert to V1
342            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
343
344            // Check that conversion worked correctly
345            assert_eq!(v1_tx.fee, v0_tx.fee);
346            assert_eq!(v1_tx.seq_num, v0_tx.seq_num);
347            assert_eq!(v1_tx.memo, v0_tx.memo);
348            assert_eq!(v1_tx.operations, v0_tx.operations);
349            assert!(matches!(v1_tx.ext, xdr::TransactionExt::V0));
350            assert!(matches!(v1_tx.cond, xdr::Preconditions::None));
351
352            // Check source account conversion
353            match v1_tx.source_account {
354                xdr::MuxedAccount::Ed25519(addr) => {
355                    assert_eq!(addr, v0_tx.source_account_ed25519);
356                }
357                _ => panic!("Expected Ed25519 muxed account"),
358            }
359        }
360
361        #[test]
362        fn test_convert_v0_to_v1_transaction_with_time_bounds() {
363            // Create a V0 transaction with time bounds
364            let time_bounds = xdr::TimeBounds {
365                min_time: xdr::TimePoint(100),
366                max_time: xdr::TimePoint(200),
367            };
368
369            let v0_tx = TransactionV0 {
370                source_account_ed25519: Uint256([2u8; 32]),
371                fee: 200,
372                seq_num: SequenceNumber(456),
373                time_bounds: Some(time_bounds.clone()),
374                memo: xdr::Memo::Text("test".try_into().unwrap()),
375                operations: vec![].try_into().unwrap(),
376                ext: xdr::TransactionV0Ext::V0,
377            };
378
379            // Convert to V1
380            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
381
382            // Check that time bounds were correctly converted to preconditions
383            match v1_tx.cond {
384                xdr::Preconditions::Time(tb) => {
385                    assert_eq!(tb, time_bounds);
386                }
387                _ => panic!("Expected Time preconditions"),
388            }
389        }
390    }
391}