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}