Skip to main content

dig_node_control_interface/
error.rs

1//! The canonical control-plane error taxonomy.
2//!
3//! One definition point for every error code a dig-node control method emits, the [`ControlError`]
4//! envelope (`{code, message, data:{code, origin}}`), and the constructor both sides use so every
5//! error carries a machine-branchable `data.code` (UPPER_SNAKE) and `data.origin`.
6//!
7//! The numeric values are the SAME published wire contract as dig-node's `meta::ErrorCode` and the
8//! canonical dig-rpc `-320xx` control range — they never change once assigned. A client keys its UX
9//! off `data.code` (the stable symbol), never the human `message`.
10//!
11//! | Code | Variant | Origin | Meaning |
12//! |---|---|---|---|
13//! | `-32700` | [`ParseError`](ControlErrorCode::ParseError) | shell | request body was not valid JSON |
14//! | `-32600` | [`InvalidRequest`](ControlErrorCode::InvalidRequest) | shell | not a single JSON-RPC object |
15//! | `-32601` | [`MethodNotFound`](ControlErrorCode::MethodNotFound) | boundary | control method is not resolved |
16//! | `-32602` | [`InvalidParams`](ControlErrorCode::InvalidParams) | node | missing/malformed params |
17//! | `-32000` | [`DispatchFailed`](ControlErrorCode::DispatchFailed) | shell | the node failed to dispatch |
18//! | `-32030` | [`Unauthorized`](ControlErrorCode::Unauthorized) | shell | called without a valid control token |
19//! | `-32031` | [`NotSupported`](ControlErrorCode::NotSupported) | shell | control op unsupported on this build |
20//! | `-32032` | [`ControlError`](ControlErrorCode::ControlError) | shell | control op failed at runtime |
21//! | `-32044` | [`WalletCoinsReserved`](ControlErrorCode::WalletCoinsReserved) | node | coins are held by an in-flight spend |
22//! | `-32045` | [`WalletReservationsUnavailable`](ControlErrorCode::WalletReservationsUnavailable) | node | the reservation set could not be read |
23
24use serde::{Deserialize, Serialize};
25
26/// A canonical control-plane error code.
27///
28/// `#[non_exhaustive]` so adding a code in a minor release is additive; downstream matches must use
29/// a `_ => …` arm.
30#[non_exhaustive]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum ControlErrorCode {
33    /// `-32700` — request body was not valid JSON.
34    ParseError,
35    /// `-32600` — the request was not a single JSON-RPC object (batches are unsupported).
36    InvalidRequest,
37    /// `-32601` — the control method is not resolved by the node.
38    MethodNotFound,
39    /// `-32602` — invalid or missing method parameters.
40    InvalidParams,
41    /// `-32000` — the node failed to dispatch a well-formed control call.
42    DispatchFailed,
43    /// `-32030` — a `control.*` method was called without a valid local control token.
44    Unauthorized,
45    /// `-32031` — the control operation is not supported on this node build (e.g. §21 sync with no
46    /// identity loaded, or a capability the running engine does not expose).
47    NotSupported,
48    /// `-32032` — a control operation failed at runtime (e.g. a config write or cache op errored).
49    ControlError,
50    /// `-32040` — a wallet chain read had NO live chain source able to answer. Reported instead of
51    /// a fabricated empty answer: nothing was consulted, so nothing is known.
52    WalletNoChainSource,
53    /// `-32041` — a wallet chain read of the wallet's OWN address while its local replica is still
54    /// syncing and no fallback is attached: nothing can answer YET.
55    WalletNotSynced,
56    /// `-32042` — a wallet chain read failed at the underlying DB / chain-source layer.
57    WalletReadFailed,
58    /// `-32043` — a wallet chain read was refused because the global fallback rate bound is spent.
59    /// The caller should back off and retry; the answer is unknown, not empty.
60    WalletRateLimited,
61    /// `-32044` — one or more named coins are already committed to a live in-flight spend, so
62    /// NOTHING was reserved by the failed call.
63    ///
64    /// Deliberately distinct from any shortfall code. The user has the money; it is briefly
65    /// committed elsewhere and comes back when that spend settles or its reservation lapses.
66    /// Reporting a shortfall here sends someone to an exchange to solve a wait, which is the
67    /// money-lie this taxonomy exists to prevent.
68    WalletCoinsReserved,
69    /// `-32045` — the node's coin-reservation set could not be read or written, so what is
70    /// in flight is UNKNOWN.
71    ///
72    /// Never collapsed into an empty answer. "Nothing is reserved" and "I cannot tell you what is
73    /// reserved" demand opposite actions from a caller: the first permits a spend, the second must
74    /// refuse one. A guard that fails open is not a guard.
75    WalletReservationsUnavailable,
76}
77
78impl ControlErrorCode {
79    /// The numeric JSON-RPC error code.
80    pub const fn code(self) -> i64 {
81        match self {
82            ControlErrorCode::ParseError => -32700,
83            ControlErrorCode::InvalidRequest => -32600,
84            ControlErrorCode::MethodNotFound => -32601,
85            ControlErrorCode::InvalidParams => -32602,
86            ControlErrorCode::DispatchFailed => -32000,
87            ControlErrorCode::Unauthorized => -32030,
88            ControlErrorCode::NotSupported => -32031,
89            ControlErrorCode::ControlError => -32032,
90            ControlErrorCode::WalletNoChainSource => -32040,
91            ControlErrorCode::WalletNotSynced => -32041,
92            ControlErrorCode::WalletReadFailed => -32042,
93            ControlErrorCode::WalletRateLimited => -32043,
94            ControlErrorCode::WalletCoinsReserved => -32044,
95            ControlErrorCode::WalletReservationsUnavailable => -32045,
96        }
97    }
98
99    /// The stable UPPER_SNAKE symbolic name a client branches on. Never derived from the message.
100    pub const fn name(self) -> &'static str {
101        match self {
102            ControlErrorCode::ParseError => "PARSE_ERROR",
103            ControlErrorCode::InvalidRequest => "INVALID_REQUEST",
104            ControlErrorCode::MethodNotFound => "METHOD_NOT_FOUND",
105            ControlErrorCode::InvalidParams => "INVALID_PARAMS",
106            ControlErrorCode::DispatchFailed => "DISPATCH_FAILED",
107            ControlErrorCode::Unauthorized => "UNAUTHORIZED",
108            ControlErrorCode::NotSupported => "NOT_SUPPORTED",
109            ControlErrorCode::ControlError => "CONTROL_ERROR",
110            ControlErrorCode::WalletNoChainSource => "WALLET_NO_CHAIN_SOURCE",
111            ControlErrorCode::WalletNotSynced => "WALLET_NOT_SYNCED",
112            ControlErrorCode::WalletReadFailed => "WALLET_READ_FAILED",
113            ControlErrorCode::WalletRateLimited => "WALLET_RATE_LIMITED",
114            ControlErrorCode::WalletCoinsReserved => "WALLET_COINS_RESERVED",
115            ControlErrorCode::WalletReservationsUnavailable => "WALLET_RESERVATIONS_UNAVAILABLE",
116        }
117    }
118
119    /// Where the error originates, mirroring dig-node's classification.
120    pub const fn origin(self) -> &'static str {
121        match self {
122            ControlErrorCode::MethodNotFound => "boundary",
123            // Minted by the node's own wallet backend, matching dig-node's classification.
124            ControlErrorCode::InvalidParams
125            | ControlErrorCode::WalletNoChainSource
126            | ControlErrorCode::WalletNotSynced
127            | ControlErrorCode::WalletReadFailed
128            | ControlErrorCode::WalletRateLimited
129            | ControlErrorCode::WalletCoinsReserved
130            | ControlErrorCode::WalletReservationsUnavailable => "node",
131            _ => "shell",
132        }
133    }
134
135    /// A one-line description for the error catalogue.
136    pub const fn description(self) -> &'static str {
137        match self {
138            ControlErrorCode::ParseError => "Request body was not valid JSON.",
139            ControlErrorCode::InvalidRequest => {
140                "Request was not a single JSON-RPC object (batch arrays are not supported)."
141            }
142            ControlErrorCode::MethodNotFound => "The control method is not resolved by the node.",
143            ControlErrorCode::InvalidParams => "Invalid or missing method parameters.",
144            ControlErrorCode::DispatchFailed => "The node failed to dispatch the control request.",
145            ControlErrorCode::Unauthorized => {
146                "A control.* method was called without a valid local control token."
147            }
148            ControlErrorCode::NotSupported => {
149                "The requested control operation is not supported on this node build."
150            }
151            ControlErrorCode::ControlError => "A control operation failed at runtime.",
152            ControlErrorCode::WalletNoChainSource => {
153                "A wallet chain read had no live chain source to answer."
154            }
155            ControlErrorCode::WalletNotSynced => {
156                "A wallet chain read of the wallet's own address is still syncing with no fallback."
157            }
158            ControlErrorCode::WalletReadFailed => {
159                "A wallet chain read failed at the DB / chain-source layer."
160            }
161            ControlErrorCode::WalletRateLimited => {
162                "A wallet chain read was refused: the open fallback rate bound is exhausted."
163            }
164            ControlErrorCode::WalletCoinsReserved => {
165                "Coins named by this call are committed to a live in-flight spend; nothing was reserved. This is a wait, NOT a shortfall."
166            }
167            ControlErrorCode::WalletReservationsUnavailable => {
168                "The node's coin-reservation set could not be read, so coin selection cannot be trusted."
169            }
170        }
171    }
172
173    /// Resolve a numeric wire code back to its variant, or `None` for an uncatalogued code.
174    pub fn from_code(code: i64) -> Option<ControlErrorCode> {
175        ControlErrorCode::ALL
176            .iter()
177            .copied()
178            .find(|c| c.code() == code)
179    }
180
181    /// Every catalogued code, for the error-catalogue document + drift tests.
182    pub const ALL: &'static [ControlErrorCode] = &[
183        ControlErrorCode::ParseError,
184        ControlErrorCode::InvalidRequest,
185        ControlErrorCode::MethodNotFound,
186        ControlErrorCode::InvalidParams,
187        ControlErrorCode::DispatchFailed,
188        ControlErrorCode::Unauthorized,
189        ControlErrorCode::NotSupported,
190        ControlErrorCode::ControlError,
191        ControlErrorCode::WalletNoChainSource,
192        ControlErrorCode::WalletNotSynced,
193        ControlErrorCode::WalletReadFailed,
194        ControlErrorCode::WalletRateLimited,
195        ControlErrorCode::WalletCoinsReserved,
196        ControlErrorCode::WalletReservationsUnavailable,
197    ];
198}
199
200/// The machine-branchable inner data on a control error: the stable symbol + its origin.
201///
202/// Serializes to `{"code": "UNAUTHORIZED", "origin": "shell"}`, matching dig-node's `error.data`.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct ControlErrorData {
205    /// The stable UPPER_SNAKE symbol (e.g. `"UNAUTHORIZED"`) — see [`ControlErrorCode::name`].
206    pub code: String,
207    /// Where the error originated (`shell` / `node` / `boundary`) — see [`ControlErrorCode::origin`].
208    pub origin: String,
209}
210
211/// A control-plane error, serialized as the JSON-RPC `error` object
212/// (`{code, message, data:{code, origin}}`).
213///
214/// [`code`](ControlError::code) is the numeric wire value; [`data`](ControlError::data) carries the
215/// stable symbol + origin a client actually branches on.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct ControlError {
218    /// The numeric JSON-RPC error code.
219    pub code: i64,
220    /// A human-readable message (never contract-stable — for display/logs only).
221    pub message: String,
222    /// The machine-branchable symbol + origin.
223    pub data: ControlErrorData,
224}
225
226impl ControlError {
227    /// Build an error from a catalogued [`ControlErrorCode`] and a human message.
228    pub fn of(code: ControlErrorCode, message: impl Into<String>) -> ControlError {
229        ControlError {
230            code: code.code(),
231            message: message.into(),
232            data: ControlErrorData {
233                code: code.name().to_string(),
234                origin: code.origin().to_string(),
235            },
236        }
237    }
238
239    /// The catalogued code, resolved from the numeric wire value (`None` if uncatalogued).
240    pub fn code_enum(&self) -> Option<ControlErrorCode> {
241        ControlErrorCode::from_code(self.code)
242    }
243}
244
245impl std::fmt::Display for ControlError {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        write!(f, "{} ({}): {}", self.data.code, self.code, self.message)
248    }
249}
250
251impl std::error::Error for ControlError {}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn codes_and_symbols_and_origins_are_stable() {
259        assert_eq!(ControlErrorCode::Unauthorized.code(), -32030);
260        assert_eq!(ControlErrorCode::NotSupported.code(), -32031);
261        assert_eq!(ControlErrorCode::ControlError.code(), -32032);
262        assert_eq!(ControlErrorCode::InvalidParams.name(), "INVALID_PARAMS");
263        assert_eq!(ControlErrorCode::InvalidParams.origin(), "node");
264        assert_eq!(ControlErrorCode::MethodNotFound.origin(), "boundary");
265        assert_eq!(ControlErrorCode::Unauthorized.origin(), "shell");
266    }
267
268    #[test]
269    fn from_code_round_trips_every_code() {
270        for &c in ControlErrorCode::ALL {
271            assert_eq!(ControlErrorCode::from_code(c.code()), Some(c));
272        }
273        assert_eq!(ControlErrorCode::from_code(0), None);
274    }
275
276    #[test]
277    fn every_code_has_a_description() {
278        for &c in ControlErrorCode::ALL {
279            assert!(!c.description().is_empty());
280        }
281    }
282
283    #[test]
284    fn error_serializes_to_the_canonical_json_shape() {
285        let e = ControlError::of(ControlErrorCode::Unauthorized, "no token");
286        let v = serde_json::to_value(&e).unwrap();
287        assert_eq!(v["code"], -32030);
288        assert_eq!(v["message"], "no token");
289        assert_eq!(v["data"]["code"], "UNAUTHORIZED");
290        assert_eq!(v["data"]["origin"], "shell");
291        assert_eq!(e.code_enum(), Some(ControlErrorCode::Unauthorized));
292    }
293
294    #[test]
295    fn display_is_symbol_code_and_message() {
296        let e = ControlError::of(ControlErrorCode::ControlError, "boom");
297        assert_eq!(e.to_string(), "CONTROL_ERROR (-32032): boom");
298    }
299}