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