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