1use bitcoin::secp256k1::{PublicKey, ecdsa::Signature};
2use spark_wallet::MasterIdentityPublicKeyUpdate;
3use std::str::FromStr;
4use std::sync::Arc;
5use tracing::{debug, info};
6
7use crate::{
8 BridgeFromCashAppRequest, BridgeFromCashAppResponse, BridgeToCashAppRequest,
9 BridgeToCashAppResponse, CheckMessageRequest, CheckMessageResponse, CrossChainRouteFilter,
10 CrossChainRoutePair, GetTokensMetadataRequest, GetTokensMetadataResponse, InputType,
11 ListFiatCurrenciesResponse, ListFiatRatesResponse, Network, OptimizationMode,
12 OptimizeLeavesRequest, OptimizeLeavesResponse, PreparePaymentLinkRequest,
13 PreparePaymentLinkResponse, RegisterWebhookRequest, RegisterWebhookResponse,
14 SignMessageRequest, SignMessageResponse, UnregisterWebhookRequest, UpdateUserSettingsRequest,
15 UserSettings, Webhook,
16 chain::RecommendedFees,
17 error::SdkError,
18 events::EventListener,
19 issuer::TokenIssuer,
20 models::{
21 GetInfoRequest, GetInfoResponse, SparkMasterIdentityPublicKey, StableBalanceActiveLabel,
22 },
23 persist::ObjectCacheRepository,
24 utils::token::get_tokens_metadata_cached_or_query,
25};
26
27use super::BreezSdk;
28use super::bridging::BridgingClient;
29
30#[macros::instance_span]
31#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
32#[allow(clippy::needless_pass_by_value)]
33impl BreezSdk {
34 pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> String {
49 self.event_emitter.add_external_listener(listener).await
50 }
51
52 pub async fn remove_event_listener(&self, id: &str) -> bool {
62 self.event_emitter.remove_external_listener(id).await
63 }
64
65 pub async fn disconnect(&self) -> Result<(), SdkError> {
77 info!("Disconnecting Breez SDK");
78 self.event_emitter.clear_external_listeners().await;
79 if self.shutdown_sender.send(()).is_err() {
80 debug!("No shutdown receivers; SDK has no background tasks to stop");
86 return Ok(());
87 }
88
89 self.shutdown_sender.closed().await;
90 info!("Breez SDK disconnected");
91 Ok(())
92 }
93
94 pub async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
95 Ok(self.input_parser.parse(input).await?.into())
96 }
97
98 pub async fn get_cross_chain_routes(
112 &self,
113 filter: &CrossChainRouteFilter,
114 ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
115 self.cross_chain_context.list_routes(filter).await
116 }
117
118 #[allow(unused_variables)]
120 pub async fn get_info(&self, request: GetInfoRequest) -> Result<GetInfoResponse, SdkError> {
121 self.runtime.get_info(self, request).await
122 }
123
124 pub async fn list_fiat_currencies(&self) -> Result<ListFiatCurrenciesResponse, SdkError> {
127 let currencies = self
128 .fiat_service
129 .fetch_fiat_currencies()
130 .await?
131 .into_iter()
132 .map(From::from)
133 .collect();
134 Ok(ListFiatCurrenciesResponse { currencies })
135 }
136
137 pub async fn list_fiat_rates(&self) -> Result<ListFiatRatesResponse, SdkError> {
139 let rates = self
140 .fiat_service
141 .fetch_fiat_rates()
142 .await?
143 .into_iter()
144 .map(From::from)
145 .collect();
146 Ok(ListFiatRatesResponse { rates })
147 }
148
149 pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
151 Ok(self.chain_service.recommended_fees().await?)
152 }
153
154 pub async fn get_tokens_metadata(
161 &self,
162 request: GetTokensMetadataRequest,
163 ) -> Result<GetTokensMetadataResponse, SdkError> {
164 let metadata = get_tokens_metadata_cached_or_query(
165 &self.spark_wallet,
166 &ObjectCacheRepository::new(self.storage.clone()),
167 &request
168 .token_identifiers
169 .iter()
170 .map(String::as_str)
171 .collect::<Vec<_>>(),
172 )
173 .await?;
174 Ok(GetTokensMetadataResponse {
175 tokens_metadata: metadata,
176 })
177 }
178
179 pub async fn sign_message(
187 &self,
188 request: SignMessageRequest,
189 ) -> Result<SignMessageResponse, SdkError> {
190 use bitcoin::hex::DisplayHex;
191
192 reject_reserved_namespace(&request.message)?;
193
194 let pubkey = self.spark_wallet.get_identity_public_key().to_string();
195 let signature = self.spark_wallet.sign_message(&request.message).await?;
196 let signature_hex = if request.compact {
197 signature.serialize_compact().to_lower_hex_string()
198 } else {
199 signature.serialize_der().to_lower_hex_string()
200 };
201
202 Ok(SignMessageResponse {
203 pubkey,
204 signature: signature_hex,
205 })
206 }
207
208 pub async fn check_message(
212 &self,
213 request: CheckMessageRequest,
214 ) -> Result<CheckMessageResponse, SdkError> {
215 let pubkey = PublicKey::from_str(&request.pubkey)
216 .map_err(|_| SdkError::InvalidInput("Invalid public key".to_string()))?;
217 let signature_bytes = hex::decode(&request.signature)
218 .map_err(|_| SdkError::InvalidInput("Not a valid hex encoded signature".to_string()))?;
219 let signature = Signature::from_der(&signature_bytes)
220 .or_else(|_| Signature::from_compact(&signature_bytes))
221 .map_err(|_| {
222 SdkError::InvalidInput("Not a valid DER or compact encoded signature".to_string())
223 })?;
224
225 let is_valid = self
226 .spark_wallet
227 .verify_message(&request.message, &signature, &pubkey)
228 .await
229 .is_ok();
230 Ok(CheckMessageResponse { is_valid })
231 }
232
233 pub async fn get_user_settings(&self) -> Result<UserSettings, SdkError> {
237 self.maybe_ensure_spark_private_mode_initialized().await?;
239
240 let spark_user_settings = self.spark_wallet.query_wallet_settings().await?;
241
242 let stable_balance_active_label = match &self.stable_balance {
243 Some(sb) => sb.get_active_label().await,
244 None => None,
245 };
246
247 Ok(UserSettings {
248 spark_private_mode_enabled: spark_user_settings.private_enabled,
249 stable_balance_active_label,
250 spark_master_identity_public_key: spark_user_settings
251 .master_identity_public_key
252 .map(|key| key.to_string()),
253 })
254 }
255
256 pub async fn update_user_settings(
260 &self,
261 request: UpdateUserSettingsRequest,
262 ) -> Result<(), SdkError> {
263 let master_identity_public_key = request
264 .spark_master_identity_public_key
265 .map(|update| match update {
266 SparkMasterIdentityPublicKey::Set { public_key } => {
267 parse_compressed_public_key(&public_key).map(MasterIdentityPublicKeyUpdate::Set)
268 }
269 SparkMasterIdentityPublicKey::Unset => Ok(MasterIdentityPublicKeyUpdate::Clear),
270 })
271 .transpose()?;
272
273 self.spark_wallet
274 .update_wallet_settings(
275 request.spark_private_mode_enabled,
276 master_identity_public_key,
277 )
278 .await?;
279
280 if let Some(active_label) = request.stable_balance_active_label {
281 let sb = self
282 .stable_balance
283 .as_ref()
284 .ok_or_else(|| SdkError::Generic("Stable balance is not configured".to_string()))?;
285 let label = if let StableBalanceActiveLabel::Set { label } = active_label {
286 Some(label)
287 } else {
288 None
289 };
290 sb.set_active_token(label).await?;
291 }
292
293 Ok(())
294 }
295
296 pub fn get_token_issuer(&self) -> TokenIssuer {
298 TokenIssuer::new(self.spark_wallet.clone(), self.storage.clone())
299 }
300
301 pub async fn optimize_leaves(
319 &self,
320 request: OptimizeLeavesRequest,
321 ) -> Result<OptimizeLeavesResponse, SdkError> {
322 let max_rounds = match request.mode {
323 OptimizationMode::Full => None,
324 OptimizationMode::SingleRound => Some(1),
325 };
326 let result = self.spark_wallet.optimize_leaves(max_rounds).await;
327 Ok(OptimizeLeavesResponse {
328 outcome: result?.into(),
329 })
330 }
331
332 pub async fn register_webhook(
346 &self,
347 request: RegisterWebhookRequest,
348 ) -> Result<RegisterWebhookResponse, SdkError> {
349 let event_types = request.event_types.into_iter().map(Into::into).collect();
350 let webhook_id = self
351 .spark_wallet
352 .register_wallet_webhook(&request.url, &request.secret, event_types)
353 .await
354 .map_err(|e| SdkError::Generic(format!("Failed to register webhook: {e}")))?;
355 Ok(RegisterWebhookResponse { webhook_id })
356 }
357
358 pub async fn unregister_webhook(
367 &self,
368 request: UnregisterWebhookRequest,
369 ) -> Result<(), SdkError> {
370 self.spark_wallet
371 .delete_wallet_webhook(&request.webhook_id)
372 .await
373 .map_err(|e| SdkError::Generic(format!("Failed to unregister webhook: {e}")))?;
374 Ok(())
375 }
376
377 pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, SdkError> {
383 let webhooks = self
384 .spark_wallet
385 .list_wallet_webhooks()
386 .await
387 .map_err(|e| SdkError::Generic(format!("Failed to list webhooks: {e}")))?;
388 Ok(webhooks.into_iter().map(Into::into).collect())
389 }
390
391 pub async fn prepare_payment_link(
394 &self,
395 request: PreparePaymentLinkRequest,
396 ) -> Result<PreparePaymentLinkResponse, SdkError> {
397 self.bridge_from_cash_app(request.into())
398 .await
399 .map(Into::into)
400 }
401
402 pub async fn bridge_from_cash_app(
413 &self,
414 request: BridgeFromCashAppRequest,
415 ) -> Result<BridgeFromCashAppResponse, SdkError> {
416 if !matches!(self.config.network, Network::Mainnet) {
417 return Err(SdkError::Generic("Only available on mainnet".to_string()));
418 }
419 self.cross_chain_context.ensure_enabled()?;
420 self.get_bridging_client()?
421 .legacy_bridge_from_cash_app(request)
422 .await
423 }
424
425 pub async fn bridge_to_cash_app(
436 &self,
437 request: BridgeToCashAppRequest,
438 ) -> Result<BridgeToCashAppResponse, SdkError> {
439 if !matches!(self.config.network, Network::Mainnet) {
440 return Err(SdkError::Generic("Only available on mainnet".to_string()));
441 }
442 self.cross_chain_context.ensure_enabled()?;
443 self.get_bridging_client()?
444 .legacy_bridge_to_cash_app(request)
445 .await
446 }
447
448 pub fn get_bridging_client(&self) -> Result<Arc<BridgingClient>, SdkError> {
454 let quoter = self
455 .cross_chain_context
456 .orchestra_quoter()
457 .ok_or_else(|| SdkError::Generic("Only available on mainnet".to_string()))?;
458 Ok(Arc::new(BridgingClient::new(
459 self.config.cross_chain_config.clone(),
460 Arc::clone(quoter),
461 Arc::clone(self.cross_chain_context.fiat_service()),
462 Arc::clone(&self.input_parser),
463 )))
464 }
465}
466
467const RESERVED_PREFIXES: [(&str, &str); 2] = [
470 (
471 lnurl_models::signed_message::RESERVED_NAMESPACE,
472 "Lightning address requests",
473 ),
474 (
475 flashnet::rewards::ENDORSEMENT_PAYLOAD_PREFIX,
476 "Flashnet rewards endorsements",
477 ),
478];
479
480fn reject_reserved_namespace(message: &str) -> Result<(), SdkError> {
483 for (prefix, purpose) in RESERVED_PREFIXES {
484 let reserved = message
485 .as_bytes()
486 .get(..prefix.len())
487 .is_some_and(|start| start.eq_ignore_ascii_case(prefix.as_bytes()));
488 if reserved {
489 return Err(SdkError::InvalidInput(format!(
490 "messages starting with '{prefix}' are reserved for {purpose} and cannot be signed here"
491 )));
492 }
493 }
494 Ok(())
495}
496
497fn parse_compressed_public_key(hex_encoded: &str) -> Result<PublicKey, SdkError> {
503 let invalid = || SdkError::InvalidInput("Invalid master identity public key".to_string());
504 let bytes: [u8; 33] = hex::decode(hex_encoded)
505 .map_err(|_| invalid())?
506 .try_into()
507 .map_err(|_| invalid())?;
508 PublicKey::from_slice(&bytes).map_err(|_| invalid())
509}
510
511#[cfg(test)]
512mod tests {
513 use super::{SdkError, parse_compressed_public_key, reject_reserved_namespace};
514
515 #[cfg(feature = "browser-tests")]
516 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
517
518 const COMPRESSED: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
519 const UNCOMPRESSED: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179\
520 8483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
521
522 #[test]
523 fn parse_compressed_public_key_round_trips() {
524 let key = parse_compressed_public_key(COMPRESSED).unwrap();
525 assert_eq!(key.to_string(), COMPRESSED);
526 }
527
528 #[test]
529 fn reject_reserved_namespace_refuses_lnurl_messages() {
530 let messages = [
531 lnurl_models::signed_message::register("lnurl.example.com", "alice", Some("d"), 1),
532 lnurl_models::signed_message::unregister("lnurl.example.com", "alice", 1),
533 lnurl_models::signed_message::recover("lnurl.example.com", COMPRESSED, 1),
534 lnurl_models::signed_message::metadata("lnurl.example.com", COMPRESSED, 1),
535 "BREEZ-LNURL:v2\nregister\n...".to_string(),
536 "breez-lnurl:".to_string(),
537 "breez-lnurl:v3 whatever comes next".to_string(),
538 ];
539 for message in messages {
540 assert!(
541 matches!(
542 reject_reserved_namespace(&message),
543 Err(SdkError::InvalidInput(_))
544 ),
545 "expected {message:?} to be refused"
546 );
547 }
548 }
549
550 #[test]
551 fn reject_reserved_namespace_refuses_rewards_endorsements() {
552 for message in [
553 "kind: rewards_endorsement\nversion: 1\naction: upsert\n...",
554 "KIND: REWARDS_ENDORSEMENT\naction: delete",
555 "kind: rewards_endorsement",
556 ] {
557 assert!(
558 matches!(
559 reject_reserved_namespace(message),
560 Err(SdkError::InvalidInput(_))
561 ),
562 "expected {message:?} to be refused"
563 );
564 }
565 }
566
567 #[test]
568 fn reject_reserved_namespace_allows_everything_else() {
569 for message in [
570 "",
571 "hello",
572 "breez-lnur",
573 "breez lnurl:v2",
574 "please sign breez-lnurl:v2\nregister",
576 "please sign kind: rewards_endorsement",
577 "kind: rewards",
578 "🥖",
580 ] {
581 assert!(
582 reject_reserved_namespace(message).is_ok(),
583 "expected {message:?} to be allowed"
584 );
585 }
586 }
587
588 #[test]
589 fn parse_compressed_public_key_rejects_invalid() {
590 for input in ["", "not-a-public-key", UNCOMPRESSED, &COMPRESSED[..64]] {
591 assert!(
592 matches!(
593 parse_compressed_public_key(input),
594 Err(SdkError::InvalidInput(_))
595 ),
596 "expected {input} to be rejected"
597 );
598 }
599 }
600}