Skip to main content

breez_sdk_spark/
error.rs

1use crate::{
2    Fee,
3    lnurl::LnurlServerError,
4    persist::{self},
5};
6use bitcoin::consensus::encode::FromHexError;
7use breez_sdk_common::error::ServiceConnectivityError;
8use platform_utils::time::SystemTimeError;
9use serde::{Deserialize, Serialize};
10use spark_wallet::SparkWalletError;
11use std::{convert::Infallible, num::TryFromIntError};
12use thiserror::Error;
13use tracing_subscriber::util::TryInitError;
14
15/// Error type for the `BreezSdk`
16#[derive(Debug, Error, Clone)]
17#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
18pub enum SdkError {
19    #[error("SparkSdkError: {0}")]
20    SparkError(String),
21
22    #[error("Insufficient funds{}", .token_identifier.as_deref().map(|t| format!(" for token {t}")).unwrap_or_default())]
23    InsufficientFunds {
24        /// The token that cannot cover the payment. Unset when the shortfall is
25        /// in sats or when no single token can be named.
26        token_identifier: Option<String>,
27    },
28
29    #[error("Invalid UUID: {0}")]
30    InvalidUuid(String),
31
32    /// Invalid input error
33    #[error("Invalid input: {0}")]
34    InvalidInput(String),
35
36    /// A cross-chain provider rejected the amount as outside what it accepts
37    /// for the route.
38    ///
39    /// The bound fields carry what the route publishes in the direction that
40    /// failed, in whichever denominations the provider publishes. A route can
41    /// enforce a tighter bound than it publishes, so an amount inside the
42    /// published one can still land here.
43    ///
44    /// The message is the provider's own rejection text with the published
45    /// bound appended, so it already names the direction.
46    #[error("{reason}{}", render_bound(*too_small, *bound_amount, *bound_usd_cents))]
47    CrossChainAmountOutOfRange {
48        reason: String,
49        /// `true` for a rejection below the minimum, `false` for one above the
50        /// maximum or beyond available liquidity.
51        too_small: bool,
52        /// The published bound in the base units of the asset paid in: the
53        /// Spark-side asset on a send, the external asset on a receive.
54        bound_amount: Option<u128>,
55        /// The published bound as an order value in USD cents.
56        bound_usd_cents: Option<u64>,
57    },
58
59    /// A cross-chain provider won't serve the route, for now or at all.
60    ///
61    /// The message leads with a fixed phrase for each case, for bindings that
62    /// only see the text, followed by the provider's own reason.
63    #[error("{}: {reason}", if *temporary { "Cross-chain route temporarily unavailable" } else { "Cross-chain route not supported" })]
64    CrossChainRouteUnavailable {
65        reason: String,
66        /// `true` when the provider expects the route back shortly, so the
67        /// same request can succeed later. Otherwise try another route.
68        temporary: bool,
69    },
70
71    /// Network error
72    #[error("Network error: {0}")]
73    NetworkError(String),
74
75    /// Storage error
76    #[error("Storage error: {0}")]
77    StorageError(String),
78
79    #[error("Chain service error: {0}")]
80    ChainServiceError(String),
81
82    #[error(
83        "Max deposit claim fee exceeded for utxo: {tx}:{vout} with max fee: {max_fee:?} and required fee: {required_fee_sats} sats or {required_fee_rate_sat_per_vbyte} sats/vbyte"
84    )]
85    MaxDepositClaimFeeExceeded {
86        tx: String,
87        vout: u32,
88        max_fee: Option<Fee>,
89        required_fee_sats: u64,
90        required_fee_rate_sat_per_vbyte: u64,
91    },
92
93    #[error("Missing utxo: {tx}:{vout}")]
94    MissingUtxo { tx: String, vout: u32 },
95
96    /// Another claim on this deposit is already running.
97    #[error("Deposit claim already in progress: {tx}:{vout}")]
98    DepositClaimInProgress { tx: String, vout: u32 },
99
100    /// A refund for this deposit is already on the network, and the requested
101    /// replacement does not pay enough to displace it.
102    #[error(
103        "A refund is already pending at {pending_fee_sats} sats: a replacement must pay at least {required_fee_sats} sats"
104    )]
105    RefundReplacementFeeTooLow {
106        pending_fee_sats: u64,
107        required_fee_sats: u64,
108    },
109
110    #[error("Lnurl error: {0}")]
111    LnurlError(String),
112
113    #[error("Signer error: {0}")]
114    Signer(String),
115
116    /// `optimize_leaves` was called while another optimization run (auto or
117    /// manual) was already in flight.
118    #[error("Optimization is already in progress")]
119    OptimizationAlreadyRunning,
120
121    /// `optimize_leaves` was preempted by the SDK to free leaves for a
122    /// higher-priority operation (typically a payment).
123    #[error("Optimization was cancelled by the SDK to free leaves")]
124    OptimizationCancelled,
125
126    /// The provided CPFP funding is too low to cover the exit's on-chain fees.
127    #[error("Insufficient CPFP funding: need at least {required_sat} sats")]
128    InsufficientCpfpFunds { required_sat: u64 },
129
130    #[error("Error: {0}")]
131    Generic(String),
132}
133
134impl From<crate::chain::ChainServiceError> for SdkError {
135    fn from(e: crate::chain::ChainServiceError) -> Self {
136        SdkError::ChainServiceError(e.to_string())
137    }
138}
139
140impl From<breez_sdk_common::lnurl::error::LnurlError> for SdkError {
141    fn from(e: breez_sdk_common::lnurl::error::LnurlError) -> Self {
142        SdkError::LnurlError(e.to_string())
143    }
144}
145
146impl From<breez_sdk_common::input::ParseError> for SdkError {
147    fn from(e: breez_sdk_common::input::ParseError) -> Self {
148        SdkError::InvalidInput(e.to_string())
149    }
150}
151
152impl From<bitcoin::address::ParseError> for SdkError {
153    fn from(e: bitcoin::address::ParseError) -> Self {
154        SdkError::InvalidInput(e.to_string())
155    }
156}
157
158impl From<flashnet::FlashnetError> for SdkError {
159    fn from(e: flashnet::FlashnetError) -> Self {
160        match e {
161            flashnet::FlashnetError::AmountOutOfRange { reason, too_small } => {
162                SdkError::CrossChainAmountOutOfRange {
163                    reason,
164                    too_small,
165                    bound_amount: None,
166                    bound_usd_cents: None,
167                }
168            }
169            flashnet::FlashnetError::RouteUnavailable { reason, temporary } => {
170                SdkError::CrossChainRouteUnavailable { reason, temporary }
171            }
172            flashnet::FlashnetError::Network { reason, code } => {
173                let code = match code {
174                    Some(c) => format!(" (code: {c})"),
175                    None => String::new(),
176                };
177                SdkError::NetworkError(format!("{reason}{code}"))
178            }
179            _ => SdkError::Generic(e.to_string()),
180        }
181    }
182}
183
184impl From<boltz_client::BoltzError> for SdkError {
185    fn from(e: boltz_client::BoltzError) -> Self {
186        use boltz_client::BoltzError;
187        match e {
188            BoltzError::Api { reason, code } => {
189                let code = match code {
190                    Some(c) => format!(" (code: {c})"),
191                    None => String::new(),
192                };
193                SdkError::NetworkError(format!("Boltz API: {reason}{code}"))
194            }
195            BoltzError::WebSocket(s) => SdkError::NetworkError(format!("Boltz WebSocket: {s}")),
196            BoltzError::Store(s) => SdkError::StorageError(format!("Boltz store: {s}")),
197            BoltzError::AmountOutOfRange { .. }
198            | BoltzError::QuoteExpired
199            | BoltzError::InvalidQuote(_)
200            | BoltzError::QuoteDegradedBeyondSlippage { .. }
201            | BoltzError::DuplicatePreimage
202            | BoltzError::InvalidConfig(_) => SdkError::InvalidInput(e.to_string()),
203            _ => SdkError::Generic(format!("Boltz: {e}")),
204        }
205    }
206}
207
208impl From<crate::token_conversion::ConversionError> for SdkError {
209    fn from(e: crate::token_conversion::ConversionError) -> Self {
210        use crate::token_conversion::ConversionError;
211        match e {
212            ConversionError::NoPoolsAvailable => {
213                SdkError::Generic("No conversion pools available".to_string())
214            }
215            ConversionError::ConversionFailed(msg)
216            | ConversionError::ValidationFailed(msg)
217            | ConversionError::RefundFailed(msg) => SdkError::Generic(msg),
218            ConversionError::DuplicateTransfer => {
219                SdkError::Generic("Duplicate transfer: conversion already handled".to_string())
220            }
221            ConversionError::Sdk(e) => e,
222            ConversionError::Storage(e) => SdkError::StorageError(e.to_string()),
223            ConversionError::Wallet(e) => SdkError::SparkError(e.to_string()),
224        }
225    }
226}
227
228impl From<persist::StorageError> for SdkError {
229    fn from(e: persist::StorageError) -> Self {
230        match e {
231            persist::StorageError::NotFound => SdkError::InvalidInput("Not found".to_string()),
232            _ => SdkError::StorageError(e.to_string()),
233        }
234    }
235}
236
237impl From<Infallible> for SdkError {
238    fn from(value: Infallible) -> Self {
239        SdkError::Generic(value.to_string())
240    }
241}
242
243impl From<String> for SdkError {
244    fn from(s: String) -> Self {
245        Self::Generic(s)
246    }
247}
248
249impl From<&str> for SdkError {
250    fn from(s: &str) -> Self {
251        Self::Generic(s.to_string())
252    }
253}
254
255impl From<SystemTimeError> for SdkError {
256    fn from(e: SystemTimeError) -> Self {
257        SdkError::Generic(e.to_string())
258    }
259}
260
261impl From<TryFromIntError> for SdkError {
262    fn from(e: TryFromIntError) -> Self {
263        SdkError::Generic(e.to_string())
264    }
265}
266
267impl From<serde_json::Error> for SdkError {
268    fn from(e: serde_json::Error) -> Self {
269        SdkError::Generic(e.to_string())
270    }
271}
272
273impl From<SparkWalletError> for SdkError {
274    fn from(e: SparkWalletError) -> Self {
275        match e {
276            SparkWalletError::InsufficientFunds => SdkError::InsufficientFunds {
277                token_identifier: None,
278            },
279            SparkWalletError::TokenOutputServiceError(
280                spark_wallet::TokenOutputServiceError::InsufficientFunds { token_identifier },
281            ) => SdkError::InsufficientFunds { token_identifier },
282            SparkWalletError::ServiceError(spark_wallet::ServiceError::InvalidInput(msg)) => {
283                SdkError::InvalidInput(msg)
284            }
285            SparkWalletError::ServiceError(
286                spark_wallet::ServiceError::InsufficientCpfpBudget { required_sat },
287            ) => SdkError::InsufficientCpfpFunds { required_sat },
288            _ => SdkError::SparkError(e.to_string()),
289        }
290    }
291}
292
293impl From<spark_wallet::OptimizationError> for SdkError {
294    fn from(e: spark_wallet::OptimizationError) -> Self {
295        match e {
296            spark_wallet::OptimizationError::AlreadyRunning => SdkError::OptimizationAlreadyRunning,
297            spark_wallet::OptimizationError::Cancelled => SdkError::OptimizationCancelled,
298            spark_wallet::OptimizationError::Tree(inner) => SdkError::SparkError(inner.to_string()),
299        }
300    }
301}
302
303impl From<FromHexError> for SdkError {
304    fn from(e: FromHexError) -> Self {
305        SdkError::Generic(e.to_string())
306    }
307}
308
309impl From<uuid::Error> for SdkError {
310    fn from(e: uuid::Error) -> Self {
311        SdkError::InvalidUuid(e.to_string())
312    }
313}
314
315impl From<ServiceConnectivityError> for SdkError {
316    fn from(value: ServiceConnectivityError) -> Self {
317        SdkError::NetworkError(value.to_string())
318    }
319}
320
321impl From<LnurlServerError> for SdkError {
322    fn from(value: LnurlServerError) -> Self {
323        match value {
324            LnurlServerError::InvalidApiKey => {
325                SdkError::InvalidInput("Invalid api key".to_string())
326            }
327            LnurlServerError::Network {
328                statuscode,
329                message,
330            } => SdkError::NetworkError(format!(
331                "network request failed with status {statuscode}: {}",
332                message.unwrap_or(String::new())
333            )),
334            LnurlServerError::RequestFailure(e) => SdkError::NetworkError(e),
335            LnurlServerError::SigningError(e) => {
336                SdkError::Generic(format!("Failed to sign message: {e}"))
337            }
338        }
339    }
340}
341
342impl From<TryInitError> for SdkError {
343    fn from(_value: TryInitError) -> Self {
344        SdkError::Generic("Logging can only be initialized once".to_string())
345    }
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, Error, PartialEq)]
349#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
350pub enum DepositClaimError {
351    #[error(
352        "Max deposit claim fee exceeded for utxo: {tx}:{vout} with max fee: {max_fee:?} and required fee: {required_fee_sats} sats or {required_fee_rate_sat_per_vbyte} sats/vbyte"
353    )]
354    MaxDepositClaimFeeExceeded {
355        tx: String,
356        vout: u32,
357        max_fee: Option<Fee>,
358        required_fee_sats: u64,
359        required_fee_rate_sat_per_vbyte: u64,
360    },
361
362    #[error("Missing utxo: {tx}:{vout}")]
363    MissingUtxo { tx: String, vout: u32 },
364
365    #[error("Generic error: {message}")]
366    Generic { message: String },
367}
368
369impl From<SdkError> for DepositClaimError {
370    fn from(value: SdkError) -> Self {
371        match value {
372            SdkError::MaxDepositClaimFeeExceeded {
373                tx,
374                vout,
375                max_fee,
376                required_fee_sats,
377                required_fee_rate_sat_per_vbyte,
378            } => DepositClaimError::MaxDepositClaimFeeExceeded {
379                tx,
380                vout,
381                max_fee,
382                required_fee_sats,
383                required_fee_rate_sat_per_vbyte,
384            },
385            SdkError::MissingUtxo { tx, vout } => DepositClaimError::MissingUtxo { tx, vout },
386            SdkError::Generic(e) => DepositClaimError::Generic { message: e },
387            _ => DepositClaimError::Generic {
388                message: value.to_string(),
389            },
390        }
391    }
392}
393
394/// Error type for signer operations
395#[derive(Debug, Error, Clone)]
396#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
397pub enum SignerError {
398    #[error("Key derivation error: {0}")]
399    KeyDerivation(String),
400
401    #[error("Signing error: {0}")]
402    Signing(String),
403
404    #[error("Encryption error: {0}")]
405    Encryption(String),
406
407    #[error("Decryption error: {0}")]
408    Decryption(String),
409
410    #[error("Encryption unavailable: {0}")]
411    EncryptionUnavailable(String),
412
413    #[error("FROST error: {0}")]
414    Frost(String),
415
416    #[error("Invalid input: {0}")]
417    InvalidInput(String),
418
419    #[error("Generic signer error: {0}")]
420    Generic(String),
421}
422
423impl From<String> for SignerError {
424    fn from(s: String) -> Self {
425        SignerError::Generic(s)
426    }
427}
428
429impl From<&str> for SignerError {
430    fn from(s: &str) -> Self {
431        SignerError::Generic(s.to_string())
432    }
433}
434
435/// Renders the bound an amount rejection ran into, for the error message.
436/// Empty when the provider publishes nothing in that direction.
437///
438/// Both denominations are named when both are published: which one the provider
439/// applied is not reported, and they are not interconvertible without a rate.
440/// The bound is the published one, which a route can enforce more tightly than,
441/// so the wording says "published" rather than naming it as the limit.
442fn render_bound(
443    too_small: bool,
444    bound_amount: Option<u128>,
445    bound_usd_cents: Option<u64>,
446) -> String {
447    let mut bounds = Vec::new();
448    if let Some(amount) = bound_amount {
449        bounds.push(format!("{amount} base units"));
450    }
451    if let Some(cents) = bound_usd_cents {
452        bounds.push(format!("{}.{:02} USD", cents / 100, cents % 100));
453    }
454    if bounds.is_empty() {
455        return String::new();
456    }
457    let label = if too_small { "minimum" } else { "maximum" };
458    format!(" (published {label}: {})", bounds.join(" or "))
459}
460
461#[cfg(test)]
462mod render_bound_tests {
463    use super::*;
464
465    /// The rendered message is the only channel bindings that flatten
466    /// `SdkError` to a string have, so the number has to survive into it.
467    fn message(
468        too_small: bool,
469        bound_amount: Option<u128>,
470        bound_usd_cents: Option<u64>,
471    ) -> String {
472        // Orchestra's own wording for each direction.
473        let reason = if too_small {
474            "Amount too small"
475        } else {
476            "Amount too large"
477        };
478        SdkError::CrossChainAmountOutOfRange {
479            reason: reason.to_string(),
480            too_small,
481            bound_amount,
482            bound_usd_cents,
483        }
484        .to_string()
485    }
486
487    #[test]
488    fn names_both_denominations_when_both_are_published() {
489        assert_eq!(
490            message(true, Some(1200), Some(80)),
491            "Amount too small (published minimum: 1200 base units or 0.80 USD)"
492        );
493    }
494
495    #[test]
496    fn a_too_large_rejection_reads_as_a_maximum() {
497        assert_eq!(
498            message(false, None, Some(8_980_000)),
499            "Amount too large (published maximum: 89800.00 USD)"
500        );
501    }
502
503    #[test]
504    fn says_nothing_when_the_provider_publishes_no_bound() {
505        assert_eq!(message(true, None, None), "Amount too small");
506    }
507
508    #[test]
509    fn pads_a_sub_dollar_bound_to_two_decimals() {
510        assert!(message(true, None, Some(5)).ends_with("(published minimum: 0.05 USD)"));
511    }
512}
513
514#[cfg(test)]
515mod route_unavailable_tests {
516    use super::*;
517
518    fn from_provider(temporary: bool, reason: &str) -> SdkError {
519        flashnet::FlashnetError::RouteUnavailable {
520            reason: reason.to_string(),
521            temporary,
522        }
523        .into()
524    }
525
526    #[test]
527    fn keeps_whether_a_retry_can_succeed() {
528        assert!(matches!(
529            from_provider(true, "busy"),
530            SdkError::CrossChainRouteUnavailable {
531                temporary: true,
532                ..
533            }
534        ));
535    }
536
537    #[test]
538    fn the_message_names_the_case_before_the_provider_reason() {
539        assert_eq!(
540            from_provider(
541                true,
542                "This route is temporarily unavailable. Please try again later."
543            )
544            .to_string(),
545            "Cross-chain route temporarily unavailable: This route is temporarily unavailable. \
546             Please try again later."
547        );
548        assert_eq!(
549            from_provider(false, "Unsupported route").to_string(),
550            "Cross-chain route not supported: Unsupported route"
551        );
552    }
553}