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, DeliveryMethod,
10 GetTokensMetadataRequest, GetTokensMetadataResponse, InputType, ListFiatCurrenciesResponse,
11 ListFiatRatesResponse, Network, OptimizationMode, OptimizeLeavesRequest,
12 OptimizeLeavesResponse, PreparePaymentLinkRequest, PreparePaymentLinkResponse,
13 RegisterWebhookRequest, RegisterWebhookResponse, SignMessageRequest, SignMessageResponse,
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 validate_address_family_against_route, validate_amount,
32 validate_recipient_not_contract_address,
33};
34use super::{BreezSdk, helpers::get_deposit_address};
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 Ok(self.input_parser.parse(input).await?.into())
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, &self.storage, 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 "breez-onramp".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_delivery_method(&route, DeliveryMethod::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 = crate::cross_chain::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_send(
555 &recipient.address,
556 &route,
557 source_sats,
558 Some(DeliveryMethod::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_delivery_method(route: &CrossChainRoutePair, required: DeliveryMethod) -> bool {
594 route.delivery_methods.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::CrossChainSendPrepared,
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 service_fee_asset_decimals: prepared.service_fee_asset_decimals,
653 expires_at: normalize_expires_at(&prepared.expires_at),
654 }
655}
656
657fn normalize_expires_at(raw: &str) -> String {
661 if let Ok(secs) = raw.parse::<i64>()
662 && let Some(dt) = chrono::DateTime::from_timestamp(secs, 0)
663 {
664 return dt.to_rfc3339();
665 }
666 raw.to_string()
667}
668
669fn reject_reserved_namespace(message: &str) -> Result<(), SdkError> {
676 let namespace = lnurl_models::signed_message::RESERVED_NAMESPACE;
677 let reserved = message
678 .as_bytes()
679 .get(..namespace.len())
680 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(namespace.as_bytes()));
681 if reserved {
682 return Err(SdkError::InvalidInput(format!(
683 "messages starting with '{namespace}' are reserved for Lightning address requests and cannot be signed here"
684 )));
685 }
686 Ok(())
687}
688
689fn parse_compressed_public_key(hex_encoded: &str) -> Result<PublicKey, SdkError> {
695 let invalid = || SdkError::InvalidInput("Invalid master identity public key".to_string());
696 let bytes: [u8; 33] = hex::decode(hex_encoded)
697 .map_err(|_| invalid())?
698 .try_into()
699 .map_err(|_| invalid())?;
700 PublicKey::from_slice(&bytes).map_err(|_| invalid())
701}
702
703#[cfg(test)]
704mod tests {
705 use super::{
706 CashAppProvider, SdkError, deposit_target, parse_compressed_public_key,
707 prepare_payment_link_response, reject_reserved_namespace, route_supports_delivery_method,
708 };
709 use crate::cross_chain::{CrossChainProviderContext, CrossChainSendPrepared};
710 use crate::{CrossChainFeeMode, CrossChainProvider, CrossChainRoutePair, DeliveryMethod};
711 use macros::test_all;
712
713 #[cfg(feature = "browser-tests")]
714 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
715
716 fn orchestra_context() -> CrossChainProviderContext {
717 CrossChainProviderContext::Orchestra {
718 quote_id: "q1".to_string(),
719 deposit_address: "lnbc100u1pxyz".to_string(),
720 deposit_amount: 5000,
721 }
722 }
723
724 fn route_with_delivery_methods(methods: Vec<DeliveryMethod>) -> CrossChainRoutePair {
725 CrossChainRoutePair {
726 provider: CrossChainProvider::Orchestra,
727 chain: "base".to_string(),
728 chain_id: Some("8453".to_string()),
729 asset: "USDC".to_string(),
730 contract_address: None,
731 decimals: 6,
732 exact_out_eligible: false,
733 accepted_assets: vec![],
734 delivery_methods: methods,
735 }
736 }
737
738 #[test_all]
739 fn route_supports_delivery_method_checks_membership() {
740 let lightning_only = route_with_delivery_methods(vec![DeliveryMethod::Lightning]);
741 assert!(route_supports_delivery_method(
743 &lightning_only,
744 DeliveryMethod::Lightning
745 ));
746 assert!(!route_supports_delivery_method(
747 &lightning_only,
748 DeliveryMethod::Bitcoin
749 ));
750
751 let both =
752 route_with_delivery_methods(vec![DeliveryMethod::Lightning, DeliveryMethod::Bitcoin]);
753 assert!(route_supports_delivery_method(
754 &both,
755 DeliveryMethod::Bitcoin
756 ));
757
758 let empty = route_with_delivery_methods(vec![]);
761 assert!(!route_supports_delivery_method(
762 &empty,
763 DeliveryMethod::Bitcoin
764 ));
765 }
766
767 fn prepared(provider_context: CrossChainProviderContext) -> CrossChainSendPrepared {
768 CrossChainSendPrepared {
769 amount_in: 5000,
770 asset_amount_in: 6_500_000,
771 estimated_out: 6_450_000,
772 fee_amount: 50_000,
773 service_fee_amount: 47_684,
774 service_fee_asset: Some("USDC".to_string()),
775 service_fee_asset_decimals: Some(6),
776 source_transfer_fee_sats: 0,
777 fee_mode: CrossChainFeeMode::FeesExcluded,
778 expires_at: "2026-07-25T08:09:26.770Z".to_string(),
779 pair: CrossChainRoutePair {
780 provider: CrossChainProvider::Orchestra,
781 chain: "base".to_string(),
782 chain_id: Some("8453".to_string()),
783 asset: "USDC".to_string(),
784 contract_address: None,
785 decimals: 6,
786 exact_out_eligible: false,
787 accepted_assets: vec![],
788 delivery_methods: vec![],
789 },
790 recipient_address: "0xabc".to_string(),
791 token_identifier: None,
792 provider_context,
793 }
794 }
795
796 #[test_all]
797 fn deposit_target_reads_orchestra_address_and_boltz_invoice() {
798 assert_eq!(deposit_target(&orchestra_context()), "lnbc100u1pxyz");
799 assert_eq!(
800 deposit_target(&CrossChainProviderContext::Boltz {
801 swap_id: "s1".to_string(),
802 invoice: "lnbc200u1pboltz".to_string(),
803 invoice_amount_sats: 5000,
804 max_slippage_bps: 100,
805 }),
806 "lnbc200u1pboltz"
807 );
808 }
809
810 #[test_all]
811 fn cashapp_url_built_from_deposit_target() {
812 let url = CashAppProvider::build_url(&deposit_target(&orchestra_context()));
813 assert_eq!(url, "https://cash.app/launch/lightning/lnbc100u1pxyz");
814 }
815
816 #[test_all]
817 fn orchestra_response_maps_stablecoin_fee() {
818 let resp = prepare_payment_link_response(
819 prepared(orchestra_context()),
820 "https://x".to_string(),
821 5000,
822 );
823 assert_eq!(resp.url, "https://x");
824 assert_eq!(resp.amount_sats, 5000);
825 assert_eq!(resp.estimated_out, 6_450_000);
826 assert_eq!(resp.asset, "USDC");
827 assert_eq!(resp.service_fee_amount, 47_684);
829 assert_eq!(resp.service_fee_asset.as_deref(), Some("USDC"));
830 assert_eq!(resp.service_fee_asset_decimals, Some(6));
831 }
832
833 #[test_all]
834 fn boltz_response_reports_native_sats_fee() {
835 let mut prepared = prepared(CrossChainProviderContext::Boltz {
836 swap_id: "s1".to_string(),
837 invoice: "lnbc200u1pboltz".to_string(),
838 invoice_amount_sats: 5000,
839 max_slippage_bps: 100,
840 });
841 prepared.service_fee_amount = 17;
843 prepared.service_fee_asset = None;
844 prepared.service_fee_asset_decimals = None;
845 let resp = prepare_payment_link_response(prepared, "https://x".to_string(), 5000);
846 assert_eq!(resp.asset, "USDC");
847 assert_eq!(resp.service_fee_amount, 17);
848 assert_eq!(resp.service_fee_asset, None);
849 assert_eq!(resp.service_fee_asset_decimals, None);
850 }
851
852 #[test_all]
853 fn normalize_expires_at_converts_unix_and_passes_rfc3339() {
854 let converted = super::normalize_expires_at("1784895588");
856 assert_ne!(converted, "1784895588");
857 assert!(chrono::DateTime::parse_from_rfc3339(&converted).is_ok());
858 let iso = "2026-07-25T08:09:26.770Z";
860 assert_eq!(super::normalize_expires_at(iso), iso);
861 }
862
863 const COMPRESSED: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
864 const UNCOMPRESSED: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179\
865 8483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
866
867 #[test]
868 fn parse_compressed_public_key_round_trips() {
869 let key = parse_compressed_public_key(COMPRESSED).unwrap();
870 assert_eq!(key.to_string(), COMPRESSED);
871 }
872
873 #[test]
874 fn reject_reserved_namespace_refuses_lnurl_messages() {
875 let messages = [
876 lnurl_models::signed_message::register("lnurl.example.com", "alice", "d", 1),
877 lnurl_models::signed_message::unregister("lnurl.example.com", "alice", 1),
878 lnurl_models::signed_message::recover("lnurl.example.com", COMPRESSED, 1),
879 lnurl_models::signed_message::metadata("lnurl.example.com", COMPRESSED, 1),
880 "BREEZ-LNURL:v2\nregister\n...".to_string(),
881 "breez-lnurl:".to_string(),
882 "breez-lnurl:v3 whatever comes next".to_string(),
883 ];
884 for message in messages {
885 assert!(
886 matches!(
887 reject_reserved_namespace(&message),
888 Err(SdkError::InvalidInput(_))
889 ),
890 "expected {message:?} to be refused"
891 );
892 }
893 }
894
895 #[test]
896 fn reject_reserved_namespace_allows_everything_else() {
897 for message in [
898 "",
899 "hello",
900 "breez-lnur",
901 "breez lnurl:v2",
902 "please sign breez-lnurl:v2\nregister",
904 "🥖",
906 ] {
907 assert!(
908 reject_reserved_namespace(message).is_ok(),
909 "expected {message:?} to be allowed"
910 );
911 }
912 }
913
914 #[test]
915 fn parse_compressed_public_key_rejects_invalid() {
916 for input in ["", "not-a-public-key", UNCOMPRESSED, &COMPRESSED[..64]] {
917 assert!(
918 matches!(
919 parse_compressed_public_key(input),
920 Err(SdkError::InvalidInput(_))
921 ),
922 "expected {input} to be rejected"
923 );
924 }
925 }
926}