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}