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
19const USD_AMOUNT_DECIMALS: u32 = 6;
21
22#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
23#[macros::instance_span]
24impl BreezSdk {
25 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 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 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 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 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 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
212fn 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}