1mod models;
5mod orchestra;
6
7pub use models::*;
8
9use std::collections::HashSet;
10use std::sync::Arc;
11
12use breez_sdk_common::{
13 breez_server::{BreezServer, PRODUCTION_BREEZSERVER_URL},
14 buy::cashapp::{CashAppProvider, cash_app_lightning_address},
15 fiat::FiatService,
16 input::{CrossChainAddressFamily, detect_address_family},
17 lnurl::pay::validate_user_input,
18};
19
20use crate::{
21 BitcoinNetwork, BridgeFromCashAppRequest, BridgeFromCashAppResponse, BridgeToCashAppRequest,
22 BridgeToCashAppResponse, CrossChainAcceptedAsset, CrossChainAddressDetails, CrossChainConfig,
23 CrossChainProvider, CrossChainReceiveInfo, CrossChainRoutePair, DeliveryMethod, FeePolicy,
24 InputType, LightningAddressDetails, SparkAddressDetails, SparkAsset,
25 cross_chain::{
26 BreezServerOrchestraConfigResolver, CachedFiatService, CrossChainContext,
27 CrossChainFeeMode, CrossChainProviderContext, CrossChainReceivePrepared,
28 CrossChainSendPrepared, CrossChainService, DEFAULT_FIAT_CACHE_TTL, DepositDestination,
29 OrchestraQuoter, convert_destination_amount_to_sats, delivery_method_from_wire,
30 delivery_method_to_wire, fetch_btc_usd_rate, is_usd_stable_asset,
31 parse_rfc3339_to_unix_seconds, rescale_decimals,
32 },
33 error::SdkError,
34};
35
36use self::orchestra::{bridge_status_response, to_bridge_route_pair};
37use super::SdkInputParser;
38use super::helpers::{partner_id, validate_breez_api_key};
39use super::payments::receive::{
40 quote_cross_chain_receive, resolve_cross_chain_receive_params, validate_receive_destination,
41};
42use super::payments::validation::{
43 known_token_contracts, resolve_direct_overpay_amount, resolve_slippage_bps,
44 validate_address_family_against_route, validate_amount,
45 validate_recipient_not_contract_address,
46};
47
48#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
53pub struct BridgingClient {
54 cross_chain_config: Option<CrossChainConfig>,
55 cross_chain_context: CrossChainContext,
56 quoter: Arc<OrchestraQuoter>,
57 input_parser: Arc<SdkInputParser>,
58}
59
60#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
62pub async fn new_bridging_client(config: BridgingConfig) -> Result<Arc<BridgingClient>, SdkError> {
63 validate_breez_api_key(&config.api_key)?;
64 if let Some(proxy) = &config.proxy {
65 proxy.validate()?;
66 }
67 if let Some(cross_chain_config) = &config.cross_chain_config {
68 cross_chain_config.validate()?;
69 }
70
71 let user_agent = crate::default_user_agent();
72 let transport_proxy = config.proxy.as_ref().map(platform_utils::ProxyConfig::from);
73 let http_client =
74 platform_utils::create_http_client_with_proxy(Some(&user_agent), transport_proxy.as_ref())
75 .map_err(|e| {
76 SdkError::InvalidInput(format!("Failed to build proxied HTTP client: {e}"))
77 })?;
78 let breez_server = Arc::new(
79 BreezServer::new(
80 PRODUCTION_BREEZSERVER_URL,
81 None,
82 &user_agent,
83 transport_proxy.as_ref(),
84 )
85 .map_err(|e| SdkError::Generic(e.to_string()))?,
86 );
87 let lnurl_http_client: Arc<dyn platform_utils::HttpClient> =
90 platform_utils::create_http_client_with_redirect_filter(
91 Some(&user_agent),
92 transport_proxy.as_ref(),
93 breez_sdk_common::lnurl::security::lnurl_redirect_filter(),
94 )
95 .map_err(|e| SdkError::Generic(format!("Failed to build LNURL HTTP client: {e}")))?;
96 let input_parser = Arc::new(SdkInputParser::new(
97 breez_sdk_common::dns::Resolver::with_proxy(transport_proxy.as_ref())
98 .map_err(|e| SdkError::Generic(format!("Failed to build DNS resolver: {e}")))?,
99 lnurl_http_client,
100 None,
101 ));
102 let fiat_service: Arc<dyn FiatService> = Arc::new(CachedFiatService::new(
103 Arc::clone(&breez_server) as Arc<dyn FiatService>,
104 DEFAULT_FIAT_CACHE_TTL,
105 ));
106 let quoter = Arc::new(OrchestraQuoter::new(
107 Arc::new(BreezServerOrchestraConfigResolver::new(breez_server)),
108 partner_id(&config.api_key),
109 Arc::clone(&fiat_service),
110 http_client,
111 ));
112 Ok(Arc::new(BridgingClient::new(
113 config.cross_chain_config,
114 quoter,
115 fiat_service,
116 input_parser,
117 )))
118}
119
120#[cfg_attr(feature = "uniffi", uniffi::export)]
122pub fn default_bridging_config(api_key: String) -> BridgingConfig {
123 BridgingConfig {
124 api_key,
125 proxy: None,
126 cross_chain_config: None,
127 }
128}
129
130impl BridgingClient {
131 pub(crate) fn new(
132 cross_chain_config: Option<CrossChainConfig>,
133 quoter: Arc<OrchestraQuoter>,
134 fiat_service: Arc<dyn FiatService>,
135 input_parser: Arc<SdkInputParser>,
136 ) -> Self {
137 let mut cross_chain_context = CrossChainContext::new(fiat_service);
138 cross_chain_context.insert(
139 CrossChainProvider::Orchestra,
140 Arc::clone("er) as Arc<dyn CrossChainService>,
141 );
142 Self {
143 cross_chain_config,
144 cross_chain_context,
145 quoter,
146 input_parser,
147 }
148 }
149}
150
151#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
152impl BridgingClient {
153 pub async fn get_routes(
155 &self,
156 request: GetBridgeRoutesRequest,
157 ) -> Result<GetBridgeRoutesResponse, SdkError> {
158 let recipient = self.parse_recipient(&request.recipient).await?;
159 let mut routes = match &recipient {
160 BridgeRecipient::Spark(_) => {
161 self.listed_routes(delivery_method_to_wire(DeliveryMethod::Spark), false)
162 .await?
163 }
164 BridgeRecipient::Lightning(_) => {
165 self.listed_routes(delivery_method_to_wire(DeliveryMethod::Lightning), false)
166 .await?
167 }
168 BridgeRecipient::CrossChain(_) => self.routes_to_other_chain(&recipient).await?,
169 };
170 routes.retain(|route| route_pays(&recipient, route));
171 let mut seen = HashSet::new();
172 routes.retain(|route| seen.insert((route.source.clone(), route.destination.clone())));
173 routes.sort_by(|a, b| {
174 (
175 &a.source.asset,
176 &a.source.chain,
177 &a.destination.asset,
178 &a.destination.chain,
179 )
180 .cmp(&(
181 &b.source.asset,
182 &b.source.chain,
183 &b.destination.asset,
184 &b.destination.chain,
185 ))
186 });
187 Ok(GetBridgeRoutesResponse { routes })
188 }
189
190 pub async fn create_bridge(
194 &self,
195 request: CreateBridgeRequest,
196 ) -> Result<CreateBridgeResponse, SdkError> {
197 validate_amount(Some(request.amount))?;
198 if !matches!(request.route.provider, CrossChainProvider::Orchestra) {
199 return Err(SdkError::InvalidInput(
200 "Bridging is only supported on Orchestra routes".to_string(),
201 ));
202 }
203 let recipient = self.parse_recipient(&request.recipient).await?;
204 self.create_bridge_for(&recipient, request).await
205 }
206
207 pub async fn get_bridge_status(
209 &self,
210 request: GetBridgeStatusRequest,
211 ) -> Result<GetBridgeStatusResponse, SdkError> {
212 if request.bridge_id.trim().is_empty() {
213 return Err(SdkError::InvalidInput("Bridge id is empty".to_string()));
214 }
215 let status_token = request
216 .status_token
217 .as_deref()
218 .map(str::trim)
219 .filter(|token| !token.is_empty());
220 let order = self
221 .quoter
222 .find_order(request.bridge_id.trim(), status_token)
223 .await?;
224 Ok(bridge_status_response(order.as_ref()))
225 }
226
227 pub async fn bridge_from_cash_app(
231 &self,
232 request: CreateBridgeRequest,
233 ) -> Result<CreateBridgeResponse, SdkError> {
234 if delivery_method(&request.route.source) != Some(DeliveryMethod::Lightning) {
235 return Err(SdkError::InvalidInput(
236 "Cash App pays a recipient on another chain over Lightning".to_string(),
237 ));
238 }
239 let mut response = self.create_bridge(request).await?;
240 response.payment_request = CashAppProvider::build_url(&response.info.deposit_address);
241 Ok(response)
242 }
243
244 pub async fn bridge_to_cash_app(
248 &self,
249 request: CreateBridgeRequest,
250 ) -> Result<CreateBridgeResponse, SdkError> {
251 let Some(address) = cash_app_lightning_address(request.recipient.trim()) else {
252 return Err(SdkError::InvalidInput(format!(
253 "{} is not a Cash App username",
254 request.recipient
255 )));
256 };
257 self.create_bridge(CreateBridgeRequest {
258 recipient: address,
259 ..request
260 })
261 .await
262 }
263}
264
265impl BridgingClient {
266 pub(crate) async fn legacy_bridge_from_cash_app(
268 &self,
269 request: BridgeFromCashAppRequest,
270 ) -> Result<BridgeFromCashAppResponse, SdkError> {
271 validate_amount(Some(request.amount))?;
272
273 let BridgeFromCashAppRequest {
274 address,
275 route,
276 amount,
277 fee_policy,
278 max_slippage_bps,
279 } = request;
280
281 if !matches!(route.provider, CrossChainProvider::Orchestra) {
285 return Err(SdkError::InvalidInput(
286 "Bridging from Cash App is only supported on Orchestra routes".to_string(),
287 ));
288 }
289
290 if !route_supports_delivery_method(&route, DeliveryMethod::Lightning) {
295 return Err(SdkError::InvalidInput(
296 "The selected route can't be funded over Lightning".to_string(),
297 ));
298 }
299
300 let InputType::CrossChainAddress(recipient) = self.parse(&address).await? else {
303 return Err(SdkError::InvalidInput(
304 "Recipient must be a cross-chain (EVM/Solana/Tron) address".to_string(),
305 ));
306 };
307 self.validate_cross_chain_recipient(&recipient, &route)
308 .await?;
309
310 let (prepared, deposit_target, amount_sats) = self
311 .quote_lightning_funded(
312 &recipient.address,
313 &route,
314 amount,
315 fee_policy,
316 max_slippage_bps,
317 )
318 .await?;
319
320 let url = CashAppProvider::build_url(&deposit_target);
321
322 bridge_from_cash_app_response(prepared, url, amount_sats)
323 }
324
325 pub(crate) async fn legacy_bridge_to_cash_app(
327 &self,
328 request: BridgeToCashAppRequest,
329 ) -> Result<BridgeToCashAppResponse, SdkError> {
330 validate_amount(Some(request.amount))?;
331
332 let BridgeToCashAppRequest {
333 recipient,
334 route,
335 amount,
336 fee_policy,
337 refund_address,
338 max_slippage_bps,
339 } = request;
340
341 if !matches!(route.provider, CrossChainProvider::Orchestra) {
344 return Err(SdkError::InvalidInput(
345 "Bridging to Cash App is only supported on Orchestra routes".to_string(),
346 ));
347 }
348 if !route_supports_delivery_method(&route, DeliveryMethod::Lightning) {
349 return Err(SdkError::InvalidInput(
350 "The selected route can't deliver over Lightning".to_string(),
351 ));
352 }
353
354 let Some(address) = cash_app_lightning_address(recipient.trim()) else {
355 return Err(SdkError::InvalidInput(format!(
356 "{recipient} is not a Cash App username"
357 )));
358 };
359 let Ok(InputType::LightningAddress(recipient)) = self.parse(&address).await else {
362 return Err(SdkError::InvalidInput(format!(
363 "{recipient} is not a reachable Cash App account"
364 )));
365 };
366
367 let refund = self
368 .validate_refund_address(Some(&refund_address), &route)
369 .await?;
370
371 let prepared = self
372 .quote_external_funded(
373 &route,
374 amount,
375 SparkAsset::Bitcoin,
376 DeliveryMethod::Lightning,
377 &recipient.address,
378 refund.as_deref(),
379 fee_policy,
380 max_slippage_bps,
381 )
382 .await?;
383 check_lightning_address_accepts(&recipient, prepared.info.expected_received_amount)?;
384
385 Ok(BridgeToCashAppResponse {
386 payment_request: prepared.payment_request,
387 info: prepared.info,
388 })
389 }
390
391 async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
392 Ok(self.input_parser.parse(input).await?.into())
393 }
394
395 async fn parse_recipient(&self, input: &str) -> Result<BridgeRecipient, SdkError> {
398 match self.parse(input).await? {
399 InputType::SparkAddress(details) => {
400 validate_spark_recipient(&details)?;
401 Ok(BridgeRecipient::Spark(details))
402 }
403 InputType::LightningAddress(details) => Ok(BridgeRecipient::Lightning(details)),
404 InputType::CrossChainAddress(details) => Ok(BridgeRecipient::CrossChain(details)),
405 _ => Err(SdkError::InvalidInput(
406 "A bridge can only pay a Spark address, a Lightning address, or an \
407 EVM/Solana/Tron address"
408 .to_string(),
409 )),
410 }
411 }
412
413 async fn create_bridge_for(
415 &self,
416 recipient: &BridgeRecipient,
417 request: CreateBridgeRequest,
418 ) -> Result<CreateBridgeResponse, SdkError> {
419 let CreateBridgeRequest {
420 route,
421 amount,
422 refund_address,
423 fee_policy,
424 max_slippage_bps,
425 ..
426 } = request;
427 if !route_pays(recipient, &route) {
428 return Err(SdkError::InvalidInput(
429 "The route doesn't pay this recipient".to_string(),
430 ));
431 }
432 match recipient {
433 BridgeRecipient::Spark(details) => validate_spark_recipient(details)?,
434 BridgeRecipient::Lightning(_) if refund_address.is_none() => {
436 return Err(SdkError::InvalidInput(
437 "A refund address is required to pay a Lightning address".to_string(),
438 ));
439 }
440 BridgeRecipient::Lightning(_) | BridgeRecipient::CrossChain(_) => {}
441 }
442 let listed = self.listed_routes(&route.destination.chain, false).await?;
445 if !is_listed(&listed, &route) {
446 return Err(SdkError::InvalidInput(
447 "The provider doesn't serve this route".to_string(),
448 ));
449 }
450
451 match recipient {
452 BridgeRecipient::CrossChain(details) => {
453 self.create_bridge_to_other_chain(
454 details,
455 &route,
456 amount,
457 refund_address.as_deref(),
458 fee_policy,
459 max_slippage_bps,
460 )
461 .await
462 }
463 BridgeRecipient::Spark(details) => {
464 let destination = spark_asset(&route.destination);
465 let source = external_pair(
466 &route,
467 &route.source,
468 Some((destination.clone(), DeliveryMethod::Spark)),
469 );
470 let refund = self
471 .validate_refund_address(refund_address.as_deref(), &source)
472 .await?;
473 let prepared = self
474 .quote_external_funded(
475 &source,
476 source_units(&route, amount)?,
477 destination,
478 DeliveryMethod::Spark,
479 &details.address,
480 refund.as_deref(),
481 fee_policy,
482 max_slippage_bps,
483 )
484 .await?;
485 Ok(create_bridge_response(prepared))
486 }
487 BridgeRecipient::Lightning(details) => {
488 let source = external_pair(
489 &route,
490 &route.source,
491 Some((SparkAsset::Bitcoin, DeliveryMethod::Lightning)),
492 );
493 let refund = self
494 .validate_refund_address(refund_address.as_deref(), &source)
495 .await?;
496 let prepared = self
497 .quote_external_funded(
498 &source,
499 amount,
500 SparkAsset::Bitcoin,
501 DeliveryMethod::Lightning,
502 &details.address,
503 refund.as_deref(),
504 fee_policy,
505 max_slippage_bps,
506 )
507 .await?;
508 check_lightning_address_accepts(details, prepared.info.expected_received_amount)?;
509 Ok(create_bridge_response(prepared))
510 }
511 }
512 }
513
514 fn default_slippage_bps(&self) -> Option<u32> {
515 self.cross_chain_config
516 .as_ref()
517 .and_then(|c| c.default_slippage_bps)
518 }
519
520 fn default_target_overpay_bps(&self) -> Option<u32> {
521 self.cross_chain_config
522 .as_ref()
523 .and_then(|c| c.default_target_overpay_bps)
524 }
525
526 async fn validate_cross_chain_recipient(
529 &self,
530 recipient: &CrossChainAddressDetails,
531 route: &CrossChainRoutePair,
532 ) -> Result<(), SdkError> {
533 let recipient_family: CrossChainAddressFamily = recipient.address_family.into();
534 validate_address_family_against_route(recipient_family, route)?;
535 let known_contracts = known_token_contracts(
536 &self.cross_chain_context,
537 &recipient.address,
538 recipient_family,
539 route,
540 )
541 .await;
542 validate_recipient_not_contract_address(
543 &recipient.address,
544 recipient_family,
545 &known_contracts,
546 )
547 }
548
549 async fn validate_refund_address(
552 &self,
553 refund_address: Option<&str>,
554 route: &CrossChainRoutePair,
555 ) -> Result<Option<String>, SdkError> {
556 let Some(refund_address) = refund_address else {
557 return Ok(None);
558 };
559 let InputType::CrossChainAddress(refund) = self.parse(refund_address).await? else {
560 return Err(SdkError::InvalidInput(
561 "Refund address must be a cross-chain (EVM/Solana/Tron) address".to_string(),
562 ));
563 };
564 let refund_family: CrossChainAddressFamily = refund.address_family.into();
565 validate_address_family_against_route(refund_family, route)?;
566 let known_contracts = known_token_contracts(
567 &self.cross_chain_context,
568 &refund.address,
569 refund_family,
570 route,
571 )
572 .await;
573 validate_recipient_not_contract_address(&refund.address, refund_family, &known_contracts)
574 .map_err(|_| {
575 SdkError::InvalidInput(
576 "Refund address is a token contract. Funds refunded to it cannot be \
577 recovered. Use the payer's wallet address instead."
578 .to_string(),
579 )
580 })?;
581 Ok(Some(refund.address))
582 }
583
584 async fn quote_lightning_funded(
588 &self,
589 recipient_address: &str,
590 route: &CrossChainRoutePair,
591 amount: u128,
592 fee_policy: Option<FeePolicy>,
593 max_slippage_bps: Option<u32>,
594 ) -> Result<(CrossChainSendPrepared, String, u64), SdkError> {
595 let service = self.cross_chain_context.get(route.provider)?;
596 let slippage_bps = resolve_slippage_bps(max_slippage_bps, self.default_slippage_bps())?;
597
598 let btc_usd = fetch_btc_usd_rate(self.cross_chain_context.fiat_service().as_ref()).await?;
603 let source_sats =
604 convert_destination_amount_to_sats(amount, btc_usd, route.decimals.into())?;
605 if source_sats == 0 {
606 return Err(SdkError::InvalidInput(
607 "Amount is too small to fund over Lightning".to_string(),
608 ));
609 }
610
611 let fee_policy = fee_policy.unwrap_or_default();
615 let overpay_bps = crate::cross_chain::resolve_target_overpay_bps(
616 None,
617 self.default_target_overpay_bps(),
618 )?;
619 let source_sats = resolve_direct_overpay_amount(source_sats, fee_policy, overpay_bps);
620
621 let prepared = service
622 .prepare_send(
623 recipient_address,
624 route,
625 source_sats,
626 Some(DeliveryMethod::Lightning),
627 None,
628 slippage_bps,
629 fee_policy.into(),
630 )
631 .await?;
632
633 let amount_sats = u64::try_from(prepared.amount_in).map_err(|_| {
634 SdkError::Generic(format!(
635 "Deposit amount {} sats exceeds u64::MAX",
636 prepared.amount_in
637 ))
638 })?;
639
640 let deposit_target = deposit_target(&prepared.provider_context);
645
646 let parsed_target = self.parse(&deposit_target).await;
649 check_deposit_target(&parsed_target, amount_sats)?;
650
651 Ok((prepared, deposit_target, amount_sats))
652 }
653
654 #[allow(clippy::too_many_arguments)]
657 async fn quote_external_funded(
658 &self,
659 route: &CrossChainRoutePair,
660 amount: u128,
661 destination: SparkAsset,
662 delivery_method: DeliveryMethod,
663 recipient: &str,
664 refund_address: Option<&str>,
665 fee_policy: Option<FeePolicy>,
666 max_slippage_bps: Option<u32>,
667 ) -> Result<CrossChainReceivePrepared, SdkError> {
668 let params = resolve_cross_chain_receive_params(
669 &self.cross_chain_context,
670 self.cross_chain_config.as_ref(),
671 route,
672 amount,
673 delivery_method,
674 fee_policy.map(Into::into),
675 max_slippage_bps,
676 None,
677 )?;
678 let destination = validate_receive_destination(route, destination)?;
679 quote_cross_chain_receive(
680 &self.cross_chain_context,
681 route,
682 amount,
683 &destination,
684 ¶ms,
685 recipient,
686 refund_address,
687 )
688 .await
689 }
690
691 async fn create_bridge_to_other_chain(
694 &self,
695 recipient: &CrossChainAddressDetails,
696 route: &BridgeRoutePair,
697 amount: u128,
698 refund_address: Option<&str>,
699 fee_policy: Option<FeePolicy>,
700 max_slippage_bps: Option<u32>,
701 ) -> Result<CreateBridgeResponse, SdkError> {
702 if delivery_method(&route.source) == Some(DeliveryMethod::Lightning) {
703 let destination = external_pair(
704 route,
705 &route.destination,
706 Some((SparkAsset::Bitcoin, DeliveryMethod::Lightning)),
707 );
708 self.validate_cross_chain_recipient(recipient, &destination)
709 .await?;
710 let (prepared, deposit_target, amount_sats) = self
711 .quote_lightning_funded(
712 &recipient.address,
713 &destination,
714 amount,
715 fee_policy,
716 max_slippage_bps,
717 )
718 .await?;
719 return lightning_funded_bridge_response(prepared, deposit_target, amount_sats);
720 }
721
722 let source = external_pair(route, &route.source, None);
723 let refund = self
724 .validate_refund_address(refund_address, &source)
725 .await?;
726 self.validate_cross_chain_recipient(
727 recipient,
728 &external_pair(route, &route.destination, None),
729 )
730 .await?;
731 let prepared = self
732 .quote_chain_to_chain(
733 route,
734 &source,
735 amount,
736 &recipient.address,
737 refund.as_deref(),
738 fee_policy,
739 max_slippage_bps,
740 )
741 .await?;
742 Ok(create_bridge_response(prepared))
743 }
744
745 #[allow(clippy::too_many_arguments)]
749 async fn quote_chain_to_chain(
750 &self,
751 route: &BridgeRoutePair,
752 source: &CrossChainRoutePair,
753 amount: u128,
754 recipient: &str,
755 refund_address: Option<&str>,
756 fee_policy: Option<FeePolicy>,
757 max_slippage_bps: Option<u32>,
758 ) -> Result<CrossChainReceivePrepared, SdkError> {
759 let slippage_bps = resolve_slippage_bps(max_slippage_bps, self.default_slippage_bps())?;
760 let overpay_bps = crate::cross_chain::resolve_target_overpay_bps(
761 None,
762 self.default_target_overpay_bps(),
763 )?;
764 let fee_mode: CrossChainFeeMode = fee_policy.unwrap_or_default().into();
765 let provider_amount = match fee_mode {
767 CrossChainFeeMode::FeesExcluded => amount,
768 CrossChainFeeMode::FeesIncluded => source_units(route, amount)?,
769 };
770 if provider_amount == 0 {
771 return Err(SdkError::InvalidInput(
772 "Amount is too small for this route".to_string(),
773 ));
774 }
775 let destination = DepositDestination {
776 chain: route.destination.chain.clone(),
777 asset: route.destination.asset.clone(),
778 decimals: route.destination.decimals.into(),
779 token_identifier: None,
780 };
781 let quote = self
782 .quoter
783 .quote_deposit(
784 source,
785 &destination,
786 route.limits.as_ref(),
787 recipient,
788 provider_amount,
789 slippage_bps,
790 fee_mode,
791 overpay_bps,
792 refund_address,
793 )
794 .await?;
795 Ok(quote.prepared)
796 }
797
798 async fn listed_routes(
801 &self,
802 chain: &str,
803 is_send: bool,
804 ) -> Result<Vec<BridgeRoutePair>, SdkError> {
805 let routes = self.quoter.route_listing(chain, is_send).await?;
806 Ok(routes.iter().map(to_bridge_route_pair).collect())
807 }
808
809 async fn routes_to_other_chain(
812 &self,
813 recipient: &BridgeRecipient,
814 ) -> Result<Vec<BridgeRoutePair>, SdkError> {
815 let mut routes = self
816 .listed_routes(delivery_method_to_wire(DeliveryMethod::Lightning), true)
817 .await?;
818 routes.retain(|route| route_pays(recipient, route));
819 let mut chains: Vec<String> = routes.iter().map(|r| r.destination.chain.clone()).collect();
820 chains.sort();
821 chains.dedup();
822 let listings =
823 futures::future::join_all(chains.iter().map(|chain| self.listed_routes(chain, false)))
824 .await;
825 for (chain, listing) in chains.iter().zip(listings) {
826 match listing {
827 Ok(listed) => routes.extend(listed),
828 Err(e) => tracing::warn!("Bridging: listing the routes into {chain} failed: {e}"),
829 }
830 }
831 Ok(routes)
832 }
833}
834
835#[derive(Debug, Clone)]
837enum BridgeRecipient {
838 Spark(SparkAddressDetails),
840 Lightning(LightningAddressDetails),
842 CrossChain(CrossChainAddressDetails),
844}
845
846fn route_pays(recipient: &BridgeRecipient, route: &BridgeRoutePair) -> bool {
851 let (source, destination) = (&route.source, &route.destination);
852 match recipient {
853 BridgeRecipient::Spark(_) => {
854 is_deposit(source)
855 && delivery_method(destination) == Some(DeliveryMethod::Spark)
856 && (is_bitcoin(destination)
857 || (destination.contract_address.is_some()
858 && is_usd_stable_asset(&destination.asset)))
859 }
860 BridgeRecipient::Lightning(_) => {
861 is_deposit(source) && delivery_method(destination) == Some(DeliveryMethod::Lightning)
862 }
863 BridgeRecipient::CrossChain(details) => {
864 (is_deposit(source) || delivery_method(source) == Some(DeliveryMethod::Lightning))
865 && pays_address(destination, details)
866 }
867 }
868}
869
870fn is_listed(listed: &[BridgeRoutePair], route: &BridgeRoutePair) -> bool {
872 listed
873 .iter()
874 .any(|r| r.source == route.source && r.destination == route.destination)
875}
876
877fn delivery_method(side: &BridgeRoutePairSide) -> Option<DeliveryMethod> {
880 delivery_method_from_wire(&side.chain)
881}
882
883fn is_bitcoin(side: &BridgeRoutePairSide) -> bool {
884 side.asset.eq_ignore_ascii_case("BTC")
885}
886
887fn is_deposit(side: &BridgeRoutePairSide) -> bool {
890 delivery_method(side).is_none()
891 && is_usd_stable_asset(&side.asset)
892 && side
893 .contract_address
894 .as_deref()
895 .and_then(detect_address_family)
896 .is_some()
897}
898
899fn pays_address(side: &BridgeRoutePairSide, recipient: &CrossChainAddressDetails) -> bool {
902 let family: CrossChainAddressFamily = recipient.address_family.into();
903 delivery_method(side).is_none()
904 && is_usd_stable_asset(&side.asset)
905 && family.matches_chain(&side.chain, side.contract_address.as_deref())
906 && recipient.contract_address.as_deref().is_none_or(|wanted| {
907 side.contract_address
908 .as_deref()
909 .is_some_and(|contract| family.addresses_equal(contract, wanted))
910 })
911 && recipient.chain_id.is_none_or(|wanted| {
912 side.chain_id
913 .as_deref()
914 .and_then(|id| id.parse::<u64>().ok())
915 == Some(wanted)
916 })
917}
918
919fn spark_asset(side: &BridgeRoutePairSide) -> SparkAsset {
921 match &side.contract_address {
922 Some(token_identifier) if !is_bitcoin(side) => SparkAsset::Token {
923 token_identifier: token_identifier.clone(),
924 },
925 _ => SparkAsset::Bitcoin,
926 }
927}
928
929fn source_units(route: &BridgeRoutePair, amount: u128) -> Result<u128, SdkError> {
932 if is_bitcoin(&route.destination) {
933 return Ok(amount);
934 }
935 rescale_decimals(
936 amount,
937 route.destination.decimals.into(),
938 route.source.decimals.into(),
939 )
940}
941
942fn external_pair(
946 route: &BridgeRoutePair,
947 side: &BridgeRoutePairSide,
948 local: Option<(SparkAsset, DeliveryMethod)>,
949) -> CrossChainRoutePair {
950 let (accepted_assets, delivery_methods) = match local {
951 Some((asset, delivery_method)) => (
952 vec![CrossChainAcceptedAsset {
953 asset,
954 limits: route.limits.clone(),
955 }],
956 vec![delivery_method],
957 ),
958 None => (Vec::new(), Vec::new()),
959 };
960 CrossChainRoutePair {
961 provider: route.provider,
962 chain: side.chain.clone(),
963 chain_id: side.chain_id.clone(),
964 asset: side.asset.clone(),
965 contract_address: side.contract_address.clone(),
966 decimals: side.decimals,
967 exact_out_eligible: false,
968 accepted_assets,
969 delivery_methods,
970 }
971}
972
973fn validate_spark_recipient(details: &crate::SparkAddressDetails) -> Result<(), SdkError> {
975 if details.network != BitcoinNetwork::Bitcoin {
976 return Err(SdkError::InvalidInput(
977 "The Spark address is not a mainnet address".to_string(),
978 ));
979 }
980 Ok(())
981}
982
983fn check_lightning_address_accepts(
986 recipient: &LightningAddressDetails,
987 expected_sats: u128,
988) -> Result<(), SdkError> {
989 let amount_msat = u64::try_from(expected_sats)
990 .ok()
991 .and_then(|sats| sats.checked_mul(1000))
992 .ok_or_else(|| SdkError::InvalidInput("Amount is too large".to_string()))?;
993 validate_user_input(
994 amount_msat,
995 &None,
996 recipient.pay_request.min_sendable,
997 recipient.pay_request.max_sendable,
998 0,
999 )
1000 .map_err(|e| SdkError::InvalidInput(e.to_string()))
1001}
1002
1003fn create_bridge_response(prepared: CrossChainReceivePrepared) -> CreateBridgeResponse {
1004 CreateBridgeResponse {
1005 bridge_id: prepared.info.quote_id.clone(),
1006 status_token: prepared.quote_read_token,
1007 payment_request: prepared.payment_request,
1008 info: prepared.info,
1009 }
1010}
1011
1012fn lightning_funded_bridge_response(
1016 prepared: CrossChainSendPrepared,
1017 deposit_target: String,
1018 amount_sats: u64,
1019) -> Result<CreateBridgeResponse, SdkError> {
1020 let CrossChainProviderContext::Orchestra { quote_id, .. } = prepared.provider_context else {
1021 return Err(SdkError::Generic(
1022 "Bridging is only supported on Orchestra routes".to_string(),
1023 ));
1024 };
1025 Ok(CreateBridgeResponse {
1026 bridge_id: quote_id.clone(),
1027 status_token: prepared.quote_read_token,
1028 payment_request: deposit_target.clone(),
1029 info: CrossChainReceiveInfo {
1030 quote_id,
1031 deposit_address: deposit_target,
1032 deposit_amount: u128::from(amount_sats),
1033 expected_received_amount: prepared.estimated_out,
1034 destination_asset: prepared.pair.asset,
1035 token_identifier: None,
1036 service_fee_amount: prepared.service_fee_amount,
1037 service_fee_asset: prepared.service_fee_asset,
1038 service_fee_asset_decimals: prepared.service_fee_asset_decimals,
1039 expires_at: parse_rfc3339_to_unix_seconds(&prepared.expires_at)?,
1040 },
1041 })
1042}
1043
1044fn route_supports_delivery_method(route: &CrossChainRoutePair, required: DeliveryMethod) -> bool {
1048 route.delivery_methods.contains(&required)
1049}
1050
1051pub(super) fn check_deposit_target(
1055 parsed_target: &Result<InputType, SdkError>,
1056 expected_sats: u64,
1057) -> Result<(), SdkError> {
1058 let Ok(InputType::Bolt11Invoice(details)) = parsed_target else {
1059 return Err(SdkError::Generic(
1060 "The provider returned a deposit target that is not a valid Lightning \
1061 payment request"
1062 .to_string(),
1063 ));
1064 };
1065 if details.amount_msat.map(u128::from) != Some(u128::from(expected_sats) * 1000) {
1068 return Err(SdkError::Generic(format!(
1069 "The provider's deposit invoice does not request the quoted \
1070 {expected_sats} sats"
1071 )));
1072 }
1073 Ok(())
1074}
1075
1076fn deposit_target(context: &CrossChainProviderContext) -> String {
1079 match context {
1080 CrossChainProviderContext::Orchestra {
1081 deposit_address, ..
1082 } => deposit_address.clone(),
1083 CrossChainProviderContext::Boltz { invoice, .. } => invoice.clone(),
1084 }
1085}
1086
1087fn bridge_from_cash_app_response(
1095 prepared: CrossChainSendPrepared,
1096 url: String,
1097 amount_sats: u64,
1098) -> Result<BridgeFromCashAppResponse, SdkError> {
1099 Ok(BridgeFromCashAppResponse {
1100 url,
1101 amount_sats,
1102 estimated_out: prepared.estimated_out,
1103 asset: prepared.pair.asset,
1104 service_fee_amount: prepared.service_fee_amount,
1105 service_fee_asset: prepared.service_fee_asset,
1106 service_fee_asset_decimals: prepared.service_fee_asset_decimals,
1107 expires_at: parse_rfc3339_to_unix_seconds(&prepared.expires_at)?,
1108 })
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use std::sync::Arc;
1114
1115 use breez_sdk_common::buy::cashapp::CashAppProvider;
1116 use macros::{async_test_all, test_all};
1117
1118 use super::{
1119 BridgeRecipient, BridgeRoutePair, BridgeRoutePairSide, BridgingClient, CreateBridgeRequest,
1120 bridge_from_cash_app_response, check_lightning_address_accepts, deposit_target,
1121 external_pair, is_listed, lightning_funded_bridge_response, route_pays,
1122 route_supports_delivery_method, source_units, validate_spark_recipient,
1123 };
1124 use crate::cross_chain::{CrossChainProviderContext, CrossChainSendPrepared};
1125 use crate::{
1126 BitcoinNetwork, CrossChainAddressDetails, CrossChainAddressFamily, CrossChainFeeMode,
1127 CrossChainProvider, CrossChainRouteLimits, CrossChainRoutePair, DeliveryMethod,
1128 LightningAddressDetails, LnurlPayRequestDetails, PaymentRequestSource, SdkError,
1129 SparkAddressDetails, SparkAsset,
1130 };
1131
1132 #[cfg(feature = "browser-tests")]
1133 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
1134
1135 fn orchestra_context() -> CrossChainProviderContext {
1136 CrossChainProviderContext::Orchestra {
1137 quote_id: "q1".to_string(),
1138 deposit_address: "lnbc100u1pxyz".to_string(),
1139 deposit_amount: 5000,
1140 }
1141 }
1142
1143 fn route_with_delivery_methods(methods: Vec<DeliveryMethod>) -> CrossChainRoutePair {
1144 CrossChainRoutePair {
1145 provider: CrossChainProvider::Orchestra,
1146 chain: "base".to_string(),
1147 chain_id: Some("8453".to_string()),
1148 asset: "USDC".to_string(),
1149 contract_address: None,
1150 decimals: 6,
1151 exact_out_eligible: false,
1152 accepted_assets: vec![],
1153 delivery_methods: methods,
1154 }
1155 }
1156
1157 #[test_all]
1158 fn route_supports_delivery_method_checks_membership() {
1159 let lightning_only = route_with_delivery_methods(vec![DeliveryMethod::Lightning]);
1160 assert!(route_supports_delivery_method(
1162 &lightning_only,
1163 DeliveryMethod::Lightning
1164 ));
1165 assert!(!route_supports_delivery_method(
1166 &lightning_only,
1167 DeliveryMethod::Bitcoin
1168 ));
1169
1170 let both =
1171 route_with_delivery_methods(vec![DeliveryMethod::Lightning, DeliveryMethod::Bitcoin]);
1172 assert!(route_supports_delivery_method(
1173 &both,
1174 DeliveryMethod::Bitcoin
1175 ));
1176
1177 let empty = route_with_delivery_methods(vec![]);
1180 assert!(!route_supports_delivery_method(
1181 &empty,
1182 DeliveryMethod::Bitcoin
1183 ));
1184 }
1185
1186 fn prepared(provider_context: CrossChainProviderContext) -> CrossChainSendPrepared {
1187 CrossChainSendPrepared {
1188 amount_in: 5000,
1189 asset_amount_in: 6_500_000,
1190 estimated_out: 6_450_000,
1191 fee_amount: 50_000,
1192 service_fee_amount: 47_684,
1193 service_fee_asset: Some("USDC".to_string()),
1194 service_fee_asset_decimals: Some(6),
1195 source_transfer_fee_sats: 0,
1196 fee_mode: CrossChainFeeMode::FeesExcluded,
1197 expires_at: "2026-07-25T08:09:26.770Z".to_string(),
1198 pair: CrossChainRoutePair {
1199 provider: CrossChainProvider::Orchestra,
1200 chain: "base".to_string(),
1201 chain_id: Some("8453".to_string()),
1202 asset: "USDC".to_string(),
1203 contract_address: None,
1204 decimals: 6,
1205 exact_out_eligible: false,
1206 accepted_assets: vec![],
1207 delivery_methods: vec![],
1208 },
1209 recipient_address: "0xabc".to_string(),
1210 token_identifier: None,
1211 provider_context,
1212 quote_read_token: None,
1213 }
1214 }
1215
1216 #[test_all]
1217 fn deposit_target_reads_orchestra_address_and_boltz_invoice() {
1218 assert_eq!(deposit_target(&orchestra_context()), "lnbc100u1pxyz");
1219 assert_eq!(
1220 deposit_target(&CrossChainProviderContext::Boltz {
1221 swap_id: "s1".to_string(),
1222 invoice: "lnbc200u1pboltz".to_string(),
1223 invoice_amount_sats: 5000,
1224 max_slippage_bps: 100,
1225 }),
1226 "lnbc200u1pboltz"
1227 );
1228 }
1229
1230 #[test_all]
1231 fn cashapp_url_built_from_deposit_target() {
1232 let url = CashAppProvider::build_url(&deposit_target(&orchestra_context()));
1233 assert_eq!(url, "https://cash.app/launch/lightning/lnbc100u1pxyz");
1234 }
1235
1236 #[test_all]
1237 fn orchestra_response_maps_stablecoin_fee() {
1238 let resp = bridge_from_cash_app_response(
1239 prepared(orchestra_context()),
1240 "https://x".to_string(),
1241 5000,
1242 )
1243 .unwrap();
1244 assert_eq!(resp.url, "https://x");
1245 assert_eq!(resp.expires_at, 1_784_966_966);
1246 assert_eq!(resp.amount_sats, 5000);
1247 assert_eq!(resp.estimated_out, 6_450_000);
1248 assert_eq!(resp.asset, "USDC");
1249 assert_eq!(resp.service_fee_amount, 47_684);
1251 assert_eq!(resp.service_fee_asset.as_deref(), Some("USDC"));
1252 assert_eq!(resp.service_fee_asset_decimals, Some(6));
1253 }
1254
1255 #[test_all]
1256 fn boltz_response_reports_native_sats_fee() {
1257 let mut prepared = prepared(CrossChainProviderContext::Boltz {
1258 swap_id: "s1".to_string(),
1259 invoice: "lnbc200u1pboltz".to_string(),
1260 invoice_amount_sats: 5000,
1261 max_slippage_bps: 100,
1262 });
1263 prepared.service_fee_amount = 17;
1265 prepared.service_fee_asset = None;
1266 prepared.service_fee_asset_decimals = None;
1267 let resp = bridge_from_cash_app_response(prepared, "https://x".to_string(), 5000).unwrap();
1268 assert_eq!(resp.asset, "USDC");
1269 assert_eq!(resp.service_fee_amount, 17);
1270 assert_eq!(resp.service_fee_asset, None);
1271 assert_eq!(resp.service_fee_asset_decimals, None);
1272 }
1273
1274 fn spark_address(network: BitcoinNetwork) -> SparkAddressDetails {
1275 SparkAddressDetails {
1276 address: "spark1merchant".to_string(),
1277 identity_public_key: "02".to_string(),
1278 network,
1279 source: PaymentRequestSource::default(),
1280 }
1281 }
1282
1283 fn lightning_address(min_sendable: u64, max_sendable: u64) -> LightningAddressDetails {
1284 LightningAddressDetails {
1285 address: "alice@cash.app".to_string(),
1286 pay_request: LnurlPayRequestDetails {
1287 callback: "https://cash.app/callback".to_string(),
1288 min_sendable,
1289 max_sendable,
1290 metadata_str: "[]".to_string(),
1291 comment_allowed: 0,
1292 domain: "cash.app".to_string(),
1293 url: "https://cash.app/.well-known/lnurlp/alice".to_string(),
1294 address: Some("alice@cash.app".to_string()),
1295 allows_nostr: None,
1296 nostr_pubkey: None,
1297 },
1298 }
1299 }
1300
1301 #[test_all]
1302 fn spark_recipient_must_be_on_mainnet() {
1303 assert!(validate_spark_recipient(&spark_address(BitcoinNetwork::Bitcoin)).is_ok());
1304 assert!(matches!(
1305 validate_spark_recipient(&spark_address(BitcoinNetwork::Regtest)),
1306 Err(SdkError::InvalidInput(_))
1307 ));
1308 }
1309
1310 #[test_all]
1311 fn lightning_address_bounds_are_checked_in_msat() {
1312 let recipient = lightning_address(1_000_000, 100_000_000);
1313 assert!(check_lightning_address_accepts(&recipient, 1_000).is_ok());
1314 assert!(check_lightning_address_accepts(&recipient, 100_000).is_ok());
1315 assert!(check_lightning_address_accepts(&recipient, 999).is_err());
1316 assert!(check_lightning_address_accepts(&recipient, 100_001).is_err());
1317 }
1318
1319 fn offline_client() -> BridgingClient {
1322 struct Unreachable;
1323
1324 #[macros::async_trait]
1325 impl platform_utils::HttpClient for Unreachable {
1326 async fn get(
1327 &self,
1328 url: String,
1329 _: Option<std::collections::HashMap<String, String>>,
1330 ) -> Result<platform_utils::HttpResponse, platform_utils::HttpError> {
1331 panic!("unexpected GET {url}")
1332 }
1333 async fn post(
1334 &self,
1335 url: String,
1336 _: Option<std::collections::HashMap<String, String>>,
1337 _: Option<String>,
1338 ) -> Result<platform_utils::HttpResponse, platform_utils::HttpError> {
1339 panic!("unexpected POST {url}")
1340 }
1341 async fn delete(
1342 &self,
1343 url: String,
1344 _: Option<std::collections::HashMap<String, String>>,
1345 _: Option<String>,
1346 ) -> Result<platform_utils::HttpResponse, platform_utils::HttpError> {
1347 panic!("unexpected DELETE {url}")
1348 }
1349 }
1350
1351 struct NoConfig;
1352
1353 #[macros::async_trait]
1354 impl flashnet::OrchestraConfigResolver for NoConfig {
1355 async fn resolve(&self) -> Result<flashnet::OrchestraConfig, flashnet::FlashnetError> {
1356 panic!("unexpected Orchestra config lookup")
1357 }
1358 }
1359
1360 struct NoFiat;
1361
1362 #[macros::async_trait]
1363 impl breez_sdk_common::fiat::FiatService for NoFiat {
1364 async fn fetch_fiat_currencies(
1365 &self,
1366 ) -> Result<
1367 Vec<breez_sdk_common::fiat::FiatCurrency>,
1368 breez_sdk_common::error::ServiceConnectivityError,
1369 > {
1370 panic!("unexpected fiat lookup")
1371 }
1372 async fn fetch_fiat_rates(
1373 &self,
1374 ) -> Result<
1375 Vec<breez_sdk_common::fiat::Rate>,
1376 breez_sdk_common::error::ServiceConnectivityError,
1377 > {
1378 panic!("unexpected fiat lookup")
1379 }
1380 }
1381
1382 let http: Arc<dyn platform_utils::HttpClient> = Arc::new(Unreachable);
1383 let fiat: Arc<dyn breez_sdk_common::fiat::FiatService> = Arc::new(NoFiat);
1384 let quoter = Arc::new(crate::cross_chain::OrchestraQuoter::new(
1385 Arc::new(NoConfig),
1386 None,
1387 Arc::clone(&fiat),
1388 Arc::clone(&http),
1389 ));
1390 let input_parser = Arc::new(crate::sdk::SdkInputParser::new(
1391 breez_sdk_common::dns::Resolver::with_proxy(None).unwrap(),
1392 http,
1393 None,
1394 ));
1395 BridgingClient::new(None, quoter, fiat, input_parser)
1396 }
1397
1398 fn create_request(recipient: &str, route: BridgeRoutePair) -> CreateBridgeRequest {
1399 CreateBridgeRequest {
1400 recipient: recipient.to_string(),
1401 route,
1402 amount: 10_000_000,
1403 refund_address: None,
1404 fee_policy: None,
1405 max_slippage_bps: None,
1406 }
1407 }
1408
1409 fn assert_invalid_input(result: Result<super::CreateBridgeResponse, SdkError>, contains: &str) {
1410 match result {
1411 Err(SdkError::InvalidInput(message)) => {
1412 assert!(message.contains(contains), "{message}");
1413 }
1414 other => panic!("expected InvalidInput containing {contains:?}, got {other:?}"),
1415 }
1416 }
1417
1418 fn base_address() -> CrossChainAddressDetails {
1419 CrossChainAddressDetails {
1420 address: "0x0000000000000000000000000000000000000001".to_string(),
1421 address_family: CrossChainAddressFamily::Evm,
1422 contract_address: None,
1423 chain_id: None,
1424 amount: None,
1425 }
1426 }
1427
1428 const BASE_USDC: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
1429
1430 fn side(chain: &str, asset: &str, contract: Option<&str>, decimals: u8) -> BridgeRoutePairSide {
1431 BridgeRoutePairSide {
1432 chain: chain.to_string(),
1433 chain_id: None,
1434 asset: asset.to_string(),
1435 contract_address: contract.map(str::to_string),
1436 decimals,
1437 }
1438 }
1439
1440 fn base_usdc() -> BridgeRoutePairSide {
1441 BridgeRoutePairSide {
1442 chain_id: Some("8453".to_string()),
1443 ..side("base", "USDC", Some(BASE_USDC), 6)
1444 }
1445 }
1446
1447 fn base_usdt() -> BridgeRoutePairSide {
1448 BridgeRoutePairSide {
1449 chain_id: Some("8453".to_string()),
1450 ..side(
1451 "base",
1452 "USDT",
1453 Some("0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"),
1454 6,
1455 )
1456 }
1457 }
1458
1459 fn tron_usdt() -> BridgeRoutePairSide {
1460 side(
1461 "tron",
1462 "USDT",
1463 Some("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
1464 6,
1465 )
1466 }
1467
1468 fn solana_usdc() -> BridgeRoutePairSide {
1469 side(
1470 "solana",
1471 "USDC",
1472 Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"),
1473 6,
1474 )
1475 }
1476
1477 fn base_weth() -> BridgeRoutePairSide {
1478 side(
1479 "base",
1480 "WETH",
1481 Some("0x4200000000000000000000000000000000000006"),
1482 18,
1483 )
1484 }
1485
1486 fn spark_btc() -> BridgeRoutePairSide {
1487 side("spark", "BTC", None, 8)
1488 }
1489
1490 fn spark_usdb() -> BridgeRoutePairSide {
1491 side("spark", "USDB", Some("btkn1usdb"), 6)
1492 }
1493
1494 fn lightning_btc() -> BridgeRoutePairSide {
1495 side("lightning", "BTC", None, 8)
1496 }
1497
1498 fn pair(source: BridgeRoutePairSide, destination: BridgeRoutePairSide) -> BridgeRoutePair {
1499 BridgeRoutePair {
1500 provider: CrossChainProvider::Orchestra,
1501 source,
1502 destination,
1503 limits: None,
1504 }
1505 }
1506
1507 fn spark_recipient() -> BridgeRecipient {
1508 BridgeRecipient::Spark(spark_address(BitcoinNetwork::Bitcoin))
1509 }
1510
1511 fn lightning_recipient() -> BridgeRecipient {
1512 BridgeRecipient::Lightning(lightning_address(1_000, 1_000_000))
1513 }
1514
1515 fn base_recipient() -> BridgeRecipient {
1516 BridgeRecipient::CrossChain(base_address())
1517 }
1518
1519 #[test_all]
1520 fn route_pays_each_kind_of_recipient() {
1521 let (spark, lightning, base) = (spark_recipient(), lightning_recipient(), base_recipient());
1522
1523 assert!(route_pays(&spark, &pair(base_usdc(), spark_btc())));
1524 assert!(route_pays(&spark, &pair(tron_usdt(), spark_usdb())));
1525 assert!(!route_pays(&spark, &pair(base_usdc(), lightning_btc())));
1526 assert!(!route_pays(&spark, &pair(lightning_btc(), spark_btc())));
1527
1528 assert!(route_pays(&lightning, &pair(base_usdc(), lightning_btc())));
1529 assert!(!route_pays(&lightning, &pair(base_usdc(), spark_btc())));
1530
1531 assert!(route_pays(&base, &pair(lightning_btc(), base_usdc())));
1532 assert!(route_pays(&base, &pair(tron_usdt(), base_usdc())));
1533 assert!(route_pays(&base, &pair(base_usdt(), base_usdc())));
1534 assert!(!route_pays(&base, &pair(spark_btc(), base_usdc())));
1536 assert!(!route_pays(&base, &pair(tron_usdt(), solana_usdc())));
1537 }
1538
1539 #[test_all]
1540 fn route_pays_only_in_usd_stablecoins_and_bitcoin() {
1541 let (spark, base) = (spark_recipient(), base_recipient());
1542 assert!(!route_pays(&spark, &pair(base_weth(), spark_btc())));
1543 assert!(!route_pays(&base, &pair(tron_usdt(), base_weth())));
1544 assert!(!route_pays(
1545 &spark,
1546 &pair(side("bitcoin", "BTC", None, 8), spark_btc())
1547 ));
1548 assert!(!route_pays(
1551 &spark,
1552 &pair(side("ton", "USDT", None, 6), spark_btc())
1553 ));
1554 }
1555
1556 #[test_all]
1557 fn route_pays_the_token_and_chain_the_address_names() {
1558 let usdc = BridgeRecipient::CrossChain(CrossChainAddressDetails {
1559 contract_address: Some(BASE_USDC.to_lowercase()),
1560 ..base_address()
1561 });
1562 assert!(route_pays(&usdc, &pair(tron_usdt(), base_usdc())));
1563 assert!(!route_pays(&usdc, &pair(tron_usdt(), base_usdt())));
1564
1565 let on_base = BridgeRecipient::CrossChain(CrossChainAddressDetails {
1566 chain_id: Some(8453),
1567 ..base_address()
1568 });
1569 assert!(route_pays(&on_base, &pair(tron_usdt(), base_usdc())));
1570 let on_arbitrum = BridgeRecipient::CrossChain(CrossChainAddressDetails {
1571 chain_id: Some(42161),
1572 ..base_address()
1573 });
1574 assert!(!route_pays(&on_arbitrum, &pair(tron_usdt(), base_usdc())));
1575 }
1576
1577 #[test_all]
1578 fn is_listed_matches_both_sides_exactly() {
1579 let listed = vec![pair(tron_usdt(), base_usdc())];
1580 assert!(is_listed(&listed, &pair(tron_usdt(), base_usdc())));
1581 let wrong_chain = BridgeRoutePairSide {
1582 chain_id: Some("1".to_string()),
1583 ..base_usdc()
1584 };
1585 assert!(!is_listed(&listed, &pair(tron_usdt(), wrong_chain)));
1586 let wrong_token = BridgeRoutePairSide {
1587 contract_address: Some("TOther".to_string()),
1588 ..tron_usdt()
1589 };
1590 assert!(!is_listed(&listed, &pair(wrong_token, base_usdc())));
1591 }
1592
1593 #[test_all]
1594 fn source_units_rescales_a_stablecoin_destination() {
1595 let bsc_usdt = side(
1596 "bsc",
1597 "USDT",
1598 Some("0x55d398326f99059fF775485246999027B3197955"),
1599 18,
1600 );
1601 assert_eq!(
1602 source_units(&pair(bsc_usdt.clone(), base_usdc()), 10_000_000).unwrap(),
1603 10_000_000_000_000_000_000
1604 );
1605 assert_eq!(
1606 source_units(&pair(bsc_usdt, lightning_btc()), 10_000_000_000_000_000_000).unwrap(),
1607 10_000_000_000_000_000_000
1608 );
1609 }
1610
1611 #[test_all]
1612 fn external_pair_moves_the_local_asset_within_the_route_limits() {
1613 let usdb = SparkAsset::Token {
1614 token_identifier: "btkn1usdb".to_string(),
1615 };
1616 let route = BridgeRoutePair {
1617 limits: Some(CrossChainRouteLimits {
1618 min_amount: None,
1619 max_amount: None,
1620 min_usd_cents: Some(200),
1621 max_usd_cents: None,
1622 }),
1623 ..pair(tron_usdt(), spark_usdb())
1624 };
1625 let external = external_pair(
1626 &route,
1627 &route.source,
1628 Some((usdb.clone(), DeliveryMethod::Spark)),
1629 );
1630 assert_eq!(external.chain, "tron");
1631 assert_eq!(
1632 external.contract_address.as_deref(),
1633 Some("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")
1634 );
1635 assert_eq!(
1636 external.limits_for(&usdb).and_then(|l| l.min_usd_cents),
1637 Some(200)
1638 );
1639 assert_eq!(external.delivery_methods, vec![DeliveryMethod::Spark]);
1640
1641 let bare = external_pair(&route, &route.source, None);
1642 assert!(bare.accepted_assets.is_empty() && bare.delivery_methods.is_empty());
1643 }
1644
1645 #[async_test_all]
1646 async fn create_bridge_rejects_a_route_that_does_not_pay_the_recipient() {
1647 let client = offline_client();
1648 for (recipient, route) in [
1649 (spark_recipient(), pair(base_usdc(), lightning_btc())),
1650 (lightning_recipient(), pair(base_usdc(), spark_btc())),
1651 (base_recipient(), pair(spark_btc(), base_usdc())),
1652 (base_recipient(), pair(tron_usdt(), base_weth())),
1653 ] {
1654 assert_invalid_input(
1655 client
1656 .create_bridge_for(&recipient, create_request("", route))
1657 .await,
1658 "doesn't pay this recipient",
1659 );
1660 }
1661 assert_invalid_input(
1663 client
1664 .create_bridge(create_request(
1665 "0x0000000000000000000000000000000000000001",
1666 pair(spark_btc(), base_usdc()),
1667 ))
1668 .await,
1669 "doesn't pay this recipient",
1670 );
1671 }
1672
1673 #[async_test_all]
1674 async fn create_bridge_requires_a_refund_address_to_pay_a_lightning_address() {
1675 let result = offline_client()
1676 .create_bridge_for(
1677 &lightning_recipient(),
1678 create_request("", pair(base_usdc(), lightning_btc())),
1679 )
1680 .await;
1681 assert_invalid_input(result, "refund address is required");
1682 }
1683
1684 #[async_test_all]
1685 async fn a_refund_address_is_optional_otherwise() {
1686 let client = offline_client();
1687 let route = external_pair(&pair(tron_usdt(), base_usdc()), &tron_usdt(), None);
1688 assert_eq!(
1689 client.validate_refund_address(None, &route).await.unwrap(),
1690 None
1691 );
1692 }
1693
1694 #[async_test_all]
1695 async fn create_bridge_rejects_a_non_mainnet_spark_recipient() {
1696 let result = offline_client()
1697 .create_bridge_for(
1698 &BridgeRecipient::Spark(spark_address(BitcoinNetwork::Regtest)),
1699 create_request("", pair(base_usdc(), spark_btc())),
1700 )
1701 .await;
1702 assert_invalid_input(result, "not a mainnet address");
1703 }
1704
1705 #[async_test_all]
1706 async fn create_bridge_rejects_a_zero_amount_and_other_providers_before_any_lookup() {
1707 let client = offline_client();
1709 let route = pair(base_usdc(), lightning_btc());
1710
1711 let mut request = create_request("alice@example.com", route.clone());
1712 request.amount = 0;
1713 assert_invalid_input(client.create_bridge(request).await, "greater than 0");
1714
1715 let mut route = route;
1716 route.provider = CrossChainProvider::Boltz;
1717 assert_invalid_input(
1718 client
1719 .create_bridge(create_request("alice@example.com", route))
1720 .await,
1721 "only supported on Orchestra",
1722 );
1723 }
1724
1725 #[async_test_all]
1726 async fn cash_app_bridges_take_only_cash_app_routes_and_recipients() {
1727 let client = offline_client();
1728 assert_invalid_input(
1729 client
1730 .bridge_from_cash_app(create_request(
1731 "0x0000000000000000000000000000000000000001",
1732 pair(tron_usdt(), base_usdc()),
1733 ))
1734 .await,
1735 "over Lightning",
1736 );
1737
1738 for recipient in [
1739 "alice@example.com",
1740 "sp1pgss8s9wq7kafdwppyynpr0xzx7v9x8nyx0",
1741 ] {
1742 assert_invalid_input(
1743 client
1744 .bridge_to_cash_app(create_request(
1745 recipient,
1746 pair(base_usdc(), lightning_btc()),
1747 ))
1748 .await,
1749 "not a Cash App username",
1750 );
1751 }
1752 for recipient in ["alice", "$alice", "alice@cash.app"] {
1754 let mut request = create_request(recipient, pair(base_usdc(), lightning_btc()));
1755 request.amount = 0;
1756 assert_invalid_input(client.bridge_to_cash_app(request).await, "greater than 0");
1757 }
1758 }
1759
1760 #[test_all]
1761 fn lightning_funded_response_describes_the_invoice_and_the_stablecoin() {
1762 let response = lightning_funded_bridge_response(
1763 prepared(orchestra_context()),
1764 "lnbc100u1pxyz".to_string(),
1765 5000,
1766 )
1767 .unwrap();
1768 assert_eq!(response.bridge_id, "q1");
1769 assert_eq!(response.payment_request, "lnbc100u1pxyz");
1770 assert_eq!(response.info.deposit_address, "lnbc100u1pxyz");
1771 assert_eq!(response.info.deposit_amount, 5000);
1772 assert_eq!(response.info.expected_received_amount, 6_450_000);
1773 assert_eq!(response.info.destination_asset, "USDC");
1774 assert_eq!(response.info.token_identifier, None);
1775 assert_eq!(response.info.service_fee_asset.as_deref(), Some("USDC"));
1776 assert_eq!(response.info.expires_at, 1_784_966_966);
1777 }
1778}