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