Skip to main content

nautilus_dydx/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Error handling for the dYdX adapter.
17//!
18//! This module provides error types for all dYdX operations, including
19//! HTTP, WebSocket, and gRPC errors.
20
21use thiserror::Error;
22
23use crate::{http::error::DydxHttpError, websocket::error::DydxWsError};
24
25/// Result type for dYdX operations.
26pub type DydxResult<T> = Result<T, DydxError>;
27
28/// The main error type for all dYdX adapter operations.
29#[derive(Debug, Error)]
30pub enum DydxError {
31    /// HTTP client errors.
32    #[error("HTTP error: {0}")]
33    Http(#[from] DydxHttpError),
34
35    /// WebSocket connection errors.
36    #[error("WebSocket error: {0}")]
37    WebSocket(#[from] DydxWsError),
38
39    /// gRPC errors from Cosmos SDK node.
40    #[error("gRPC error: {0}")]
41    Grpc(#[from] Box<tonic::Status>),
42
43    /// Transaction signing errors.
44    #[error("Signing error: {0}")]
45    Signing(String),
46
47    /// Protocol buffer encoding errors.
48    #[error("Encoding error: {0}")]
49    Encoding(#[from] prost::EncodeError),
50
51    /// Protocol buffer decoding errors.
52    #[error("Decoding error: {0}")]
53    Decoding(#[from] prost::DecodeError),
54
55    /// JSON serialization/deserialization errors.
56    #[error("JSON error: {message}")]
57    Json {
58        message: String,
59        /// The raw JSON that failed to parse, if available.
60        raw: Option<String>,
61    },
62
63    /// Configuration errors.
64    #[error("Configuration error: {0}")]
65    Config(String),
66
67    /// Invalid data errors.
68    #[error("Invalid data: {0}")]
69    InvalidData(String),
70
71    /// Invalid order side error.
72    #[error("Invalid order side: {0}")]
73    InvalidOrderSide(String),
74
75    /// Unsupported order type error.
76    #[error("Unsupported order type: {0}")]
77    UnsupportedOrderType(String),
78
79    /// Feature not yet implemented.
80    #[error("Not implemented: {0}")]
81    NotImplemented(String),
82
83    /// Order construction and submission errors.
84    #[error("Order error: {0}")]
85    Order(String),
86
87    /// Parsing errors (e.g., string to number conversions).
88    #[error("Parse error: {0}")]
89    Parse(String),
90
91    /// Wallet and account derivation errors.
92    #[error("Wallet error: {0}")]
93    Wallet(String),
94
95    /// Nautilus core errors.
96    #[error("Nautilus error: {0}")]
97    Nautilus(#[from] anyhow::Error),
98}
99
100/// Cosmos SDK error code for account sequence mismatch.
101/// See: https://github.com/cosmos/cosmos-sdk/blob/main/types/errors/errors.go
102const COSMOS_ERROR_CODE_SEQUENCE_MISMATCH: u32 = 32;
103
104/// dYdX AllOf authenticator error code (ErrAllOfVerification).
105/// On dYdX v4, sequence mismatches surface as code=104 when using permissioned keys:
106/// the AllOf composite authenticator wraps the inner SignatureVerification failure
107/// (code=100) which includes "please verify sequence" in its diagnostic message.
108const DYDX_ERROR_CODE_ALL_OF_FAILED: u32 = 104;
109
110impl DydxError {
111    /// Returns true if this error is a sequence mismatch (code=32 or code=104 with sequence hint).
112    ///
113    /// Sequence mismatch occurs when:
114    /// - Multiple transactions race for the same sequence number
115    /// - A transaction was submitted but not yet included in a block
116    /// - The local sequence counter is out of sync with chain state
117    ///
118    /// On dYdX v4, sequence mismatches can manifest as either:
119    /// - code=32: Standard Cosmos SDK "account sequence mismatch"
120    /// - code=104: dYdX authenticator "signature verification failed; please verify sequence"
121    ///
122    /// These errors are typically recoverable by resyncing the sequence from chain
123    /// and rebuilding the transaction.
124    #[must_use]
125    pub fn is_sequence_mismatch(&self) -> bool {
126        match self {
127            Self::Grpc(status) => {
128                let msg = status.message();
129                Self::message_indicates_sequence_mismatch(msg)
130            }
131            Self::Nautilus(e) => {
132                let msg = e.to_string();
133                Self::message_indicates_sequence_mismatch(&msg)
134            }
135            _ => false,
136        }
137    }
138
139    /// Checks if an error message indicates a sequence mismatch.
140    ///
141    /// Matches:
142    /// - code=32 (standard Cosmos SDK sequence mismatch)
143    /// - code=104 with "sequence" (dYdX authenticator failure due to wrong sequence)
144    /// - "account sequence mismatch" text
145    fn message_indicates_sequence_mismatch(msg: &str) -> bool {
146        // Standard Cosmos SDK error code 32
147        if msg.contains(&format!("code={COSMOS_ERROR_CODE_SEQUENCE_MISMATCH}"))
148            || msg.contains("account sequence mismatch")
149        {
150            return true;
151        }
152        // dYdX authenticator error code 104 with sequence hint
153        msg.contains(&format!("code={DYDX_ERROR_CODE_ALL_OF_FAILED}")) && msg.contains("sequence")
154    }
155
156    /// Returns true if this error is likely transient and worth retrying.
157    ///
158    /// Transient errors include:
159    /// - Sequence mismatch (recoverable by resync)
160    /// - Network timeouts
161    /// - Temporary node unavailability
162    #[must_use]
163    pub fn is_transient(&self) -> bool {
164        if self.is_sequence_mismatch() {
165            return true;
166        }
167
168        match self {
169            Self::Grpc(status) => {
170                matches!(
171                    status.code(),
172                    tonic::Code::Unavailable
173                        | tonic::Code::DeadlineExceeded
174                        | tonic::Code::ResourceExhausted
175                )
176            }
177            _ => false,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use rstest::rstest;
185
186    use super::*;
187
188    #[rstest]
189    fn test_sequence_mismatch_from_code_pattern() {
190        // Simulate error message from grpc/client.rs broadcast_tx
191        let err = DydxError::Nautilus(anyhow::anyhow!(
192            "Transaction broadcast failed: code=32, log=account sequence mismatch, expected 15, received 14"
193        ));
194        assert!(err.is_sequence_mismatch());
195    }
196
197    #[rstest]
198    fn test_sequence_mismatch_from_text_pattern() {
199        let err = DydxError::Nautilus(anyhow::anyhow!(
200            "account sequence mismatch: expected 100, received 99"
201        ));
202        assert!(err.is_sequence_mismatch());
203    }
204
205    #[rstest]
206    fn test_sequence_mismatch_grpc_error() {
207        let status =
208            tonic::Status::invalid_argument("account sequence mismatch, expected 42, received 41");
209        let err = DydxError::Grpc(Box::new(status));
210        assert!(err.is_sequence_mismatch());
211    }
212
213    #[rstest]
214    fn test_sequence_mismatch_dydx_authenticator_code_104() {
215        let err = DydxError::Nautilus(anyhow::anyhow!(
216            "Transaction broadcast failed: code=104, log=authentication failed for message 0, \
217             authenticator id 966, type AllOf: signature verification failed; \
218             please verify account number (0), sequence (545) and chain-id (dydx-mainnet-1): \
219             Signature verification failed: AllOf verification failed"
220        ));
221        assert!(err.is_sequence_mismatch());
222    }
223
224    #[rstest]
225    fn test_code_104_without_sequence_not_matched() {
226        // code=104 without "sequence" in the message should NOT match
227        let err = DydxError::Nautilus(anyhow::anyhow!(
228            "Transaction broadcast failed: code=104, log=authentication failed: invalid pubkey"
229        ));
230        assert!(!err.is_sequence_mismatch());
231    }
232
233    #[rstest]
234    fn test_non_sequence_error_not_matched() {
235        let err = DydxError::Nautilus(anyhow::anyhow!("insufficient funds"));
236        assert!(!err.is_sequence_mismatch());
237    }
238
239    #[rstest]
240    fn test_other_error_variants_not_matched() {
241        let err = DydxError::Config("bad config".to_string());
242        assert!(!err.is_sequence_mismatch());
243
244        let err = DydxError::Order("order rejected".to_string());
245        assert!(!err.is_sequence_mismatch());
246    }
247
248    #[rstest]
249    fn test_is_transient_sequence_mismatch() {
250        let err = DydxError::Nautilus(anyhow::anyhow!("account sequence mismatch"));
251        assert!(err.is_transient());
252    }
253
254    #[rstest]
255    fn test_is_transient_unavailable() {
256        let status = tonic::Status::unavailable("node unavailable");
257        let err = DydxError::Grpc(Box::new(status));
258        assert!(err.is_transient());
259    }
260
261    #[rstest]
262    fn test_is_transient_deadline_exceeded() {
263        let status = tonic::Status::deadline_exceeded("timeout");
264        let err = DydxError::Grpc(Box::new(status));
265        assert!(err.is_transient());
266    }
267
268    #[rstest]
269    fn test_is_not_transient_permission_denied() {
270        let status = tonic::Status::permission_denied("unauthorized");
271        let err = DydxError::Grpc(Box::new(status));
272        assert!(!err.is_transient());
273    }
274
275    #[rstest]
276    fn test_is_not_transient_config_error() {
277        let err = DydxError::Config("invalid".to_string());
278        assert!(!err.is_transient());
279    }
280}