Skip to main content

breez_sdk_spark/sdk/
buy.rs

1use breez_sdk_common::buy::{cashapp::CashAppProvider, moonpay::USDC_SOLANA_PRECISION};
2use tracing::warn;
3
4use crate::{
5    BuyBitcoinRequest, BuyBitcoinResponse, MoonpayDelivery, Network, ReceivePaymentMethod,
6    ReceivePaymentRequest,
7    cross_chain::{
8        CrossChainFeeMode, CrossChainProvider, CrossChainReceiveInfo, CrossChainRouteFilter,
9        CrossChainRoutePair, rescale_decimals,
10    },
11    error::SdkError,
12};
13
14use super::{BreezSdk, bridging::check_deposit_target, helpers::get_deposit_address};
15
16const SOLANA_CHAIN: &str = "solana";
17const USDC_ASSET: &str = "USDC";
18
19/// Decimal places of `MoonpayDelivery::CrossChain::amount`.
20const USD_AMOUNT_DECIMALS: u32 = 6;
21
22#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
23#[macros::instance_span]
24impl BreezSdk {
25    /// Initiates a Bitcoin purchase flow via an external provider.
26    ///
27    /// Returns a URL the user should open to complete the purchase.
28    /// The request variant determines the provider and its parameters:
29    ///
30    /// - [`BuyBitcoinRequest::Moonpay`]: Fiat-to-Bitcoin, delivered to an
31    ///   on-chain deposit address or as USDC on Solana (see [`MoonpayDelivery`]).
32    /// - [`BuyBitcoinRequest::CashApp`]: Lightning invoice + `cash.app` deep link (mainnet only).
33    pub async fn buy_bitcoin(
34        &self,
35        request: BuyBitcoinRequest,
36    ) -> Result<BuyBitcoinResponse, SdkError> {
37        let url = match request {
38            BuyBitcoinRequest::Moonpay {
39                delivery,
40                redirect_url,
41            } => match delivery.unwrap_or_default() {
42                MoonpayDelivery::Bitcoin { amount_sat } => {
43                    let address =
44                        get_deposit_address(&self.spark_wallet, &self.storage, true).await?;
45                    self.buy_bitcoin_provider
46                        .buy_bitcoin(address, amount_sat, redirect_url)
47                        .await
48                        .map_err(|e| {
49                            SdkError::Generic(format!("Failed to create buy bitcoin URL: {e}"))
50                        })?
51                }
52                MoonpayDelivery::CrossChain { amount, fee_mode } => {
53                    let (info, decimals) = self
54                        .quote_moonpay_usdc_solana(
55                            amount,
56                            fee_mode.unwrap_or(CrossChainFeeMode::FeesExcluded),
57                        )
58                        .await?;
59                    let url = self
60                        .buy_bitcoin_provider
61                        .buy_usdc_solana(
62                            info.deposit_address.clone(),
63                            info.deposit_amount,
64                            u32::from(decimals),
65                            redirect_url,
66                        )
67                        .await
68                        .map_err(|e| {
69                            SdkError::Generic(format!("Failed to create buy bitcoin URL: {e}"))
70                        })?;
71                    return Ok(BuyBitcoinResponse {
72                        url,
73                        cross_chain_info: Some(info),
74                    });
75                }
76            },
77            BuyBitcoinRequest::CashApp { amount_sats } => {
78                if !matches!(self.config.network, Network::Mainnet) {
79                    return Err(SdkError::Generic(
80                        "CashApp is only available on mainnet".to_string(),
81                    ));
82                }
83                if amount_sats == 0 {
84                    return Err(SdkError::Generic(
85                        "CashApp requires a non-zero amount".to_string(),
86                    ));
87                }
88                let (url, info) = self.cash_app_purchase(amount_sats).await?;
89                return Ok(BuyBitcoinResponse {
90                    url,
91                    cross_chain_info: Some(info),
92                });
93            }
94        };
95
96        Ok(BuyBitcoinResponse {
97            url,
98            cross_chain_info: None,
99        })
100    }
101}
102
103impl BreezSdk {
104    /// The `cash.app` link for a Cash App purchase that delivers `amount_sats`,
105    /// and the order behind it. Cash App pays the invoice of a Flashnet
106    /// Orchestra onramp order, which delivers the sats to this wallet, or
107    /// refunds them to its Lightning address, when one is registered, if the
108    /// order fails. The link is built around the invoice checked here rather
109    /// than taken from the order.
110    async fn cash_app_purchase(
111        &self,
112        amount_sats: u64,
113    ) -> Result<(String, CrossChainReceiveInfo), SdkError> {
114        let orchestra = self.orchestra.as_ref().ok_or_else(|| {
115            SdkError::Generic(
116                "CashApp is not available when background tasks are disabled".to_string(),
117            )
118        })?;
119        // Best effort: without an address the order has no refund destination.
120        let refund_address = self
121            .get_lightning_address()
122            .await
123            .inspect_err(|e| warn!("CashApp: no refund address, lookup failed: {e}"))
124            .ok()
125            .flatten()
126            .map(|info| info.lightning_address);
127        let order = orchestra
128            .create_lightning_receive_delivering(amount_sats, refund_address.as_deref())
129            .await?;
130        let invoice = order.info.deposit_address.clone();
131        let expected_sats = u64::try_from(order.info.deposit_amount).map_err(|_| {
132            SdkError::Generic(format!(
133                "Deposit amount {} sats exceeds u64::MAX",
134                order.info.deposit_amount
135            ))
136        })?;
137        check_deposit_target(&self.parse(&invoice).await, expected_sats)?;
138        let info = orchestra.track_lightning_receive(order).await?;
139        Ok((CashAppProvider::build_url(&invoice), info))
140    }
141
142    /// Quotes a USDC-on-Solana receive for a `MoonPay` purchase to fund.
143    /// `amount` is in USD at 6 decimals. Returns the quote, with its deposit
144    /// rounded up to whole cents, and the route's decimals.
145    async fn quote_moonpay_usdc_solana(
146        &self,
147        amount: u128,
148        fee_mode: CrossChainFeeMode,
149    ) -> Result<(CrossChainReceiveInfo, u8), SdkError> {
150        let service = self
151            .cross_chain_context
152            .get(CrossChainProvider::Orchestra)?;
153        if amount == 0 {
154            return Err(SdkError::InvalidInput(
155                "MoonPay cross-chain delivery requires a non-zero amount".to_string(),
156            ));
157        }
158        let route = service
159            .get_routes(&CrossChainRouteFilter::Receive {
160                contract_address: None,
161                delivery_method: None,
162            })
163            .await?
164            .into_iter()
165            .find(is_usdc_solana)
166            .ok_or_else(|| {
167                SdkError::cross_chain_route_unavailable(
168                    "USDC on Solana is not offered".to_string(),
169                    false,
170                )
171            })?;
172        let decimals = route.decimals;
173
174        // With fees included the amount is the deposit itself, so it has to
175        // be whole cents already.
176        let amount = match fee_mode {
177            CrossChainFeeMode::FeesIncluded => {
178                round_up_to_precision(amount, USD_AMOUNT_DECIMALS, USDC_SOLANA_PRECISION)
179            }
180            CrossChainFeeMode::FeesExcluded => amount,
181        };
182        let amount = rescale_decimals(amount, USD_AMOUNT_DECIMALS, u32::from(decimals))?;
183        let response = self
184            .receive_payment(ReceivePaymentRequest {
185                payment_method: ReceivePaymentMethod::CrossChain {
186                    route,
187                    amount,
188                    destination: None,
189                    fee_mode: Some(fee_mode),
190                    max_slippage_bps: None,
191                    target_overpay_bps: None,
192                },
193            })
194            .await?;
195        let mut info = response.cross_chain_info.ok_or_else(|| {
196            SdkError::Generic("Cross-chain receive returned no quote".to_string())
197        })?;
198        // MoonPay buys whole cents. The quote can call for a fraction of one.
199        info.deposit_amount = round_up_to_precision(
200            info.deposit_amount,
201            u32::from(decimals),
202            USDC_SOLANA_PRECISION,
203        );
204        Ok((info, decimals))
205    }
206}
207
208fn is_usdc_solana(route: &CrossChainRoutePair) -> bool {
209    route.chain.eq_ignore_ascii_case(SOLANA_CHAIN) && route.asset.eq_ignore_ascii_case(USDC_ASSET)
210}
211
212/// Rounds `amount` (base units at `decimals`) up to the nearest whole unit
213/// at `precision` fractional digits.
214fn round_up_to_precision(amount: u128, decimals: u32, precision: u32) -> u128 {
215    let Some(shift) = decimals.checked_sub(precision) else {
216        return amount;
217    };
218    let unit = 10u128.saturating_pow(shift);
219    amount.div_ceil(unit).saturating_mul(unit)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::round_up_to_precision;
225
226    #[test]
227    fn test_round_up_to_precision() {
228        assert_eq!(round_up_to_precision(12_340_000, 6, 2), 12_340_000);
229        assert_eq!(round_up_to_precision(12_340_001, 6, 2), 12_350_000);
230        assert_eq!(round_up_to_precision(12_349_999, 6, 2), 12_350_000);
231        assert_eq!(round_up_to_precision(0, 6, 2), 0);
232        assert_eq!(round_up_to_precision(1_234, 2, 2), 1_234);
233        assert_eq!(round_up_to_precision(5, 1, 2), 5);
234    }
235}