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#[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 token_identifier: Option<String>,
27 },
28
29 #[error("Invalid UUID: {0}")]
30 InvalidUuid(String),
31
32 #[error("Invalid input: {0}")]
34 InvalidInput(String),
35
36 #[error("{reason}{}", render_bound(*too_small, *bound_amount, *bound_usd_cents))]
47 CrossChainAmountOutOfRange {
48 reason: String,
49 too_small: bool,
52 bound_amount: Option<u128>,
55 bound_usd_cents: Option<u64>,
57 },
58
59 #[error("{}: {reason}", if *temporary { "Cross-chain route temporarily unavailable" } else { "Cross-chain route not supported" })]
64 CrossChainRouteUnavailable {
65 reason: String,
66 temporary: bool,
69 },
70
71 #[error("Network error: {0}")]
73 NetworkError(String),
74
75 #[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 #[error("Deposit claim already in progress: {tx}:{vout}")]
98 DepositClaimInProgress { tx: String, vout: u32 },
99
100 #[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 #[error("Optimization is already in progress")]
119 OptimizationAlreadyRunning,
120
121 #[error("Optimization was cancelled by the SDK to free leaves")]
124 OptimizationCancelled,
125
126 #[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#[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
435fn 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 fn message(
468 too_small: bool,
469 bound_amount: Option<u128>,
470 bound_usd_cents: Option<u64>,
471 ) -> String {
472 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}