1use std::{str::FromStr, time::Duration};
2
3use bitcoin::{consensus::serialize, hex::DisplayHex};
4use platform_utils::tokio;
5use spark_wallet::{
6 InstantStaticDepositPlan, InstantStaticDepositQuoteResult, ListTransfersRequest, TransferId,
7 WalletTransfer,
8};
9use tracing::{error, info, trace};
10
11use crate::{
12 ClaimDepositRequest, ClaimDepositResponse, InstantClaimDeclineReason, InstantClaimStatus,
13 ListUnclaimedDepositsRequest, ListUnclaimedDepositsResponse, RefundDepositRequest,
14 RefundDepositResponse,
15 error::SdkError,
16 models::Payment,
17 persist::UpdateDepositPayload,
18 sdk::RuntimeEvent,
19 utils::utxo_fetcher::{CachedUtxoFetcher, DetailedUtxo},
20};
21
22use super::BreezSdk;
23
24const CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS: u32 = 3;
27const CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS: u64 = 500;
28
29#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
30#[allow(clippy::needless_pass_by_value)]
31impl BreezSdk {
32 pub async fn claim_deposit(
33 &self,
34 request: ClaimDepositRequest,
35 ) -> Result<ClaimDepositResponse, SdkError> {
36 self.maybe_ensure_spark_private_mode_initialized().await?;
37 let detailed_utxo =
38 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
39 .fetch_detailed_utxo(&request.txid, request.vout)
40 .await?;
41
42 if let Some(max_instant_fee_bps) = request.max_instant_fee_bps {
43 return self
44 .instant_claim_deposit(&detailed_utxo, max_instant_fee_bps)
45 .await;
46 }
47
48 let max_fee = request
49 .max_fee
50 .or(self.config.max_deposit_claim_fee.clone());
51 match self.claim_utxo(&detailed_utxo, max_fee).await {
52 Ok(transfer_id) => {
53 let transfer = self.lookup_claim_transfer_with_retry(transfer_id).await?;
54 let payment: Payment = transfer.try_into()?;
55 let should_emit_event = self.storage.apply_payment_update(payment.clone()).await?;
58 self.storage
59 .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
60 .await?;
61 self.event_emitter
62 .emit_runtime_event(RuntimeEvent::DepositClaimed {
63 payment: Box::new(payment.clone()),
64 should_emit_event,
65 })
66 .await;
67 Ok(ClaimDepositResponse {
68 payment: Some(payment),
69 })
70 }
71 Err(e) => {
72 error!("Failed to claim deposit: {e:?}");
73 self.storage
74 .update_deposit(
75 detailed_utxo.txid.to_string(),
76 detailed_utxo.vout,
77 UpdateDepositPayload::ClaimError {
78 error: e.clone().into(),
79 },
80 )
81 .await?;
82 Err(e)
83 }
84 }
85 }
86
87 pub async fn refund_deposit(
88 &self,
89 request: RefundDepositRequest,
90 ) -> Result<RefundDepositResponse, SdkError> {
91 let detailed_utxo =
92 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
93 .fetch_detailed_utxo(&request.txid, request.vout)
94 .await?;
95 let tx = self
96 .spark_wallet
97 .refund_static_deposit(
98 detailed_utxo.clone().tx,
99 Some(detailed_utxo.vout),
100 &request.destination_address,
101 request.fee.into(),
102 )
103 .await?;
104 let tx_hex = serialize(&tx).as_hex().to_string();
105 let tx_id = tx.compute_txid().as_raw_hash().to_string();
106
107 self.storage
109 .update_deposit(
110 detailed_utxo.txid.to_string(),
111 detailed_utxo.vout,
112 UpdateDepositPayload::Refund {
113 refund_tx: tx_hex.clone(),
114 refund_txid: tx_id.clone(),
115 },
116 )
117 .await?;
118
119 self.chain_service
120 .broadcast_transaction(tx_hex.clone())
121 .await?;
122 Ok(RefundDepositResponse { tx_id, tx_hex })
123 }
124
125 #[allow(unused_variables)]
126 pub async fn list_unclaimed_deposits(
127 &self,
128 request: ListUnclaimedDepositsRequest,
129 ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
130 let deposits = self.storage.list_deposits().await?;
131 Ok(ListUnclaimedDepositsResponse { deposits })
132 }
133}
134
135impl BreezSdk {
136 async fn lookup_claim_transfer_with_retry(
143 &self,
144 transfer_id: String,
145 ) -> Result<WalletTransfer, SdkError> {
146 let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
147 let mut last_error: Option<SdkError> = None;
148
149 for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
150 if attempt > 0 {
151 let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
152 .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
153 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
154 trace!(
155 "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
156 attempt.saturating_add(1),
157 CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
158 );
159 }
160
161 match self
162 .spark_wallet
163 .list_transfers(ListTransfersRequest {
164 transfer_ids: vec![parsed_id.clone()],
165 paging: None,
166 })
167 .await
168 {
169 Ok(mut resp) => {
170 if let Some(transfer) = resp.items.pop() {
171 return Ok(transfer);
172 }
173 last_error = None;
174 }
175 Err(e) => last_error = Some(e.into()),
176 }
177 }
178
179 Err(last_error
180 .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
181 }
182
183 async fn instant_claim_deposit(
186 &self,
187 detailed_utxo: &DetailedUtxo,
188 max_instant_fee_bps: u32,
189 ) -> Result<ClaimDepositResponse, SdkError> {
190 let existing = self
191 .storage
192 .list_deposits()
193 .await?
194 .into_iter()
195 .find(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout);
196 if matches!(
198 existing
199 .as_ref()
200 .and_then(|d| d.instant_claim_status.as_ref()),
201 Some(InstantClaimStatus::Submitted { .. })
202 ) {
203 info!(
204 "Instant claim already in flight for utxo {}:{}",
205 detailed_utxo.txid, detailed_utxo.vout
206 );
207 return Ok(ClaimDepositResponse { payment: None });
208 }
209 let row_exists = existing.is_some();
210
211 let outcome = match self
212 .instant_claim_utxo(detailed_utxo, max_instant_fee_bps)
213 .await
214 {
215 Ok(outcome) => outcome,
216 Err(e) => {
218 error!("Instant claim transient error: {e:?}");
219 return Err(e);
220 }
221 };
222
223 if !row_exists {
229 self.storage
230 .add_deposit(
231 detailed_utxo.txid.to_string(),
232 detailed_utxo.vout,
233 detailed_utxo.value,
234 false,
235 )
236 .await?;
237 }
238 self.storage
239 .update_deposit(
240 detailed_utxo.txid.to_string(),
241 detailed_utxo.vout,
242 UpdateDepositPayload::InstantClaim {
243 status: outcome.status(),
244 },
245 )
246 .await?;
247
248 match outcome {
249 InstantClaimOutcome::Submitted(claim_id) => {
250 info!(
251 "Instant claimed utxo {}:{} with claim_id: {claim_id}",
252 detailed_utxo.txid, detailed_utxo.vout
253 );
254 Ok(ClaimDepositResponse { payment: None })
255 }
256 InstantClaimOutcome::Declined { error, .. } => {
257 error!("Instant claim declined: {error:?}");
258 Err(error)
259 }
260 }
261 }
262
263 pub(super) async fn instant_claim_utxo(
269 &self,
270 detailed_utxo: &DetailedUtxo,
271 max_instant_fee_bps: u32,
272 ) -> Result<InstantClaimOutcome, SdkError> {
273 let quote_result = self
276 .spark_wallet
277 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
278 .await?;
279 match select_instant_claim_plan("e_result, detailed_utxo.value, max_instant_fee_bps) {
282 InstantClaimPlan::Claimable(plan) => {
283 match self
284 .spark_wallet
285 .claim_instant_static_deposit(
286 detailed_utxo.tx.clone(),
287 quote_result.quote,
288 plan,
289 )
290 .await
291 {
292 Ok(claim_id) => Ok(InstantClaimOutcome::Submitted(claim_id)),
293 Err(e) => Ok(InstantClaimOutcome::Declined {
294 error: e.into(),
295 reason: InstantClaimDeclineReason::SubmissionFailed,
296 }),
297 }
298 }
299 InstantClaimPlan::NoPlan => Ok(InstantClaimOutcome::Declined {
300 error: SdkError::Generic("No instant (0-conf) claim plan available".to_string()),
301 reason: InstantClaimDeclineReason::NoPlan,
302 }),
303 InstantClaimPlan::FeeExceeded {
304 quoted_sats,
305 quoted_bps,
306 } => Ok(InstantClaimOutcome::Declined {
307 error: SdkError::Generic(format!(
308 "Instant claim declined for {}:{}: SSP spread {quoted_bps} bps ({quoted_sats} sats) exceeds max {max_instant_fee_bps} bps",
309 detailed_utxo.txid, detailed_utxo.vout
310 )),
311 reason: InstantClaimDeclineReason::FeeExceeded {
312 max_bps: max_instant_fee_bps,
313 quoted_bps,
314 quoted_sats,
315 },
316 }),
317 }
318 }
319}
320
321pub(super) enum InstantClaimOutcome {
323 Submitted(String),
325 Declined {
330 error: SdkError,
331 reason: InstantClaimDeclineReason,
332 },
333}
334
335impl InstantClaimOutcome {
336 pub(super) fn status(&self) -> InstantClaimStatus {
338 match self {
339 InstantClaimOutcome::Submitted(claim_id) => InstantClaimStatus::Submitted {
340 claim_id: claim_id.clone(),
341 },
342 InstantClaimOutcome::Declined { reason, .. } => InstantClaimStatus::Declined {
343 reason: reason.clone(),
344 },
345 }
346 }
347}
348
349enum InstantClaimPlan {
351 Claimable(InstantStaticDepositPlan),
353 NoPlan,
355 FeeExceeded { quoted_sats: u64, quoted_bps: u32 },
358}
359
360fn select_instant_claim_plan(
366 quote_result: &InstantStaticDepositQuoteResult,
367 deposit_sats: u64,
368 max_bps: u32,
369) -> InstantClaimPlan {
370 let Some(plan) = quote_result
371 .fulfillment_plans
372 .iter()
373 .find(|p| p.confirmations == 0)
374 else {
375 return InstantClaimPlan::NoPlan;
376 };
377 let quoted_sats = deposit_sats.saturating_sub(plan.amount.original_value);
378 let within = u128::from(quoted_sats).saturating_mul(10_000)
379 <= u128::from(max_bps).saturating_mul(u128::from(deposit_sats));
380 if within {
381 InstantClaimPlan::Claimable(plan.clone())
382 } else {
383 let quoted_bps = u128::from(quoted_sats)
385 .saturating_mul(10_000)
386 .checked_div(u128::from(deposit_sats))
387 .and_then(|bps| u32::try_from(bps).ok())
388 .unwrap_or(0);
389 InstantClaimPlan::FeeExceeded {
390 quoted_sats,
391 quoted_bps,
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::{InstantClaimPlan, select_instant_claim_plan};
399 use spark_wallet::{
400 CurrencyAmount, InstantStaticDepositPlan, InstantStaticDepositQuote,
401 InstantStaticDepositQuoteResult,
402 };
403
404 fn sats(value: u64) -> CurrencyAmount {
405 CurrencyAmount {
406 original_value: value,
407 ..Default::default()
408 }
409 }
410
411 fn quote_result(
414 deposit_sats: u64,
415 credit_sats: u64,
416 plan_confirmations: &[i64],
417 ) -> InstantStaticDepositQuoteResult {
418 InstantStaticDepositQuoteResult {
419 quote: InstantStaticDepositQuote {
420 id: "quote-id".to_string(),
421 transaction_id: "tx".to_string(),
422 output_index: 0,
423 deposit_amount: sats(deposit_sats),
424 credit_amount: sats(credit_sats),
425 quote_signature: "00".to_string(),
426 },
427 fulfillment_plans: plan_confirmations
428 .iter()
429 .enumerate()
430 .map(|(i, confirmations)| InstantStaticDepositPlan {
431 id: format!("plan-{i}"),
432 amount: sats(credit_sats),
433 confirmations: *confirmations,
434 })
435 .collect(),
436 }
437 }
438
439 #[test]
440 fn selects_zero_conf_plan_within_bps() {
441 let q = quote_result(100_000, 99_000, &[0, 1]);
443 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 200) else {
444 panic!("expected a claimable 0-conf plan");
445 };
446 assert_eq!(plan.confirmations, 0);
447 }
448
449 #[test]
450 fn skips_when_no_zero_conf_plan() {
451 let q = quote_result(100_000, 99_000, &[1, 2]);
453 assert!(matches!(
454 select_instant_claim_plan(&q, 100_000, 10_000),
455 InstantClaimPlan::NoPlan
456 ));
457 }
458
459 #[test]
460 fn skips_when_spread_over_bps_ceiling() {
461 let q = quote_result(100_000, 95_000, &[0]);
463 assert!(matches!(
464 select_instant_claim_plan(&q, 100_000, 100),
465 InstantClaimPlan::FeeExceeded {
466 quoted_sats: 5_000,
467 quoted_bps: 500
468 }
469 ));
470 }
471
472 #[test]
473 fn rejects_any_spread_at_zero_bps() {
474 let q = quote_result(100_000, 99_000, &[0]);
476 assert!(matches!(
477 select_instant_claim_plan(&q, 100_000, 0),
478 InstantClaimPlan::FeeExceeded { .. }
479 ));
480 }
481
482 #[test]
483 fn accepts_spread_equal_to_bps_ceiling() {
484 let q = quote_result(100_000, 99_000, &[0]);
486 assert!(matches!(
487 select_instant_claim_plan(&q, 100_000, 100),
488 InstantClaimPlan::Claimable(_)
489 ));
490 }
491
492 #[test]
493 fn one_bps_cap_admits_large_declines_small() {
494 let cap_bps = 400;
498 let small = quote_result(1_000, 771, &[0]);
500 assert!(matches!(
501 select_instant_claim_plan(&small, 1_000, cap_bps),
502 InstantClaimPlan::FeeExceeded { .. }
503 ));
504 let large = quote_result(100_000, 96_801, &[0]);
506 assert!(matches!(
507 select_instant_claim_plan(&large, 100_000, cap_bps),
508 InstantClaimPlan::Claimable(_)
509 ));
510 }
511
512 #[test]
513 fn prices_spread_off_passed_deposit_not_quote() {
514 let q = quote_result(100_000, 49_500, &[0]);
520 assert!(matches!(
521 select_instant_claim_plan(&q, 50_000, 150),
522 InstantClaimPlan::Claimable(_)
523 ));
524 }
525}