openzeppelin_relayer/domain/transaction/stellar/prepare/
mod.rs

1//! This module contains the preparation-related functionality for Stellar transactions.
2//! It includes methods for preparing transactions with robust error handling,
3//! ensuring lanes are always properly cleaned up on failure.
4
5// Declare submodules from the prepare/ directory
6pub mod common;
7pub mod fee_bump;
8pub mod operations;
9pub mod unsigned_xdr;
10
11use eyre::Result;
12use tracing::{debug, info, warn};
13
14use super::{is_final_state, lane_gate, StellarRelayerTransaction};
15use crate::models::RelayerRepoModel;
16use crate::{
17    jobs::JobProducerTrait,
18    models::{
19        TransactionError, TransactionInput, TransactionRepoModel, TransactionStatus,
20        TransactionUpdateRequest,
21    },
22    repositories::{Repository, TransactionCounterTrait, TransactionRepository},
23    services::{provider::StellarProviderTrait, signer::Signer},
24};
25
26use common::{sign_and_finalize_transaction, update_and_notify_transaction};
27
28impl<R, T, J, S, P, C, D> StellarRelayerTransaction<R, T, J, S, P, C, D>
29where
30    R: Repository<RelayerRepoModel, String> + Send + Sync,
31    T: TransactionRepository + Send + Sync,
32    J: JobProducerTrait + Send + Sync,
33    S: Signer + Send + Sync,
34    P: StellarProviderTrait + Send + Sync,
35    C: TransactionCounterTrait + Send + Sync,
36    D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static,
37{
38    /// Main preparation method with robust error handling and guaranteed lane cleanup.
39    pub async fn prepare_transaction_impl(
40        &self,
41        tx: TransactionRepoModel,
42    ) -> Result<TransactionRepoModel, TransactionError> {
43        debug!(status = ?tx.status, "preparing stellar transaction");
44
45        // Defensive check: if transaction is in a final state or unexpected state, don't retry
46        if is_final_state(&tx.status) {
47            warn!(
48                tx_id = %tx.id,
49                status = ?tx.status,
50                "transaction already in final state, skipping preparation"
51            );
52            return Ok(tx);
53        }
54
55        if tx.status != TransactionStatus::Pending {
56            debug!(
57                tx_id = %tx.id,
58                status = ?tx.status,
59                expected_status = ?TransactionStatus::Pending,
60                "transaction in unexpected state for preparation, skipping"
61            );
62            return Ok(tx);
63        }
64
65        if !self.concurrent_transactions_enabled() && !lane_gate::claim(&self.relayer().id, &tx.id)
66        {
67            info!("relayer already has a transaction in flight, must wait");
68            return Ok(tx);
69        }
70
71        debug!("preparing transaction {}", tx.id);
72
73        // Call core preparation logic with error handling
74        match self.prepare_core(tx.clone()).await {
75            Ok(prepared_tx) => Ok(prepared_tx),
76            Err(error) => {
77                // Always cleanup on failure - this is the critical safety mechanism
78                self.handle_prepare_failure(tx, error).await
79            }
80        }
81    }
82
83    /// Core preparation logic
84    async fn prepare_core(
85        &self,
86        tx: TransactionRepoModel,
87    ) -> Result<TransactionRepoModel, TransactionError> {
88        let stellar_data = tx.network_data.get_stellar_transaction_data()?;
89
90        // Simple dispatch to appropriate processing function based on input type
91        let policy = self.relayer().policies.get_stellar_policy();
92        match &stellar_data.transaction_input {
93            TransactionInput::Operations(_) => {
94                debug!("preparing operations-based transaction {}", tx.id);
95                let stellar_data_with_sim = operations::process_operations(
96                    self.transaction_counter_service(),
97                    &self.relayer().id,
98                    &self.relayer().address,
99                    &tx,
100                    stellar_data,
101                    self.provider(),
102                    self.signer(),
103                    Some(&policy),
104                )
105                .await?;
106                self.finalize_with_signature(tx, stellar_data_with_sim)
107                    .await
108            }
109            TransactionInput::UnsignedXdr(_) => {
110                debug!("preparing unsigned xdr transaction {}", tx.id);
111                let stellar_data_with_sim = unsigned_xdr::process_unsigned_xdr(
112                    self.transaction_counter_service(),
113                    &self.relayer().id,
114                    &self.relayer().address,
115                    stellar_data,
116                    self.provider(),
117                    self.signer(),
118                    Some(&policy),
119                    self.dex_service(),
120                )
121                .await?;
122                self.finalize_with_signature(tx, stellar_data_with_sim)
123                    .await
124            }
125            TransactionInput::SignedXdr { .. } => {
126                debug!("preparing fee-bump transaction {}", tx.id);
127                let stellar_data_with_fee_bump = fee_bump::process_fee_bump(
128                    &self.relayer().address,
129                    stellar_data,
130                    self.provider(),
131                    self.signer(),
132                    Some(&policy),
133                    self.dex_service(),
134                )
135                .await?;
136                update_and_notify_transaction(
137                    self.transaction_repository(),
138                    self.job_producer(),
139                    tx.id,
140                    stellar_data_with_fee_bump,
141                    self.relayer().notification_id.as_deref(),
142                )
143                .await
144            }
145        }
146    }
147
148    /// Helper to sign and finalize transactions for Operations and UnsignedXdr inputs.
149    async fn finalize_with_signature(
150        &self,
151        tx: TransactionRepoModel,
152        stellar_data: crate::models::StellarTransactionData,
153    ) -> Result<TransactionRepoModel, TransactionError> {
154        let (tx, final_stellar_data) =
155            sign_and_finalize_transaction(self.signer(), tx, stellar_data).await?;
156        update_and_notify_transaction(
157            self.transaction_repository(),
158            self.job_producer(),
159            tx.id,
160            final_stellar_data,
161            self.relayer().notification_id.as_deref(),
162        )
163        .await
164    }
165
166    /// Handles preparation failures with comprehensive cleanup and error reporting.
167    /// This method ensures lanes are never left claimed after any failure.
168    async fn handle_prepare_failure(
169        &self,
170        tx: TransactionRepoModel,
171        error: TransactionError,
172    ) -> Result<TransactionRepoModel, TransactionError> {
173        let error_reason = format!("Preparation failed: {error}");
174        let tx_id = tx.id.clone(); // Clone the ID before moving tx
175        warn!(reason = %error_reason, "transaction preparation failed");
176
177        // Step 1: Sync sequence from chain to recover from any potential sequence drift
178        if let Ok(stellar_data) = tx.network_data.get_stellar_transaction_data() {
179            info!("syncing sequence from chain after failed transaction preparation");
180            // Always sync from chain on preparation failure to ensure correct sequence state
181            match self
182                .sync_sequence_from_chain(&stellar_data.source_account)
183                .await
184            {
185                Ok(()) => {
186                    info!("successfully synced sequence from chain");
187                }
188                Err(sync_error) => {
189                    warn!(error = %sync_error, "failed to sync sequence from chain");
190                }
191            }
192        }
193
194        // Step 2: Mark transaction as Failed with detailed reason
195        let update_request = TransactionUpdateRequest {
196            status: Some(TransactionStatus::Failed),
197            status_reason: Some(error_reason.clone()),
198            ..Default::default()
199        };
200        let _failed_tx = match self
201            .finalize_transaction_state(tx_id.clone(), update_request)
202            .await
203        {
204            Ok(updated_tx) => updated_tx,
205            Err(finalize_error) => {
206                warn!(error = %finalize_error, "failed to mark transaction as failed, proceeding with lane cleanup");
207                // Continue with cleanup even if we can't update the transaction
208                tx
209            }
210        };
211
212        // Step 3: Handle lane cleanup (only needed in sequential mode)
213        if !self.concurrent_transactions_enabled() {
214            // In sequential mode, attempt to hand off to next transaction or release lane
215            if let Err(enqueue_error) = self.enqueue_next_pending_transaction(&tx_id).await {
216                warn!(error = %enqueue_error, "failed to enqueue next pending transaction after failure, releasing lane directly");
217                // Fallback: release lane directly if we can't hand it over
218                lane_gate::free(&self.relayer().id, &tx_id);
219            }
220        }
221
222        // Step 4: Log failure for monitoring (prepare_fail_total metric would go here)
223        info!(error = %error_reason, "transaction preparation failure handled, lane cleaned up");
224
225        // Step 5: Return original error to maintain API compatibility
226        Err(error)
227    }
228}
229
230#[cfg(test)]
231mod prepare_transaction_tests {
232    use std::future::ready;
233
234    use super::*;
235    use crate::{
236        domain::SignTransactionResponse,
237        models::{NetworkTransactionData, OperationSpec, RepositoryError, TransactionStatus},
238        services::provider::ProviderError,
239    };
240    use soroban_rs::xdr::{Limits, ReadXdr, TransactionEnvelope};
241
242    use crate::domain::transaction::stellar::test_helpers::*;
243
244    #[tokio::test]
245    async fn prepare_transaction_happy_path() {
246        let relayer = create_test_relayer();
247        let mut mocks = default_test_mocks();
248
249        // sequence counter
250        mocks
251            .counter
252            .expect_get_and_increment()
253            .returning(|_, _| Box::pin(ready(Ok(1))));
254
255        // signer
256        mocks.signer.expect_sign_transaction().returning(|_| {
257            Box::pin(async {
258                Ok(SignTransactionResponse::Stellar(
259                    crate::domain::SignTransactionResponseStellar {
260                        signature: dummy_signature(),
261                    },
262                ))
263            })
264        });
265
266        mocks
267            .tx_repo
268            .expect_partial_update()
269            .withf(|_, upd| {
270                upd.status == Some(TransactionStatus::Sent) && upd.network_data.is_some()
271            })
272            .returning(|id, upd| {
273                let mut tx = create_test_transaction("relayer-1");
274                tx.id = id;
275                tx.status = upd.status.unwrap();
276                tx.network_data = upd.network_data.unwrap();
277                Ok::<_, RepositoryError>(tx)
278            });
279
280        // submit-job + notification
281        mocks
282            .job_producer
283            .expect_produce_submit_transaction_job()
284            .times(1)
285            .returning(|_, _| Box::pin(async { Ok(()) }));
286
287        mocks
288            .job_producer
289            .expect_produce_send_notification_job()
290            .times(1)
291            .returning(|_, _| Box::pin(async { Ok(()) }));
292
293        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
294        let tx = create_test_transaction(&relayer.id);
295
296        assert!(handler.prepare_transaction_impl(tx).await.is_ok());
297    }
298
299    #[tokio::test]
300    async fn prepare_transaction_stores_signed_envelope_xdr() {
301        let relayer = create_test_relayer();
302        let mut mocks = default_test_mocks();
303
304        // sequence counter
305        mocks
306            .counter
307            .expect_get_and_increment()
308            .returning(|_, _| Box::pin(ready(Ok(1))));
309
310        // signer
311        mocks.signer.expect_sign_transaction().returning(|_| {
312            Box::pin(async {
313                Ok(SignTransactionResponse::Stellar(
314                    crate::domain::SignTransactionResponseStellar {
315                        signature: dummy_signature(),
316                    },
317                ))
318            })
319        });
320
321        mocks
322            .tx_repo
323            .expect_partial_update()
324            .withf(|_, upd| {
325                upd.status == Some(TransactionStatus::Sent) && upd.network_data.is_some()
326            })
327            .returning(move |id, upd| {
328                let mut tx = create_test_transaction("relayer-1");
329                tx.id = id;
330                tx.status = upd.status.unwrap();
331                tx.network_data = upd.network_data.clone().unwrap();
332                Ok::<_, RepositoryError>(tx)
333            });
334
335        // submit-job + notification
336        mocks
337            .job_producer
338            .expect_produce_submit_transaction_job()
339            .times(1)
340            .returning(|_, _| Box::pin(async { Ok(()) }));
341
342        mocks
343            .job_producer
344            .expect_produce_send_notification_job()
345            .times(1)
346            .returning(|_, _| Box::pin(async { Ok(()) }));
347
348        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
349        let tx = create_test_transaction(&relayer.id);
350
351        let result = handler.prepare_transaction_impl(tx).await;
352        assert!(result.is_ok());
353
354        // Verify the signed_envelope_xdr was populated
355        if let Ok(prepared_tx) = result {
356            if let NetworkTransactionData::Stellar(stellar_data) = &prepared_tx.network_data {
357                assert!(
358                    stellar_data.signed_envelope_xdr.is_some(),
359                    "signed_envelope_xdr should be populated"
360                );
361
362                // Verify it's valid XDR by attempting to parse it
363                let xdr = stellar_data.signed_envelope_xdr.as_ref().unwrap();
364                let envelope_result = TransactionEnvelope::from_xdr_base64(xdr, Limits::none());
365                assert!(
366                    envelope_result.is_ok(),
367                    "signed_envelope_xdr should be valid XDR"
368                );
369
370                // Verify the envelope has signatures
371                if let Ok(envelope) = envelope_result {
372                    match envelope {
373                        TransactionEnvelope::Tx(ref e) => {
374                            assert!(!e.signatures.is_empty(), "Envelope should have signatures");
375                        }
376                        _ => panic!("Expected Tx envelope type"),
377                    }
378                }
379            } else {
380                panic!("Expected Stellar transaction data");
381            }
382        }
383    }
384
385    #[tokio::test]
386    async fn prepare_transaction_sequence_failure_cleans_up_lane() {
387        let relayer = create_test_relayer();
388        let mut mocks = default_test_mocks();
389
390        // Mock sequence counter to fail
391        mocks.counter.expect_get_and_increment().returning(|_, _| {
392            Box::pin(async {
393                Err(RepositoryError::NotFound(
394                    "Counter service failure".to_string(),
395                ))
396            })
397        });
398
399        // Mock sync_sequence_from_chain for error handling
400        mocks.provider.expect_get_account().returning(|_| {
401            Box::pin(async {
402                use soroban_rs::xdr::{
403                    AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32,
404                    Thresholds, Uint256,
405                };
406                use stellar_strkey::ed25519;
407
408                let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
409                let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
410
411                Ok(AccountEntry {
412                    account_id,
413                    balance: 1000000,
414                    seq_num: SequenceNumber(0),
415                    num_sub_entries: 0,
416                    inflation_dest: None,
417                    flags: 0,
418                    home_domain: String32::default(),
419                    thresholds: Thresholds([1, 1, 1, 1]),
420                    signers: Default::default(),
421                    ext: AccountEntryExt::V0,
422                })
423            })
424        });
425
426        mocks
427            .counter
428            .expect_set()
429            .returning(|_, _, _| Box::pin(ready(Ok(()))));
430
431        // Mock finalize_transaction_state for failure handling
432        mocks
433            .tx_repo
434            .expect_partial_update()
435            .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
436            .returning(|id, upd| {
437                let mut tx = create_test_transaction("relayer-1");
438                tx.id = id;
439                tx.status = upd.status.unwrap();
440                Ok::<_, RepositoryError>(tx)
441            });
442
443        // Mock notification for failed transaction
444        mocks
445            .job_producer
446            .expect_produce_send_notification_job()
447            .times(1)
448            .returning(|_, _| Box::pin(async { Ok(()) }));
449
450        // Mock find_by_status for enqueue_next_pending_transaction
451        mocks
452            .tx_repo
453            .expect_find_by_status()
454            .returning(|_, _| Ok(vec![])); // No pending transactions
455
456        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
457        let mut tx = create_test_transaction(&relayer.id);
458
459        // Remove the sequence number since it wouldn't be set if get_and_increment fails
460        if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
461            data.sequence_number = None;
462        }
463
464        // Verify that lane is claimed initially
465        assert!(lane_gate::claim(&relayer.id, &tx.id));
466
467        let result = handler.prepare_transaction_impl(tx.clone()).await;
468
469        // Should return error but lane should be cleaned up
470        assert!(result.is_err());
471
472        // Verify lane is released - another transaction should be able to claim it
473        let another_tx_id = "another-tx";
474        assert!(lane_gate::claim(&relayer.id, another_tx_id));
475        lane_gate::free(&relayer.id, another_tx_id)
476    }
477
478    #[tokio::test]
479    async fn prepare_transaction_signer_failure_cleans_up_lane() {
480        let relayer = create_test_relayer();
481        let mut mocks = default_test_mocks();
482
483        // sequence counter succeeds
484        mocks
485            .counter
486            .expect_get_and_increment()
487            .returning(|_, _| Box::pin(ready(Ok(1))));
488
489        // Expect sync_sequence_from_chain to be called in handle_prepare_failure
490        mocks.provider.expect_get_account().returning(|_| {
491            Box::pin(async {
492                use soroban_rs::xdr::{
493                    AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32,
494                    Thresholds, Uint256,
495                };
496                use stellar_strkey::ed25519;
497
498                let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
499                let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
500
501                Ok(AccountEntry {
502                    account_id,
503                    balance: 1000000,
504                    seq_num: SequenceNumber(0),
505                    num_sub_entries: 0,
506                    inflation_dest: None,
507                    flags: 0,
508                    home_domain: String32::default(),
509                    thresholds: Thresholds([1, 1, 1, 1]),
510                    signers: Default::default(),
511                    ext: AccountEntryExt::V0,
512                })
513            })
514        });
515
516        mocks
517            .counter
518            .expect_set()
519            .returning(|_, _, _| Box::pin(ready(Ok(()))));
520
521        // signer fails
522        mocks.signer.expect_sign_transaction().returning(|_| {
523            Box::pin(async {
524                Err(crate::models::SignerError::SigningError(
525                    "Signer failure".to_string(),
526                ))
527            })
528        });
529
530        // Mock finalize_transaction_state for failure handling
531        mocks
532            .tx_repo
533            .expect_partial_update()
534            .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
535            .returning(|id, upd| {
536                let mut tx = create_test_transaction("relayer-1");
537                tx.id = id;
538                tx.status = upd.status.unwrap();
539                Ok::<_, RepositoryError>(tx)
540            });
541
542        // Mock notification for failed transaction
543        mocks
544            .job_producer
545            .expect_produce_send_notification_job()
546            .times(1)
547            .returning(|_, _| Box::pin(async { Ok(()) }));
548
549        // Mock find_by_status for enqueue_next_pending_transaction
550        mocks
551            .tx_repo
552            .expect_find_by_status()
553            .returning(|_, _| Ok(vec![])); // No pending transactions
554
555        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
556        let tx = create_test_transaction(&relayer.id);
557
558        let result = handler.prepare_transaction_impl(tx.clone()).await;
559
560        // Should return error but lane should be cleaned up
561        assert!(result.is_err());
562
563        // Verify lane is released
564        let another_tx_id = "another-tx";
565        assert!(lane_gate::claim(&relayer.id, another_tx_id));
566        lane_gate::free(&relayer.id, another_tx_id); // cleanup
567    }
568
569    #[tokio::test]
570    async fn prepare_transaction_already_claimed_lane_returns_original() {
571        let mut relayer = create_test_relayer();
572        relayer.id = "unique-relayer-for-lane-test".to_string(); // Use unique relayer ID
573        let mocks = default_test_mocks();
574
575        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
576        let tx = create_test_transaction(&relayer.id);
577
578        // Claim lane with different transaction
579        assert!(lane_gate::claim(&relayer.id, "other-tx"));
580
581        let result = handler.prepare_transaction_impl(tx.clone()).await;
582
583        // Should return Ok with original transaction (waiting)
584        assert!(result.is_ok());
585        let returned_tx = result.unwrap();
586        assert_eq!(returned_tx.id, tx.id);
587        assert_eq!(returned_tx.status, tx.status);
588
589        // Cleanup
590        lane_gate::free(&relayer.id, "other-tx");
591    }
592
593    #[tokio::test]
594    async fn test_prepare_failure_syncs_sequence() {
595        let relayer = create_test_relayer();
596        let mut mocks = default_test_mocks();
597
598        // Track sequence operations
599        let sequence_value = 42u64;
600
601        // Mock get_and_increment to return 42
602        mocks
603            .counter
604            .expect_get_and_increment()
605            .times(1)
606            .returning(move |_, _| Box::pin(ready(Ok(sequence_value))));
607
608        // Mock sync_sequence_from_chain to verify it's called on failure
609        mocks.provider.expect_get_account().times(1).returning(|_| {
610            Box::pin(async {
611                use soroban_rs::xdr::{
612                    AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32,
613                    Thresholds, Uint256,
614                };
615                use stellar_strkey::ed25519;
616
617                let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
618                let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
619
620                Ok(AccountEntry {
621                    account_id,
622                    balance: 1000000,
623                    seq_num: SequenceNumber(41), // On-chain sequence is 41
624                    num_sub_entries: 0,
625                    inflation_dest: None,
626                    flags: 0,
627                    home_domain: String32::default(),
628                    thresholds: Thresholds([1, 1, 1, 1]),
629                    signers: Default::default(),
630                    ext: AccountEntryExt::V0,
631                })
632            })
633        });
634
635        mocks
636            .counter
637            .expect_set()
638            .times(1)
639            .withf(|_, _, seq| *seq == 42) // Next usable = 41 + 1
640            .returning(|_, _, _| Box::pin(ready(Ok(()))));
641
642        // Mock signer to fail after sequence is incremented
643        mocks
644            .signer
645            .expect_sign_transaction()
646            .times(1)
647            .returning(|_| {
648                Box::pin(async {
649                    Err(crate::models::SignerError::SigningError(
650                        "Simulated signing failure".to_string(),
651                    ))
652                })
653            });
654
655        // Mock transaction update for failure
656        mocks
657            .tx_repo
658            .expect_partial_update()
659            .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
660            .returning(|id, upd| {
661                let mut tx = create_test_transaction("relayer-1");
662                tx.id = id;
663                tx.status = upd.status.unwrap();
664                Ok::<_, RepositoryError>(tx)
665            });
666
667        // Mock notification
668        mocks
669            .job_producer
670            .expect_produce_send_notification_job()
671            .times(1)
672            .returning(|_, _| Box::pin(async { Ok(()) }));
673
674        // Mock find_by_status for enqueue_next_pending_transaction
675        mocks
676            .tx_repo
677            .expect_find_by_status()
678            .returning(|_, _| Ok(vec![]));
679
680        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
681        let tx = create_test_transaction(&relayer.id);
682
683        let result = handler.prepare_transaction_impl(tx).await;
684
685        // Should fail with signing error
686        assert!(result.is_err());
687        match result.unwrap_err() {
688            TransactionError::SignerError(msg) => {
689                assert!(msg.contains("Simulated signing failure"));
690            }
691            _ => panic!("Expected SignerError"),
692        }
693    }
694
695    #[tokio::test]
696    async fn test_prepare_simulation_failure_syncs_sequence() {
697        let relayer = create_test_relayer();
698        let mut mocks = default_test_mocks();
699
700        // Mock sequence increment
701        mocks
702            .counter
703            .expect_get_and_increment()
704            .times(1)
705            .returning(|_, _| Box::pin(ready(Ok(100))));
706
707        // Mock sync on failure
708        mocks.provider.expect_get_account().times(1).returning(|_| {
709            Box::pin(async {
710                use soroban_rs::xdr::{
711                    AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32,
712                    Thresholds, Uint256,
713                };
714                use stellar_strkey::ed25519;
715
716                let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
717                let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
718
719                Ok(AccountEntry {
720                    account_id,
721                    balance: 1000000,
722                    seq_num: SequenceNumber(99),
723                    num_sub_entries: 0,
724                    inflation_dest: None,
725                    flags: 0,
726                    home_domain: String32::default(),
727                    thresholds: Thresholds([1, 1, 1, 1]),
728                    signers: Default::default(),
729                    ext: AccountEntryExt::V0,
730                })
731            })
732        });
733
734        mocks
735            .counter
736            .expect_set()
737            .times(1)
738            .returning(|_, _, _| Box::pin(ready(Ok(()))));
739
740        // Mock provider to fail simulation for Soroban operations
741        mocks
742            .provider
743            .expect_simulate_transaction_envelope()
744            .times(1)
745            .returning(|_| {
746                Box::pin(async {
747                    Err(ProviderError::Other(
748                        "Simulation failed: insufficient resources".to_string(),
749                    ))
750                })
751            });
752
753        // Mock transaction update for failure
754        mocks
755            .tx_repo
756            .expect_partial_update()
757            .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
758            .returning(|id, upd| {
759                let mut tx = create_test_transaction("relayer-1");
760                tx.id = id;
761                tx.status = upd.status.unwrap();
762                Ok::<_, RepositoryError>(tx)
763            });
764
765        // Mock notification and enqueue
766        mocks
767            .job_producer
768            .expect_produce_send_notification_job()
769            .times(1)
770            .returning(|_, _| Box::pin(async { Ok(()) }));
771
772        mocks
773            .tx_repo
774            .expect_find_by_status()
775            .returning(|_, _| Ok(vec![]));
776
777        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
778
779        // Create transaction with Soroban operation to trigger simulation
780        let mut tx = create_test_transaction(&relayer.id);
781        if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
782            data.transaction_input =
783                crate::models::TransactionInput::Operations(vec![OperationSpec::InvokeContract {
784                    contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
785                        .to_string(),
786                    function_name: "test".to_string(),
787                    args: vec![],
788                    auth: None,
789                }]);
790        }
791
792        let result = handler.prepare_transaction_impl(tx).await;
793
794        // Should fail with provider error
795        assert!(result.is_err());
796    }
797
798    #[tokio::test]
799    async fn test_prepare_xdr_parsing_failure_syncs_sequence() {
800        let relayer = create_test_relayer();
801        let mut mocks = default_test_mocks();
802
803        // For unsigned XDR, validation happens before sequence increment
804        // Source account mismatch is detected before get_and_increment is called
805        // But we still sync sequence on any prepare failure
806
807        // Mock sync_sequence_from_chain
808        mocks.provider.expect_get_account().times(1).returning(|_| {
809            Box::pin(async {
810                use soroban_rs::xdr::{
811                    AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32,
812                    Thresholds, Uint256,
813                };
814                use stellar_strkey::ed25519;
815
816                let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
817                let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
818
819                Ok(AccountEntry {
820                    account_id,
821                    balance: 1000000,
822                    seq_num: SequenceNumber(50),
823                    num_sub_entries: 0,
824                    inflation_dest: None,
825                    flags: 0,
826                    home_domain: String32::default(),
827                    thresholds: Thresholds([1, 1, 1, 1]),
828                    signers: Default::default(),
829                    ext: AccountEntryExt::V0,
830                })
831            })
832        });
833
834        mocks
835            .counter
836            .expect_set()
837            .times(1)
838            .returning(|_, _, _| Box::pin(ready(Ok(()))));
839
840        // Mock transaction update for failure
841        mocks
842            .tx_repo
843            .expect_partial_update()
844            .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
845            .returning(|id, upd| {
846                let mut tx = create_test_transaction("relayer-1");
847                tx.id = id;
848                tx.status = upd.status.unwrap();
849                Ok::<_, RepositoryError>(tx)
850            });
851
852        // Mock notification and enqueue
853        mocks
854            .job_producer
855            .expect_produce_send_notification_job()
856            .times(1)
857            .returning(|_, _| Box::pin(async { Ok(()) }));
858
859        mocks
860            .tx_repo
861            .expect_find_by_status()
862            .returning(|_, _| Ok(vec![]));
863
864        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
865
866        // Create transaction with invalid unsigned XDR
867        let mut tx = create_test_transaction(&relayer.id);
868        if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
869            // Remove sequence since it will never be set due to early validation failure
870            data.sequence_number = None;
871            // Use a different source account to trigger validation error
872            data.transaction_input = crate::models::TransactionInput::UnsignedXdr(
873                // This will fail validation due to source account mismatch
874                "AAAAAgAAAAA5MbUzuTfU6p3NeJp5w3TpKhZmx6p1pR7mq9wFwCnEIgAAAGQAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADk4GIHV/3i2tOMBkqKqN3Y9x3FvNm8z4B5PEzPn7hEaAAAAAAAAAAAAAAAZAAAAAAAAAAA".to_string()
875            );
876        }
877
878        let result = handler.prepare_transaction_impl(tx).await;
879
880        // Should fail with validation error
881        assert!(result.is_err());
882        match result.unwrap_err() {
883            TransactionError::ValidationError(msg) => {
884                assert!(msg.contains("does not match relayer account"));
885            }
886            _ => panic!("Expected ValidationError"),
887        }
888    }
889}
890
891#[cfg(test)]
892mod refactoring_tests {
893    use crate::domain::transaction::stellar::prepare::common::update_and_notify_transaction;
894    use crate::domain::transaction::stellar::test_helpers::*;
895    use crate::domain::{stellar::lane_gate, SignTransactionResponse};
896    use crate::models::{
897        NetworkTransactionData, RepositoryError, StellarTransactionData, TransactionInput,
898        TransactionStatus,
899    };
900    use std::future::ready;
901
902    #[tokio::test]
903    async fn test_prepare_with_concurrent_mode_no_lane_claiming() {
904        // With concurrent transactions enabled, prepare should NOT claim lanes
905        let mut relayer = create_test_relayer();
906        if let crate::models::RelayerNetworkPolicy::Stellar(ref mut policy) = relayer.policies {
907            policy.concurrent_transactions = Some(true);
908        }
909        let mut mocks = default_test_mocks();
910
911        // Setup mocks for successful prepare
912        mocks
913            .counter
914            .expect_get_and_increment()
915            .returning(|_, _| Box::pin(ready(Ok(1))));
916
917        mocks.signer.expect_sign_transaction().returning(|_| {
918            Box::pin(async {
919                Ok(SignTransactionResponse::Stellar(
920                    crate::domain::SignTransactionResponseStellar {
921                        signature: dummy_signature(),
922                    },
923                ))
924            })
925        });
926
927        mocks.tx_repo.expect_partial_update().returning(|id, upd| {
928            let mut tx = create_test_transaction("relayer-1");
929            tx.id = id;
930            tx.status = upd.status.unwrap();
931            tx.network_data = upd.network_data.unwrap();
932            Ok::<_, RepositoryError>(tx)
933        });
934
935        mocks
936            .job_producer
937            .expect_produce_submit_transaction_job()
938            .returning(|_, _| Box::pin(async { Ok(()) }));
939
940        mocks
941            .job_producer
942            .expect_produce_send_notification_job()
943            .returning(|_, _| Box::pin(async { Ok(()) }));
944
945        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
946        let tx = create_test_transaction(&relayer.id);
947
948        // In concurrent mode, another transaction should be able to claim the lane
949        // even while this one is being processed
950        let other_tx_id = "concurrent-tx";
951        assert!(lane_gate::claim(&relayer.id, other_tx_id));
952
953        // Prepare should succeed without claiming the lane
954        let result = handler.prepare_transaction_impl(tx).await;
955        assert!(result.is_ok());
956
957        // Cleanup
958        lane_gate::free(&relayer.id, other_tx_id);
959    }
960
961    #[tokio::test]
962    async fn test_prepare_failure_with_concurrent_mode_no_lane_cleanup() {
963        // With concurrent transactions enabled, prepare failure should NOT manage lanes
964        let mut relayer = create_test_relayer();
965        if let crate::models::RelayerNetworkPolicy::Stellar(ref mut policy) = relayer.policies {
966            policy.concurrent_transactions = Some(true);
967        }
968        let mut mocks = default_test_mocks();
969
970        // Mock sequence counter to fail
971        mocks.counter.expect_get_and_increment().returning(|_, _| {
972            Box::pin(ready(Err(RepositoryError::Unknown(
973                "Counter error".to_string(),
974            ))))
975        });
976
977        // Mock sync_sequence_from_chain for error recovery
978        mocks.provider.expect_get_account().returning(|_| {
979            Box::pin(async {
980                use soroban_rs::xdr::{
981                    AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32,
982                    Thresholds, Uint256,
983                };
984                use stellar_strkey::ed25519;
985
986                let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
987                let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
988
989                Ok(AccountEntry {
990                    account_id,
991                    balance: 1000000,
992                    seq_num: SequenceNumber(0),
993                    num_sub_entries: 0,
994                    inflation_dest: None,
995                    flags: 0,
996                    home_domain: String32::default(),
997                    thresholds: Thresholds([1, 1, 1, 1]),
998                    signers: Default::default(),
999                    ext: AccountEntryExt::V0,
1000                })
1001            })
1002        });
1003
1004        mocks
1005            .counter
1006            .expect_set()
1007            .returning(|_, _, _| Box::pin(ready(Ok(()))));
1008
1009        // Mock finalize_transaction_state for failure
1010        mocks.tx_repo.expect_partial_update().returning(|id, upd| {
1011            let mut tx = create_test_transaction("relayer-1");
1012            tx.id = id;
1013            tx.status = upd.status.unwrap();
1014            Ok::<_, RepositoryError>(tx)
1015        });
1016
1017        mocks
1018            .job_producer
1019            .expect_produce_send_notification_job()
1020            .returning(|_, _| Box::pin(async { Ok(()) }));
1021
1022        // In concurrent mode, should NOT look for pending transactions
1023        mocks.tx_repo.expect_find_by_status().times(0); // Should not be called
1024
1025        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1026        let tx = create_test_transaction(&relayer.id);
1027
1028        let result = handler.prepare_transaction_impl(tx).await;
1029        assert!(result.is_err());
1030    }
1031
1032    #[tokio::test]
1033    async fn test_update_and_notify_transaction_consistency() {
1034        let relayer = create_test_relayer();
1035        let mut mocks = default_test_mocks();
1036
1037        // Mock the repository update
1038        let expected_stellar_data = StellarTransactionData {
1039            source_account: TEST_PK.to_string(),
1040            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1041            fee: Some(100),
1042            sequence_number: Some(1),
1043            transaction_input: TransactionInput::Operations(vec![]),
1044            memo: None,
1045            valid_until: None,
1046            signatures: vec![],
1047            hash: None,
1048            simulation_transaction_data: None,
1049            signed_envelope_xdr: Some("test-xdr".to_string()),
1050        };
1051
1052        let expected_xdr = expected_stellar_data.signed_envelope_xdr.clone();
1053        mocks
1054            .tx_repo
1055            .expect_partial_update()
1056            .withf(move |id, upd| {
1057                id == "tx-1"
1058                    && upd.status == Some(TransactionStatus::Sent)
1059                    && if let Some(NetworkTransactionData::Stellar(ref data)) = upd.network_data {
1060                        data.signed_envelope_xdr == expected_xdr
1061                    } else {
1062                        false
1063                    }
1064            })
1065            .returning(|id, upd| {
1066                let mut tx = create_test_transaction("relayer-1");
1067                tx.id = id;
1068                tx.status = upd.status.unwrap();
1069                tx.network_data = upd.network_data.unwrap();
1070                Ok::<_, RepositoryError>(tx)
1071            });
1072
1073        // Mock job production
1074        mocks
1075            .job_producer
1076            .expect_produce_submit_transaction_job()
1077            .times(1)
1078            .returning(|_, _| Box::pin(async { Ok(()) }));
1079
1080        mocks
1081            .job_producer
1082            .expect_produce_send_notification_job()
1083            .times(1)
1084            .returning(|_, _| Box::pin(async { Ok(()) }));
1085
1086        let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1087
1088        // Test update_and_notify_transaction directly
1089        let result = update_and_notify_transaction(
1090            handler.transaction_repository(),
1091            handler.job_producer(),
1092            "tx-1".to_string(),
1093            expected_stellar_data,
1094            handler.relayer().notification_id.as_deref(),
1095        )
1096        .await;
1097
1098        assert!(result.is_ok());
1099        let updated_tx = result.unwrap();
1100        assert_eq!(updated_tx.status, TransactionStatus::Sent);
1101
1102        if let NetworkTransactionData::Stellar(data) = &updated_tx.network_data {
1103            assert_eq!(data.signed_envelope_xdr, Some("test-xdr".to_string()));
1104        } else {
1105            panic!("Expected Stellar transaction data");
1106        }
1107    }
1108}