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::{
24    http::error::DydxHttpError,
25    proto::cosmos_sdk_proto::prost::{DecodeError, EncodeError},
26    websocket::error::DydxWsError,
27};
28
29/// Result type for dYdX operations.
30pub type DydxResult<T> = Result<T, DydxError>;
31
32/// The main error type for all dYdX adapter operations.
33#[derive(Debug, Error)]
34pub enum DydxError {
35    /// HTTP client errors.
36    #[error("HTTP error: {0}")]
37    Http(#[from] DydxHttpError),
38
39    /// WebSocket connection errors.
40    #[error("WebSocket error: {0}")]
41    WebSocket(#[from] DydxWsError),
42
43    /// gRPC errors from Cosmos SDK node.
44    #[error("gRPC error: {0}")]
45    Grpc(#[from] Box<tonic::Status>),
46
47    /// Transaction signing errors.
48    #[error("Signing error: {0}")]
49    Signing(String),
50
51    /// Protocol buffer encoding errors.
52    #[error("Encoding error: {0}")]
53    Encoding(#[from] EncodeError),
54
55    /// Protocol buffer decoding errors.
56    #[error("Decoding error: {0}")]
57    Decoding(#[from] DecodeError),
58
59    /// JSON serialization/deserialization errors.
60    #[error("JSON error: {message}")]
61    Json {
62        message: String,
63        /// The raw JSON that failed to parse, if available.
64        raw: Option<String>,
65    },
66
67    /// Configuration errors.
68    #[error("Configuration error: {0}")]
69    Config(String),
70
71    /// Invalid data errors.
72    #[error("Invalid data: {0}")]
73    InvalidData(String),
74
75    /// Invalid order side error.
76    #[error("Invalid order side: {0}")]
77    InvalidOrderSide(String),
78
79    /// Unsupported order type error.
80    #[error("Unsupported order type: {0}")]
81    UnsupportedOrderType(String),
82
83    /// Feature not yet implemented.
84    #[error("Not implemented: {0}")]
85    NotImplemented(String),
86
87    /// Order construction and submission errors.
88    #[error("Order error: {0}")]
89    Order(String),
90
91    /// Parsing errors (e.g., string to number conversions).
92    #[error("Parse error: {0}")]
93    Parse(String),
94
95    /// Wallet and account derivation errors.
96    #[error("Wallet error: {0}")]
97    Wallet(String),
98
99    /// Nautilus core errors.
100    #[error("Nautilus error: {0}")]
101    Nautilus(#[from] anyhow::Error),
102}
103
104/// Cosmos SDK error code for transaction already in mempool cache (`ErrTxInMempoolCache`).
105///
106/// Returned when the exact same transaction bytes (same hash) are submitted to a node
107/// that already has the transaction in its mempool cache. For short-term dYdX orders,
108/// this is benign -- the original transaction is already queued for processing.
109pub const COSMOS_ERROR_CODE_TX_IN_MEMPOOL_CACHE: u32 = 19;
110
111const COSMOS_ERROR_CODE_SEQUENCE_MISMATCH: u32 = 32;
112
113/// dYdX CLOB error code for duplicate cancel in memclob.
114///
115/// Returned when a cancel message is submitted for an order that already has a pending
116/// cancel with a greater-than-or-equal `GoodTilBlock`. This is benign for short-term
117/// cancel operations -- the previous cancel is already queued and will be processed.
118///
119/// Common scenario: overlapping `cancel_all_orders` waves from a grid MM strategy.
120pub const DYDX_ERROR_CODE_CANCEL_ALREADY_IN_MEMCLOB: u32 = 9;
121
122/// dYdX CLOB error code for cancelling a non-existent order.
123///
124/// Returned when attempting to cancel an order that has already been filled, expired,
125/// or previously cancelled. This is benign -- the order is already gone.
126pub const DYDX_ERROR_CODE_ORDER_DOES_NOT_EXIST: u32 = 3006;
127
128const DYDX_ERROR_CODE_ALL_OF_FAILED: u32 = 104;
129
130impl DydxError {
131    /// Returns true if this error is a sequence mismatch (code=32 or code=104 with sequence hint).
132    ///
133    /// Sequence mismatch occurs when:
134    /// - Multiple transactions race for the same sequence number
135    /// - A transaction was submitted but not yet included in a block
136    /// - The local sequence counter is out of sync with chain state
137    ///
138    /// On dYdX v4, sequence mismatches can manifest as either:
139    /// - code=32: Standard Cosmos SDK "account sequence mismatch"
140    /// - code=104: dYdX authenticator "signature verification failed; please verify sequence"
141    ///
142    /// These errors are typically recoverable by resyncing the sequence from chain
143    /// and rebuilding the transaction.
144    #[must_use]
145    pub fn is_sequence_mismatch(&self) -> bool {
146        match self {
147            Self::Grpc(status) => {
148                let msg = status.message();
149                Self::message_indicates_sequence_mismatch(msg)
150            }
151            Self::Nautilus(e) => {
152                let msg = e.to_string();
153                Self::message_indicates_sequence_mismatch(&msg)
154            }
155            _ => false,
156        }
157    }
158
159    fn message_indicates_sequence_mismatch(msg: &str) -> bool {
160        // Standard Cosmos SDK error code 32
161        if msg.contains(&format!("code={COSMOS_ERROR_CODE_SEQUENCE_MISMATCH}"))
162            || msg.contains("account sequence mismatch")
163        {
164            return true;
165        }
166        // dYdX authenticator error code 104 with sequence hint
167        msg.contains(&format!("code={DYDX_ERROR_CODE_ALL_OF_FAILED}")) && msg.contains("sequence")
168    }
169
170    /// Returns true if this error indicates the transaction is already in the mempool (code=19).
171    ///
172    /// This is benign for short-term orders -- the transaction was already accepted by the
173    /// mempool on a previous submission and will be processed. Callers can safely treat
174    /// this as success.
175    #[must_use]
176    pub fn is_tx_in_mempool(&self) -> bool {
177        match self {
178            Self::Nautilus(e) => {
179                let msg = e.to_string();
180                msg.contains(&format!("code={COSMOS_ERROR_CODE_TX_IN_MEMPOOL_CACHE}"))
181                    || msg.contains("tx already in mempool")
182            }
183            _ => false,
184        }
185    }
186
187    /// Returns true if this error indicates a duplicate cancel already in the memclob (code=9).
188    ///
189    /// dYdX rejects cancel messages when an existing cancel for the same order has a
190    /// greater-than-or-equal `GoodTilBlock`. The original cancel will be processed.
191    #[must_use]
192    pub fn is_cancel_already_in_memclob(&self) -> bool {
193        match self {
194            Self::Nautilus(e) => {
195                let msg = e.to_string();
196                msg.contains(&format!("code={DYDX_ERROR_CODE_CANCEL_ALREADY_IN_MEMCLOB}"))
197                    && msg.contains("cancel already exists")
198            }
199            _ => false,
200        }
201    }
202
203    /// Returns true if this error indicates the order to cancel does not exist (code=3006).
204    ///
205    /// The order was already filled, expired, or previously cancelled.
206    #[must_use]
207    pub fn is_order_does_not_exist(&self) -> bool {
208        match self {
209            Self::Nautilus(e) => {
210                let msg = e.to_string();
211                msg.contains(&format!("code={DYDX_ERROR_CODE_ORDER_DOES_NOT_EXIST}"))
212                    || msg.contains("Order Id to cancel does not exist")
213            }
214            _ => false,
215        }
216    }
217
218    /// Returns true if this error is benign for short-term cancel operations.
219    ///
220    /// Benign cancel errors occur during overlapping cancel waves (common in grid MM):
221    /// - code=19: Transaction already in mempool cache (duplicate tx bytes)
222    /// - code=9: Cancel already exists in memclob with >= GoodTilBlock
223    /// - code=3006: Order to cancel does not exist (already filled/expired/cancelled)
224    #[must_use]
225    pub fn is_benign_cancel_error(&self) -> bool {
226        self.is_tx_in_mempool()
227            || self.is_cancel_already_in_memclob()
228            || self.is_order_does_not_exist()
229    }
230
231    /// Returns true if this error is likely transient and worth retrying.
232    ///
233    /// Transient errors include:
234    /// - Sequence mismatch (recoverable by resync)
235    /// - Network timeouts
236    /// - Temporary node unavailability
237    #[must_use]
238    pub fn is_transient(&self) -> bool {
239        if self.is_sequence_mismatch() {
240            return true;
241        }
242
243        match self {
244            Self::Grpc(status) => {
245                matches!(
246                    status.code(),
247                    tonic::Code::Unavailable
248                        | tonic::Code::DeadlineExceeded
249                        | tonic::Code::ResourceExhausted
250                )
251            }
252            _ => false,
253        }
254    }
255
256    /// Returns true if this error is a definitive CheckTx rejection of the broadcast
257    /// transaction.
258    ///
259    /// `broadcast_tx` uses sync mode, so a `code=N` failure is the node's verdict:
260    /// the transaction never entered the mempool and no message in it executed.
261    /// Benign codes (tx already in mempool, duplicate cancel, order already gone)
262    /// mean the command was already handled, and transient errors (sequence
263    /// mismatch, timeouts) are never a final verdict; both return false, as do
264    /// transport failures that leave the outcome unknown.
265    #[must_use]
266    pub fn is_definitive_broadcast_rejection(&self) -> bool {
267        if self.is_benign_cancel_error() || self.is_transient() {
268            return false;
269        }
270
271        match self {
272            Self::Nautilus(e) => e
273                .to_string()
274                .contains("Transaction broadcast failed: code="),
275            _ => false,
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use rstest::rstest;
283
284    use super::*;
285
286    #[rstest]
287    fn test_sequence_mismatch_from_code_pattern() {
288        // Simulate error message from grpc/client.rs broadcast_tx
289        let err = DydxError::Nautilus(anyhow::anyhow!(
290            "Transaction broadcast failed: code=32, log=account sequence mismatch, expected 15, received 14"
291        ));
292        assert!(err.is_sequence_mismatch());
293    }
294
295    #[rstest]
296    fn test_sequence_mismatch_from_text_pattern() {
297        let err = DydxError::Nautilus(anyhow::anyhow!(
298            "account sequence mismatch: expected 100, received 99"
299        ));
300        assert!(err.is_sequence_mismatch());
301    }
302
303    #[rstest]
304    fn test_sequence_mismatch_grpc_error() {
305        let status =
306            tonic::Status::invalid_argument("account sequence mismatch, expected 42, received 41");
307        let err = DydxError::Grpc(Box::new(status));
308        assert!(err.is_sequence_mismatch());
309    }
310
311    #[rstest]
312    fn test_sequence_mismatch_dydx_authenticator_code_104() {
313        let err = DydxError::Nautilus(anyhow::anyhow!(
314            "Transaction broadcast failed: code=104, log=authentication failed for message 0, \
315             authenticator id 966, type AllOf: signature verification failed; \
316             please verify account number (0), sequence (545) and chain-id (dydx-mainnet-1): \
317             Signature verification failed: AllOf verification failed"
318        ));
319        assert!(err.is_sequence_mismatch());
320    }
321
322    #[rstest]
323    fn test_code_104_without_sequence_not_matched() {
324        // code=104 without "sequence" in the message should NOT match
325        let err = DydxError::Nautilus(anyhow::anyhow!(
326            "Transaction broadcast failed: code=104, log=authentication failed: invalid pubkey"
327        ));
328        assert!(!err.is_sequence_mismatch());
329    }
330
331    #[rstest]
332    fn test_non_sequence_error_not_matched() {
333        let err = DydxError::Nautilus(anyhow::anyhow!("insufficient funds"));
334        assert!(!err.is_sequence_mismatch());
335    }
336
337    #[rstest]
338    fn test_other_error_variants_not_matched() {
339        let err = DydxError::Config("bad config".to_string());
340        assert!(!err.is_sequence_mismatch());
341
342        let err = DydxError::Order("order rejected".to_string());
343        assert!(!err.is_sequence_mismatch());
344    }
345
346    #[rstest]
347    fn test_is_transient_sequence_mismatch() {
348        let err = DydxError::Nautilus(anyhow::anyhow!("account sequence mismatch"));
349        assert!(err.is_transient());
350    }
351
352    #[rstest]
353    fn test_is_transient_unavailable() {
354        let status = tonic::Status::unavailable("node unavailable");
355        let err = DydxError::Grpc(Box::new(status));
356        assert!(err.is_transient());
357    }
358
359    #[rstest]
360    fn test_is_transient_deadline_exceeded() {
361        let status = tonic::Status::deadline_exceeded("timeout");
362        let err = DydxError::Grpc(Box::new(status));
363        assert!(err.is_transient());
364    }
365
366    #[rstest]
367    fn test_is_not_transient_permission_denied() {
368        let status = tonic::Status::permission_denied("unauthorized");
369        let err = DydxError::Grpc(Box::new(status));
370        assert!(!err.is_transient());
371    }
372
373    #[rstest]
374    fn test_is_not_transient_config_error() {
375        let err = DydxError::Config("invalid".to_string());
376        assert!(!err.is_transient());
377    }
378
379    #[rstest]
380    fn test_benign_cancel_tx_in_mempool() {
381        let err = DydxError::Nautilus(anyhow::anyhow!(
382            "Transaction broadcast failed: code=19, tx already in mempool cache"
383        ));
384        assert!(err.is_tx_in_mempool());
385        assert!(err.is_benign_cancel_error());
386    }
387
388    #[rstest]
389    fn test_benign_cancel_already_in_memclob() {
390        let err = DydxError::Nautilus(anyhow::anyhow!(
391            "Transaction broadcast failed: code=9, cancel already exists in memclob with >= GoodTilBlock"
392        ));
393        assert!(err.is_cancel_already_in_memclob());
394        assert!(err.is_benign_cancel_error());
395    }
396
397    #[rstest]
398    fn test_benign_cancel_order_does_not_exist() {
399        let err = DydxError::Nautilus(anyhow::anyhow!(
400            "Transaction broadcast failed: code=3006, Order Id to cancel does not exist"
401        ));
402        assert!(err.is_order_does_not_exist());
403        assert!(err.is_benign_cancel_error());
404    }
405
406    #[rstest]
407    fn test_non_benign_error_not_treated_as_benign() {
408        let err = DydxError::Nautilus(anyhow::anyhow!("insufficient funds"));
409        assert!(!err.is_benign_cancel_error());
410    }
411
412    #[rstest]
413    fn test_benign_cancel_non_nautilus_variant() {
414        let err = DydxError::Order("order rejected".to_string());
415        assert!(!err.is_benign_cancel_error());
416    }
417
418    #[rstest]
419    fn test_definitive_broadcast_rejection_checktx_code() {
420        let err = DydxError::Nautilus(anyhow::anyhow!(
421            "Transaction broadcast failed: code=2000, log=insufficient margin"
422        ));
423        assert!(err.is_definitive_broadcast_rejection());
424    }
425
426    #[rstest]
427    fn test_definitive_broadcast_rejection_excludes_benign_codes() {
428        let err = DydxError::Nautilus(anyhow::anyhow!(
429            "Transaction broadcast failed: code=19, tx already in mempool cache"
430        ));
431        assert!(!err.is_definitive_broadcast_rejection());
432
433        let err = DydxError::Nautilus(anyhow::anyhow!(
434            "Transaction broadcast failed: code=3006, Order Id to cancel does not exist"
435        ));
436        assert!(!err.is_definitive_broadcast_rejection());
437    }
438
439    #[rstest]
440    fn test_definitive_broadcast_rejection_excludes_transport_errors() {
441        let status = tonic::Status::unavailable("node unavailable");
442        let err = DydxError::Grpc(Box::new(status));
443        assert!(!err.is_definitive_broadcast_rejection());
444
445        let err = DydxError::Nautilus(anyhow::anyhow!("connection reset by peer"));
446        assert!(!err.is_definitive_broadcast_rejection());
447    }
448
449    #[rstest]
450    fn test_definitive_broadcast_rejection_excludes_sequence_mismatch() {
451        let err = DydxError::Nautilus(anyhow::anyhow!(
452            "Transaction broadcast failed: code=32, log=account sequence mismatch, expected 15, received 14"
453        ));
454        assert!(!err.is_definitive_broadcast_rejection());
455
456        let err = DydxError::Nautilus(anyhow::anyhow!(
457            "Transaction broadcast failed: code=104, log=signature verification failed; please verify sequence (545)"
458        ));
459        assert!(!err.is_definitive_broadcast_rejection());
460    }
461}