Skip to main content

mostro_core/
error.rs

1//! Error taxonomy used across the crate.
2//!
3//! Errors surfaced to clients are modelled as [`MostroError`], which is split
4//! into two branches:
5//!
6//! * [`MostroError::MostroCantDo`] — a "soft" error: the request was
7//!   well-formed but the server refuses to perform the action (e.g. the order
8//!   is not in the right state). Clients should surface the inner
9//!   [`CantDoReason`] to the user.
10//! * [`MostroError::MostroInternalErr`] — a "hard" error: something went
11//!   wrong while processing the request (database failure, Nostr relay
12//!   issue, malformed invoice, etc.). The inner [`ServiceError`] carries the
13//!   diagnostic detail.
14//!
15//! Both inner enums implement [`Display`](std::fmt::Display) with
16//! human-readable messages suited for logging.
17
18use crate::prelude::*;
19
20/// Machine-readable reasons carried by a `CantDo` response.
21///
22/// Serialized in `snake_case` so clients can pattern-match on the value
23/// transported in [`crate::message::Payload::CantDo`].
24#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum CantDoReason {
27    /// The provided signature is invalid or missing.
28    InvalidSignature,
29    /// The specified trade index does not exist or is invalid.
30    InvalidTradeIndex,
31    /// The provided amount is invalid or out of acceptable range.
32    InvalidAmount,
33    /// The provided invoice is malformed or expired.
34    InvalidInvoice,
35    /// The payment request is invalid or cannot be processed.
36    InvalidPaymentRequest,
37    /// The specified peer is invalid or not found.
38    InvalidPeer,
39    /// The rating value is invalid or out of range.
40    InvalidRating,
41    /// The text message is invalid or contains prohibited content.
42    InvalidTextMessage,
43    /// The order kind is invalid.
44    InvalidOrderKind,
45    /// The order status is invalid.
46    InvalidOrderStatus,
47    /// The provided public key is invalid.
48    InvalidPubkey,
49    /// One or more request parameters are invalid.
50    InvalidParameters,
51    /// The provided payload is the wrong shape for this action,
52    /// or carries values that cannot be processed (e.g. a
53    /// `BondResolution` requesting a slash against a side with no
54    /// active bond row). The caller should correct and resend.
55    InvalidPayload,
56    /// The targeted order has already been canceled.
57    OrderAlreadyCanceled,
58    /// User creation failed on the server side.
59    CantCreateUser,
60    /// The caller tried to operate on an order that does not belong to them.
61    IsNotYourOrder,
62    /// The requested action is not allowed in the order's current status.
63    NotAllowedByStatus,
64    /// The fiat amount is outside the allowed range for this order.
65    OutOfRangeFiatAmount,
66    /// The sats amount is outside the allowed range for this order.
67    OutOfRangeSatsAmount,
68    /// No fresh exchange rate is available for the order's fiat currency:
69    /// the last-known-good price is older than the node's staleness window,
70    /// so a market-priced order cannot be created or taken right now. The
71    /// caller should retry once pricing recovers, or use a fixed rate.
72    PriceTooStale,
73    /// The caller tried to operate on a dispute that does not belong to them.
74    IsNotYourDispute,
75    /// A solver is being notified that an admin has taken over their dispute.
76    DisputeTakenByAdmin,
77    /// The caller is authenticated but lacks the permission for this action.
78    NotAuthorized,
79    /// A dispute could not be created (e.g. order not in a disputable state).
80    DisputeCreationError,
81    /// Generic "resource not found" error.
82    NotFound,
83    /// The dispute is in an invalid state for the requested action.
84    InvalidDisputeStatus,
85    /// The requested action is invalid.
86    InvalidAction,
87    /// The caller already has a pending order and cannot create another.
88    PendingOrderExists,
89    /// The fiat currency code is not accepted by this Mostro node.
90    InvalidFiatCurrency,
91    /// The caller is being rate-limited.
92    TooManyRequests,
93    /// The submitted Cashu token is malformed, cannot be parsed, or its
94    /// 2-of-3 spending condition does not match the expected
95    /// buyer/seller/Mostro pubkeys.
96    InvalidCashuToken,
97    /// The order's Cashu mint could not be reached or did not answer the
98    /// state check. Nothing was recorded, so the request can be retried.
99    CashuMintUnavailable,
100    /// The mint URL is malformed, names a mint the node does not accept for
101    /// a new order, or does not match the mint the order's escrow must be
102    /// locked on.
103    InvalidMintUrl,
104    /// The requested action needs a locked Cashu escrow, but none has been
105    /// recorded for this order.
106    CashuEscrowNotLocked,
107    /// A required Cashu signature is missing from the request.
108    CashuSignatureMissing,
109    /// Mostro is in maintenance mode (for example draining escrow before a
110    /// Lightning node migration) and is not accepting new orders or takes.
111    /// Actions on already existing orders keep working.
112    MaintenanceMode,
113    /// The `payment_hash` of a payer declaration is not 64 lowercase hex
114    /// characters.
115    InvalidPaymentHash,
116    /// `fiat-sent` was refused because this node requires the buyer to send
117    /// `declare-payer` first.
118    PayerNotDeclared,
119    /// A reputation request carries no identity proof: in full privacy mode
120    /// there is no identity-bound reputation to read, export or import into.
121    ReputationIdentityRequired,
122    /// The account cannot export its reputation: it is banned, or has fewer
123    /// than 10 completed trades or 5 ratings received.
124    NotEligibleForReputationExport,
125    /// The account is bound to another identity and the export request
126    /// carries no rebind authorisation.
127    ReputationBoundToOtherIdentity,
128    /// The rebind authorisation is malformed, badly signed, not signed by the
129    /// bound identity, names another issuer or has expired.
130    InvalidReputationRebind,
131    /// The attestation does not parse, is badly signed, breaks a tag rule,
132    /// is dated in the future or outlives the destination's lifetime cap.
133    InvalidReputationAttestation,
134    /// The attestation is signed by a key in no trust-list entry, or by the
135    /// destination's own issuer key.
136    UntrustedReputationIssuer,
137    /// The attestation has expired.
138    ExpiredReputationAttestation,
139    /// The attestation names another identity than the one the request
140    /// proves.
141    ReputationIdentityMismatch,
142    /// The source account, or another account from the same issuer for this
143    /// identity, was imported before.
144    ReputationAlreadyImported,
145    /// Catch-all for reasons this build of `mostro-core` does not know yet.
146    ///
147    /// Newer daemons may emit reasons added after this release; without this
148    /// variant the whole `CantDo` payload would fail to deserialize. Never
149    /// emitted by a daemon on purpose.
150    #[serde(other)]
151    Unknown,
152}
153
154/// Internal errors raised by services behind the Mostro API.
155///
156/// Unlike [`CantDoReason`], values of this enum are not expected to be
157/// forwarded verbatim to end users; they are meant for logs, telemetry and
158/// other server-to-server diagnostics.
159#[derive(Debug, PartialEq, Eq)]
160pub enum ServiceError {
161    /// Wraps an error returned by `nostr_sdk`.
162    NostrError(String),
163    /// The invoice string could not be parsed as a valid BOLT-11 invoice.
164    ParsingInvoiceError,
165    /// A numeric value could not be parsed.
166    ParsingNumberError,
167    /// The invoice has expired.
168    InvoiceExpiredError,
169    /// The invoice is otherwise invalid.
170    InvoiceInvalidError,
171    /// The invoice expiration time is below the minimum required.
172    MinExpirationTimeError,
173    /// The invoice amount is below the minimum allowed.
174    MinAmountError,
175    /// The invoice amount does not match the expected value.
176    WrongAmountError,
177    /// The price API did not answer in time.
178    NoAPIResponse,
179    /// The requested currency is not listed by the exchange API.
180    NoCurrency,
181    /// A price exists but is older than the configured staleness window, so
182    /// it must not be used to price an order (the multi-source price
183    /// manager serves last-known-good only up to that TTL).
184    PriceTooStale,
185    /// The exchange API returned a response that could not be parsed.
186    MalformedAPIRes,
187    /// Amount value is negative where only positives are allowed.
188    NegativeAmount,
189    /// A Lightning Address could not be parsed.
190    LnAddressParseError,
191    /// A Lightning Address payment was attempted with a wrong amount.
192    LnAddressWrongAmount,
193    /// A Lightning payment failed; the inner string carries the reason.
194    LnPaymentError(String),
195    /// Communication with the Lightning node failed.
196    LnNodeError(String),
197    /// Order id was not found in the database.
198    InvalidOrderId,
199    /// Database access failed; the inner string carries the detail.
200    DbAccessError(String),
201    /// The provided public key is invalid.
202    InvalidPubkey,
203    /// Hold-invoice operation failed; the inner string carries the detail.
204    HoldInvoiceError(String),
205    /// Could not update the order status in the database.
206    UpdateOrderStatusError,
207    /// The order status is invalid.
208    InvalidOrderStatus,
209    /// The order kind is invalid.
210    InvalidOrderKind,
211    /// A dispute already exists for this order.
212    DisputeAlreadyExists,
213    /// Could not publish the dispute Nostr event.
214    DisputeEventError,
215    /// The rating message itself is invalid.
216    InvalidRating,
217    /// The rating value is outside the accepted range.
218    InvalidRatingValue,
219    /// Failed to serialize or deserialize a [`crate::message::Message`].
220    MessageSerializationError,
221    /// The dispute id is invalid or unknown.
222    InvalidDisputeId,
223    /// The dispute status is invalid.
224    InvalidDisputeStatus,
225    /// The payload does not match the action.
226    InvalidPayload,
227    /// Any other unexpected error; inner string carries the detail.
228    UnexpectedError(String),
229    /// An environment variable could not be read or parsed.
230    EnvVarError(String),
231    /// Underlying I/O error.
232    IOError(String),
233    /// NIP-44/NIP-59 encryption failed.
234    EncryptionError(String),
235    /// NIP-44/NIP-59 decryption failed.
236    DecryptionError(String),
237}
238
239/// Top-level error type returned by the Mostro API surface.
240///
241/// Most public functions in this crate return `Result<T, MostroError>`.
242/// Match on the variants to distinguish between user-actionable "can't do"
243/// responses and internal service errors.
244#[derive(Debug, PartialEq, Eq)]
245pub enum MostroError {
246    /// An internal service-level error; diagnostic only.
247    MostroInternalErr(ServiceError),
248    /// A structured "can't do" response to surface to the user.
249    MostroCantDo(CantDoReason),
250}
251
252impl std::error::Error for MostroError {}
253
254impl std::fmt::Display for MostroError {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            MostroError::MostroInternalErr(m) => write!(f, "Error caused by {}", m),
258            MostroError::MostroCantDo(m) => write!(f, "Sending cantDo message to user for {:?}", m),
259        }
260    }
261}
262
263impl std::fmt::Display for ServiceError {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            ServiceError::ParsingInvoiceError => write!(f, "Incorrect invoice"),
267            ServiceError::ParsingNumberError => write!(f, "Error parsing the number"),
268            ServiceError::InvoiceExpiredError => write!(f, "Invoice has expired"),
269            ServiceError::MinExpirationTimeError => write!(f, "Minimal expiration time on invoice"),
270            ServiceError::InvoiceInvalidError => write!(f, "Invoice is invalid"),
271            ServiceError::MinAmountError => write!(f, "Minimal payment amount"),
272            ServiceError::WrongAmountError => write!(f, "The amount on this invoice is wrong"),
273            ServiceError::NoAPIResponse => write!(f, "Price API not answered - retry"),
274            ServiceError::NoCurrency => write!(f, "Currency requested is not present in the exchange list, please specify a fixed rate"),
275            ServiceError::PriceTooStale => write!(f, "Exchange rate is too stale to price an order - retry or use a fixed rate"),
276            ServiceError::MalformedAPIRes => write!(f, "Malformed answer from exchange quoting request"),
277            ServiceError::NegativeAmount => write!(f, "Negative amount is not valid"),
278            ServiceError::LnAddressWrongAmount => write!(f, "Ln address need amount of 0 sats - please check your order"),
279            ServiceError::LnAddressParseError  => write!(f, "Ln address parsing error - please check your address"),
280            ServiceError::LnPaymentError(e) => write!(f, "Lightning payment failure cause: {}",e),
281            ServiceError::LnNodeError(e) => write!(f, "Lightning node connection failure caused by: {}",e),
282            ServiceError::InvalidOrderId => write!(f, "Order id not present in database"),
283            ServiceError::InvalidPubkey => write!(f, "Invalid pubkey"),
284            ServiceError::DbAccessError(e) => write!(f, "Error in database access: {}",e),
285            ServiceError::HoldInvoiceError(e) => write!(f, "Error holding invoice: {}",e),
286            ServiceError::UpdateOrderStatusError => write!(f, "Error updating order status"),
287            ServiceError::InvalidOrderStatus => write!(f, "Invalid order status"),
288            ServiceError::InvalidOrderKind => write!(f, "Invalid order kind"),
289            ServiceError::DisputeAlreadyExists => write!(f, "Dispute already exists"),
290            ServiceError::DisputeEventError => write!(f, "Error publishing dispute event"),
291            ServiceError::NostrError(e) => write!(f, "Error in nostr: {}",e),
292            ServiceError::InvalidRating => write!(f, "Invalid rating message"),
293            ServiceError::InvalidRatingValue => write!(f, "Invalid rating value"),
294            ServiceError::MessageSerializationError => write!(f, "Error serializing message"),
295            ServiceError::InvalidDisputeId => write!(f, "Invalid dispute id"),
296            ServiceError::InvalidDisputeStatus => write!(f, "Invalid dispute status"),
297            ServiceError::InvalidPayload => write!(f, "Invalid payload"),
298            ServiceError::UnexpectedError(e) => write!(f, "Unexpected error: {}", e),
299            ServiceError::EnvVarError(e) => write!(f, "Environment variable error: {}", e),
300            ServiceError::IOError(e) => write!(f, "IO error: {}", e),
301            ServiceError::EncryptionError(e) => write!(f, "Encryption error: {}", e),
302            ServiceError::DecryptionError(e) => write!(f, "Decryption error: {}", e),
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn invalid_payload_serializes_to_snake_case() {
313        let json = serde_json::to_string(&CantDoReason::InvalidPayload).unwrap();
314        assert_eq!(json, "\"invalid_payload\"");
315        let round: CantDoReason = serde_json::from_str(&json).unwrap();
316        assert_eq!(round, CantDoReason::InvalidPayload);
317    }
318
319    #[test]
320    fn price_too_stale_serializes_to_snake_case() {
321        let json = serde_json::to_string(&CantDoReason::PriceTooStale).unwrap();
322        assert_eq!(json, "\"price_too_stale\"");
323        let round: CantDoReason = serde_json::from_str(&json).unwrap();
324        assert_eq!(round, CantDoReason::PriceTooStale);
325    }
326    #[test]
327    fn maintenance_mode_serializes_to_snake_case() {
328        let json = serde_json::to_string(&CantDoReason::MaintenanceMode).unwrap();
329        assert_eq!(json, "\"maintenance_mode\"");
330        let round: CantDoReason = serde_json::from_str(&json).unwrap();
331        assert_eq!(round, CantDoReason::MaintenanceMode);
332    }
333
334    #[test]
335    fn payer_history_reasons_serialize_to_snake_case() {
336        for (reason, wire) in [
337            (CantDoReason::InvalidPaymentHash, "\"invalid_payment_hash\""),
338            (CantDoReason::PayerNotDeclared, "\"payer_not_declared\""),
339        ] {
340            let json = serde_json::to_string(&reason).unwrap();
341            assert_eq!(json, wire);
342            let round: CantDoReason = serde_json::from_str(&json).unwrap();
343            assert_eq!(round, reason);
344        }
345    }
346
347    #[test]
348    fn cashu_reasons_serialize_to_snake_case() {
349        // Wire names documented in the protocol's CantDo catalog.
350        for (reason, wire) in [
351            (CantDoReason::InvalidCashuToken, "\"invalid_cashu_token\""),
352            (
353                CantDoReason::CashuMintUnavailable,
354                "\"cashu_mint_unavailable\"",
355            ),
356            (CantDoReason::InvalidMintUrl, "\"invalid_mint_url\""),
357            (
358                CantDoReason::CashuEscrowNotLocked,
359                "\"cashu_escrow_not_locked\"",
360            ),
361            (
362                CantDoReason::CashuSignatureMissing,
363                "\"cashu_signature_missing\"",
364            ),
365        ] {
366            let json = serde_json::to_string(&reason).unwrap();
367            assert_eq!(json, wire);
368            let round: CantDoReason = serde_json::from_str(&json).unwrap();
369            assert_eq!(round, reason);
370        }
371    }
372
373    #[test]
374    fn unknown_reason_deserializes_to_unknown_catch_all() {
375        let round: CantDoReason = serde_json::from_str("\"reason_from_the_future\"").unwrap();
376        assert_eq!(round, CantDoReason::Unknown);
377    }
378
379    #[test]
380    fn unknown_reason_inside_cant_do_payload_does_not_break_the_message() {
381        let payload: crate::message::Payload =
382            serde_json::from_str(r#"{"cant_do":"reason_from_the_future"}"#).unwrap();
383        assert!(matches!(
384            payload,
385            crate::message::Payload::CantDo(Some(CantDoReason::Unknown))
386        ));
387    }
388
389    #[test]
390    fn unknown_serializes_to_snake_case() {
391        let json = serde_json::to_string(&CantDoReason::Unknown).unwrap();
392        assert_eq!(json, "\"unknown\"");
393    }
394
395    /// The reasons the protocol's reputation chapter documents, by the exact
396    /// snake_case spelling clients match on.
397    #[test]
398    fn reputation_reasons_travel_with_their_documented_names() {
399        let reasons = [
400            (
401                CantDoReason::ReputationIdentityRequired,
402                "reputation_identity_required",
403            ),
404            (
405                CantDoReason::NotEligibleForReputationExport,
406                "not_eligible_for_reputation_export",
407            ),
408            (
409                CantDoReason::ReputationBoundToOtherIdentity,
410                "reputation_bound_to_other_identity",
411            ),
412            (
413                CantDoReason::InvalidReputationRebind,
414                "invalid_reputation_rebind",
415            ),
416            (
417                CantDoReason::InvalidReputationAttestation,
418                "invalid_reputation_attestation",
419            ),
420            (
421                CantDoReason::UntrustedReputationIssuer,
422                "untrusted_reputation_issuer",
423            ),
424            (
425                CantDoReason::ExpiredReputationAttestation,
426                "expired_reputation_attestation",
427            ),
428            (
429                CantDoReason::ReputationIdentityMismatch,
430                "reputation_identity_mismatch",
431            ),
432            (
433                CantDoReason::ReputationAlreadyImported,
434                "reputation_already_imported",
435            ),
436        ];
437        for (reason, wire) in reasons {
438            let json = serde_json::to_string(&reason).unwrap();
439            assert_eq!(json, format!("\"{wire}\""));
440            assert_eq!(serde_json::from_str::<CantDoReason>(&json).unwrap(), reason);
441        }
442    }
443}