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//! | `-32040` | [`WalletNoChainSource`](ControlErrorCode::WalletNoChainSource) | node | no live chain source could answer |
22//! | `-32041` | [`WalletNotSynced`](ControlErrorCode::WalletNotSynced) | node | the local replica is still syncing |
23//! | `-32042` | [`WalletReadFailed`](ControlErrorCode::WalletReadFailed) | node | the read failed at the DB / chain layer |
24//! | `-32043` | [`WalletRateLimited`](ControlErrorCode::WalletRateLimited) | node | the open fallback rate bound is exhausted |
25//! | `-32044` | [`WalletNodeSpendDisabled`](ControlErrorCode::WalletNodeSpendDisabled) | node | the bundle spends the NODE's own coins and live broadcast is off |
26//! | `-32046` | [`WalletCoinsReserved`](ControlErrorCode::WalletCoinsReserved) | node | coins are held by an in-flight spend |
27//! | `-32047` | [`WalletReservationsUnavailable`](ControlErrorCode::WalletReservationsUnavailable) | node | the reservation set could not be read |
28//! | `-32048` | [`SpendAuditUnreadable`](ControlErrorCode::SpendAuditUnreadable) | shell | the automated-spend audit record could not be read |
29//!
30//! The `-3204x` block is OWNED BY THIS CRATE. A node or client MUST NOT mint a code in it that is
31//! not declared here: a privately-minted code is invisible to allocation, and two implementations
32//! then disagree about what one number means. `-32044` is the worked example — it was minted in
33//! dig-node for a permanent custody refusal and, independently, here for a transient wait, so a
34//! client conflating them either retries forever on a refusal or gives up on a wait.
35
36use serde::{Deserialize, Serialize};
37
38/// A canonical control-plane error code.
39///
40/// `#[non_exhaustive]` so adding a code in a minor release is additive; downstream matches must use
41/// a `_ => …` arm.
42#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum ControlErrorCode {
45    /// `-32700` — request body was not valid JSON.
46    ParseError,
47    /// `-32600` — the request was not a single JSON-RPC object (batches are unsupported).
48    InvalidRequest,
49    /// `-32601` — the control method is not resolved by the node.
50    MethodNotFound,
51    /// `-32602` — invalid or missing method parameters.
52    InvalidParams,
53    /// `-32000` — the node failed to dispatch a well-formed control call.
54    DispatchFailed,
55    /// `-32030` — a `control.*` method was called without a valid local control token.
56    Unauthorized,
57    /// `-32031` — the control operation is not supported on this node build (e.g. §21 sync with no
58    /// identity loaded, or a capability the running engine does not expose).
59    NotSupported,
60    /// `-32032` — a control operation failed at runtime (e.g. a config write or cache op errored).
61    ControlError,
62    /// `-32040` — a wallet chain read had NO live chain source able to answer. Reported instead of
63    /// a fabricated empty answer: nothing was consulted, so nothing is known.
64    WalletNoChainSource,
65    /// `-32041` — a wallet chain read of the wallet's OWN address while its local replica is still
66    /// syncing and no fallback is attached: nothing can answer YET.
67    WalletNotSynced,
68    /// `-32042` — a wallet chain read failed at the underlying DB / chain-source layer.
69    WalletReadFailed,
70    /// `-32043` — a wallet chain read was refused because the global fallback rate bound is spent.
71    /// The caller should back off and retry; the answer is unknown, not empty.
72    WalletRateLimited,
73    /// `-32044` — `control.wallet.broadcast` refused: the bundle requires a signature from one of
74    /// the NODE's OWN custodied keys while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off (dig-node
75    /// SPEC §18.12).
76    ///
77    /// The node relays bundles somebody else signed on every install; sending its own money is a
78    /// separate, default-OFF custody decision, and a caller could otherwise sign through the node
79    /// and hand the bundle straight back. **Retrying cannot help**: the remedy is a bundle that
80    /// does not spend the node's coins, or the flag.
81    ///
82    /// The exact opposite disposition to [`WalletCoinsReserved`](Self::WalletCoinsReserved), which is a wait. Keeping the two
83    /// on distinct numbers is the whole point of this code being catalogued here.
84    WalletNodeSpendDisabled,
85    /// `-32046` — one or more named coins are already committed to a live in-flight spend, so
86    /// NOTHING was reserved by the failed call.
87    ///
88    /// Deliberately distinct from any shortfall code. The user has the money; it is briefly
89    /// committed elsewhere and comes back when that spend settles or its reservation lapses.
90    /// Reporting a shortfall here sends someone to an exchange to solve a wait, which is the
91    /// money-lie this taxonomy exists to prevent.
92    WalletCoinsReserved,
93    /// `-32047` — the node's coin-reservation set could not be read or written, so what is
94    /// in flight is UNKNOWN.
95    ///
96    /// Never collapsed into an empty answer. "Nothing is reserved" and "I cannot tell you what is
97    /// reserved" demand opposite actions from a caller: the first permits a spend, the second must
98    /// refuse one. A guard that fails open is not a guard.
99    WalletReservationsUnavailable,
100    /// `-32048` — the automated-spend audit record could not be read at all, so what this node
101    /// spent unattended is UNKNOWN.
102    ///
103    /// Never collapsed into an empty page. "This node has moved no money unattended" and "I cannot
104    /// tell you what this node moved" are opposite answers, and the first is the one a person stops
105    /// investigating on. A partial read is a different thing again and is NOT an error: entries the
106    /// node could parse are returned, and the ones it could not are counted in
107    /// [`SpendsListResult::unreadable_lines`](crate::results::SpendsListResult::unreadable_lines).
108    /// This code is for the case where nothing at all could be read.
109    ///
110    /// An audit record that has never been written is likewise NOT an error — a node that has never
111    /// spent automatically is the ordinary case, and it answers with an empty page.
112    SpendAuditUnreadable,
113}
114
115impl ControlErrorCode {
116    /// The numeric JSON-RPC error code.
117    pub const fn code(self) -> i64 {
118        match self {
119            ControlErrorCode::ParseError => -32700,
120            ControlErrorCode::InvalidRequest => -32600,
121            ControlErrorCode::MethodNotFound => -32601,
122            ControlErrorCode::InvalidParams => -32602,
123            ControlErrorCode::DispatchFailed => -32000,
124            ControlErrorCode::Unauthorized => -32030,
125            ControlErrorCode::NotSupported => -32031,
126            ControlErrorCode::ControlError => -32032,
127            ControlErrorCode::WalletNoChainSource => -32040,
128            ControlErrorCode::WalletNotSynced => -32041,
129            ControlErrorCode::WalletReadFailed => -32042,
130            ControlErrorCode::WalletRateLimited => -32043,
131            ControlErrorCode::WalletNodeSpendDisabled => -32044,
132            ControlErrorCode::WalletCoinsReserved => -32046,
133            ControlErrorCode::WalletReservationsUnavailable => -32047,
134            ControlErrorCode::SpendAuditUnreadable => -32048,
135        }
136    }
137
138    /// The stable UPPER_SNAKE symbolic name a client branches on. Never derived from the message.
139    pub const fn name(self) -> &'static str {
140        match self {
141            ControlErrorCode::ParseError => "PARSE_ERROR",
142            ControlErrorCode::InvalidRequest => "INVALID_REQUEST",
143            ControlErrorCode::MethodNotFound => "METHOD_NOT_FOUND",
144            ControlErrorCode::InvalidParams => "INVALID_PARAMS",
145            ControlErrorCode::DispatchFailed => "DISPATCH_FAILED",
146            ControlErrorCode::Unauthorized => "UNAUTHORIZED",
147            ControlErrorCode::NotSupported => "NOT_SUPPORTED",
148            ControlErrorCode::ControlError => "CONTROL_ERROR",
149            ControlErrorCode::WalletNoChainSource => "WALLET_NO_CHAIN_SOURCE",
150            ControlErrorCode::WalletNotSynced => "WALLET_NOT_SYNCED",
151            ControlErrorCode::WalletReadFailed => "WALLET_READ_FAILED",
152            ControlErrorCode::WalletRateLimited => "WALLET_RATE_LIMITED",
153            ControlErrorCode::WalletNodeSpendDisabled => "WALLET_NODE_SPEND_DISABLED",
154            ControlErrorCode::WalletCoinsReserved => "WALLET_COINS_RESERVED",
155            ControlErrorCode::WalletReservationsUnavailable => "WALLET_RESERVATIONS_UNAVAILABLE",
156            ControlErrorCode::SpendAuditUnreadable => "SPEND_AUDIT_UNREADABLE",
157        }
158    }
159
160    /// Where the error originates, mirroring dig-node's classification.
161    pub const fn origin(self) -> &'static str {
162        match self {
163            ControlErrorCode::MethodNotFound => "boundary",
164            // Minted by the node's own wallet backend, matching dig-node's classification.
165            ControlErrorCode::InvalidParams
166            | ControlErrorCode::WalletNoChainSource
167            | ControlErrorCode::WalletNotSynced
168            | ControlErrorCode::WalletReadFailed
169            | ControlErrorCode::WalletRateLimited
170            | ControlErrorCode::WalletNodeSpendDisabled
171            | ControlErrorCode::WalletCoinsReserved
172            | ControlErrorCode::WalletReservationsUnavailable => "node",
173            _ => "shell",
174        }
175    }
176
177    /// A one-line description for the error catalogue.
178    pub const fn description(self) -> &'static str {
179        match self {
180            ControlErrorCode::ParseError => "Request body was not valid JSON.",
181            ControlErrorCode::InvalidRequest => {
182                "Request was not a single JSON-RPC object (batch arrays are not supported)."
183            }
184            ControlErrorCode::MethodNotFound => "The control method is not resolved by the node.",
185            ControlErrorCode::InvalidParams => "Invalid or missing method parameters.",
186            ControlErrorCode::DispatchFailed => "The node failed to dispatch the control request.",
187            ControlErrorCode::Unauthorized => {
188                "A control.* method was called without a valid local control token."
189            }
190            ControlErrorCode::NotSupported => {
191                "The requested control operation is not supported on this node build."
192            }
193            ControlErrorCode::ControlError => "A control operation failed at runtime.",
194            ControlErrorCode::WalletNoChainSource => {
195                "A wallet chain read had no live chain source to answer."
196            }
197            ControlErrorCode::WalletNotSynced => {
198                "A wallet chain read of the wallet's own address is still syncing with no fallback."
199            }
200            ControlErrorCode::WalletReadFailed => {
201                "A wallet chain read failed at the DB / chain-source layer."
202            }
203            ControlErrorCode::WalletRateLimited => {
204                "A wallet chain read was refused: the open fallback rate bound is exhausted."
205            }
206            ControlErrorCode::WalletNodeSpendDisabled => {
207                "control.wallet.broadcast refused: the bundle spends the NODE's own custodied coins while live broadcast is off. Retrying cannot help."
208            }
209            ControlErrorCode::WalletCoinsReserved => {
210                "Coins named by this call are committed to a live in-flight spend; nothing was reserved. This is a wait, NOT a shortfall."
211            }
212            ControlErrorCode::WalletReservationsUnavailable => {
213                "The node's coin-reservation set could not be read, so coin selection cannot be trusted."
214            }
215            ControlErrorCode::SpendAuditUnreadable => {
216                "The automated-spend audit record could not be read, so what this node spent unattended is unknown. This is NOT an empty record."
217            }
218        }
219    }
220
221    /// Resolve a numeric wire code back to its variant, or `None` for an uncatalogued code.
222    pub fn from_code(code: i64) -> Option<ControlErrorCode> {
223        ControlErrorCode::ALL
224            .iter()
225            .copied()
226            .find(|c| c.code() == code)
227    }
228
229    /// Every catalogued code, for the error-catalogue document + drift tests.
230    pub const ALL: &'static [ControlErrorCode] = &[
231        ControlErrorCode::ParseError,
232        ControlErrorCode::InvalidRequest,
233        ControlErrorCode::MethodNotFound,
234        ControlErrorCode::InvalidParams,
235        ControlErrorCode::DispatchFailed,
236        ControlErrorCode::Unauthorized,
237        ControlErrorCode::NotSupported,
238        ControlErrorCode::ControlError,
239        ControlErrorCode::WalletNoChainSource,
240        ControlErrorCode::WalletNotSynced,
241        ControlErrorCode::WalletReadFailed,
242        ControlErrorCode::WalletRateLimited,
243        ControlErrorCode::WalletNodeSpendDisabled,
244        ControlErrorCode::WalletCoinsReserved,
245        ControlErrorCode::WalletReservationsUnavailable,
246        ControlErrorCode::SpendAuditUnreadable,
247    ];
248}
249
250/// The machine-branchable inner data on a control error: the stable symbol + its origin.
251///
252/// Serializes to `{"code": "UNAUTHORIZED", "origin": "shell"}`, matching dig-node's `error.data`.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct ControlErrorData {
255    /// The stable UPPER_SNAKE symbol (e.g. `"UNAUTHORIZED"`) — see [`ControlErrorCode::name`].
256    pub code: String,
257    /// Where the error originated (`shell` / `node` / `boundary`) — see [`ControlErrorCode::origin`].
258    pub origin: String,
259}
260
261/// A control-plane error, serialized as the JSON-RPC `error` object
262/// (`{code, message, data:{code, origin}}`).
263///
264/// [`code`](ControlError::code) is the numeric wire value; [`data`](ControlError::data) carries the
265/// stable symbol + origin a client actually branches on.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct ControlError {
268    /// The numeric JSON-RPC error code.
269    pub code: i64,
270    /// A human-readable message (never contract-stable — for display/logs only).
271    pub message: String,
272    /// The machine-branchable symbol + origin.
273    pub data: ControlErrorData,
274}
275
276impl ControlError {
277    /// Build an error from a catalogued [`ControlErrorCode`] and a human message.
278    pub fn of(code: ControlErrorCode, message: impl Into<String>) -> ControlError {
279        ControlError {
280            code: code.code(),
281            message: message.into(),
282            data: ControlErrorData {
283                code: code.name().to_string(),
284                origin: code.origin().to_string(),
285            },
286        }
287    }
288
289    /// The catalogued code, resolved from the numeric wire value (`None` if uncatalogued).
290    pub fn code_enum(&self) -> Option<ControlErrorCode> {
291        ControlErrorCode::from_code(self.code)
292    }
293}
294
295impl std::fmt::Display for ControlError {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        write!(f, "{} ({}): {}", self.data.code, self.code, self.message)
298    }
299}
300
301impl std::error::Error for ControlError {}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn codes_and_symbols_and_origins_are_stable() {
309        assert_eq!(ControlErrorCode::Unauthorized.code(), -32030);
310        assert_eq!(ControlErrorCode::NotSupported.code(), -32031);
311        assert_eq!(ControlErrorCode::ControlError.code(), -32032);
312        assert_eq!(ControlErrorCode::InvalidParams.name(), "INVALID_PARAMS");
313        assert_eq!(ControlErrorCode::InvalidParams.origin(), "node");
314        assert_eq!(ControlErrorCode::MethodNotFound.origin(), "boundary");
315        assert_eq!(ControlErrorCode::Unauthorized.origin(), "shell");
316    }
317
318    #[test]
319    fn from_code_round_trips_every_code() {
320        for &c in ControlErrorCode::ALL {
321            assert_eq!(ControlErrorCode::from_code(c.code()), Some(c));
322        }
323        assert_eq!(ControlErrorCode::from_code(0), None);
324    }
325
326    #[test]
327    fn every_code_has_a_description() {
328        for &c in ControlErrorCode::ALL {
329            assert!(!c.description().is_empty());
330        }
331    }
332
333    #[test]
334    fn error_serializes_to_the_canonical_json_shape() {
335        let e = ControlError::of(ControlErrorCode::Unauthorized, "no token");
336        let v = serde_json::to_value(&e).unwrap();
337        assert_eq!(v["code"], -32030);
338        assert_eq!(v["message"], "no token");
339        assert_eq!(v["data"]["code"], "UNAUTHORIZED");
340        assert_eq!(v["data"]["origin"], "shell");
341        assert_eq!(e.code_enum(), Some(ControlErrorCode::Unauthorized));
342    }
343
344    #[test]
345    fn display_is_symbol_code_and_message() {
346        let e = ControlError::of(ControlErrorCode::ControlError, "boom");
347        assert_eq!(e.to_string(), "CONTROL_ERROR (-32032): boom");
348    }
349}