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 transaction already in mempool cache (`ErrTxInMempoolCache`).
101///
102/// Returned when the exact same transaction bytes (same hash) are submitted to a node
103/// that already has the transaction in its mempool cache. For short-term dYdX orders,
104/// this is benign — the original transaction is already queued for processing.
105pub const COSMOS_ERROR_CODE_TX_IN_MEMPOOL_CACHE: u32 = 19;
106
107/// Cosmos SDK error code for account sequence mismatch.
108const COSMOS_ERROR_CODE_SEQUENCE_MISMATCH: u32 = 32;
109
110/// dYdX CLOB error code for duplicate cancel in memclob.
111///
112/// Returned when a cancel message is submitted for an order that already has a pending
113/// cancel with a greater-than-or-equal `GoodTilBlock`. This is benign for short-term
114/// cancel operations — the previous cancel is already queued and will be processed.
115///
116/// Common scenario: overlapping `cancel_all_orders` waves from a grid MM strategy.
117pub const DYDX_ERROR_CODE_CANCEL_ALREADY_IN_MEMCLOB: u32 = 9;
118
119/// dYdX CLOB error code for cancelling a non-existent order.
120///
121/// Returned when attempting to cancel an order that has already been filled, expired,
122/// or previously cancelled. This is benign — the order is already gone.
123pub const DYDX_ERROR_CODE_ORDER_DOES_NOT_EXIST: u32 = 3006;
124
125/// dYdX AllOf authenticator error code (ErrAllOfVerification).
126/// On dYdX v4, sequence mismatches surface as code=104 when using permissioned keys:
127/// the AllOf composite authenticator wraps the inner SignatureVerification failure
128/// (code=100) which includes "please verify sequence" in its diagnostic message.
129const DYDX_ERROR_CODE_ALL_OF_FAILED: u32 = 104;
130
131impl DydxError {
132    /// Returns true if this error is a sequence mismatch (code=32 or code=104 with sequence hint).
133    ///
134    /// Sequence mismatch occurs when:
135    /// - Multiple transactions race for the same sequence number
136    /// - A transaction was submitted but not yet included in a block
137    /// - The local sequence counter is out of sync with chain state
138    ///
139    /// On dYdX v4, sequence mismatches can manifest as either:
140    /// - code=32: Standard Cosmos SDK "account sequence mismatch"
141    /// - code=104: dYdX authenticator "signature verification failed; please verify sequence"
142    ///
143    /// These errors are typically recoverable by resyncing the sequence from chain
144    /// and rebuilding the transaction.
145    #[must_use]
146    pub fn is_sequence_mismatch(&self) -> bool {
147        match self {
148            Self::Grpc(status) => {
149                let msg = status.message();
150                Self::message_indicates_sequence_mismatch(msg)
151            }
152            Self::Nautilus(e) => {
153                let msg = e.to_string();
154                Self::message_indicates_sequence_mismatch(&msg)
155            }
156            _ => false,
157        }
158    }
159
160    /// Checks if an error message indicates a sequence mismatch.
161    ///
162    /// Matches:
163    /// - code=32 (standard Cosmos SDK sequence mismatch)
164    /// - code=104 with "sequence" (dYdX authenticator failure due to wrong sequence)
165    /// - "account sequence mismatch" text
166    fn message_indicates_sequence_mismatch(msg: &str) -> bool {
167        // Standard Cosmos SDK error code 32
168        if msg.contains(&format!("code={COSMOS_ERROR_CODE_SEQUENCE_MISMATCH}"))
169            || msg.contains("account sequence mismatch")
170        {
171            return true;
172        }
173        // dYdX authenticator error code 104 with sequence hint
174        msg.contains(&format!("code={DYDX_ERROR_CODE_ALL_OF_FAILED}")) && msg.contains("sequence")
175    }
176
177    /// Returns true if this error indicates the transaction is already in the mempool (code=19).
178    ///
179    /// This is benign for short-term orders — the transaction was already accepted by the
180    /// mempool on a previous submission and will be processed. Callers can safely treat
181    /// this as success.
182    #[must_use]
183    pub fn is_tx_in_mempool(&self) -> bool {
184        match self {
185            Self::Nautilus(e) => {
186                let msg = e.to_string();
187                msg.contains(&format!("code={COSMOS_ERROR_CODE_TX_IN_MEMPOOL_CACHE}"))
188                    || msg.contains("tx already in mempool")
189            }
190            _ => false,
191        }
192    }
193
194    /// Returns true if this error indicates a duplicate cancel already in the memclob (code=9).
195    ///
196    /// dYdX rejects cancel messages when an existing cancel for the same order has a
197    /// greater-than-or-equal `GoodTilBlock`. The original cancel will be processed.
198    #[must_use]
199    pub fn is_cancel_already_in_memclob(&self) -> bool {
200        match self {
201            Self::Nautilus(e) => {
202                let msg = e.to_string();
203                msg.contains(&format!("code={DYDX_ERROR_CODE_CANCEL_ALREADY_IN_MEMCLOB}"))
204                    && msg.contains("cancel already exists")
205            }
206            _ => false,
207        }
208    }
209
210    /// Returns true if this error indicates the order to cancel does not exist (code=3006).
211    ///
212    /// The order was already filled, expired, or previously cancelled.
213    #[must_use]
214    pub fn is_order_does_not_exist(&self) -> bool {
215        match self {
216            Self::Nautilus(e) => {
217                let msg = e.to_string();
218                msg.contains(&format!("code={DYDX_ERROR_CODE_ORDER_DOES_NOT_EXIST}"))
219                    || msg.contains("Order Id to cancel does not exist")
220            }
221            _ => false,
222        }
223    }
224
225    /// Returns true if this error is benign for short-term cancel operations.
226    ///
227    /// Benign cancel errors occur during overlapping cancel waves (common in grid MM):
228    /// - code=19: Transaction already in mempool cache (duplicate tx bytes)
229    /// - code=9: Cancel already exists in memclob with >= GoodTilBlock
230    /// - code=3006: Order to cancel does not exist (already filled/expired/cancelled)
231    #[must_use]
232    pub fn is_benign_cancel_error(&self) -> bool {
233        self.is_tx_in_mempool()
234            || self.is_cancel_already_in_memclob()
235            || self.is_order_does_not_exist()
236    }
237
238    /// Returns true if this error is likely transient and worth retrying.
239    ///
240    /// Transient errors include:
241    /// - Sequence mismatch (recoverable by resync)
242    /// - Network timeouts
243    /// - Temporary node unavailability
244    #[must_use]
245    pub fn is_transient(&self) -> bool {
246        if self.is_sequence_mismatch() {
247            return true;
248        }
249
250        match self {
251            Self::Grpc(status) => {
252                matches!(
253                    status.code(),
254                    tonic::Code::Unavailable
255                        | tonic::Code::DeadlineExceeded
256                        | tonic::Code::ResourceExhausted
257                )
258            }
259            _ => false,
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use rstest::rstest;
267
268    use super::*;
269
270    #[rstest]
271    fn test_sequence_mismatch_from_code_pattern() {
272        // Simulate error message from grpc/client.rs broadcast_tx
273        let err = DydxError::Nautilus(anyhow::anyhow!(
274            "Transaction broadcast failed: code=32, log=account sequence mismatch, expected 15, received 14"
275        ));
276        assert!(err.is_sequence_mismatch());
277    }
278
279    #[rstest]
280    fn test_sequence_mismatch_from_text_pattern() {
281        let err = DydxError::Nautilus(anyhow::anyhow!(
282            "account sequence mismatch: expected 100, received 99"
283        ));
284        assert!(err.is_sequence_mismatch());
285    }
286
287    #[rstest]
288    fn test_sequence_mismatch_grpc_error() {
289        let status =
290            tonic::Status::invalid_argument("account sequence mismatch, expected 42, received 41");
291        let err = DydxError::Grpc(Box::new(status));
292        assert!(err.is_sequence_mismatch());
293    }
294
295    #[rstest]
296    fn test_sequence_mismatch_dydx_authenticator_code_104() {
297        let err = DydxError::Nautilus(anyhow::anyhow!(
298            "Transaction broadcast failed: code=104, log=authentication failed for message 0, \
299             authenticator id 966, type AllOf: signature verification failed; \
300             please verify account number (0), sequence (545) and chain-id (dydx-mainnet-1): \
301             Signature verification failed: AllOf verification failed"
302        ));
303        assert!(err.is_sequence_mismatch());
304    }
305
306    #[rstest]
307    fn test_code_104_without_sequence_not_matched() {
308        // code=104 without "sequence" in the message should NOT match
309        let err = DydxError::Nautilus(anyhow::anyhow!(
310            "Transaction broadcast failed: code=104, log=authentication failed: invalid pubkey"
311        ));
312        assert!(!err.is_sequence_mismatch());
313    }
314
315    #[rstest]
316    fn test_non_sequence_error_not_matched() {
317        let err = DydxError::Nautilus(anyhow::anyhow!("insufficient funds"));
318        assert!(!err.is_sequence_mismatch());
319    }
320
321    #[rstest]
322    fn test_other_error_variants_not_matched() {
323        let err = DydxError::Config("bad config".to_string());
324        assert!(!err.is_sequence_mismatch());
325
326        let err = DydxError::Order("order rejected".to_string());
327        assert!(!err.is_sequence_mismatch());
328    }
329
330    #[rstest]
331    fn test_is_transient_sequence_mismatch() {
332        let err = DydxError::Nautilus(anyhow::anyhow!("account sequence mismatch"));
333        assert!(err.is_transient());
334    }
335
336    #[rstest]
337    fn test_is_transient_unavailable() {
338        let status = tonic::Status::unavailable("node unavailable");
339        let err = DydxError::Grpc(Box::new(status));
340        assert!(err.is_transient());
341    }
342
343    #[rstest]
344    fn test_is_transient_deadline_exceeded() {
345        let status = tonic::Status::deadline_exceeded("timeout");
346        let err = DydxError::Grpc(Box::new(status));
347        assert!(err.is_transient());
348    }
349
350    #[rstest]
351    fn test_is_not_transient_permission_denied() {
352        let status = tonic::Status::permission_denied("unauthorized");
353        let err = DydxError::Grpc(Box::new(status));
354        assert!(!err.is_transient());
355    }
356
357    #[rstest]
358    fn test_is_not_transient_config_error() {
359        let err = DydxError::Config("invalid".to_string());
360        assert!(!err.is_transient());
361    }
362}