Skip to main content

breez_sdk_spark/sdk/bridging/
mod.rs

1//! Bridging: orders in which a payer on another chain or on Lightning pays a
2//! recipient, with no funds moving through the SDK.
3
4mod 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/// Creates bridges: orders that pay a recipient from another chain or over
49/// Lightning, with no funds moving through the SDK. Get one with
50/// [`new_bridging_client`], or from a connected SDK with
51/// `get_bridging_client`. Only available on mainnet.
52#[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/// Builds a [`BridgingClient`] from an API key, with no seed or storage.
61#[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    // Lightning-address hosts are chosen by remote parties, so their lookups
88    // ride a client that revalidates every redirect hop, as in the SDK.
89    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/// Settings for [`new_bridging_client`] with every optional field unset.
121#[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(&quoter) 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    /// Lists the routes that can pay `recipient`.
154    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    /// Creates a bridge and returns what the payer pays. Once paid, the
191    /// provider delivers to the recipient, or refunds `refund_address` if it
192    /// can't. Track it with [`Self::get_bridge_status`].
193    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    /// Reads a bridge's status from the provider.
208    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    /// Pays a recipient on another chain from Cash App. Takes a route for the
228    /// address that the payer funds over Lightning, and returns the bridge
229    /// with the Cash App link as its `payment_request`.
230    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    /// Pays a Cash App user from another chain. The recipient is their
245    /// username, as `$alice`, or their `alice@cash.app` Lightning address, as
246    /// given to [`Self::get_routes`].
247    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    /// Bridges from Cash App over a `CrossChainRoutePair` route.
267    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        // A Boltz reverse swap needs the SDK online to claim before the
282        // payer's HTLC settles, so it can't back a link someone else pays
283        // later. Orchestra orders are submitless and deliver without the SDK.
284        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        // Cash App funds the deposit over Lightning. Fail fast before quoting if
291        // the route can't be funded that way. Rejecting an empty
292        // `delivery_methods` stops a hand-built route from reaching
293        // `prepare` and committing provider state it can't fund.
294        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        // Validate the recipient before creating the order, using the same guards
301        // as the send path.
302        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    /// Bridges to a Cash App user over a `CrossChainRoutePair` route.
326    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        // The provider pays out after the deposit lands, with no wallet
342        // online, which only Orchestra supports.
343        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        // Resolved before quoting so an unknown account fails before the payer
360        // funds anything.
361        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    /// Parses who a bridge pays. Anything a bridge can't pay fails with
396    /// `InvalidInput`.
397    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    /// Creates a bridge to a parsed `recipient`.
414    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            // Orchestra only pays out over Lightning with a refund address.
435            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        // The payment request is built from the route's sides, so they must be
443        // the provider's, not a hand-built or stale copy.
444        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    /// Checks a recipient on another chain against `route`, and that it isn't
527    /// a token contract, which can never pass funds on.
528    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    /// Parses the payer's refund address, when given, and checks it against
550    /// the source `route`.
551    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    /// Quotes an order the payer funds over Lightning, delivering `amount` of
585    /// `route`'s asset to `recipient_address`. Returns the quote, the BOLT11
586    /// the payer pays and its amount in sats.
587    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        // `amount` is in the destination asset's base units (the route's
599        // `decimals`); for these USD-pegged stablecoins it equals the USD value
600        // at parity. Convert it to the sats the provider's `prepare` expects
601        // (for both fee modes) via the live rate.
602        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        // Match the send path: on `FeesExcluded` pad the source so the recipient
612        // lands at or above target despite provider slippage. The pad comes from
613        // config (no per-request override on a Lightning-funded bridge).
614        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        // The provider returns the BOLT11 the external payer funds in its
641        // context. We never call the send stage: the external payer funds the
642        // provider's deposit directly, so there is no Spark-side transfer to
643        // make and the provider delivers autonomously once the payer settles.
644        let deposit_target = deposit_target(&prepared.provider_context);
645
646        // Verify the provider's target is a BOLT11 for the quoted amount before
647        // sending the payer to it.
648        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    /// Quotes an order the payer funds with a deposit on `route`'s chain,
655    /// delivering `destination` to `recipient` over `delivery_method`.
656    #[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            &params,
685            recipient,
686            refund_address,
687        )
688        .await
689    }
690
691    /// Creates a bridge to a recipient on another chain, funded over Lightning
692    /// or by a stablecoin deposit, as `route` says.
693    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    /// Quotes an order the payer funds with a stablecoin deposit on
746    /// `source`'s chain, delivering `route`'s destination stablecoin to
747    /// `recipient`. `amount` is in destination units.
748    #[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        // Fees included, `amount` is the deposit, at parity in the source asset.
766        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    /// The routes the provider serves out of `chain` when `is_send`, into it
799    /// otherwise.
800    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    /// Routes to a recipient on another chain: funded over Lightning, or by a
810    /// deposit into one of the chains Lightning-funded routes reach.
811    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/// Who a bridge pays.
836#[derive(Debug, Clone)]
837enum BridgeRecipient {
838    /// Paid in Bitcoin or a Spark token.
839    Spark(SparkAddressDetails),
840    /// Paid in Bitcoin over Lightning.
841    Lightning(LightningAddressDetails),
842    /// Paid in a USD stablecoin.
843    CrossChain(CrossChainAddressDetails),
844}
845
846/// Whether `route` pays `recipient`, moving only USD stablecoins and Bitcoin.
847/// A Spark or Lightning recipient is paid from a stablecoin deposit on another
848/// chain. A recipient on another chain is paid from one too, or over
849/// Lightning.
850fn 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
870/// Whether `listed` has a route with `route`'s sides.
871fn is_listed(listed: &[BridgeRoutePair], route: &BridgeRoutePair) -> bool {
872    listed
873        .iter()
874        .any(|r| r.source == route.source && r.destination == route.destination)
875}
876
877/// The delivery method `side` is on, or `None` for another chain. Lightning
878/// only carries Bitcoin, while Spark also carries tokens.
879fn 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
887/// A stablecoin on another chain whose token contract names its address
888/// family, so a refund address can be checked against it.
889fn 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
899/// Whether `side` delivers a stablecoin `recipient` can hold: on a chain of
900/// its address family, and the token and chain it names, if any.
901fn 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
919/// The Spark asset a route pays a Spark recipient.
920fn 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
929/// `amount` in source units. A stablecoin destination is rescaled at parity,
930/// and any other amount is already in source units.
931fn 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
942/// `side` as the external side of a [`CrossChainRoutePair`], for the quoting
943/// and checks written against one. `local` is the Spark asset the route moves
944/// and the rail it moves on.
945fn 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
973/// Rejects a Spark address on a network other than mainnet.
974fn 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
983/// Checks the Lightning address accepts `expected_sats`, the amount the
984/// provider will pay it.
985fn 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
1012/// The response for a bridge the payer funds over Lightning: the deposit is
1013/// the provider's BOLT11 for `amount_sats`, and the recipient receives the
1014/// route's stablecoin.
1015fn 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
1044/// Whether `route` advertises `required` as a fundable delivery method. An
1045/// empty `delivery_methods` (e.g. a hand-built route) matches nothing and is
1046/// rejected.
1047fn route_supports_delivery_method(route: &CrossChainRoutePair, required: DeliveryMethod) -> bool {
1048    route.delivery_methods.contains(&required)
1049}
1050
1051/// Verifies the provider's deposit `target` is a BOLT11 invoice requesting
1052/// exactly `expected_sats`, the Lightning payment request the payer funds. A
1053/// wrong type, wrong amount, or unparseable target indicates a provider bug.
1054pub(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    // The invoice must request the quoted deposit: a mismatch would have the
1066    // payer fund the wrong amount.
1067    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
1076/// The BOLT11 an external payer funds, read from the provider context
1077/// (Orchestra `deposit_address` or Boltz `invoice`).
1078fn 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
1087/// Maps a [`CrossChainSendPrepared`] + funding `url` to a
1088/// [`BridgeFromCashAppResponse`].
1089///
1090/// `estimated_out` is in the destination `asset`'s base units. The fee mirrors
1091/// the cross-chain send response: `service_fee_amount` in `service_fee_asset`
1092/// base units, where `None` means sats (Boltz denominates its fee in sats;
1093/// Orchestra in the stablecoin).
1094fn 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        // Cash App (Lightning) is supported; MoonPay (Bitcoin) is not.
1161        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        // An empty list matches nothing: a route must advertise its rails, so a
1178        // hand-built route can't slip a bad rail through to `prepare`.
1179        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        // Orchestra denominates its service fee in the stablecoin.
1250        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        // Boltz denominates its service fee in sats (service_fee_asset = None).
1264        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    /// A client whose provider calls would fail, for checks that must reject
1320    /// a request before anything is quoted.
1321    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        // A payer on Spark sends from their wallet instead.
1535        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        // A deposit whose chain can't be told from its token takes no refund
1549        // address that could be checked.
1550        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        // An address on another chain parses without a lookup.
1662        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        // The offline client fails the test on a lookup of the address.
1708        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        // A Cash App user, however written, is bridged as any other recipient.
1753        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}