openzeppelin_relayer/models/error/
transaction.rs

1use crate::{
2    domain::{
3        solana::SolanaTransactionValidationError, stellar::StellarTransactionValidationError,
4    },
5    jobs::JobProducerError,
6    models::{SignerError, SignerFactoryError},
7    services::provider::{ProviderError, SolanaProviderError},
8};
9
10use super::{ApiError, RepositoryError, StellarProviderError};
11use eyre::Report;
12use serde::Serialize;
13use soroban_rs::xdr;
14use thiserror::Error;
15
16#[derive(Error, Debug, Serialize)]
17pub enum TransactionError {
18    #[error("Transaction validation error: {0}")]
19    ValidationError(String),
20
21    #[error("Solana transaction validation error: {0}")]
22    SolanaValidation(#[from] SolanaTransactionValidationError),
23
24    #[error("Network configuration error: {0}")]
25    NetworkConfiguration(String),
26
27    #[error("Job producer error: {0}")]
28    JobProducerError(#[from] JobProducerError),
29
30    #[error("Invalid transaction type: {0}")]
31    InvalidType(String),
32
33    #[error("Underlying provider error: {0}")]
34    UnderlyingProvider(#[from] ProviderError),
35
36    #[error("Underlying Solana provider error: {0}")]
37    UnderlyingSolanaProvider(#[from] SolanaProviderError),
38
39    #[error("Stellar validation error: {0}")]
40    StellarTransactionValidationError(#[from] StellarTransactionValidationError),
41
42    #[error("Unexpected error: {0}")]
43    UnexpectedError(String),
44
45    #[error("Concurrent update conflict: {0}")]
46    ConcurrentUpdateConflict(String),
47
48    #[error("Not supported: {0}")]
49    NotSupported(String),
50
51    #[error("Signer error: {0}")]
52    SignerError(String),
53
54    #[error("Insufficient balance: {0}")]
55    InsufficientBalance(String),
56
57    #[error("Stellar transaction simulation failed: {0}")]
58    SimulationFailed(String),
59}
60
61impl TransactionError {
62    /// Determines if this error is transient (can retry) or permanent (should fail).
63    ///
64    /// **Transient (can retry):**
65    /// - `SolanaValidation`: Delegates to underlying error's is_transient()
66    /// - `UnderlyingSolanaProvider`: Delegates to underlying error's is_transient()
67    /// - `UnderlyingProvider`: Delegates to underlying error's is_transient()
68    /// - `UnexpectedError`: Unexpected errors may resolve on retry
69    /// - `JobProducerError`: Job queue issues are typically transient
70    ///
71    /// **Permanent (fail immediately):**
72    /// - `ValidationError`: Malformed data, missing fields, invalid state transitions
73    /// - `InsufficientBalance`: Balance issues won't resolve without funding
74    /// - `NetworkConfiguration`: Configuration errors are permanent
75    /// - `InvalidType`: Type mismatches are permanent
76    /// - `NotSupported`: Unsupported operations won't change
77    /// - `SignerError`: Signer issues are typically permanent
78    /// - `SimulationFailed`: Transaction simulation failures are permanent
79    pub fn is_transient(&self) -> bool {
80        match self {
81            // Delegate to underlying error's is_transient() method
82            TransactionError::SolanaValidation(err) => err.is_transient(),
83            TransactionError::UnderlyingSolanaProvider(err) => err.is_transient(),
84            TransactionError::UnderlyingProvider(err) => err.is_transient(),
85
86            // Transient errors - may resolve on retry
87            TransactionError::UnexpectedError(_) => true,
88            TransactionError::ConcurrentUpdateConflict(_) => true,
89            TransactionError::JobProducerError(_) => true,
90
91            // Permanent errors - fail immediately
92            TransactionError::ValidationError(_) => false,
93            TransactionError::InsufficientBalance(_) => false,
94            TransactionError::NetworkConfiguration(_) => false,
95            TransactionError::InvalidType(_) => false,
96            TransactionError::NotSupported(_) => false,
97            TransactionError::SignerError(_) => false,
98            TransactionError::SimulationFailed(_) => false,
99            TransactionError::StellarTransactionValidationError(_) => false,
100        }
101    }
102
103    /// Detects optimistic-lock conflicts caused by concurrent transaction updates.
104    pub fn is_concurrent_update_conflict(&self) -> bool {
105        matches!(self, TransactionError::ConcurrentUpdateConflict(_))
106    }
107}
108
109impl From<TransactionError> for ApiError {
110    fn from(error: TransactionError) -> Self {
111        match error {
112            TransactionError::ValidationError(msg) => ApiError::BadRequest(msg),
113            TransactionError::StellarTransactionValidationError(err) => {
114                ApiError::BadRequest(err.to_string())
115            }
116            TransactionError::SolanaValidation(err) => ApiError::BadRequest(err.to_string()),
117            TransactionError::NetworkConfiguration(msg) => ApiError::InternalError(msg),
118            TransactionError::JobProducerError(msg) => ApiError::InternalError(msg.to_string()),
119            TransactionError::InvalidType(msg) => ApiError::InternalError(msg),
120            TransactionError::UnderlyingProvider(err) => ApiError::InternalError(err.to_string()),
121            TransactionError::UnderlyingSolanaProvider(err) => {
122                ApiError::InternalError(err.to_string())
123            }
124            TransactionError::NotSupported(msg) => ApiError::BadRequest(msg),
125            TransactionError::UnexpectedError(msg) => ApiError::InternalError(msg),
126            TransactionError::ConcurrentUpdateConflict(msg) => ApiError::InternalError(msg),
127            TransactionError::SignerError(msg) => ApiError::InternalError(msg),
128            TransactionError::InsufficientBalance(msg) => ApiError::BadRequest(msg),
129            TransactionError::SimulationFailed(msg) => ApiError::BadRequest(msg),
130        }
131    }
132}
133
134impl From<RepositoryError> for TransactionError {
135    fn from(error: RepositoryError) -> Self {
136        match error {
137            RepositoryError::NotFound(msg)
138            | RepositoryError::InvalidData(msg)
139            | RepositoryError::ConstraintViolation(msg)
140            | RepositoryError::TransactionValidationFailed(msg) => {
141                TransactionError::ValidationError(msg)
142            }
143            RepositoryError::ConcurrentUpdateConflict(msg) => {
144                TransactionError::ConcurrentUpdateConflict(msg)
145            }
146            RepositoryError::TransactionFailure(msg)
147            | RepositoryError::LockError(msg)
148            | RepositoryError::ConnectionError(msg)
149            | RepositoryError::PermissionDenied(msg)
150            | RepositoryError::Unknown(msg)
151            | RepositoryError::UnexpectedError(msg)
152            | RepositoryError::Other(msg) => TransactionError::UnexpectedError(msg),
153            RepositoryError::NotSupported(msg) => TransactionError::NotSupported(msg),
154        }
155    }
156}
157
158impl From<Report> for TransactionError {
159    fn from(err: Report) -> Self {
160        TransactionError::UnexpectedError(err.to_string())
161    }
162}
163
164impl From<SignerFactoryError> for TransactionError {
165    fn from(error: SignerFactoryError) -> Self {
166        TransactionError::SignerError(error.to_string())
167    }
168}
169
170impl From<SignerError> for TransactionError {
171    fn from(error: SignerError) -> Self {
172        TransactionError::SignerError(error.to_string())
173    }
174}
175
176impl From<StellarProviderError> for TransactionError {
177    fn from(error: StellarProviderError) -> Self {
178        match error {
179            StellarProviderError::SimulationFailed(msg) => TransactionError::SimulationFailed(msg),
180            StellarProviderError::InsufficientBalance(msg) => {
181                TransactionError::InsufficientBalance(msg)
182            }
183            StellarProviderError::BadSeq(msg) => TransactionError::ValidationError(msg),
184            StellarProviderError::RpcError(msg) | StellarProviderError::Unknown(msg) => {
185                TransactionError::UnderlyingProvider(ProviderError::NetworkConfiguration(msg))
186            }
187        }
188    }
189}
190
191impl From<xdr::Error> for TransactionError {
192    fn from(error: xdr::Error) -> Self {
193        TransactionError::ValidationError(format!("XDR error: {error}"))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_transaction_error_display() {
203        let test_cases = vec![
204            (
205                TransactionError::ValidationError("invalid input".to_string()),
206                "Transaction validation error: invalid input",
207            ),
208            (
209                TransactionError::NetworkConfiguration("wrong network".to_string()),
210                "Network configuration error: wrong network",
211            ),
212            (
213                TransactionError::InvalidType("unknown type".to_string()),
214                "Invalid transaction type: unknown type",
215            ),
216            (
217                TransactionError::UnexpectedError("something went wrong".to_string()),
218                "Unexpected error: something went wrong",
219            ),
220            (
221                TransactionError::NotSupported("feature unavailable".to_string()),
222                "Not supported: feature unavailable",
223            ),
224            (
225                TransactionError::SignerError("key error".to_string()),
226                "Signer error: key error",
227            ),
228            (
229                TransactionError::InsufficientBalance("not enough funds".to_string()),
230                "Insufficient balance: not enough funds",
231            ),
232            (
233                TransactionError::SimulationFailed("sim failed".to_string()),
234                "Stellar transaction simulation failed: sim failed",
235            ),
236            (
237                TransactionError::ConcurrentUpdateConflict("conflict on tx-1".to_string()),
238                "Concurrent update conflict: conflict on tx-1",
239            ),
240        ];
241
242        for (error, expected_message) in test_cases {
243            assert_eq!(error.to_string(), expected_message);
244        }
245    }
246
247    #[test]
248    fn test_transaction_error_to_api_error() {
249        let test_cases = vec![
250            (
251                TransactionError::ValidationError("invalid input".to_string()),
252                ApiError::BadRequest("invalid input".to_string()),
253            ),
254            (
255                TransactionError::NetworkConfiguration("wrong network".to_string()),
256                ApiError::InternalError("wrong network".to_string()),
257            ),
258            (
259                TransactionError::InvalidType("unknown type".to_string()),
260                ApiError::InternalError("unknown type".to_string()),
261            ),
262            (
263                TransactionError::UnexpectedError("something went wrong".to_string()),
264                ApiError::InternalError("something went wrong".to_string()),
265            ),
266            (
267                TransactionError::NotSupported("feature unavailable".to_string()),
268                ApiError::BadRequest("feature unavailable".to_string()),
269            ),
270            (
271                TransactionError::SignerError("key error".to_string()),
272                ApiError::InternalError("key error".to_string()),
273            ),
274            (
275                TransactionError::InsufficientBalance("not enough funds".to_string()),
276                ApiError::BadRequest("not enough funds".to_string()),
277            ),
278            (
279                TransactionError::SimulationFailed("boom".to_string()),
280                ApiError::BadRequest("boom".to_string()),
281            ),
282            (
283                TransactionError::ConcurrentUpdateConflict("conflict".to_string()),
284                ApiError::InternalError("conflict".to_string()),
285            ),
286        ];
287
288        for (tx_error, expected_api_error) in test_cases {
289            let api_error = ApiError::from(tx_error);
290
291            match (&api_error, &expected_api_error) {
292                (ApiError::BadRequest(actual), ApiError::BadRequest(expected)) => {
293                    assert_eq!(actual, expected);
294                }
295                (ApiError::InternalError(actual), ApiError::InternalError(expected)) => {
296                    assert_eq!(actual, expected);
297                }
298                _ => panic!("Error types don't match: {api_error:?} vs {expected_api_error:?}"),
299            }
300        }
301    }
302
303    #[test]
304    fn test_repository_error_to_transaction_error() {
305        let repo_error = RepositoryError::NotFound("record not found".to_string());
306        let tx_error = TransactionError::from(repo_error);
307
308        match tx_error {
309            TransactionError::ValidationError(msg) => {
310                assert_eq!(msg, "record not found");
311            }
312            _ => panic!("Expected TransactionError::ValidationError"),
313        }
314    }
315
316    #[test]
317    fn test_concurrent_update_conflict_variant() {
318        let error = TransactionError::ConcurrentUpdateConflict("conflict on tx-1".to_string());
319        assert!(error.is_transient());
320        assert!(error.is_concurrent_update_conflict());
321        assert_eq!(
322            error.to_string(),
323            "Concurrent update conflict: conflict on tx-1"
324        );
325
326        let api_error = ApiError::from(error);
327        assert!(matches!(api_error, ApiError::InternalError(_)));
328    }
329
330    #[test]
331    fn test_concurrent_update_conflict_repo_conversion() {
332        let repo_error = RepositoryError::ConcurrentUpdateConflict("conflict on tx-1".to_string());
333        let tx_error = TransactionError::from(repo_error);
334        assert!(matches!(
335            tx_error,
336            TransactionError::ConcurrentUpdateConflict(_)
337        ));
338        assert!(tx_error.is_concurrent_update_conflict());
339    }
340
341    #[test]
342    fn test_non_conflict_errors_are_not_concurrent_update_conflict() {
343        let errors = vec![
344            TransactionError::ValidationError("some error".to_string()),
345            TransactionError::UnexpectedError("some error".to_string()),
346            TransactionError::NetworkConfiguration("some error".to_string()),
347        ];
348        for error in errors {
349            assert!(
350                !error.is_concurrent_update_conflict(),
351                "Error {error:?} should not be a concurrent update conflict"
352            );
353        }
354    }
355
356    #[test]
357    fn test_report_to_transaction_error() {
358        let report = Report::msg("An unexpected error occurred");
359        let tx_error = TransactionError::from(report);
360
361        match tx_error {
362            TransactionError::UnexpectedError(msg) => {
363                assert!(msg.contains("An unexpected error occurred"));
364            }
365            _ => panic!("Expected TransactionError::UnexpectedError"),
366        }
367    }
368
369    #[test]
370    fn test_signer_factory_error_to_transaction_error() {
371        let factory_error = SignerFactoryError::InvalidConfig("missing key".to_string());
372        let tx_error = TransactionError::from(factory_error);
373
374        match tx_error {
375            TransactionError::SignerError(msg) => {
376                assert!(msg.contains("missing key"));
377            }
378            _ => panic!("Expected TransactionError::SignerError"),
379        }
380    }
381
382    #[test]
383    fn test_signer_error_to_transaction_error() {
384        let signer_error = SignerError::KeyError("invalid key format".to_string());
385        let tx_error = TransactionError::from(signer_error);
386
387        match tx_error {
388            TransactionError::SignerError(msg) => {
389                assert!(msg.contains("invalid key format"));
390            }
391            _ => panic!("Expected TransactionError::SignerError"),
392        }
393    }
394
395    #[test]
396    fn test_provider_error_conversion() {
397        let provider_error = ProviderError::NetworkConfiguration("timeout".to_string());
398        let tx_error = TransactionError::from(provider_error);
399
400        match tx_error {
401            TransactionError::UnderlyingProvider(err) => {
402                assert!(err.to_string().contains("timeout"));
403            }
404            _ => panic!("Expected TransactionError::UnderlyingProvider"),
405        }
406    }
407
408    #[test]
409    fn test_solana_provider_error_conversion() {
410        let solana_error = SolanaProviderError::RpcError("invalid response".to_string());
411        let tx_error = TransactionError::from(solana_error);
412
413        match tx_error {
414            TransactionError::UnderlyingSolanaProvider(err) => {
415                assert!(err.to_string().contains("invalid response"));
416            }
417            _ => panic!("Expected TransactionError::UnderlyingSolanaProvider"),
418        }
419    }
420
421    #[test]
422    fn test_job_producer_error_conversion() {
423        let job_error = JobProducerError::QueueError("queue full".to_string());
424        let tx_error = TransactionError::from(job_error);
425
426        match tx_error {
427            TransactionError::JobProducerError(err) => {
428                assert!(err.to_string().contains("queue full"));
429            }
430            _ => panic!("Expected TransactionError::JobProducerError"),
431        }
432    }
433
434    #[test]
435    fn test_xdr_error_conversion() {
436        use soroban_rs::xdr::{Limits, ReadXdr, TransactionEnvelope};
437
438        // Create an XDR error by trying to parse invalid base64
439        let xdr_error =
440            TransactionEnvelope::from_xdr_base64("invalid_base64", Limits::none()).unwrap_err();
441
442        let tx_error = TransactionError::from(xdr_error);
443
444        match tx_error {
445            TransactionError::ValidationError(msg) => {
446                assert!(msg.contains("XDR error:"));
447            }
448            _ => panic!("Expected TransactionError::ValidationError"),
449        }
450    }
451
452    #[test]
453    fn test_is_transient_permanent_errors() {
454        // Test permanent errors that should return false
455        let permanent_errors = vec![
456            TransactionError::ValidationError("invalid input".to_string()),
457            TransactionError::InsufficientBalance("not enough funds".to_string()),
458            TransactionError::NetworkConfiguration("wrong network".to_string()),
459            TransactionError::InvalidType("unknown type".to_string()),
460            TransactionError::NotSupported("feature unavailable".to_string()),
461            TransactionError::SignerError("key error".to_string()),
462            TransactionError::SimulationFailed("sim failed".to_string()),
463        ];
464
465        for error in permanent_errors {
466            assert!(!error.is_transient(), "Error {error:?} should be permanent");
467        }
468    }
469
470    #[test]
471    fn test_is_transient_transient_errors() {
472        // Test transient errors that should return true
473        let transient_errors = vec![
474            TransactionError::UnexpectedError("something went wrong".to_string()),
475            TransactionError::JobProducerError(JobProducerError::QueueError(
476                "queue full".to_string(),
477            )),
478        ];
479
480        for error in transient_errors {
481            assert!(error.is_transient(), "Error {error:?} should be transient");
482        }
483    }
484
485    #[test]
486    fn test_stellar_provider_error_conversion() {
487        // Test SimulationFailed
488        let sim_error = StellarProviderError::SimulationFailed("sim failed".to_string());
489        let tx_error = TransactionError::from(sim_error);
490        match tx_error {
491            TransactionError::SimulationFailed(msg) => {
492                assert_eq!(msg, "sim failed");
493            }
494            _ => panic!("Expected TransactionError::SimulationFailed"),
495        }
496
497        // Test InsufficientBalance
498        let balance_error =
499            StellarProviderError::InsufficientBalance("not enough funds".to_string());
500        let tx_error = TransactionError::from(balance_error);
501        match tx_error {
502            TransactionError::InsufficientBalance(msg) => {
503                assert_eq!(msg, "not enough funds");
504            }
505            _ => panic!("Expected TransactionError::InsufficientBalance"),
506        }
507
508        // Test BadSeq
509        let seq_error = StellarProviderError::BadSeq("bad sequence".to_string());
510        let tx_error = TransactionError::from(seq_error);
511        match tx_error {
512            TransactionError::ValidationError(msg) => {
513                assert_eq!(msg, "bad sequence");
514            }
515            _ => panic!("Expected TransactionError::ValidationError"),
516        }
517
518        // Test RpcError
519        let rpc_error = StellarProviderError::RpcError("rpc failed".to_string());
520        let tx_error = TransactionError::from(rpc_error);
521        match tx_error {
522            TransactionError::UnderlyingProvider(ProviderError::NetworkConfiguration(msg)) => {
523                assert_eq!(msg, "rpc failed");
524            }
525            _ => panic!("Expected TransactionError::UnderlyingProvider"),
526        }
527
528        // Test Unknown
529        let unknown_error = StellarProviderError::Unknown("unknown error".to_string());
530        let tx_error = TransactionError::from(unknown_error);
531        match tx_error {
532            TransactionError::UnderlyingProvider(ProviderError::NetworkConfiguration(msg)) => {
533                assert_eq!(msg, "unknown error");
534            }
535            _ => panic!("Expected TransactionError::UnderlyingProvider"),
536        }
537    }
538
539    #[test]
540    fn test_is_transient_delegated_errors() {
541        // Test errors that delegate to underlying error's is_transient() method
542        // We need to create mock errors that have is_transient() methods
543
544        // For SolanaValidation - create a mock error
545        use crate::domain::solana::SolanaTransactionValidationError;
546        let solana_validation_error =
547            SolanaTransactionValidationError::ValidationError("bad validation".to_string());
548        let tx_error = TransactionError::SolanaValidation(solana_validation_error);
549        // This will delegate to the underlying error's is_transient method
550        // We can't easily test the delegation without mocking, so we'll just ensure it doesn't panic
551        let _ = tx_error.is_transient();
552
553        // For UnderlyingSolanaProvider
554        let solana_provider_error = SolanaProviderError::RpcError("rpc failed".to_string());
555        let tx_error = TransactionError::UnderlyingSolanaProvider(solana_provider_error);
556        let _ = tx_error.is_transient();
557
558        // For UnderlyingProvider
559        let provider_error = ProviderError::NetworkConfiguration("network issue".to_string());
560        let tx_error = TransactionError::UnderlyingProvider(provider_error);
561        let _ = tx_error.is_transient();
562    }
563}