Skip to main content

paralock_core/protocol/
error.rs

1use core::fmt;
2
3use serde::{Deserialize, Serialize};
4
5/// Machine-readable error codes. Each maps to an HTTP status ([`ParalockErrorCode::status`]).
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ParalockErrorCode {
9    /// 400: malformed input.
10    InvalidRequest,
11    /// 422: the taker refused a maker's quote (bad key, token, window, price…).
12    QuoteRejected,
13    /// 410: the quote is unknown, used or past its expiry.
14    QuoteExpired,
15    /// 404: no such swap.
16    NotFound,
17    /// 409: the request conflicts with the swap's state.
18    Conflict,
19    /// 422: outside the maker's per-swap limits.
20    LimitExceeded,
21    /// 429: too many open quotes or concurrent swaps.
22    Busy,
23    /// 503: the maker lacks the stablecoin inventory.
24    InsufficientLiquidity,
25    /// 422: the Zcash window doesn't outlast the EVM lock by the safety factor.
26    UnsafeTimeouts,
27    /// 422: an on-chain lock doesn't match the agreed terms.
28    LockMismatch,
29    /// 503: a dependency (node, price feed, maker) is unreachable.
30    Unavailable,
31    /// 504: waited too long for something on-chain.
32    Timeout,
33    /// 500: anything else. Its message is never sent to clients.
34    Internal,
35}
36
37impl ParalockErrorCode {
38    /// Every code.
39    pub const ALL: [Self; 13] = [
40        Self::InvalidRequest,
41        Self::QuoteRejected,
42        Self::QuoteExpired,
43        Self::NotFound,
44        Self::Conflict,
45        Self::LimitExceeded,
46        Self::Busy,
47        Self::InsufficientLiquidity,
48        Self::UnsafeTimeouts,
49        Self::LockMismatch,
50        Self::Unavailable,
51        Self::Timeout,
52        Self::Internal,
53    ];
54
55    /// The HTTP status of this code.
56    pub const fn status(self) -> u16 {
57        match self {
58            Self::InvalidRequest => 400,
59            Self::QuoteRejected | Self::LimitExceeded | Self::UnsafeTimeouts | Self::LockMismatch => 422,
60            Self::QuoteExpired => 410,
61            Self::NotFound => 404,
62            Self::Conflict => 409,
63            Self::Busy => 429,
64            Self::InsufficientLiquidity | Self::Unavailable => 503,
65            Self::Timeout => 504,
66            Self::Internal => 500,
67        }
68    }
69
70    /// The wire name (`invalid_request`, …).
71    pub const fn as_str(self) -> &'static str {
72        match self {
73            Self::InvalidRequest => "invalid_request",
74            Self::QuoteRejected => "quote_rejected",
75            Self::QuoteExpired => "quote_expired",
76            Self::NotFound => "not_found",
77            Self::Conflict => "conflict",
78            Self::LimitExceeded => "limit_exceeded",
79            Self::Busy => "busy",
80            Self::InsufficientLiquidity => "insufficient_liquidity",
81            Self::UnsafeTimeouts => "unsafe_timeouts",
82            Self::LockMismatch => "lock_mismatch",
83            Self::Unavailable => "unavailable",
84            Self::Timeout => "timeout",
85            Self::Internal => "internal",
86        }
87    }
88
89    /// Parses a wire name.
90    pub fn parse(s: &str) -> Option<Self> {
91        Self::ALL.into_iter().find(|c| c.as_str() == s)
92    }
93}
94
95impl fmt::Display for ParalockErrorCode {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// An expected failure with a stable [`ParalockErrorCode`]. Messages are safe to show to a counterparty: they never
102/// include keys, secrets or internal details.
103#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104#[error("{message}")]
105pub struct ParalockError {
106    /// The code.
107    pub code: ParalockErrorCode,
108    /// A human-readable message.
109    pub message: String,
110}
111
112impl ParalockError {
113    /// An error with a code and message.
114    pub fn new(code: ParalockErrorCode, message: impl Into<String>) -> Self {
115        Self { code, message: message.into() }
116    }
117
118    /// An `invalid_request` error.
119    pub fn invalid_request(message: impl Into<String>) -> Self {
120        Self::new(ParalockErrorCode::InvalidRequest, message)
121    }
122
123    /// The HTTP status of the code.
124    pub const fn status(&self) -> u16 {
125        self.code.status()
126    }
127
128    /// The body a maker sends for this error.
129    pub fn to_body(&self) -> ErrorBody {
130        ErrorBody { error: self.message.clone(), code: self.code }
131    }
132}
133
134/// The JSON body of a maker error response: `{ "error": message, "code": code }`.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct ErrorBody {
137    /// Message.
138    pub error: String,
139    /// Code.
140    pub code: ParalockErrorCode,
141}
142
143impl ErrorBody {
144    /// Parses an error response body the way the TypeScript HTTP client does: an unknown or missing code becomes
145    /// `unavailable`, a missing message becomes `HTTP <status>`, and messages are cut to 300 UTF-16 units. Never fails.
146    pub fn from_response(status: u16, body: &str) -> ParalockError {
147        let value: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
148        let code = value
149            .get("code")
150            .and_then(serde_json::Value::as_str)
151            .and_then(ParalockErrorCode::parse)
152            .unwrap_or(ParalockErrorCode::Unavailable);
153        let message = match value.get("error").and_then(serde_json::Value::as_str) {
154            Some(text) => truncate_utf16(text, 300),
155            None => format!("HTTP {status}"),
156        };
157        ParalockError::new(code, format!("maker: {message}"))
158    }
159}
160
161/// The first `max` UTF-16 code units of `s` (JavaScript's `slice(0, max)`), never splitting a character.
162pub(crate) fn truncate_utf16(s: &str, max: usize) -> String {
163    let mut units = 0;
164    let mut out = String::new();
165    for c in s.chars() {
166        units += c.len_utf16();
167        if units > max {
168            break;
169        }
170        out.push(c);
171    }
172    out
173}
174
175#[cfg(test)]
176mod tests {
177    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
178    use super::*;
179
180    #[test]
181    fn codes_round_trip_and_map_to_statuses() {
182        for code in ParalockErrorCode::ALL {
183            let json = serde_json::to_string(&code).unwrap();
184            assert_eq!(json, format!("\"{}\"", code.as_str()));
185            assert_eq!(ParalockErrorCode::parse(code.as_str()), Some(code));
186        }
187        assert_eq!(ParalockErrorCode::Busy.status(), 429);
188        assert_eq!(ParalockErrorCode::InsufficientLiquidity.status(), 503);
189    }
190
191    #[test]
192    fn error_bodies_serialize_like_the_maker() {
193        let err = ParalockError::invalid_request("quoteId must be a quote id");
194        assert_eq!(
195            serde_json::to_string(&err.to_body()).unwrap(),
196            r#"{"error":"quoteId must be a quote id","code":"invalid_request"}"#
197        );
198        let parsed = ErrorBody::from_response(422, r#"{"error":"nope","code":"quote_rejected"}"#);
199        assert_eq!(parsed, ParalockError::new(ParalockErrorCode::QuoteRejected, "maker: nope"));
200        let unknown = ErrorBody::from_response(502, "<html>");
201        assert_eq!(unknown, ParalockError::new(ParalockErrorCode::Unavailable, "maker: HTTP 502"));
202    }
203}