openzeppelin_relayer/domain/transaction/stellar/
submit.rs

1//! This module contains the submission-related functionality for Stellar transactions.
2//! It includes methods for submitting transactions with robust error handling,
3//! ensuring proper transaction state management on failure.
4
5use chrono::Utc;
6use tracing::{info, warn};
7
8use super::{is_final_state, utils::is_bad_sequence_error, StellarRelayerTransaction};
9use crate::{
10    jobs::JobProducerTrait,
11    models::{
12        NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
13        TransactionStatus, TransactionUpdateRequest,
14    },
15    repositories::{Repository, TransactionCounterTrait, TransactionRepository},
16    services::{
17        provider::StellarProviderTrait,
18        signer::{Signer, StellarSignTrait},
19    },
20};
21
22impl<R, T, J, S, P, C, D> StellarRelayerTransaction<R, T, J, S, P, C, D>
23where
24    R: Repository<RelayerRepoModel, String> + Send + Sync,
25    T: TransactionRepository + Send + Sync,
26    J: JobProducerTrait + Send + Sync,
27    S: Signer + StellarSignTrait + Send + Sync,
28    P: StellarProviderTrait + Send + Sync,
29    C: TransactionCounterTrait + Send + Sync,
30    D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static,
31{
32    /// Main submission method with robust error handling.
33    /// Unlike prepare, submit doesn't claim lanes but still needs proper error handling.
34    pub async fn submit_transaction_impl(
35        &self,
36        tx: TransactionRepoModel,
37    ) -> Result<TransactionRepoModel, TransactionError> {
38        info!(
39            tx_id = %tx.id,
40            relayer_id = %tx.relayer_id,
41            status = ?tx.status,
42            "submitting stellar transaction"
43        );
44
45        // Defensive check: if transaction is in a final state or unexpected state, don't retry
46        if is_final_state(&tx.status) {
47            warn!(
48                tx_id = %tx.id,
49                relayer_id = %tx.relayer_id,
50                status = ?tx.status,
51                "transaction already in final state, skipping submission"
52            );
53            return Ok(tx);
54        }
55
56        // Check if transaction has expired before attempting submission
57        if self.is_transaction_expired(&tx)? {
58            info!(
59                tx_id = %tx.id,
60                relayer_id = %tx.relayer_id,
61                valid_until = ?tx.valid_until,
62                "transaction has expired, marking as Expired"
63            );
64            return self
65                .mark_as_expired(tx, "Transaction time_bounds expired".to_string())
66                .await;
67        }
68
69        // Call core submission logic with error handling
70        match self.submit_core(tx.clone()).await {
71            Ok(submitted_tx) => Ok(submitted_tx),
72            Err(error) => {
73                // Handle submission failure - mark as failed and send notification
74                self.handle_submit_failure(tx, error).await
75            }
76        }
77    }
78
79    /// Core submission logic - pure business logic without error handling concerns.
80    ///
81    /// Uses `send_transaction_with_status` to get full status information from the RPC.
82    /// Handles status codes:
83    /// - PENDING: Transaction accepted for processing
84    /// - DUPLICATE: Transaction already submitted (treat as success)
85    /// - TRY_AGAIN_LATER: Transaction not queued, mark as failed
86    /// - ERROR: Transaction validation failed, mark as failed
87    async fn submit_core(
88        &self,
89        tx: TransactionRepoModel,
90    ) -> Result<TransactionRepoModel, TransactionError> {
91        let stellar_data = tx.network_data.get_stellar_transaction_data()?;
92        let tx_envelope = stellar_data
93            .get_envelope_for_submission()
94            .map_err(TransactionError::from)?;
95
96        // Use send_transaction_with_status to get full status information
97        let response = self
98            .provider()
99            .send_transaction_with_status(&tx_envelope)
100            .await
101            .map_err(TransactionError::from)?;
102
103        // Handle status codes from the RPC response
104        match response.status.as_str() {
105            "PENDING" | "DUPLICATE" => {
106                // Success - transaction is accepted or already exists
107                if response.status == "DUPLICATE" {
108                    info!(
109                        tx_id = %tx.id,
110                        relayer_id = %tx.relayer_id,
111                        hash = %response.hash,
112                        "transaction already submitted (DUPLICATE status)"
113                    );
114                }
115
116                let tx_hash_hex = response.hash.clone();
117                let updated_stellar_data = stellar_data.with_hash(tx_hash_hex.clone());
118
119                let mut hashes = tx.hashes.clone();
120                if !hashes.contains(&tx_hash_hex) {
121                    hashes.push(tx_hash_hex);
122                }
123
124                let update_req = TransactionUpdateRequest {
125                    status: Some(TransactionStatus::Submitted),
126                    sent_at: Some(Utc::now().to_rfc3339()),
127                    network_data: Some(NetworkTransactionData::Stellar(updated_stellar_data)),
128                    hashes: Some(hashes),
129                    ..Default::default()
130                };
131
132                let updated_tx = self
133                    .transaction_repository()
134                    .partial_update(tx.id.clone(), update_req)
135                    .await?;
136
137                // Send notification for newly submitted transaction
138                if response.status == "PENDING" {
139                    info!(
140                        tx_id = %tx.id,
141                        relayer_id = %tx.relayer_id,
142                        "sending transaction update notification for pending transaction"
143                    );
144                    self.send_transaction_update_notification(&updated_tx).await;
145                }
146
147                Ok(updated_tx)
148            }
149            "TRY_AGAIN_LATER" => {
150                // Transaction not queued - per acceptance criteria, mark as failed
151                Err(TransactionError::UnexpectedError(
152                    "Transaction not queued: TRY_AGAIN_LATER".to_string(),
153                ))
154            }
155            "ERROR" => {
156                // Transaction validation failed
157                let error_detail = response
158                    .error_result_xdr
159                    .unwrap_or_else(|| "No error details provided".to_string());
160                Err(TransactionError::UnexpectedError(format!(
161                    "Transaction submission error: {error_detail}"
162                )))
163            }
164            unknown => {
165                // Unknown status - treat as error
166                warn!(
167                    tx_id = %tx.id,
168                    relayer_id = %tx.relayer_id,
169                    status = %unknown,
170                    "received unknown transaction status from RPC"
171                );
172                Err(TransactionError::UnexpectedError(format!(
173                    "Unknown transaction status: {unknown}"
174                )))
175            }
176        }
177    }
178
179    /// Handles submission failures with comprehensive cleanup and error reporting.
180    /// For bad sequence errors, resets the transaction and re-enqueues it for retry.
181    async fn handle_submit_failure(
182        &self,
183        tx: TransactionRepoModel,
184        error: TransactionError,
185    ) -> Result<TransactionRepoModel, TransactionError> {
186        let error_reason = format!("Submission failed: {error}");
187        let tx_id = tx.id.clone();
188        let relayer_id = tx.relayer_id.clone();
189        warn!(
190            tx_id = %tx_id,
191            relayer_id = %relayer_id,
192            reason = %error_reason,
193            "transaction submission failed"
194        );
195
196        if is_bad_sequence_error(&error_reason) {
197            // For bad sequence errors, sync sequence from chain first
198            if let Ok(stellar_data) = tx.network_data.get_stellar_transaction_data() {
199                info!(
200                    tx_id = %tx_id,
201                    relayer_id = %relayer_id,
202                    "syncing sequence from chain after bad sequence error"
203                );
204                match self
205                    .sync_sequence_from_chain(&stellar_data.source_account)
206                    .await
207                {
208                    Ok(()) => {
209                        info!(
210                            tx_id = %tx_id,
211                            relayer_id = %relayer_id,
212                            "successfully synced sequence from chain"
213                        );
214                    }
215                    Err(sync_error) => {
216                        warn!(
217                            tx_id = %tx_id,
218                            relayer_id = %relayer_id,
219                            error = %sync_error,
220                            "failed to sync sequence from chain"
221                        );
222                    }
223                }
224            }
225
226            // Reset the transaction to pending state
227            // Status check will handle resubmission when it detects a pending transaction without hash
228            info!(
229                tx_id = %tx_id,
230                relayer_id = %relayer_id,
231                "bad sequence error detected, resetting transaction to pending state"
232            );
233            match self.reset_transaction_for_retry(tx.clone()).await {
234                Ok(reset_tx) => {
235                    info!(
236                        tx_id = %tx_id,
237                        relayer_id = %relayer_id,
238                        "transaction reset to pending, status check will handle resubmission"
239                    );
240                    // Return success since we've reset the transaction
241                    // Status check job (scheduled with delay) will detect pending without hash
242                    // and schedule a recovery job to go through the pipeline again
243                    return Ok(reset_tx);
244                }
245                Err(reset_error) => {
246                    warn!(
247                        tx_id = %tx_id,
248                        relayer_id = %relayer_id,
249                        error = %reset_error,
250                        "failed to reset transaction for retry"
251                    );
252                    // Fall through to normal failure handling
253                }
254            }
255        }
256
257        // For non-bad-sequence errors or if reset failed, mark as failed
258        // Step 1: Mark transaction as Failed with detailed reason
259        let update_request = TransactionUpdateRequest {
260            status: Some(TransactionStatus::Failed),
261            status_reason: Some(error_reason.clone()),
262            ..Default::default()
263        };
264        let failed_tx = match self
265            .finalize_transaction_state(tx_id.clone(), update_request)
266            .await
267        {
268            Ok(updated_tx) => updated_tx,
269            Err(finalize_error) => {
270                warn!(
271                    tx_id = %tx_id,
272                    relayer_id = %relayer_id,
273                    error = %finalize_error,
274                    "failed to mark transaction as failed, continuing with lane cleanup"
275                );
276                // Finalization failed — propagate error so the queue retries
277                // and the next attempt will either finalize or hit is_final_state
278                return Err(error);
279            }
280        };
281
282        // Attempt to enqueue next pending transaction or release lane
283        if let Err(enqueue_error) = self.enqueue_next_pending_transaction(&tx_id).await {
284            warn!(
285                tx_id = %tx_id,
286                relayer_id = %relayer_id,
287                error = %enqueue_error,
288                "failed to enqueue next pending transaction after submission failure"
289            );
290        }
291
292        info!(
293            tx_id = %tx_id,
294            relayer_id = %relayer_id,
295            error = %error_reason,
296            "transaction submission failure handled, marked as failed"
297        );
298
299        // Transaction successfully marked as failed — return Ok to avoid
300        // a pointless queue retry (the defensive is_final_state check at the
301        // top of submit_transaction_impl would short-circuit anyway).
302        Ok(failed_tx)
303    }
304
305    /// Resubmit transaction - delegates to submit_transaction_impl
306    pub async fn resubmit_transaction_impl(
307        &self,
308        tx: TransactionRepoModel,
309    ) -> Result<TransactionRepoModel, TransactionError> {
310        self.submit_transaction_impl(tx).await
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use soroban_rs::stellar_rpc_client::SendTransactionResponse;
318    use soroban_rs::xdr::WriteXdr;
319
320    use crate::domain::transaction::stellar::test_helpers::*;
321
322    /// Helper to create a SendTransactionResponse with given status
323    fn create_send_tx_response(status: &str, hash: &str) -> SendTransactionResponse {
324        SendTransactionResponse {
325            status: status.to_string(),
326            hash: hash.to_string(),
327            error_result_xdr: None,
328            latest_ledger: 100,
329            latest_ledger_close_time: 1700000000,
330        }
331    }
332
333    mod submit_transaction_tests {
334        use crate::{
335            models::RepositoryError, repositories::PaginatedResult,
336            services::provider::ProviderError,
337        };
338
339        use super::*;
340
341        #[tokio::test]
342        async fn submit_transaction_happy_path() {
343            let relayer = create_test_relayer();
344            let mut mocks = default_test_mocks();
345
346            // provider returns PENDING status
347            let response = create_send_tx_response(
348                "PENDING",
349                "0101010101010101010101010101010101010101010101010101010101010101",
350            );
351            mocks
352                .provider
353                .expect_send_transaction_with_status()
354                .returning(move |_| {
355                    let r = response.clone();
356                    Box::pin(async move { Ok(r) })
357                });
358
359            // expect partial update to Submitted
360            mocks
361                .tx_repo
362                .expect_partial_update()
363                .withf(|_, upd| upd.status == Some(TransactionStatus::Submitted))
364                .returning(|id, upd| {
365                    let mut tx = create_test_transaction("relayer-1");
366                    tx.id = id;
367                    tx.status = upd.status.unwrap();
368                    Ok::<_, RepositoryError>(tx)
369                });
370
371            // Expect notification
372            mocks
373                .job_producer
374                .expect_produce_send_notification_job()
375                .times(1)
376                .returning(|_, _| Box::pin(async { Ok(()) }));
377
378            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
379
380            let mut tx = create_test_transaction(&relayer.id);
381            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
382            if let NetworkTransactionData::Stellar(ref mut d) = tx.network_data {
383                d.signatures.push(dummy_signature());
384                d.signed_envelope_xdr = Some(create_signed_xdr(TEST_PK, TEST_PK_2));
385                // Valid XDR
386            }
387
388            let res = handler.submit_transaction_impl(tx).await.unwrap();
389            assert_eq!(res.status, TransactionStatus::Submitted);
390        }
391
392        #[tokio::test]
393        async fn submit_transaction_provider_error_marks_failed() {
394            let relayer = create_test_relayer();
395            let mut mocks = default_test_mocks();
396
397            // Provider fails with non-bad-sequence error
398            mocks
399                .provider
400                .expect_send_transaction_with_status()
401                .returning(|_| {
402                    Box::pin(async { Err(ProviderError::Other("Network error".to_string())) })
403                });
404
405            // Mock finalize_transaction_state for failure handling
406            mocks
407                .tx_repo
408                .expect_partial_update()
409                .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
410                .returning(|id, upd| {
411                    let mut tx = create_test_transaction("relayer-1");
412                    tx.id = id;
413                    tx.status = upd.status.unwrap();
414                    Ok::<_, RepositoryError>(tx)
415                });
416
417            // Mock notification for failed transaction
418            mocks
419                .job_producer
420                .expect_produce_send_notification_job()
421                .times(1)
422                .returning(|_, _| Box::pin(async { Ok(()) }));
423
424            // Mock find_by_status_paginated for enqueue_next_pending_transaction
425            mocks
426                .tx_repo
427                .expect_find_by_status_paginated()
428                .returning(move |_, _, _, _| {
429                    Ok(PaginatedResult {
430                        items: vec![],
431                        total: 0,
432                        page: 1,
433                        per_page: 1,
434                    })
435                }); // No pending transactions
436
437            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
438            let mut tx = create_test_transaction(&relayer.id);
439            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
440            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
441                data.signatures.push(dummy_signature());
442                data.sequence_number = Some(42); // Set sequence number
443                data.signed_envelope_xdr = Some("test-xdr".to_string()); // Required for submission
444            }
445
446            let res = handler.submit_transaction_impl(tx).await;
447
448            // Transaction is marked as failed and returned as Ok (no queue retry needed)
449            let failed_tx = res.unwrap();
450            assert_eq!(failed_tx.status, TransactionStatus::Failed);
451        }
452
453        #[tokio::test]
454        async fn submit_transaction_repository_error_marks_failed() {
455            let relayer = create_test_relayer();
456            let mut mocks = default_test_mocks();
457
458            // Provider returns PENDING status
459            let response = create_send_tx_response(
460                "PENDING",
461                "0101010101010101010101010101010101010101010101010101010101010101",
462            );
463            mocks
464                .provider
465                .expect_send_transaction_with_status()
466                .returning(move |_| {
467                    let r = response.clone();
468                    Box::pin(async move { Ok(r) })
469                });
470
471            // Repository fails on first update (submission)
472            mocks
473                .tx_repo
474                .expect_partial_update()
475                .withf(|_, upd| upd.status == Some(TransactionStatus::Submitted))
476                .returning(|_, _| Err(RepositoryError::Unknown("Database error".to_string())));
477
478            // Mock finalize_transaction_state for failure handling
479            mocks
480                .tx_repo
481                .expect_partial_update()
482                .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
483                .returning(|id, upd| {
484                    let mut tx = create_test_transaction("relayer-1");
485                    tx.id = id;
486                    tx.status = upd.status.unwrap();
487                    Ok::<_, RepositoryError>(tx)
488                });
489
490            // Mock notification for failed transaction
491            mocks
492                .job_producer
493                .expect_produce_send_notification_job()
494                .times(1)
495                .returning(|_, _| Box::pin(async { Ok(()) }));
496
497            // Mock find_by_status_paginated for enqueue_next_pending_transaction
498            mocks
499                .tx_repo
500                .expect_find_by_status_paginated()
501                .returning(move |_, _, _, _| {
502                    Ok(PaginatedResult {
503                        items: vec![],
504                        total: 0,
505                        page: 1,
506                        per_page: 1,
507                    })
508                }); // No pending transactions
509
510            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
511            let mut tx = create_test_transaction(&relayer.id);
512            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
513            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
514                data.signatures.push(dummy_signature());
515                data.sequence_number = Some(42); // Set sequence number
516                data.signed_envelope_xdr = Some("test-xdr".to_string()); // Required for submission
517            }
518
519            let res = handler.submit_transaction_impl(tx).await;
520
521            // Even though provider succeeded and repo failed on Submitted update,
522            // the failure handler marks the tx as Failed and returns Ok
523            let failed_tx = res.unwrap();
524            assert_eq!(failed_tx.status, TransactionStatus::Failed);
525        }
526
527        #[tokio::test]
528        async fn submit_transaction_uses_signed_envelope_xdr() {
529            let relayer = create_test_relayer();
530            let mut mocks = default_test_mocks();
531
532            // Create a transaction with signed_envelope_xdr set
533            let mut tx = create_test_transaction(&relayer.id);
534            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
535            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
536                data.signatures.push(dummy_signature());
537                // Build and store the signed envelope XDR
538                let envelope = data.get_envelope_for_submission().unwrap();
539                let xdr = envelope
540                    .to_xdr_base64(soroban_rs::xdr::Limits::none())
541                    .unwrap();
542                data.signed_envelope_xdr = Some(xdr);
543            }
544
545            // Provider should receive the envelope decoded from signed_envelope_xdr
546            let response = create_send_tx_response(
547                "PENDING",
548                "0202020202020202020202020202020202020202020202020202020202020202",
549            );
550            mocks
551                .provider
552                .expect_send_transaction_with_status()
553                .returning(move |_| {
554                    let r = response.clone();
555                    Box::pin(async move { Ok(r) })
556                });
557
558            // Update to Submitted
559            mocks
560                .tx_repo
561                .expect_partial_update()
562                .withf(|_, upd| upd.status == Some(TransactionStatus::Submitted))
563                .returning(|id, upd| {
564                    let mut tx = create_test_transaction("relayer-1");
565                    tx.id = id;
566                    tx.status = upd.status.unwrap();
567                    Ok::<_, RepositoryError>(tx)
568                });
569
570            // Expect notification
571            mocks
572                .job_producer
573                .expect_produce_send_notification_job()
574                .times(1)
575                .returning(|_, _| Box::pin(async { Ok(()) }));
576
577            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
578            let res = handler.submit_transaction_impl(tx).await.unwrap();
579
580            assert_eq!(res.status, TransactionStatus::Submitted);
581        }
582
583        #[tokio::test]
584        async fn resubmit_transaction_delegates_to_submit() {
585            let relayer = create_test_relayer();
586            let mut mocks = default_test_mocks();
587
588            // provider returns PENDING status
589            let response = create_send_tx_response(
590                "PENDING",
591                "0101010101010101010101010101010101010101010101010101010101010101",
592            );
593            mocks
594                .provider
595                .expect_send_transaction_with_status()
596                .returning(move |_| {
597                    let r = response.clone();
598                    Box::pin(async move { Ok(r) })
599                });
600
601            // expect partial update to Submitted
602            mocks
603                .tx_repo
604                .expect_partial_update()
605                .withf(|_, upd| upd.status == Some(TransactionStatus::Submitted))
606                .returning(|id, upd| {
607                    let mut tx = create_test_transaction("relayer-1");
608                    tx.id = id;
609                    tx.status = upd.status.unwrap();
610                    Ok::<_, RepositoryError>(tx)
611                });
612
613            // Expect notification
614            mocks
615                .job_producer
616                .expect_produce_send_notification_job()
617                .times(1)
618                .returning(|_, _| Box::pin(async { Ok(()) }));
619
620            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
621
622            let mut tx = create_test_transaction(&relayer.id);
623            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
624            if let NetworkTransactionData::Stellar(ref mut d) = tx.network_data {
625                d.signatures.push(dummy_signature());
626                d.signed_envelope_xdr = Some(create_signed_xdr(TEST_PK, TEST_PK_2));
627                // Valid XDR
628            }
629
630            let res = handler.resubmit_transaction_impl(tx).await.unwrap();
631            assert_eq!(res.status, TransactionStatus::Submitted);
632        }
633
634        #[tokio::test]
635        async fn submit_transaction_failure_enqueues_next_transaction() {
636            let relayer = create_test_relayer();
637            let mut mocks = default_test_mocks();
638
639            // Provider fails with non-bad-sequence error
640            mocks
641                .provider
642                .expect_send_transaction_with_status()
643                .returning(|_| {
644                    Box::pin(async { Err(ProviderError::Other("Network error".to_string())) })
645                });
646
647            // No sync expected for non-bad-sequence errors
648
649            // Mock finalize_transaction_state for failure handling
650            mocks
651                .tx_repo
652                .expect_partial_update()
653                .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
654                .returning(|id, upd| {
655                    let mut tx = create_test_transaction("relayer-1");
656                    tx.id = id;
657                    tx.status = upd.status.unwrap();
658                    Ok::<_, RepositoryError>(tx)
659                });
660
661            // Mock notification for failed transaction
662            mocks
663                .job_producer
664                .expect_produce_send_notification_job()
665                .times(1)
666                .returning(|_, _| Box::pin(async { Ok(()) }));
667
668            // Mock find_by_status to return a pending transaction
669            let mut pending_tx = create_test_transaction(&relayer.id);
670            pending_tx.id = "next-pending-tx".to_string();
671            pending_tx.status = TransactionStatus::Pending;
672            let captured_pending_tx = pending_tx.clone();
673            let relayer_id_clone = relayer.id.clone();
674            mocks
675                .tx_repo
676                .expect_find_by_status_paginated()
677                .withf(move |relayer_id, statuses, query, oldest_first| {
678                    *relayer_id == relayer_id_clone
679                        && statuses == [TransactionStatus::Pending]
680                        && query.page == 1
681                        && query.per_page == 1
682                        && *oldest_first
683                })
684                .times(1)
685                .returning(move |_, _, _, _| {
686                    Ok(PaginatedResult {
687                        items: vec![captured_pending_tx.clone()],
688                        total: 1,
689                        page: 1,
690                        per_page: 1,
691                    })
692                });
693
694            // Mock produce_transaction_request_job for the next pending transaction
695            mocks
696                .job_producer
697                .expect_produce_transaction_request_job()
698                .withf(move |job, _delay| job.transaction_id == "next-pending-tx")
699                .times(1)
700                .returning(|_, _| Box::pin(async { Ok(()) }));
701
702            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
703            let mut tx = create_test_transaction(&relayer.id);
704            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
705            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
706                data.signatures.push(dummy_signature());
707                data.sequence_number = Some(42); // Set sequence number
708                data.signed_envelope_xdr = Some("test-xdr".to_string()); // Required for submission
709            }
710
711            let res = handler.submit_transaction_impl(tx).await;
712
713            // Transaction marked as failed and next transaction enqueued
714            let failed_tx = res.unwrap();
715            assert_eq!(failed_tx.status, TransactionStatus::Failed);
716        }
717
718        #[tokio::test]
719        async fn test_submit_bad_sequence_resets_and_retries() {
720            let relayer = create_test_relayer();
721            let mut mocks = default_test_mocks();
722
723            // Mock provider to return bad sequence error
724            mocks
725                .provider
726                .expect_send_transaction_with_status()
727                .returning(|_| {
728                    Box::pin(async {
729                        Err(ProviderError::Other(
730                            "transaction submission failed: TxBadSeq".to_string(),
731                        ))
732                    })
733                });
734
735            // Mock get_account for sync_sequence_from_chain
736            mocks.provider.expect_get_account().times(1).returning(|_| {
737                Box::pin(async {
738                    use soroban_rs::xdr::{
739                        AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber,
740                        String32, Thresholds, Uint256,
741                    };
742                    use stellar_strkey::ed25519;
743
744                    let pk = ed25519::PublicKey::from_string(TEST_PK).unwrap();
745                    let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0)));
746
747                    Ok(AccountEntry {
748                        account_id,
749                        balance: 1000000,
750                        seq_num: SequenceNumber(100),
751                        num_sub_entries: 0,
752                        inflation_dest: None,
753                        flags: 0,
754                        home_domain: String32::default(),
755                        thresholds: Thresholds([1, 1, 1, 1]),
756                        signers: Default::default(),
757                        ext: AccountEntryExt::V0,
758                    })
759                })
760            });
761
762            // Mock counter set for sync_sequence_from_chain
763            mocks
764                .counter
765                .expect_set()
766                .times(1)
767                .returning(|_, _, _| Box::pin(async { Ok(()) }));
768
769            // Mock partial_update for reset_transaction_for_retry - should reset to Pending
770            mocks
771                .tx_repo
772                .expect_partial_update()
773                .withf(|_, upd| upd.status == Some(TransactionStatus::Pending))
774                .times(1)
775                .returning(|id, upd| {
776                    let mut tx = create_test_transaction("relayer-1");
777                    tx.id = id;
778                    tx.status = upd.status.unwrap();
779                    if let Some(network_data) = upd.network_data {
780                        tx.network_data = network_data;
781                    }
782                    Ok::<_, RepositoryError>(tx)
783                });
784
785            // Note: Status check will handle resubmission when it detects a pending transaction without hash
786            // We don't schedule the job here - it will be scheduled by status check when the transaction is old enough
787
788            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
789            let mut tx = create_test_transaction(&relayer.id);
790            tx.status = TransactionStatus::Sent; // Must be Sent for idempotent submit
791            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
792                data.signatures.push(dummy_signature());
793                data.sequence_number = Some(42);
794                data.signed_envelope_xdr = Some(create_signed_xdr(TEST_PK, TEST_PK_2));
795                // Valid XDR
796            }
797
798            let result = handler.submit_transaction_impl(tx).await;
799
800            // Should return Ok since we're handling the retry
801            assert!(result.is_ok());
802            let reset_tx = result.unwrap();
803            assert_eq!(reset_tx.status, TransactionStatus::Pending);
804
805            // Verify stellar data was reset
806            if let NetworkTransactionData::Stellar(data) = &reset_tx.network_data {
807                assert!(data.sequence_number.is_none());
808                assert!(data.signatures.is_empty());
809                assert!(data.hash.is_none());
810                assert!(data.signed_envelope_xdr.is_none());
811            } else {
812                panic!("Expected Stellar transaction data");
813            }
814        }
815
816        #[tokio::test]
817        async fn submit_transaction_duplicate_status_succeeds() {
818            let relayer = create_test_relayer();
819            let mut mocks = default_test_mocks();
820
821            // Provider returns DUPLICATE status
822            let response = create_send_tx_response(
823                "DUPLICATE",
824                "0101010101010101010101010101010101010101010101010101010101010101",
825            );
826            mocks
827                .provider
828                .expect_send_transaction_with_status()
829                .returning(move |_| {
830                    let r = response.clone();
831                    Box::pin(async move { Ok(r) })
832                });
833
834            // expect partial update to Submitted
835            mocks
836                .tx_repo
837                .expect_partial_update()
838                .withf(|_, upd| upd.status == Some(TransactionStatus::Submitted))
839                .returning(|id, upd| {
840                    let mut tx = create_test_transaction("relayer-1");
841                    tx.id = id;
842                    tx.status = upd.status.unwrap();
843                    Ok::<_, RepositoryError>(tx)
844                });
845
846            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
847
848            let mut tx = create_test_transaction(&relayer.id);
849            tx.status = TransactionStatus::Sent;
850            if let NetworkTransactionData::Stellar(ref mut d) = tx.network_data {
851                d.signatures.push(dummy_signature());
852                d.signed_envelope_xdr = Some(create_signed_xdr(TEST_PK, TEST_PK_2));
853            }
854
855            let res = handler.submit_transaction_impl(tx).await.unwrap();
856            assert_eq!(res.status, TransactionStatus::Submitted);
857        }
858
859        #[tokio::test]
860        async fn submit_transaction_try_again_later_fails() {
861            let relayer = create_test_relayer();
862            let mut mocks = default_test_mocks();
863
864            // Provider returns TRY_AGAIN_LATER status
865            let response = create_send_tx_response(
866                "TRY_AGAIN_LATER",
867                "0101010101010101010101010101010101010101010101010101010101010101",
868            );
869            mocks
870                .provider
871                .expect_send_transaction_with_status()
872                .returning(move |_| {
873                    let r = response.clone();
874                    Box::pin(async move { Ok(r) })
875                });
876
877            // Mock finalize_transaction_state for failure handling
878            mocks
879                .tx_repo
880                .expect_partial_update()
881                .withf(|_, upd| {
882                    upd.status == Some(TransactionStatus::Failed)
883                        && upd
884                            .status_reason
885                            .as_ref()
886                            .is_some_and(|r| r.contains("TRY_AGAIN_LATER"))
887                })
888                .returning(|id, upd| {
889                    let mut tx = create_test_transaction("relayer-1");
890                    tx.id = id;
891                    tx.status = upd.status.unwrap();
892                    Ok::<_, RepositoryError>(tx)
893                });
894
895            // Mock notification for failed transaction
896            mocks
897                .job_producer
898                .expect_produce_send_notification_job()
899                .times(1)
900                .returning(|_, _| Box::pin(async { Ok(()) }));
901
902            // Mock find_by_status_paginated for enqueue_next_pending_transaction
903            mocks
904                .tx_repo
905                .expect_find_by_status_paginated()
906                .returning(move |_, _, _, _| {
907                    Ok(PaginatedResult {
908                        items: vec![],
909                        total: 0,
910                        page: 1,
911                        per_page: 1,
912                    })
913                });
914
915            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
916            let mut tx = create_test_transaction(&relayer.id);
917            tx.status = TransactionStatus::Sent;
918            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
919                data.signatures.push(dummy_signature());
920                data.signed_envelope_xdr = Some(create_signed_xdr(TEST_PK, TEST_PK_2));
921            }
922
923            let res = handler.submit_transaction_impl(tx).await;
924
925            // Transaction marked as failed — no error propagated
926            let failed_tx = res.unwrap();
927            assert_eq!(failed_tx.status, TransactionStatus::Failed);
928        }
929
930        #[tokio::test]
931        async fn submit_transaction_error_status_fails() {
932            let relayer = create_test_relayer();
933            let mut mocks = default_test_mocks();
934
935            // Provider returns ERROR status with error XDR
936            let mut response = create_send_tx_response(
937                "ERROR",
938                "0101010101010101010101010101010101010101010101010101010101010101",
939            );
940            response.error_result_xdr = Some("AAAAAAAAAGT////7AAAAAA==".to_string());
941            mocks
942                .provider
943                .expect_send_transaction_with_status()
944                .returning(move |_| {
945                    let r = response.clone();
946                    Box::pin(async move { Ok(r) })
947                });
948
949            // Mock finalize_transaction_state for failure handling
950            mocks
951                .tx_repo
952                .expect_partial_update()
953                .withf(|_, upd| upd.status == Some(TransactionStatus::Failed))
954                .returning(|id, upd| {
955                    let mut tx = create_test_transaction("relayer-1");
956                    tx.id = id;
957                    tx.status = upd.status.unwrap();
958                    Ok::<_, RepositoryError>(tx)
959                });
960
961            // Mock notification for failed transaction
962            mocks
963                .job_producer
964                .expect_produce_send_notification_job()
965                .times(1)
966                .returning(|_, _| Box::pin(async { Ok(()) }));
967
968            // Mock find_by_status_paginated for enqueue_next_pending_transaction
969            mocks
970                .tx_repo
971                .expect_find_by_status_paginated()
972                .returning(move |_, _, _, _| {
973                    Ok(PaginatedResult {
974                        items: vec![],
975                        total: 0,
976                        page: 1,
977                        per_page: 1,
978                    })
979                });
980
981            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
982            let mut tx = create_test_transaction(&relayer.id);
983            tx.status = TransactionStatus::Sent;
984            if let NetworkTransactionData::Stellar(ref mut data) = tx.network_data {
985                data.signatures.push(dummy_signature());
986                data.signed_envelope_xdr = Some(create_signed_xdr(TEST_PK, TEST_PK_2));
987            }
988
989            let res = handler.submit_transaction_impl(tx).await;
990
991            // Transaction marked as failed — no error propagated
992            let failed_tx = res.unwrap();
993            assert_eq!(failed_tx.status, TransactionStatus::Failed);
994        }
995    }
996}