1use crate::{
2 lnurl::LnurlServerError,
3 persist::{self},
4};
5use bitcoin::consensus::encode::FromHexError;
6use breez_sdk_common::error::ServiceConnectivityError;
7use platform_utils::time::SystemTimeError;
8use serde::{Deserialize, Serialize};
9use spark_wallet::SparkWalletError;
10use std::{convert::Infallible, num::TryFromIntError};
11use thiserror::Error;
12use tracing_subscriber::util::TryInitError;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
16pub enum TokenAllowanceErrorReason {
17 NotEnabled,
18 NotFound,
19 Revoked,
20 Expired,
21 NotSpendable,
22 OverPerPaymentLimit,
23 OverTotalLimit,
24 RecipientNotAllowed,
25 PayerInsufficientFunds,
26 AlreadyActive,
27 QuotaExceeded,
28 PreparedPullStale,
29}
30
31impl std::fmt::Display for TokenAllowanceErrorReason {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 let text = match self {
34 Self::NotEnabled => "token allowances are not enabled on this network",
35 Self::NotFound => "no token allowance from this payer for this token",
36 Self::Revoked => "the token allowance was revoked",
37 Self::Expired => "the token allowance has expired",
38 Self::NotSpendable => "the token allowance was revoked or expired during the pull",
39 Self::OverPerPaymentLimit => "the amount exceeds the per-payment limit",
40 Self::OverTotalLimit => "the amount exceeds the remaining total limit",
41 Self::RecipientNotAllowed => "a receiver is not on the allowance's recipient list",
42 Self::PayerInsufficientFunds => "the payer's balance can't cover the pull",
43 Self::AlreadyActive => "an active allowance already exists for this spender and token",
44 Self::QuotaExceeded => "the owner has too many active allowances",
45 Self::PreparedPullStale => "the operators never accepted this pull; prepare a new one",
46 };
47 f.write_str(text)
48 }
49}
50
51impl From<spark_wallet::TokenAllowanceFailure> for TokenAllowanceErrorReason {
52 fn from(failure: spark_wallet::TokenAllowanceFailure) -> Self {
53 use spark_wallet::TokenAllowanceFailure as F;
54 match failure {
55 F::NotEnabled => Self::NotEnabled,
56 F::NotFound => Self::NotFound,
57 F::Revoked => Self::Revoked,
58 F::Expired => Self::Expired,
59 F::NotSpendable => Self::NotSpendable,
60 F::OverPerPaymentLimit => Self::OverPerPaymentLimit,
61 F::OverTotalLimit => Self::OverTotalLimit,
62 F::RecipientNotAllowed => Self::RecipientNotAllowed,
63 F::PayerInsufficientFunds => Self::PayerInsufficientFunds,
64 F::AlreadyActive => Self::AlreadyActive,
65 F::QuotaExceeded => Self::QuotaExceeded,
66 F::PreparedPullStale => Self::PreparedPullStale,
67 }
68 }
69}
70
71#[derive(Debug, Error, Clone)]
73#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
74pub enum SdkError {
75 #[error("SparkSdkError: {0}")]
76 SparkError(String),
77
78 #[error("Insufficient funds{}", .token_identifier.as_deref().map(|t| format!(" for token {t}")).unwrap_or_default())]
79 InsufficientFunds {
80 token_identifier: Option<String>,
83 },
84
85 #[error("Invalid UUID: {0}")]
86 InvalidUuid(String),
87
88 #[error("Invalid input: {0}")]
90 InvalidInput(String),
91
92 #[error("{reason}{}", render_bound(*too_small, *bound_amount, *bound_usd_cents))]
103 CrossChainAmountOutOfRange {
104 reason: String,
105 too_small: bool,
108 bound_amount: Option<u128>,
111 bound_usd_cents: Option<u64>,
113 docs_url: String,
115 },
116
117 #[error("{}: {reason}", if *temporary { "Cross-chain route temporarily unavailable" } else { "Cross-chain route not supported" })]
122 CrossChainRouteUnavailable {
123 reason: String,
124 temporary: bool,
127 docs_url: String,
129 },
130
131 #[error("Cross-chain payments are disabled: set the cross-chain config to enable them")]
133 CrossChainDisabled {
134 docs_url: String,
136 },
137
138 #[error("Network error: {0}")]
140 NetworkError(String),
141
142 #[error("Storage error: {0}")]
144 StorageError(String),
145
146 #[error("Chain service error: {0}")]
147 ChainServiceError(String),
148
149 #[error(
150 "Max deposit claim fee exceeded for utxo: {tx}:{vout} with max fee: {} and required fee: {required_fee_sats} sats",
151 .max_fee_sats.map_or_else(|| "none".to_string(), |sats| format!("{sats} sats"))
152 )]
153 MaxDepositClaimFeeExceeded {
154 tx: String,
155 vout: u32,
156 max_fee_sats: Option<u64>,
159 required_fee_sats: u64,
160 docs_url: String,
162 },
163
164 #[error("Missing utxo: {tx}:{vout}")]
165 MissingUtxo { tx: String, vout: u32 },
166
167 #[error("Deposit too small to claim: {tx}:{vout}")]
171 DepositTooSmall {
172 tx: String,
173 vout: u32,
174 docs_url: String,
176 },
177
178 #[error("Deposit claim already in progress: {tx}:{vout}")]
180 DepositClaimInProgress {
181 tx: String,
182 vout: u32,
183 docs_url: String,
185 },
186
187 #[error(
190 "A refund is already pending at {pending_fee_sats} sats: a replacement must pay at least {required_fee_sats} sats"
191 )]
192 RefundReplacementFeeTooLow {
193 pending_fee_sats: u64,
194 required_fee_sats: u64,
195 docs_url: String,
197 },
198
199 #[error("Lnurl error: {0}")]
200 LnurlError(String),
201
202 #[error("Signer error: {0}")]
203 Signer(String),
204
205 #[error("Optimization is already in progress")]
208 OptimizationAlreadyRunning,
209
210 #[error("Optimization was cancelled by the SDK to free leaves")]
213 OptimizationCancelled,
214
215 #[error("Insufficient CPFP funding: need at least {required_sats} sats")]
217 InsufficientCpfpFunds {
218 required_sats: u64,
219 docs_url: String,
221 },
222
223 #[error("Token allowance error ({reason:?}): {details}")]
225 TokenAllowance {
226 reason: TokenAllowanceErrorReason,
227 details: String,
228 docs_url: String,
230 },
231
232 #[error("Error: {0}")]
233 Generic(String),
234}
235
236mod docs_url {
239 macro_rules! errors_page {
242 ($section:literal) => {
243 concat!("https://docs.breez.technology/guide/errors.html#", $section)
244 };
245 }
246
247 pub(super) const CROSS_CHAIN_AMOUNT_OUT_OF_RANGE: &str =
248 errors_page!("cross-chain-amount-out-of-range");
249 pub(super) const CROSS_CHAIN_ROUTE_UNAVAILABLE: &str =
250 errors_page!("cross-chain-route-unavailable");
251 pub(super) const CROSS_CHAIN_DISABLED: &str = errors_page!("cross-chain-disabled");
252 pub(super) const MAX_DEPOSIT_CLAIM_FEE_EXCEEDED: &str =
253 errors_page!("max-deposit-claim-fee-exceeded");
254 pub(super) const DEPOSIT_TOO_SMALL: &str = errors_page!("deposit-too-small");
255 pub(super) const DEPOSIT_CLAIM_IN_PROGRESS: &str = errors_page!("deposit-claim-in-progress");
256 pub(super) const REFUND_REPLACEMENT_FEE_TOO_LOW: &str =
257 errors_page!("refund-replacement-fee-too-low");
258 pub(super) const INSUFFICIENT_CPFP_FUNDS: &str = errors_page!("insufficient-cpfp-funds");
259 pub(super) const TOKEN_ALLOWANCE: &str = errors_page!("token-allowance");
260
261 #[cfg(all(test, not(target_family = "wasm")))]
262 pub(super) const SECTION_PREFIX: &str = errors_page!("");
263}
264
265impl SdkError {
266 pub(crate) fn cross_chain_amount_out_of_range(
267 reason: String,
268 too_small: bool,
269 bound_amount: Option<u128>,
270 bound_usd_cents: Option<u64>,
271 ) -> Self {
272 Self::CrossChainAmountOutOfRange {
273 reason,
274 too_small,
275 bound_amount,
276 bound_usd_cents,
277 docs_url: docs_url::CROSS_CHAIN_AMOUNT_OUT_OF_RANGE.to_string(),
278 }
279 }
280
281 pub(crate) fn cross_chain_route_unavailable(reason: String, temporary: bool) -> Self {
282 Self::CrossChainRouteUnavailable {
283 reason,
284 temporary,
285 docs_url: docs_url::CROSS_CHAIN_ROUTE_UNAVAILABLE.to_string(),
286 }
287 }
288
289 pub(crate) fn cross_chain_disabled() -> Self {
290 Self::CrossChainDisabled {
291 docs_url: docs_url::CROSS_CHAIN_DISABLED.to_string(),
292 }
293 }
294
295 pub(crate) fn max_deposit_claim_fee_exceeded(
296 tx: String,
297 vout: u32,
298 max_fee_sats: Option<u64>,
299 required_fee_sats: u64,
300 ) -> Self {
301 Self::MaxDepositClaimFeeExceeded {
302 tx,
303 vout,
304 max_fee_sats,
305 required_fee_sats,
306 docs_url: docs_url::MAX_DEPOSIT_CLAIM_FEE_EXCEEDED.to_string(),
307 }
308 }
309
310 pub(crate) fn deposit_too_small(tx: String, vout: u32) -> Self {
311 Self::DepositTooSmall {
312 tx,
313 vout,
314 docs_url: docs_url::DEPOSIT_TOO_SMALL.to_string(),
315 }
316 }
317
318 pub(crate) fn deposit_claim_in_progress(tx: String, vout: u32) -> Self {
319 Self::DepositClaimInProgress {
320 tx,
321 vout,
322 docs_url: docs_url::DEPOSIT_CLAIM_IN_PROGRESS.to_string(),
323 }
324 }
325
326 pub(crate) fn refund_replacement_fee_too_low(
327 pending_fee_sats: u64,
328 required_fee_sats: u64,
329 ) -> Self {
330 Self::RefundReplacementFeeTooLow {
331 pending_fee_sats,
332 required_fee_sats,
333 docs_url: docs_url::REFUND_REPLACEMENT_FEE_TOO_LOW.to_string(),
334 }
335 }
336
337 pub(crate) fn insufficient_cpfp_funds(required_sats: u64) -> Self {
338 Self::InsufficientCpfpFunds {
339 required_sats,
340 docs_url: docs_url::INSUFFICIENT_CPFP_FUNDS.to_string(),
341 }
342 }
343
344 pub(crate) fn token_allowance(reason: TokenAllowanceErrorReason, details: String) -> Self {
345 Self::TokenAllowance {
346 reason,
347 details,
348 docs_url: docs_url::TOKEN_ALLOWANCE.to_string(),
349 }
350 }
351
352 pub fn docs_url(&self) -> Option<&str> {
354 match self {
355 Self::CrossChainAmountOutOfRange { docs_url, .. }
356 | Self::CrossChainRouteUnavailable { docs_url, .. }
357 | Self::CrossChainDisabled { docs_url }
358 | Self::MaxDepositClaimFeeExceeded { docs_url, .. }
359 | Self::DepositTooSmall { docs_url, .. }
360 | Self::DepositClaimInProgress { docs_url, .. }
361 | Self::RefundReplacementFeeTooLow { docs_url, .. }
362 | Self::InsufficientCpfpFunds { docs_url, .. }
363 | Self::TokenAllowance { docs_url, .. } => Some(docs_url),
364 _ => None,
365 }
366 }
367}
368
369impl From<crate::chain::ChainServiceError> for SdkError {
370 fn from(e: crate::chain::ChainServiceError) -> Self {
371 SdkError::ChainServiceError(e.to_string())
372 }
373}
374
375impl From<breez_sdk_common::lnurl::error::LnurlError> for SdkError {
376 fn from(e: breez_sdk_common::lnurl::error::LnurlError) -> Self {
377 SdkError::LnurlError(e.to_string())
378 }
379}
380
381impl From<breez_sdk_common::input::ParseError> for SdkError {
382 fn from(e: breez_sdk_common::input::ParseError) -> Self {
383 SdkError::InvalidInput(e.to_string())
384 }
385}
386
387impl From<bitcoin::address::ParseError> for SdkError {
388 fn from(e: bitcoin::address::ParseError) -> Self {
389 SdkError::InvalidInput(e.to_string())
390 }
391}
392
393impl From<flashnet::FlashnetError> for SdkError {
394 fn from(e: flashnet::FlashnetError) -> Self {
395 match e {
396 flashnet::FlashnetError::AmountOutOfRange { reason, too_small } => {
397 SdkError::cross_chain_amount_out_of_range(reason, too_small, None, None)
398 }
399 flashnet::FlashnetError::RouteUnavailable { reason, temporary } => {
400 SdkError::cross_chain_route_unavailable(reason, temporary)
401 }
402 flashnet::FlashnetError::Network { reason, code } => {
403 let code = match code {
404 Some(c) => format!(" (code: {c})"),
405 None => String::new(),
406 };
407 SdkError::NetworkError(format!("{reason}{code}"))
408 }
409 flashnet::FlashnetError::InvalidRequest { reason, code }
410 | flashnet::FlashnetError::AffiliateRejected { reason, code, .. } => {
411 SdkError::NetworkError(format!("{reason} (code: {code})"))
412 }
413 _ => SdkError::Generic(e.to_string()),
414 }
415 }
416}
417
418#[cfg(feature = "boltz")]
419impl From<boltz_client::BoltzError> for SdkError {
420 fn from(e: boltz_client::BoltzError) -> Self {
421 use boltz_client::BoltzError;
422 match e {
423 BoltzError::Api { reason, code } => {
424 let code = match code {
425 Some(c) => format!(" (code: {c})"),
426 None => String::new(),
427 };
428 SdkError::NetworkError(format!("Boltz API: {reason}{code}"))
429 }
430 BoltzError::WebSocket(s) => SdkError::NetworkError(format!("Boltz WebSocket: {s}")),
431 BoltzError::Store(s) => SdkError::StorageError(format!("Boltz store: {s}")),
432 BoltzError::AmountOutOfRange { .. }
433 | BoltzError::QuoteExpired
434 | BoltzError::InvalidQuote(_)
435 | BoltzError::QuoteDegradedBeyondSlippage { .. }
436 | BoltzError::DuplicatePreimage
437 | BoltzError::InvalidConfig(_) => SdkError::InvalidInput(e.to_string()),
438 _ => SdkError::Generic(format!("Boltz: {e}")),
439 }
440 }
441}
442
443impl From<crate::token_conversion::ConversionError> for SdkError {
444 fn from(e: crate::token_conversion::ConversionError) -> Self {
445 use crate::token_conversion::ConversionError;
446 match e {
447 ConversionError::NoPoolsAvailable => {
448 SdkError::Generic("No conversion pools available".to_string())
449 }
450 ConversionError::ConversionFailed(msg)
451 | ConversionError::FailedAfterSwap { message: msg, .. }
452 | ConversionError::ValidationFailed(msg)
453 | ConversionError::RefundFailed(msg) => SdkError::Generic(msg),
454 ConversionError::DuplicateTransfer => {
455 SdkError::Generic("Duplicate transfer: conversion already handled".to_string())
456 }
457 ConversionError::Sdk(e) => e,
458 ConversionError::Storage(e) => SdkError::StorageError(e.to_string()),
459 ConversionError::Wallet(e) => SdkError::SparkError(e.to_string()),
460 }
461 }
462}
463
464impl From<persist::StorageError> for SdkError {
465 fn from(e: persist::StorageError) -> Self {
466 match e {
467 persist::StorageError::NotFound => SdkError::InvalidInput("Not found".to_string()),
468 _ => SdkError::StorageError(e.to_string()),
469 }
470 }
471}
472
473impl From<Infallible> for SdkError {
474 fn from(value: Infallible) -> Self {
475 SdkError::Generic(value.to_string())
476 }
477}
478
479impl From<String> for SdkError {
480 fn from(s: String) -> Self {
481 Self::Generic(s)
482 }
483}
484
485impl From<&str> for SdkError {
486 fn from(s: &str) -> Self {
487 Self::Generic(s.to_string())
488 }
489}
490
491impl From<SystemTimeError> for SdkError {
492 fn from(e: SystemTimeError) -> Self {
493 SdkError::Generic(e.to_string())
494 }
495}
496
497impl From<TryFromIntError> for SdkError {
498 fn from(e: TryFromIntError) -> Self {
499 SdkError::Generic(e.to_string())
500 }
501}
502
503impl From<serde_json::Error> for SdkError {
504 fn from(e: serde_json::Error) -> Self {
505 SdkError::Generic(e.to_string())
506 }
507}
508
509impl From<SparkWalletError> for crate::SyncError {
510 fn from(e: SparkWalletError) -> Self {
511 let message = e.to_string();
512 if e.is_operator_unavailable() {
513 Self::Network { message }
514 } else {
515 Self::Generic { message }
516 }
517 }
518}
519
520impl From<SparkWalletError> for SdkError {
521 fn from(e: SparkWalletError) -> Self {
522 match e {
523 SparkWalletError::InsufficientFunds => SdkError::InsufficientFunds {
524 token_identifier: None,
525 },
526 SparkWalletError::TokenOutputServiceError(
527 spark_wallet::TokenOutputServiceError::InsufficientFunds { token_identifier },
528 ) => SdkError::InsufficientFunds { token_identifier },
529 SparkWalletError::ServiceError(spark_wallet::ServiceError::InvalidInput(msg)) => {
530 SdkError::InvalidInput(msg)
531 }
532 SparkWalletError::SilentPaymentAddressNotSupported(_) => {
533 SdkError::InvalidInput(e.to_string())
534 }
535 SparkWalletError::ServiceError(
536 spark_wallet::ServiceError::InsufficientCpfpBudget { required_sat },
537 ) => SdkError::insufficient_cpfp_funds(required_sat),
538 SparkWalletError::ServiceError(spark_wallet::ServiceError::TokenAllowance {
539 failure,
540 message,
541 }) => SdkError::token_allowance(failure.into(), message),
542 _ => SdkError::SparkError(e.to_string()),
543 }
544 }
545}
546
547impl From<spark_wallet::OptimizationError> for SdkError {
548 fn from(e: spark_wallet::OptimizationError) -> Self {
549 match e {
550 spark_wallet::OptimizationError::AlreadyRunning => SdkError::OptimizationAlreadyRunning,
551 spark_wallet::OptimizationError::Cancelled => SdkError::OptimizationCancelled,
552 spark_wallet::OptimizationError::Tree(inner) => SdkError::SparkError(inner.to_string()),
553 }
554 }
555}
556
557impl From<FromHexError> for SdkError {
558 fn from(e: FromHexError) -> Self {
559 SdkError::Generic(e.to_string())
560 }
561}
562
563impl From<uuid::Error> for SdkError {
564 fn from(e: uuid::Error) -> Self {
565 SdkError::InvalidUuid(e.to_string())
566 }
567}
568
569impl From<ServiceConnectivityError> for SdkError {
570 fn from(value: ServiceConnectivityError) -> Self {
571 SdkError::NetworkError(value.to_string())
572 }
573}
574
575impl From<LnurlServerError> for SdkError {
576 fn from(value: LnurlServerError) -> Self {
577 match value {
578 LnurlServerError::InvalidApiKey => {
579 SdkError::InvalidInput("Invalid api key".to_string())
580 }
581 LnurlServerError::Network {
582 statuscode,
583 message,
584 } => SdkError::NetworkError(format!(
585 "network request failed with status {statuscode}: {}",
586 message.unwrap_or(String::new())
587 )),
588 LnurlServerError::RequestFailure(e) => SdkError::NetworkError(e),
589 LnurlServerError::SigningError(e) => {
590 SdkError::Generic(format!("Failed to sign message: {e}"))
591 }
592 }
593 }
594}
595
596impl From<TryInitError> for SdkError {
597 fn from(_value: TryInitError) -> Self {
598 SdkError::Generic("Logging can only be initialized once".to_string())
599 }
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, Error, PartialEq)]
603#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
604pub enum DepositClaimError {
605 #[error(
606 "Max deposit claim fee exceeded for utxo: {tx}:{vout} with max fee: {} and required fee: {required_fee_sats} sats",
607 .max_fee_sats.map_or_else(|| "none".to_string(), |sats| format!("{sats} sats"))
608 )]
609 MaxDepositClaimFeeExceeded {
610 tx: String,
611 vout: u32,
612 max_fee_sats: Option<u64>,
615 required_fee_sats: u64,
616 },
617
618 #[error("Missing utxo: {tx}:{vout}")]
619 MissingUtxo { tx: String, vout: u32 },
620
621 #[error("Deposit too small to claim: {tx}:{vout}")]
625 DepositTooSmall { tx: String, vout: u32 },
626
627 #[error("Generic error: {message}")]
628 Generic { message: String },
629}
630
631impl From<SdkError> for DepositClaimError {
632 fn from(value: SdkError) -> Self {
633 match value {
634 SdkError::MaxDepositClaimFeeExceeded {
635 tx,
636 vout,
637 max_fee_sats,
638 required_fee_sats,
639 ..
640 } => DepositClaimError::MaxDepositClaimFeeExceeded {
641 tx,
642 vout,
643 max_fee_sats,
644 required_fee_sats,
645 },
646 SdkError::MissingUtxo { tx, vout } => DepositClaimError::MissingUtxo { tx, vout },
647 SdkError::DepositTooSmall { tx, vout, .. } => {
648 DepositClaimError::DepositTooSmall { tx, vout }
649 }
650 SdkError::Generic(e) => DepositClaimError::Generic { message: e },
651 _ => DepositClaimError::Generic {
652 message: value.to_string(),
653 },
654 }
655 }
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize, Error, PartialEq, Eq)]
660#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
661pub enum CooperativeRecoveryError {
662 #[error(
665 "A recovery of this output is already on the network: replacing it takes at least {required_fee_sats} sats or {required_fee_rate_sat_per_vbyte} sats/vbyte"
666 )]
667 ReplacementFeeTooLow {
668 required_fee_sats: u64,
669 required_fee_rate_sat_per_vbyte: u64,
670 },
671
672 #[error("Operators unavailable: {message}")]
674 OperatorsUnavailable { message: String },
675
676 #[error("Generic error: {message}")]
678 Generic { message: String },
679}
680
681#[derive(Debug, Error, Clone)]
683#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
684pub enum SignerError {
685 #[error("Key derivation error: {0}")]
686 KeyDerivation(String),
687
688 #[error("Signing error: {0}")]
689 Signing(String),
690
691 #[error("Encryption error: {0}")]
692 Encryption(String),
693
694 #[error("Decryption error: {0}")]
695 Decryption(String),
696
697 #[error("Encryption unavailable: {0}")]
698 EncryptionUnavailable(String),
699
700 #[error("FROST error: {0}")]
701 Frost(String),
702
703 #[error("Invalid input: {0}")]
704 InvalidInput(String),
705
706 #[error("Generic signer error: {0}")]
707 Generic(String),
708}
709
710impl From<String> for SignerError {
711 fn from(s: String) -> Self {
712 SignerError::Generic(s)
713 }
714}
715
716impl From<&str> for SignerError {
717 fn from(s: &str) -> Self {
718 SignerError::Generic(s.to_string())
719 }
720}
721
722fn render_bound(
730 too_small: bool,
731 bound_amount: Option<u128>,
732 bound_usd_cents: Option<u64>,
733) -> String {
734 let mut bounds = Vec::new();
735 if let Some(amount) = bound_amount {
736 bounds.push(format!("{amount} base units"));
737 }
738 if let Some(cents) = bound_usd_cents {
739 bounds.push(format!("{}.{:02} USD", cents / 100, cents % 100));
740 }
741 if bounds.is_empty() {
742 return String::new();
743 }
744 let label = if too_small { "minimum" } else { "maximum" };
745 format!(" (published {label}: {})", bounds.join(" or "))
746}
747
748#[cfg(test)]
749mod render_bound_tests {
750 use super::*;
751
752 fn message(
755 too_small: bool,
756 bound_amount: Option<u128>,
757 bound_usd_cents: Option<u64>,
758 ) -> String {
759 let reason = if too_small {
761 "Amount too small"
762 } else {
763 "Amount too large"
764 };
765 SdkError::cross_chain_amount_out_of_range(
766 reason.to_string(),
767 too_small,
768 bound_amount,
769 bound_usd_cents,
770 )
771 .to_string()
772 }
773
774 #[test]
775 fn names_both_denominations_when_both_are_published() {
776 assert_eq!(
777 message(true, Some(1200), Some(80)),
778 "Amount too small (published minimum: 1200 base units or 0.80 USD)"
779 );
780 }
781
782 #[test]
783 fn a_too_large_rejection_reads_as_a_maximum() {
784 assert_eq!(
785 message(false, None, Some(8_980_000)),
786 "Amount too large (published maximum: 89800.00 USD)"
787 );
788 }
789
790 #[test]
791 fn says_nothing_when_the_provider_publishes_no_bound() {
792 assert_eq!(message(true, None, None), "Amount too small");
793 }
794
795 #[test]
796 fn pads_a_sub_dollar_bound_to_two_decimals() {
797 assert!(message(true, None, Some(5)).ends_with("(published minimum: 0.05 USD)"));
798 }
799}
800
801#[cfg(test)]
802mod route_unavailable_tests {
803 use super::*;
804
805 fn from_provider(temporary: bool, reason: &str) -> SdkError {
806 flashnet::FlashnetError::RouteUnavailable {
807 reason: reason.to_string(),
808 temporary,
809 }
810 .into()
811 }
812
813 #[test]
814 fn keeps_whether_a_retry_can_succeed() {
815 assert!(matches!(
816 from_provider(true, "busy"),
817 SdkError::CrossChainRouteUnavailable {
818 temporary: true,
819 ..
820 }
821 ));
822 }
823
824 #[test]
825 fn the_message_names_the_case_before_the_provider_reason() {
826 assert_eq!(
827 from_provider(
828 true,
829 "This route is temporarily unavailable. Please try again later."
830 )
831 .to_string(),
832 "Cross-chain route temporarily unavailable: This route is temporarily unavailable. \
833 Please try again later."
834 );
835 assert_eq!(
836 from_provider(false, "Unsupported route").to_string(),
837 "Cross-chain route not supported: Unsupported route"
838 );
839 }
840}
841
842#[cfg(test)]
843mod invalid_request_tests {
844 use super::*;
845
846 #[test]
847 fn reads_the_same_as_a_plain_provider_error() {
848 let invalid: SdkError = flashnet::FlashnetError::InvalidRequest {
849 reason: "Unknown affiliateId: breez_ff".to_string(),
850 code: 400,
851 }
852 .into();
853 let plain: SdkError = flashnet::FlashnetError::Network {
854 reason: "Unknown affiliateId: breez_ff".to_string(),
855 code: Some(400),
856 }
857 .into();
858 assert!(matches!(invalid, SdkError::NetworkError(_)));
859 assert_eq!(invalid.to_string(), plain.to_string());
860 }
861
862 #[test]
863 fn an_affiliate_rejection_reads_the_same_as_a_plain_provider_error() {
864 let rejected: SdkError = flashnet::FlashnetError::AffiliateRejected {
865 reason: "Affiliate is disabled".to_string(),
866 code: 400,
867 }
868 .into();
869 let plain: SdkError = flashnet::FlashnetError::Network {
870 reason: "Affiliate is disabled".to_string(),
871 code: Some(400),
872 }
873 .into();
874 assert!(matches!(rejected, SdkError::NetworkError(_)));
875 assert_eq!(rejected.to_string(), plain.to_string());
876 }
877}
878
879#[cfg(test)]
880mod token_allowance_error_tests {
881 use spark_wallet::{ServiceError, SparkWalletError, TokenAllowanceFailure};
882
883 use super::{SdkError, TokenAllowanceErrorReason};
884
885 #[test]
886 fn maps_allowance_refusals_to_reasons() {
887 let error = SdkError::from(SparkWalletError::ServiceError(
888 ServiceError::TokenAllowance {
889 failure: TokenAllowanceFailure::OverTotalLimit,
890 message: "metered amount exceeds the allowance remaining budget".to_string(),
891 },
892 ));
893 assert!(matches!(
894 &error,
895 SdkError::TokenAllowance {
896 reason: TokenAllowanceErrorReason::OverTotalLimit,
897 details,
898 ..
899 } if details == "metered amount exceeds the allowance remaining budget"
900 ));
901 assert_eq!(
902 error.to_string(),
903 "Token allowance error (OverTotalLimit): metered amount exceeds the allowance remaining \
904 budget"
905 );
906 }
907}
908
909#[cfg(test)]
910#[cfg(not(target_family = "wasm"))]
911mod docs_url_tests {
912 use super::*;
913
914 fn errors_page() -> String {
915 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
916 .join("../../../docs/breez-sdk/src/guide/errors.md");
917 std::fs::read_to_string(&path)
918 .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
919 }
920
921 fn has_section(page: &str, id: &str) -> bool {
922 page.contains(&format!("id=\"{id}\""))
923 }
924
925 fn kebab_case(name: &str) -> String {
927 let mut id = String::new();
928 for c in name.chars() {
929 if c.is_ascii_uppercase() && !id.is_empty() {
930 id.push('-');
931 }
932 id.push(c.to_ascii_lowercase());
933 }
934 id
935 }
936
937 fn sdk_error_source() -> &'static str {
939 let source = include_str!("error.rs");
940 let start = source
941 .find("pub enum SdkError {")
942 .expect("SdkError definition");
943 let body = &source[start..];
944 &body[..body.find("\n}\n").expect("end of SdkError")]
945 }
946
947 fn sdk_error_variants() -> Vec<String> {
948 sdk_error_source()
949 .lines()
950 .filter_map(|line| {
951 let name = line.strip_prefix(" ")?;
952 let name: String = name
953 .chars()
954 .take_while(char::is_ascii_alphanumeric)
955 .collect();
956 name.starts_with(|c: char| c.is_ascii_uppercase())
957 .then_some(name)
958 })
959 .collect()
960 }
961
962 fn linked_errors() -> Vec<SdkError> {
964 vec![
965 SdkError::cross_chain_amount_out_of_range(String::new(), true, None, None),
966 SdkError::cross_chain_route_unavailable(String::new(), true),
967 SdkError::cross_chain_disabled(),
968 SdkError::max_deposit_claim_fee_exceeded(String::new(), 0, None, 0),
969 SdkError::deposit_too_small(String::new(), 0),
970 SdkError::deposit_claim_in_progress(String::new(), 0),
971 SdkError::refund_replacement_fee_too_low(0, 0),
972 SdkError::insufficient_cpfp_funds(0),
973 SdkError::token_allowance(TokenAllowanceErrorReason::NotFound, String::new()),
974 ]
975 }
976
977 #[test]
978 fn every_linked_variant_points_at_its_own_section_of_the_errors_page() {
979 let page = errors_page();
980 let errors = linked_errors();
981 assert_eq!(
982 errors.len(),
983 sdk_error_source().matches("docs_url: String,").count(),
984 "linked_errors() must list every variant with a docs_url field"
985 );
986 for err in errors {
987 let name: String = format!("{err:?}")
988 .chars()
989 .take_while(char::is_ascii_alphanumeric)
990 .collect();
991 let url = err
992 .docs_url()
993 .unwrap_or_else(|| panic!("{name} has no docs_url"));
994 let anchor = url
995 .strip_prefix(docs_url::SECTION_PREFIX)
996 .unwrap_or_else(|| panic!("{url} is not a section of the errors page"));
997 assert_eq!(anchor, kebab_case(&name), "{name} links to another section");
998 assert!(
999 has_section(&page, anchor),
1000 "errors.md has no section #{anchor}"
1001 );
1002 }
1003 }
1004
1005 #[test]
1006 fn every_sdk_error_variant_has_a_section_on_the_errors_page() {
1007 let page = errors_page();
1008 let variants = sdk_error_variants();
1009 assert!(variants.contains(&"CrossChainDisabled".to_string()));
1010 let missing: Vec<String> = variants
1011 .iter()
1012 .map(|v| kebab_case(v))
1013 .filter(|id| !has_section(&page, id))
1014 .collect();
1015 assert!(
1016 missing.is_empty(),
1017 "errors.md has no section for: {missing:?}"
1018 );
1019 }
1020
1021 #[test]
1022 fn messages_leave_the_link_to_docs_url() {
1023 for err in linked_errors() {
1024 assert!(!err.to_string().contains("https://"), "{err:?}");
1025 }
1026 }
1027}