1use bitcoin::secp256k1::{PublicKey, ecdsa::Signature};
2use breez_sdk_common::{buy::cashapp::CashAppProvider, input::CrossChainAddressFamily};
3use spark_wallet::MasterIdentityPublicKeyUpdate;
4use std::str::FromStr;
5use tracing::{debug, info};
6
7use crate::{
8 BuyBitcoinRequest, BuyBitcoinResponse, CheckMessageRequest, CheckMessageResponse,
9 CrossChainProvider, CrossChainRouteFilter, CrossChainRoutePair, GetTokensMetadataRequest,
10 GetTokensMetadataResponse, InputType, ListFiatCurrenciesResponse, ListFiatRatesResponse,
11 Network, OptimizationMode, OptimizeLeavesRequest, OptimizeLeavesResponse,
12 PreparePaymentLinkRequest, PreparePaymentLinkResponse, RegisterWebhookRequest,
13 RegisterWebhookResponse, SignMessageRequest, SignMessageResponse, SourceChain,
14 UnregisterWebhookRequest, UpdateUserSettingsRequest, UserSettings, Webhook,
15 chain::RecommendedFees,
16 cross_chain::{
17 CrossChainProviderContext, convert_destination_amount_to_sats, fetch_btc_usd_rate,
18 },
19 error::SdkError,
20 events::EventListener,
21 issuer::TokenIssuer,
22 models::{
23 GetInfoRequest, GetInfoResponse, SparkMasterIdentityPublicKey, StableBalanceActiveLabel,
24 },
25 persist::ObjectCacheRepository,
26 utils::token::get_tokens_metadata_cached_or_query,
27};
28
29use super::payments::validation::{
30 known_token_contracts, resolve_direct_overpay_amount, resolve_slippage_bps,
31 resolve_target_overpay_bps, validate_address_family_against_route, validate_amount,
32 validate_recipient_not_contract_address,
33};
34use super::{BreezSdk, helpers::get_deposit_address, parse_input};
35
36#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
37#[allow(clippy::needless_pass_by_value)]
38impl BreezSdk {
39 pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> String {
54 self.event_emitter.add_external_listener(listener).await
55 }
56
57 pub async fn remove_event_listener(&self, id: &str) -> bool {
67 self.event_emitter.remove_external_listener(id).await
68 }
69
70 pub async fn disconnect(&self) -> Result<(), SdkError> {
82 info!("Disconnecting Breez SDK");
83 self.event_emitter.clear_external_listeners().await;
84 if self.shutdown_sender.send(()).is_err() {
85 debug!("No shutdown receivers; SDK has no background tasks to stop");
91 return Ok(());
92 }
93
94 self.shutdown_sender.closed().await;
95 info!("Breez SDK disconnected");
96 Ok(())
97 }
98
99 pub async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
100 parse_input(input, Some(self.external_input_parsers.clone())).await
101 }
102
103 pub async fn get_cross_chain_routes(
112 &self,
113 filter: &CrossChainRouteFilter,
114 ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
115 let mut all_routes = Vec::new();
116 for svc in self.cross_chain_context.values() {
117 match svc.get_routes(filter).await {
118 Ok(routes) => all_routes.extend(routes),
119 Err(e) => tracing::warn!("Cross-chain provider route fetch failed: {e}"),
120 }
121 }
122
123 all_routes.retain(|r| crate::cross_chain::is_usd_stable_asset(&r.asset));
125
126 all_routes.sort_by(|a, b| {
127 a.asset
128 .cmp(&b.asset)
129 .then_with(|| a.chain.cmp(&b.chain))
130 .then_with(|| a.provider.cmp(&b.provider))
131 });
132 Ok(all_routes)
133 }
134
135 #[allow(unused_variables)]
137 pub async fn get_info(&self, request: GetInfoRequest) -> Result<GetInfoResponse, SdkError> {
138 self.runtime.get_info(self, request).await
139 }
140
141 pub async fn list_fiat_currencies(&self) -> Result<ListFiatCurrenciesResponse, SdkError> {
144 let currencies = self
145 .fiat_service
146 .fetch_fiat_currencies()
147 .await?
148 .into_iter()
149 .map(From::from)
150 .collect();
151 Ok(ListFiatCurrenciesResponse { currencies })
152 }
153
154 pub async fn list_fiat_rates(&self) -> Result<ListFiatRatesResponse, SdkError> {
156 let rates = self
157 .fiat_service
158 .fetch_fiat_rates()
159 .await?
160 .into_iter()
161 .map(From::from)
162 .collect();
163 Ok(ListFiatRatesResponse { rates })
164 }
165
166 pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
168 Ok(self.chain_service.recommended_fees().await?)
169 }
170
171 pub async fn get_tokens_metadata(
178 &self,
179 request: GetTokensMetadataRequest,
180 ) -> Result<GetTokensMetadataResponse, SdkError> {
181 let metadata = get_tokens_metadata_cached_or_query(
182 &self.spark_wallet,
183 &ObjectCacheRepository::new(self.storage.clone()),
184 &request
185 .token_identifiers
186 .iter()
187 .map(String::as_str)
188 .collect::<Vec<_>>(),
189 )
190 .await?;
191 Ok(GetTokensMetadataResponse {
192 tokens_metadata: metadata,
193 })
194 }
195
196 pub async fn sign_message(
203 &self,
204 request: SignMessageRequest,
205 ) -> Result<SignMessageResponse, SdkError> {
206 use bitcoin::hex::DisplayHex;
207
208 reject_reserved_namespace(&request.message)?;
209
210 let pubkey = self.spark_wallet.get_identity_public_key().to_string();
211 let signature = self.spark_wallet.sign_message(&request.message).await?;
212 let signature_hex = if request.compact {
213 signature.serialize_compact().to_lower_hex_string()
214 } else {
215 signature.serialize_der().to_lower_hex_string()
216 };
217
218 Ok(SignMessageResponse {
219 pubkey,
220 signature: signature_hex,
221 })
222 }
223
224 pub async fn check_message(
228 &self,
229 request: CheckMessageRequest,
230 ) -> Result<CheckMessageResponse, SdkError> {
231 let pubkey = PublicKey::from_str(&request.pubkey)
232 .map_err(|_| SdkError::InvalidInput("Invalid public key".to_string()))?;
233 let signature_bytes = hex::decode(&request.signature)
234 .map_err(|_| SdkError::InvalidInput("Not a valid hex encoded signature".to_string()))?;
235 let signature = Signature::from_der(&signature_bytes)
236 .or_else(|_| Signature::from_compact(&signature_bytes))
237 .map_err(|_| {
238 SdkError::InvalidInput("Not a valid DER or compact encoded signature".to_string())
239 })?;
240
241 let is_valid = self
242 .spark_wallet
243 .verify_message(&request.message, &signature, &pubkey)
244 .await
245 .is_ok();
246 Ok(CheckMessageResponse { is_valid })
247 }
248
249 pub async fn get_user_settings(&self) -> Result<UserSettings, SdkError> {
253 self.maybe_ensure_spark_private_mode_initialized().await?;
255
256 let spark_user_settings = self.spark_wallet.query_wallet_settings().await?;
257
258 let stable_balance_active_label = match &self.stable_balance {
259 Some(sb) => sb.get_active_label().await,
260 None => None,
261 };
262
263 Ok(UserSettings {
264 spark_private_mode_enabled: spark_user_settings.private_enabled,
265 stable_balance_active_label,
266 spark_master_identity_public_key: spark_user_settings
267 .master_identity_public_key
268 .map(|key| key.to_string()),
269 })
270 }
271
272 pub async fn update_user_settings(
276 &self,
277 request: UpdateUserSettingsRequest,
278 ) -> Result<(), SdkError> {
279 let master_identity_public_key = request
280 .spark_master_identity_public_key
281 .map(|update| match update {
282 SparkMasterIdentityPublicKey::Set { public_key } => {
283 parse_compressed_public_key(&public_key).map(MasterIdentityPublicKeyUpdate::Set)
284 }
285 SparkMasterIdentityPublicKey::Unset => Ok(MasterIdentityPublicKeyUpdate::Clear),
286 })
287 .transpose()?;
288
289 self.spark_wallet
290 .update_wallet_settings(
291 request.spark_private_mode_enabled,
292 master_identity_public_key,
293 )
294 .await?;
295
296 if let Some(active_label) = request.stable_balance_active_label {
297 let sb = self
298 .stable_balance
299 .as_ref()
300 .ok_or_else(|| SdkError::Generic("Stable balance is not configured".to_string()))?;
301 let label = if let StableBalanceActiveLabel::Set { label } = active_label {
302 Some(label)
303 } else {
304 None
305 };
306 sb.set_active_token(label).await?;
307 }
308
309 Ok(())
310 }
311
312 pub fn get_token_issuer(&self) -> TokenIssuer {
314 TokenIssuer::new(self.spark_wallet.clone(), self.storage.clone())
315 }
316
317 pub async fn optimize_leaves(
335 &self,
336 request: OptimizeLeavesRequest,
337 ) -> Result<OptimizeLeavesResponse, SdkError> {
338 let max_rounds = match request.mode {
339 OptimizationMode::Full => None,
340 OptimizationMode::SingleRound => Some(1),
341 };
342 let result = self.spark_wallet.optimize_leaves(max_rounds).await;
343 Ok(OptimizeLeavesResponse {
344 outcome: result?.into(),
345 })
346 }
347
348 pub async fn register_webhook(
362 &self,
363 request: RegisterWebhookRequest,
364 ) -> Result<RegisterWebhookResponse, SdkError> {
365 let event_types = request.event_types.into_iter().map(Into::into).collect();
366 let webhook_id = self
367 .spark_wallet
368 .register_wallet_webhook(&request.url, &request.secret, event_types)
369 .await
370 .map_err(|e| SdkError::Generic(format!("Failed to register webhook: {e}")))?;
371 Ok(RegisterWebhookResponse { webhook_id })
372 }
373
374 pub async fn unregister_webhook(
383 &self,
384 request: UnregisterWebhookRequest,
385 ) -> Result<(), SdkError> {
386 self.spark_wallet
387 .delete_wallet_webhook(&request.webhook_id)
388 .await
389 .map_err(|e| SdkError::Generic(format!("Failed to unregister webhook: {e}")))?;
390 Ok(())
391 }
392
393 pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, SdkError> {
399 let webhooks = self
400 .spark_wallet
401 .list_wallet_webhooks()
402 .await
403 .map_err(|e| SdkError::Generic(format!("Failed to list webhooks: {e}")))?;
404 Ok(webhooks.into_iter().map(Into::into).collect())
405 }
406
407 pub async fn buy_bitcoin(
415 &self,
416 request: BuyBitcoinRequest,
417 ) -> Result<BuyBitcoinResponse, SdkError> {
418 let url = match request {
419 BuyBitcoinRequest::Moonpay {
420 locked_amount_sat,
421 redirect_url,
422 } => {
423 let address = get_deposit_address(&self.spark_wallet, true).await?;
424 self.buy_bitcoin_provider
425 .buy_bitcoin(address, locked_amount_sat, redirect_url)
426 .await
427 .map_err(|e| {
428 SdkError::Generic(format!("Failed to create buy bitcoin URL: {e}"))
429 })?
430 }
431 BuyBitcoinRequest::CashApp { amount_sats } => {
432 if !matches!(self.config.network, Network::Mainnet) {
433 return Err(SdkError::Generic(
434 "CashApp is only available on mainnet".to_string(),
435 ));
436 }
437 if amount_sats == 0 {
438 return Err(SdkError::Generic(
439 "CashApp requires a non-zero amount".to_string(),
440 ));
441 }
442 let receive_response = self
443 .receive_bolt11_invoice(
444 "Buy Bitcoin via CashApp".to_string(),
445 Some(amount_sats),
446 None,
447 None,
448 None,
449 )
450 .await?;
451 CashAppProvider::build_url(&receive_response.payment_request)
452 }
453 };
454
455 Ok(BuyBitcoinResponse { url })
456 }
457
458 pub async fn prepare_payment_link(
465 &self,
466 request: PreparePaymentLinkRequest,
467 ) -> Result<PreparePaymentLinkResponse, SdkError> {
468 if !matches!(self.config.network, Network::Mainnet) {
469 return Err(SdkError::Generic("Only available on mainnet".to_string()));
470 }
471 validate_amount(Some(request.amount))?;
472
473 let PreparePaymentLinkRequest {
474 address,
475 route,
476 amount,
477 fee_policy,
478 max_slippage_bps,
479 } = request;
480
481 if !matches!(route.provider, CrossChainProvider::Orchestra) {
486 return Err(SdkError::InvalidInput(
487 "Payment links are only supported on Orchestra routes".to_string(),
488 ));
489 }
490
491 if !route_supports_source_chain(&route, SourceChain::Lightning) {
496 return Err(SdkError::InvalidInput(
497 "The selected route can't be funded over Lightning".to_string(),
498 ));
499 }
500
501 let InputType::CrossChainAddress(recipient) = self.parse(&address).await? else {
504 return Err(SdkError::InvalidInput(
505 "Recipient must be a cross-chain (EVM/Solana/Tron) address".to_string(),
506 ));
507 };
508 let recipient_family: CrossChainAddressFamily = recipient.address_family.into();
509 validate_address_family_against_route(recipient_family, &route)?;
510 let known_contracts =
511 known_token_contracts(self, &recipient.address, recipient_family, &route).await;
512 validate_recipient_not_contract_address(
513 &recipient.address,
514 recipient_family,
515 &known_contracts,
516 )?;
517
518 let service = self.cross_chain_context.get(route.provider)?;
519 let slippage_bps = resolve_slippage_bps(
520 max_slippage_bps,
521 self.config
522 .cross_chain_config
523 .as_ref()
524 .and_then(|c| c.default_slippage_bps),
525 )?;
526
527 let btc_usd = fetch_btc_usd_rate(self.cross_chain_context.fiat_service().as_ref()).await?;
532 let source_sats =
533 convert_destination_amount_to_sats(amount, btc_usd, route.decimals.into())?;
534 if source_sats == 0 {
535 return Err(SdkError::InvalidInput(
536 "Amount is too small to fund over Lightning".to_string(),
537 ));
538 }
539
540 let fee_policy = fee_policy.unwrap_or_default();
544 let overpay_bps = resolve_target_overpay_bps(
545 None,
546 self.config
547 .cross_chain_config
548 .as_ref()
549 .and_then(|c| c.default_target_overpay_bps),
550 )?;
551 let source_sats = resolve_direct_overpay_amount(source_sats, fee_policy, overpay_bps);
552
553 let prepared = service
554 .prepare(
555 &recipient.address,
556 &route,
557 source_sats,
558 Some(SourceChain::Lightning),
559 None,
560 slippage_bps,
561 fee_policy.into(),
562 )
563 .await?;
564
565 let amount_sats = u64::try_from(prepared.amount_in).map_err(|_| {
566 SdkError::Generic(format!(
567 "Deposit amount {} sats exceeds u64::MAX",
568 prepared.amount_in
569 ))
570 })?;
571
572 let deposit_target = deposit_target(&prepared.provider_context);
578
579 let parsed_target = self.parse(&deposit_target).await;
582 check_deposit_target(&parsed_target, amount_sats)?;
583
584 let url = CashAppProvider::build_url(&deposit_target);
585
586 Ok(prepare_payment_link_response(prepared, url, amount_sats))
587 }
588}
589
590fn route_supports_source_chain(route: &CrossChainRoutePair, required: SourceChain) -> bool {
594 route.supported_source_chains.contains(&required)
595}
596
597fn check_deposit_target(
601 parsed_target: &Result<InputType, SdkError>,
602 expected_sats: u64,
603) -> Result<(), SdkError> {
604 let Ok(InputType::Bolt11Invoice(details)) = parsed_target else {
605 return Err(SdkError::Generic(
606 "The provider returned a deposit target that is not a valid Lightning \
607 payment request"
608 .to_string(),
609 ));
610 };
611 if details.amount_msat.map(u128::from) != Some(u128::from(expected_sats) * 1000) {
614 return Err(SdkError::Generic(format!(
615 "The provider's deposit invoice does not request the quoted \
616 {expected_sats} sats"
617 )));
618 }
619 Ok(())
620}
621
622fn deposit_target(context: &CrossChainProviderContext) -> String {
625 match context {
626 CrossChainProviderContext::Orchestra {
627 deposit_address, ..
628 } => deposit_address.clone(),
629 CrossChainProviderContext::Boltz { invoice, .. } => invoice.clone(),
630 }
631}
632
633fn prepare_payment_link_response(
641 prepared: crate::cross_chain::CrossChainPrepared,
642 url: String,
643 amount_sats: u64,
644) -> PreparePaymentLinkResponse {
645 PreparePaymentLinkResponse {
646 url,
647 amount_sats,
648 estimated_out: prepared.estimated_out,
649 asset: prepared.pair.asset,
650 service_fee_amount: prepared.service_fee_amount,
651 service_fee_asset: prepared.service_fee_asset,
652 expires_at: normalize_expires_at(&prepared.expires_at),
653 }
654}
655
656fn normalize_expires_at(raw: &str) -> String {
660 if let Ok(secs) = raw.parse::<i64>()
661 && let Some(dt) = chrono::DateTime::from_timestamp(secs, 0)
662 {
663 return dt.to_rfc3339();
664 }
665 raw.to_string()
666}
667
668fn reject_reserved_namespace(message: &str) -> Result<(), SdkError> {
675 let namespace = lnurl_models::signed_message::RESERVED_NAMESPACE;
676 let reserved = message
677 .as_bytes()
678 .get(..namespace.len())
679 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(namespace.as_bytes()));
680 if reserved {
681 return Err(SdkError::InvalidInput(format!(
682 "messages starting with '{namespace}' are reserved for Lightning address requests and cannot be signed here"
683 )));
684 }
685 Ok(())
686}
687
688fn parse_compressed_public_key(hex_encoded: &str) -> Result<PublicKey, SdkError> {
694 let invalid = || SdkError::InvalidInput("Invalid master identity public key".to_string());
695 let bytes: [u8; 33] = hex::decode(hex_encoded)
696 .map_err(|_| invalid())?
697 .try_into()
698 .map_err(|_| invalid())?;
699 PublicKey::from_slice(&bytes).map_err(|_| invalid())
700}
701
702#[cfg(test)]
703mod tests {
704 use super::{
705 CashAppProvider, SdkError, deposit_target, parse_compressed_public_key,
706 prepare_payment_link_response, reject_reserved_namespace, route_supports_source_chain,
707 };
708 use crate::cross_chain::{CrossChainPrepared, CrossChainProviderContext};
709 use crate::{CrossChainFeeMode, CrossChainProvider, CrossChainRoutePair, SourceChain};
710 use macros::test_all;
711
712 #[cfg(feature = "browser-tests")]
713 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
714
715 fn orchestra_context() -> CrossChainProviderContext {
716 CrossChainProviderContext::Orchestra {
717 quote_id: "q1".to_string(),
718 deposit_address: "lnbc100u1pxyz".to_string(),
719 deposit_amount: 5000,
720 }
721 }
722
723 fn route_with_source_chains(chains: Vec<SourceChain>) -> CrossChainRoutePair {
724 CrossChainRoutePair {
725 provider: CrossChainProvider::Orchestra,
726 chain: "base".to_string(),
727 chain_id: Some("8453".to_string()),
728 asset: "USDC".to_string(),
729 contract_address: None,
730 decimals: 6,
731 exact_out_eligible: false,
732 supported_sources: vec![],
733 supported_source_chains: chains,
734 }
735 }
736
737 #[test_all]
738 fn route_supports_source_chain_checks_membership() {
739 let lightning_only = route_with_source_chains(vec![SourceChain::Lightning]);
740 assert!(route_supports_source_chain(
742 &lightning_only,
743 SourceChain::Lightning
744 ));
745 assert!(!route_supports_source_chain(
746 &lightning_only,
747 SourceChain::Bitcoin
748 ));
749
750 let both = route_with_source_chains(vec![SourceChain::Lightning, SourceChain::Bitcoin]);
751 assert!(route_supports_source_chain(&both, SourceChain::Bitcoin));
752
753 let empty = route_with_source_chains(vec![]);
756 assert!(!route_supports_source_chain(&empty, SourceChain::Bitcoin));
757 }
758
759 fn prepared(provider_context: CrossChainProviderContext) -> CrossChainPrepared {
760 CrossChainPrepared {
761 amount_in: 5000,
762 asset_amount_in: 6_500_000,
763 estimated_out: 6_450_000,
764 fee_amount: 50_000,
765 service_fee_amount: 47_684,
766 service_fee_asset: Some("USDC".to_string()),
767 source_transfer_fee_sats: 0,
768 fee_mode: CrossChainFeeMode::FeesExcluded,
769 expires_at: "2026-07-25T08:09:26.770Z".to_string(),
770 pair: CrossChainRoutePair {
771 provider: CrossChainProvider::Orchestra,
772 chain: "base".to_string(),
773 chain_id: Some("8453".to_string()),
774 asset: "USDC".to_string(),
775 contract_address: None,
776 decimals: 6,
777 exact_out_eligible: false,
778 supported_sources: vec![],
779 supported_source_chains: vec![],
780 },
781 recipient_address: "0xabc".to_string(),
782 token_identifier: None,
783 provider_context,
784 }
785 }
786
787 #[test_all]
788 fn deposit_target_reads_orchestra_address_and_boltz_invoice() {
789 assert_eq!(deposit_target(&orchestra_context()), "lnbc100u1pxyz");
790 assert_eq!(
791 deposit_target(&CrossChainProviderContext::Boltz {
792 swap_id: "s1".to_string(),
793 invoice: "lnbc200u1pboltz".to_string(),
794 invoice_amount_sats: 5000,
795 max_slippage_bps: 100,
796 }),
797 "lnbc200u1pboltz"
798 );
799 }
800
801 #[test_all]
802 fn cashapp_url_built_from_deposit_target() {
803 let url = CashAppProvider::build_url(&deposit_target(&orchestra_context()));
804 assert_eq!(url, "https://cash.app/launch/lightning/lnbc100u1pxyz");
805 }
806
807 #[test_all]
808 fn orchestra_response_maps_stablecoin_fee() {
809 let resp = prepare_payment_link_response(
810 prepared(orchestra_context()),
811 "https://x".to_string(),
812 5000,
813 );
814 assert_eq!(resp.url, "https://x");
815 assert_eq!(resp.amount_sats, 5000);
816 assert_eq!(resp.estimated_out, 6_450_000);
817 assert_eq!(resp.asset, "USDC");
818 assert_eq!(resp.service_fee_amount, 47_684);
820 assert_eq!(resp.service_fee_asset.as_deref(), Some("USDC"));
821 }
822
823 #[test_all]
824 fn boltz_response_reports_native_sats_fee() {
825 let mut prepared = prepared(CrossChainProviderContext::Boltz {
826 swap_id: "s1".to_string(),
827 invoice: "lnbc200u1pboltz".to_string(),
828 invoice_amount_sats: 5000,
829 max_slippage_bps: 100,
830 });
831 prepared.service_fee_amount = 17;
833 prepared.service_fee_asset = None;
834 let resp = prepare_payment_link_response(prepared, "https://x".to_string(), 5000);
835 assert_eq!(resp.asset, "USDC");
836 assert_eq!(resp.service_fee_amount, 17);
837 assert_eq!(resp.service_fee_asset, None);
838 }
839
840 #[test_all]
841 fn normalize_expires_at_converts_unix_and_passes_rfc3339() {
842 let converted = super::normalize_expires_at("1784895588");
844 assert_ne!(converted, "1784895588");
845 assert!(chrono::DateTime::parse_from_rfc3339(&converted).is_ok());
846 let iso = "2026-07-25T08:09:26.770Z";
848 assert_eq!(super::normalize_expires_at(iso), iso);
849 }
850
851 const COMPRESSED: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
852 const UNCOMPRESSED: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179\
853 8483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
854
855 #[test]
856 fn parse_compressed_public_key_round_trips() {
857 let key = parse_compressed_public_key(COMPRESSED).unwrap();
858 assert_eq!(key.to_string(), COMPRESSED);
859 }
860
861 #[test]
862 fn reject_reserved_namespace_refuses_lnurl_messages() {
863 let messages = [
864 lnurl_models::signed_message::register("lnurl.example.com", "alice", "d", 1),
865 lnurl_models::signed_message::unregister("lnurl.example.com", "alice", 1),
866 lnurl_models::signed_message::recover("lnurl.example.com", COMPRESSED, 1),
867 lnurl_models::signed_message::metadata("lnurl.example.com", COMPRESSED, 1),
868 "BREEZ-LNURL:v2\nregister\n...".to_string(),
869 "breez-lnurl:".to_string(),
870 "breez-lnurl:v3 whatever comes next".to_string(),
871 ];
872 for message in messages {
873 assert!(
874 matches!(
875 reject_reserved_namespace(&message),
876 Err(SdkError::InvalidInput(_))
877 ),
878 "expected {message:?} to be refused"
879 );
880 }
881 }
882
883 #[test]
884 fn reject_reserved_namespace_allows_everything_else() {
885 for message in [
886 "",
887 "hello",
888 "breez-lnur",
889 "breez lnurl:v2",
890 "please sign breez-lnurl:v2\nregister",
892 "🥖",
894 ] {
895 assert!(
896 reject_reserved_namespace(message).is_ok(),
897 "expected {message:?} to be allowed"
898 );
899 }
900 }
901
902 #[test]
903 fn parse_compressed_public_key_rejects_invalid() {
904 for input in ["", "not-a-public-key", UNCOMPRESSED, &COMPRESSED[..64]] {
905 assert!(
906 matches!(
907 parse_compressed_public_key(input),
908 Err(SdkError::InvalidInput(_))
909 ),
910 "expected {input} to be rejected"
911 );
912 }
913 }
914}