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