Skip to main content

aptos_sdk/
error.rs

1//! Error types for the Aptos SDK.
2//!
3//! This module provides a unified error type [`AptosError`] that encompasses
4//! all possible errors that can occur when using the SDK.
5
6use std::fmt;
7use thiserror::Error;
8
9/// A specialized Result type for Aptos SDK operations.
10pub type AptosResult<T> = Result<T, AptosError>;
11
12/// The main error type for the Aptos SDK.
13///
14/// This enum covers all possible error conditions that can occur when
15/// interacting with the Aptos blockchain through this SDK.
16///
17/// # Security: Logging Errors
18///
19/// **WARNING:** The `Display` implementation on this type may include sensitive
20/// information (e.g., partial key material, JWT tokens, or mnemonic phrases) in
21/// its output. When logging errors, always use [`sanitized_message()`](AptosError::sanitized_message)
22/// instead of `to_string()` or `Display`:
23///
24/// ```rust,ignore
25/// // WRONG - may leak sensitive data:
26/// log::error!("Failed: {}", err);
27///
28/// // CORRECT - redacts sensitive patterns:
29/// log::error!("Failed: {}", err.sanitized_message());
30/// ```
31#[derive(Error, Debug)]
32pub enum AptosError {
33    /// Error occurred during HTTP communication
34    #[error("HTTP error: {0}")]
35    Http(#[from] reqwest::Error),
36
37    /// Error occurred during JSON serialization/deserialization
38    #[error("JSON error: {0}")]
39    Json(#[from] serde_json::Error),
40
41    /// Error occurred during BCS serialization/deserialization
42    #[error("BCS error: {0}")]
43    Bcs(String),
44
45    /// Error occurred during URL parsing
46    #[error("URL error: {0}")]
47    Url(#[from] url::ParseError),
48
49    /// Error occurred during hex encoding/decoding
50    #[error("Hex error: {0}")]
51    Hex(#[from] const_hex::FromHexError),
52
53    /// Invalid account address
54    #[error("Invalid address: {0}")]
55    InvalidAddress(String),
56
57    /// Invalid public key
58    #[error("Invalid public key: {0}")]
59    InvalidPublicKey(String),
60
61    /// Invalid private key
62    #[error("Invalid private key: {0}")]
63    InvalidPrivateKey(String),
64
65    /// Invalid signature
66    #[error("Invalid signature: {0}")]
67    InvalidSignature(String),
68
69    /// Signature verification failed
70    #[error("Signature verification failed")]
71    SignatureVerificationFailed,
72
73    /// Invalid type tag format
74    #[error("Invalid type tag: {0}")]
75    InvalidTypeTag(String),
76
77    /// Transaction building error
78    #[error("Transaction error: {0}")]
79    Transaction(String),
80
81    /// Transaction simulation failed
82    #[error("Simulation failed: {0}")]
83    SimulationFailed(String),
84
85    /// Transaction submission failed
86    #[error("Submission failed: {0}")]
87    SubmissionFailed(String),
88
89    /// Transaction execution failed on chain
90    #[error("Execution failed: {vm_status}")]
91    ExecutionFailed {
92        /// The VM status message explaining the failure
93        vm_status: String,
94    },
95
96    /// Transaction timed out waiting for confirmation
97    #[error("Transaction timed out after {timeout_secs} seconds")]
98    TransactionTimeout {
99        /// The hash of the transaction that timed out
100        hash: String,
101        /// How long we waited before timing out
102        timeout_secs: u64,
103    },
104
105    /// API returned an error response
106    #[error("API error ({status_code}): {message}")]
107    Api {
108        /// HTTP status code
109        status_code: u16,
110        /// Error message from the API
111        message: String,
112        /// Optional error code from the API
113        error_code: Option<String>,
114        /// Optional VM error code
115        vm_error_code: Option<u64>,
116    },
117
118    /// Rate limited by the API
119    #[error("Rate limited: retry after {retry_after_secs:?} seconds")]
120    RateLimited {
121        /// How long to wait before retrying (if provided)
122        retry_after_secs: Option<u64>,
123    },
124
125    /// Resource not found
126    #[error("Resource not found: {0}")]
127    NotFound(String),
128
129    /// Account not found
130    #[error("Account not found: {0}")]
131    AccountNotFound(String),
132
133    /// Invalid mnemonic phrase
134    #[error("Invalid mnemonic: {0}")]
135    InvalidMnemonic(String),
136
137    /// Invalid JWT
138    #[error("Invalid JWT: {0}")]
139    InvalidJwt(String),
140
141    /// Key derivation error
142    #[error("Key derivation error: {0}")]
143    KeyDerivation(String),
144
145    /// Insufficient signatures for multi-signature operation
146    #[error("Insufficient signatures: need {required}, got {provided}")]
147    InsufficientSignatures {
148        /// Number of signatures required
149        required: usize,
150        /// Number of signatures provided
151        provided: usize,
152    },
153
154    /// Feature not enabled
155    #[error("Feature not enabled: {0}. Enable the '{0}' feature in Cargo.toml")]
156    FeatureNotEnabled(String),
157
158    /// Configuration error
159    #[error("Configuration error: {0}")]
160    Config(String),
161
162    /// Internal SDK error (should not happen)
163    #[error("Internal error: {0}")]
164    Internal(String),
165
166    /// Any other error
167    #[error("{0}")]
168    Other(#[from] anyhow::Error),
169}
170
171/// Maximum length for error messages to prevent excessive memory usage in logs.
172const MAX_ERROR_MESSAGE_LENGTH: usize = 1000;
173
174/// Patterns that might indicate sensitive information in error messages.
175///
176/// # Security
177///
178/// This list is used by [`AptosError::sanitized_message()`] to redact potentially
179/// sensitive content from error messages before logging. The check is case-insensitive.
180const SENSITIVE_PATTERNS: &[&str] = &[
181    "private_key",
182    "secret",
183    "password",
184    "mnemonic",
185    "seed",
186    "bearer",
187    "authorization",
188    "token",
189    "jwt",
190    "credential",
191    "api_key",
192    "apikey",
193    "access_token",
194    "refresh_token",
195    "pepper",
196];
197
198impl AptosError {
199    /// Creates a new BCS error
200    pub fn bcs<E: fmt::Display>(err: E) -> Self {
201        Self::Bcs(err.to_string())
202    }
203
204    /// Creates a new transaction error
205    pub fn transaction<S: Into<String>>(msg: S) -> Self {
206        Self::Transaction(msg.into())
207    }
208
209    /// Creates a new API error from response details
210    pub fn api(status_code: u16, message: impl Into<String>) -> Self {
211        Self::Api {
212            status_code,
213            message: message.into(),
214            error_code: None,
215            vm_error_code: None,
216        }
217    }
218
219    /// Creates a new API error with additional details
220    pub fn api_with_details(
221        status_code: u16,
222        message: impl Into<String>,
223        error_code: Option<String>,
224        vm_error_code: Option<u64>,
225    ) -> Self {
226        Self::Api {
227            status_code,
228            message: message.into(),
229            error_code,
230            vm_error_code,
231        }
232    }
233
234    /// Returns true if this is a "not found" error
235    pub fn is_not_found(&self) -> bool {
236        matches!(
237            self,
238            Self::NotFound(_)
239                | Self::AccountNotFound(_)
240                | Self::Api {
241                    status_code: 404,
242                    ..
243                }
244        )
245    }
246
247    /// Returns true if this is a timeout error
248    pub fn is_timeout(&self) -> bool {
249        matches!(self, Self::TransactionTimeout { .. })
250    }
251
252    /// Returns true if this is a transient error that might succeed on retry
253    pub fn is_retryable(&self) -> bool {
254        match self {
255            Self::Http(e) => e.is_timeout() || e.is_connect(),
256            Self::Api { status_code, .. } => {
257                matches!(status_code, 429 | 500 | 502 | 503 | 504)
258            }
259            Self::RateLimited { .. } => true,
260            _ => false,
261        }
262    }
263
264    /// Returns a sanitized version of the error message safe for logging.
265    ///
266    /// This method:
267    /// - Removes control characters that could corrupt logs
268    /// - Truncates very long messages to prevent log flooding
269    /// - Redacts patterns that might indicate sensitive information
270    ///
271    /// # Example
272    ///
273    /// ```rust
274    /// use aptos_sdk::AptosError;
275    ///
276    /// let err = AptosError::api(500, "Internal server error with details...");
277    /// let safe_msg = err.sanitized_message();
278    /// // safe_msg is guaranteed to be safe for logging
279    /// ```
280    pub fn sanitized_message(&self) -> String {
281        let raw_message = self.to_string();
282        Self::sanitize_string(&raw_message)
283    }
284
285    /// Sanitizes a string for safe logging.
286    fn sanitize_string(s: &str) -> String {
287        // Remove control characters (except newline and tab for readability)
288        let cleaned: String = s
289            .chars()
290            .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
291            .collect();
292
293        // Check for sensitive patterns (case-insensitive)
294        let lower = cleaned.to_lowercase();
295        for pattern in SENSITIVE_PATTERNS {
296            if lower.contains(pattern) {
297                return format!("[REDACTED: message contained sensitive pattern '{pattern}']");
298            }
299        }
300
301        // SECURITY: Redact URLs with query parameters, which may contain API keys
302        // or other credentials not caught by keyword patterns above.
303        // e.g., reqwest errors include the request URL.
304        // Only redact when '?' appears within a URL token (after the scheme),
305        // not just anywhere in the message.
306        for scheme in ["http://", "https://"] {
307            if let Some(scheme_pos) = lower.find(scheme) {
308                // Look for '?' after the scheme, within the URL token
309                // (URLs end at whitespace or common delimiters)
310                let url_start = scheme_pos;
311                let url_rest = &lower[url_start..];
312                let url_end = url_rest
313                    .find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == '\'')
314                    .unwrap_or(url_rest.len());
315                let url_token = &url_rest[..url_end];
316                if url_token.contains('?') {
317                    return "[REDACTED: message contained URL with query parameters]".into();
318                }
319            }
320        }
321
322        // Truncate if too long (find a valid UTF-8 boundary to avoid panic)
323        if cleaned.len() > MAX_ERROR_MESSAGE_LENGTH {
324            let mut end = MAX_ERROR_MESSAGE_LENGTH;
325            while end > 0 && !cleaned.is_char_boundary(end) {
326                end -= 1;
327            }
328            format!(
329                "{}... [truncated, total length: {}]",
330                &cleaned[..end],
331                cleaned.len()
332            )
333        } else {
334            cleaned
335        }
336    }
337
338    /// Returns the error message suitable for display to end users.
339    ///
340    /// This is a more conservative sanitization that provides less detail
341    /// but is safer for user-facing error messages.
342    pub fn user_message(&self) -> &'static str {
343        match self {
344            Self::Http(_) => "Network error occurred",
345            Self::Json(_) => "Failed to process response",
346            Self::Bcs(_) => "Failed to process data",
347            Self::Url(_) => "Invalid URL",
348            Self::Hex(_) => "Invalid hex format",
349            Self::InvalidAddress(_) => "Invalid account address",
350            Self::InvalidPublicKey(_) => "Invalid public key",
351            Self::InvalidPrivateKey(_) => "Invalid private key",
352            Self::InvalidSignature(_) => "Invalid signature",
353            Self::SignatureVerificationFailed => "Signature verification failed",
354            Self::InvalidTypeTag(_) => "Invalid type format",
355            Self::Transaction(_) => "Transaction error",
356            Self::SimulationFailed(_) => "Transaction simulation failed",
357            Self::SubmissionFailed(_) => "Transaction submission failed",
358            Self::ExecutionFailed { .. } => "Transaction execution failed",
359            Self::TransactionTimeout { .. } => "Transaction timed out",
360            Self::NotFound(_)
361            | Self::Api {
362                status_code: 404, ..
363            } => "Resource not found",
364            Self::RateLimited { .. }
365            | Self::Api {
366                status_code: 429, ..
367            } => "Rate limit exceeded",
368            Self::Api { status_code, .. } if *status_code >= 500 => "Server error",
369            Self::Api { .. } => "API error",
370            Self::AccountNotFound(_) => "Account not found",
371            Self::InvalidMnemonic(_) => "Invalid recovery phrase",
372            Self::InvalidJwt(_) => "Invalid authentication token",
373            Self::KeyDerivation(_) => "Key derivation failed",
374            Self::InsufficientSignatures { .. } => "Insufficient signatures",
375            Self::FeatureNotEnabled(_) => "Feature not enabled",
376            Self::Config(_) => "Configuration error",
377            Self::Internal(_) => "Internal error",
378            Self::Other(_) => "An error occurred",
379        }
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_error_display() {
389        let err = AptosError::InvalidAddress("bad address".to_string());
390        assert_eq!(err.to_string(), "Invalid address: bad address");
391    }
392
393    #[test]
394    fn test_is_not_found() {
395        assert!(AptosError::NotFound("test".to_string()).is_not_found());
396        assert!(AptosError::AccountNotFound("0x1".to_string()).is_not_found());
397        assert!(AptosError::api(404, "not found").is_not_found());
398        assert!(!AptosError::api(500, "server error").is_not_found());
399    }
400
401    #[test]
402    fn test_is_retryable() {
403        assert!(AptosError::api(429, "rate limited").is_retryable());
404        assert!(AptosError::api(503, "unavailable").is_retryable());
405        assert!(AptosError::api(500, "internal error").is_retryable());
406        assert!(AptosError::api(502, "bad gateway").is_retryable());
407        assert!(AptosError::api(504, "timeout").is_retryable());
408        assert!(!AptosError::api(400, "bad request").is_retryable());
409    }
410
411    #[test]
412    fn test_is_timeout() {
413        let err = AptosError::TransactionTimeout {
414            hash: "0x123".to_string(),
415            timeout_secs: 30,
416        };
417        assert!(err.is_timeout());
418        assert!(!AptosError::InvalidAddress("test".to_string()).is_timeout());
419    }
420
421    #[test]
422    fn test_bcs_error() {
423        let err = AptosError::bcs("serialization failed");
424        assert!(matches!(err, AptosError::Bcs(_)));
425        assert!(err.to_string().contains("serialization failed"));
426    }
427
428    #[test]
429    fn test_transaction_error() {
430        let err = AptosError::transaction("invalid payload");
431        assert!(matches!(err, AptosError::Transaction(_)));
432        assert!(err.to_string().contains("invalid payload"));
433    }
434
435    #[test]
436    fn test_api_error() {
437        let err = AptosError::api(400, "bad request");
438        assert!(err.to_string().contains("400"));
439        assert!(err.to_string().contains("bad request"));
440    }
441
442    #[test]
443    fn test_api_error_with_details() {
444        let err = AptosError::api_with_details(
445            400,
446            "invalid argument",
447            Some("INVALID_ARGUMENT".to_string()),
448            Some(42),
449        );
450        if let AptosError::Api {
451            status_code,
452            message,
453            error_code,
454            vm_error_code,
455        } = err
456        {
457            assert_eq!(status_code, 400);
458            assert_eq!(message, "invalid argument");
459            assert_eq!(error_code, Some("INVALID_ARGUMENT".to_string()));
460            assert_eq!(vm_error_code, Some(42));
461        } else {
462            panic!("Expected Api error variant");
463        }
464    }
465
466    #[test]
467    fn test_various_error_displays() {
468        assert!(
469            AptosError::InvalidPublicKey("bad key".to_string())
470                .to_string()
471                .contains("public key")
472        );
473        assert!(
474            AptosError::InvalidPrivateKey("bad key".to_string())
475                .to_string()
476                .contains("private key")
477        );
478        assert!(
479            AptosError::InvalidSignature("bad sig".to_string())
480                .to_string()
481                .contains("signature")
482        );
483        assert!(
484            AptosError::SignatureVerificationFailed
485                .to_string()
486                .contains("verification")
487        );
488        assert!(
489            AptosError::InvalidTypeTag("bad tag".to_string())
490                .to_string()
491                .contains("type tag")
492        );
493        assert!(
494            AptosError::SimulationFailed("error".to_string())
495                .to_string()
496                .contains("Simulation")
497        );
498        assert!(
499            AptosError::SubmissionFailed("error".to_string())
500                .to_string()
501                .contains("Submission")
502        );
503    }
504
505    #[test]
506    fn test_execution_failed() {
507        let err = AptosError::ExecutionFailed {
508            vm_status: "ABORTED".to_string(),
509        };
510        assert!(err.to_string().contains("ABORTED"));
511    }
512
513    #[test]
514    fn test_rate_limited() {
515        let err = AptosError::RateLimited {
516            retry_after_secs: Some(30),
517        };
518        assert!(err.to_string().contains("Rate limited"));
519    }
520
521    #[test]
522    fn test_insufficient_signatures() {
523        let err = AptosError::InsufficientSignatures {
524            required: 3,
525            provided: 1,
526        };
527        assert!(err.to_string().contains('3'));
528        assert!(err.to_string().contains('1'));
529    }
530
531    #[test]
532    fn test_feature_not_enabled() {
533        let err = AptosError::FeatureNotEnabled("ed25519".to_string());
534        assert!(err.to_string().contains("ed25519"));
535        assert!(err.to_string().contains("Cargo.toml"));
536    }
537
538    #[test]
539    fn test_config_error() {
540        let err = AptosError::Config("invalid config".to_string());
541        assert!(err.to_string().contains("Configuration"));
542    }
543
544    #[test]
545    fn test_internal_error() {
546        let err = AptosError::Internal("bug".to_string());
547        assert!(err.to_string().contains("Internal"));
548    }
549
550    #[test]
551    fn test_invalid_mnemonic() {
552        let err = AptosError::InvalidMnemonic("bad phrase".to_string());
553        assert!(err.to_string().contains("mnemonic"));
554    }
555
556    #[test]
557    fn test_invalid_jwt() {
558        let err = AptosError::InvalidJwt("bad token".to_string());
559        assert!(err.to_string().contains("JWT"));
560    }
561
562    #[test]
563    fn test_key_derivation() {
564        let err = AptosError::KeyDerivation("failed".to_string());
565        assert!(err.to_string().contains("derivation"));
566    }
567
568    #[test]
569    fn test_sanitized_message_basic() {
570        let err = AptosError::api(400, "bad request");
571        let sanitized = err.sanitized_message();
572        assert!(sanitized.contains("bad request"));
573    }
574
575    #[test]
576    fn test_sanitized_message_truncates_long_messages() {
577        let long_message = "x".repeat(2000);
578        let err = AptosError::api(500, long_message);
579        let sanitized = err.sanitized_message();
580        assert!(sanitized.len() < 1200); // Should be truncated
581        assert!(sanitized.contains("truncated"));
582    }
583
584    #[test]
585    fn test_sanitized_message_removes_control_chars() {
586        let err = AptosError::api(400, "bad\x00request\x1f");
587        let sanitized = err.sanitized_message();
588        assert!(!sanitized.contains('\x00'));
589        assert!(!sanitized.contains('\x1f'));
590    }
591
592    #[test]
593    fn test_sanitized_message_redacts_sensitive_patterns() {
594        let err = AptosError::Internal("private_key: abc123".to_string());
595        let sanitized = err.sanitized_message();
596        assert!(sanitized.contains("REDACTED"));
597        assert!(!sanitized.contains("abc123"));
598
599        let err = AptosError::Internal("mnemonic phrase here".to_string());
600        let sanitized = err.sanitized_message();
601        assert!(sanitized.contains("REDACTED"));
602    }
603
604    #[test]
605    fn test_user_message() {
606        assert_eq!(
607            AptosError::api(404, "not found").user_message(),
608            "Resource not found"
609        );
610        assert_eq!(
611            AptosError::api(429, "rate limited").user_message(),
612            "Rate limit exceeded"
613        );
614        assert_eq!(
615            AptosError::api(500, "internal error").user_message(),
616            "Server error"
617        );
618        assert_eq!(
619            AptosError::InvalidAddress("bad".to_string()).user_message(),
620            "Invalid account address"
621        );
622    }
623
624    #[test]
625    fn test_user_message_all_variants() {
626        // Test all user_message variants for coverage
627        assert_eq!(
628            AptosError::InvalidPublicKey("bad".to_string()).user_message(),
629            "Invalid public key"
630        );
631        assert_eq!(
632            AptosError::InvalidPrivateKey("bad".to_string()).user_message(),
633            "Invalid private key"
634        );
635        assert_eq!(
636            AptosError::InvalidSignature("bad".to_string()).user_message(),
637            "Invalid signature"
638        );
639        assert_eq!(
640            AptosError::SignatureVerificationFailed.user_message(),
641            "Signature verification failed"
642        );
643        assert_eq!(
644            AptosError::InvalidTypeTag("bad".to_string()).user_message(),
645            "Invalid type format"
646        );
647        assert_eq!(
648            AptosError::Transaction("bad".to_string()).user_message(),
649            "Transaction error"
650        );
651        assert_eq!(
652            AptosError::SimulationFailed("bad".to_string()).user_message(),
653            "Transaction simulation failed"
654        );
655        assert_eq!(
656            AptosError::SubmissionFailed("bad".to_string()).user_message(),
657            "Transaction submission failed"
658        );
659        assert_eq!(
660            AptosError::ExecutionFailed {
661                vm_status: "ABORTED".to_string()
662            }
663            .user_message(),
664            "Transaction execution failed"
665        );
666        assert_eq!(
667            AptosError::TransactionTimeout {
668                hash: "0x1".to_string(),
669                timeout_secs: 30
670            }
671            .user_message(),
672            "Transaction timed out"
673        );
674        assert_eq!(
675            AptosError::NotFound("x".to_string()).user_message(),
676            "Resource not found"
677        );
678        assert_eq!(
679            AptosError::RateLimited {
680                retry_after_secs: Some(30)
681            }
682            .user_message(),
683            "Rate limit exceeded"
684        );
685        assert_eq!(
686            AptosError::api(503, "unavailable").user_message(),
687            "Server error"
688        );
689        assert_eq!(
690            AptosError::api(400, "bad request").user_message(),
691            "API error"
692        );
693        assert_eq!(
694            AptosError::AccountNotFound("0x1".to_string()).user_message(),
695            "Account not found"
696        );
697        assert_eq!(
698            AptosError::InvalidMnemonic("bad".to_string()).user_message(),
699            "Invalid recovery phrase"
700        );
701        assert_eq!(
702            AptosError::InvalidJwt("bad".to_string()).user_message(),
703            "Invalid authentication token"
704        );
705        assert_eq!(
706            AptosError::KeyDerivation("bad".to_string()).user_message(),
707            "Key derivation failed"
708        );
709        assert_eq!(
710            AptosError::InsufficientSignatures {
711                required: 3,
712                provided: 1
713            }
714            .user_message(),
715            "Insufficient signatures"
716        );
717        assert_eq!(
718            AptosError::FeatureNotEnabled("ed25519".to_string()).user_message(),
719            "Feature not enabled"
720        );
721        assert_eq!(
722            AptosError::Config("bad".to_string()).user_message(),
723            "Configuration error"
724        );
725        assert_eq!(
726            AptosError::Internal("bug".to_string()).user_message(),
727            "Internal error"
728        );
729        assert_eq!(
730            AptosError::Other(anyhow::anyhow!("misc")).user_message(),
731            "An error occurred"
732        );
733    }
734
735    #[test]
736    fn test_is_retryable_http_errors() {
737        // We can't easily test reqwest errors, so just ensure non-http errors return false
738        assert!(!AptosError::InvalidAddress("x".to_string()).is_retryable());
739        assert!(!AptosError::Transaction("x".to_string()).is_retryable());
740        assert!(!AptosError::NotFound("x".to_string()).is_retryable());
741    }
742}