Skip to main content

breez_sdk_spark/sdk/
deposits.rs

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
24// Retry parameters for looking up the transfer created by a static deposit
25// claim while it propagates across Spark operators.
26const 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                // Insert the payment before returning so callers that
56                // immediately list payments see the claim.
57                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        // Store the refund transaction details separately
108        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    /// Looks up the transfer produced by a static deposit claim, retrying
137    /// while the Spark operators have not yet indexed it. The SSP commits
138    /// the claim synchronously, but there is a brief window before the
139    /// transfer becomes queryable from the operators; transient query
140    /// errors are also retried. Returns the last error if every attempt
141    /// fails.
142    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    /// Claims a specific not-yet-mature deposit instantly (0-conf), on demand.
184    /// The transfer settles asynchronously, so no payment is returned.
185    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        // Do not re-submit a claim that is already in flight for this deposit.
197        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            // Transient quote-fetch failure: leave unmarked so a retry works.
217            Err(e) => {
218                error!("Instant claim transient error: {e:?}");
219                return Err(e);
220            }
221        };
222
223        // Persist the resolved status. A manual claim can run before the background
224        // sync has inserted the deposit row, in which case update_deposit would be a
225        // no-op and the marker (which stops the sync from re-submitting or
226        // normal-claiming a still-in-flight deposit) would be lost, so insert the row
227        // first when missing. reconcile_deposits removes it once the claim settles.
228        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    /// Attempts an instant 0-conf static deposit claim for `detailed_utxo`.
264    /// `Ok(Submitted)` on a submitted claim, `Ok(Declined)` for a terminal
265    /// outcome (no 0-conf plan, spread over the ceiling, or a failed claim
266    /// submission), and `Err` only for a failed quote fetch, which is transient
267    /// (the SSP may not have indexed the mempool tx yet) and should be retried.
268    pub(super) async fn instant_claim_utxo(
269        &self,
270        detailed_utxo: &DetailedUtxo,
271        max_instant_fee_bps: u32,
272    ) -> Result<InstantClaimOutcome, SdkError> {
273        // A failed quote fetch is transient (retry); everything after it is
274        // terminal and should be marked, not retried.
275        let quote_result = self
276            .spark_wallet
277            .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
278            .await?;
279        // Price the spread against the on-chain UTXO value we already hold, not the
280        // SSP-reported deposit amount, so the fee gate does not depend on the quote.
281        match select_instant_claim_plan(&quote_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
321/// Result of an instant (0-conf) claim attempt.
322pub(super) enum InstantClaimOutcome {
323    /// Claim submitted; carries the claim id. Settles asynchronously.
324    Submitted(String),
325    /// A resolved decline to mark rather than retry via `Err`: no 0-conf plan,
326    /// spread over the ceiling, or a failed submission. Distinct from a failed
327    /// quote fetch, which is transient and retries. Carries the error for the
328    /// caller and the reason for the persisted status.
329    Declined {
330        error: SdkError,
331        reason: InstantClaimDeclineReason,
332    },
333}
334
335impl InstantClaimOutcome {
336    /// The status to persist on the deposit for this resolved outcome.
337    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
349/// Classification of an instant quote's 0-conf plan against the bps ceiling.
350enum InstantClaimPlan {
351    /// The 0-conf plan is within the ceiling and should be claimed.
352    Claimable(InstantStaticDepositPlan),
353    /// No `confirmations == 0` fulfillment plan was offered.
354    NoPlan,
355    /// The SSP spread (`deposit - credit`) exceeds the ceiling, in both sats and
356    /// its basis-points-of-deposit form (for the decline message).
357    FeeExceeded { quoted_sats: u64, quoted_bps: u32 },
358}
359
360/// Selects the 0-conf fulfillment plan and checks the SSP spread
361/// (`deposit - credit`) against `max_bps`, as basis points of `deposit_sats` (the
362/// on-chain UTXO value). The spread carries a fixed component, so its bps grows as
363/// the deposit shrinks: a single bps ceiling therefore admits large deposits and
364/// declines small ones.
365fn 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        // spread <= deposit, so quoted_bps <= 10_000; it fits u32 (the ceiling type).
384        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    /// Builds a quote crediting `credit_sats` out of a `deposit_sats` UTXO, with
412    /// one fulfillment plan per confirmation count in `plan_confirmations`.
413    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        // Spread 1_000 of 100_000 = 100 bps, ceiling 200 bps -> claim.
442        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        // Only 1-conf+ plans available -> the background cascade waits for maturity.
452        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        // Spread 5_000 of 100_000 = 500 bps, ceiling 100 bps -> skip.
462        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        // A 0 bps ceiling admits only a zero spread.
475        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        // Spread 1_000 of 100_000 = 100 bps, ceiling exactly 100 bps -> claim (inclusive).
485        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        // The SSP spread is ~199 sats + 300 bps, so its effective bps falls as the
495        // deposit grows. A single cap therefore admits a large deposit and declines
496        // a small one (which should wait for the normal path). Values are measured.
497        let cap_bps = 400;
498        // 1_000 deposit: spread 229 -> 2290 bps -> declined.
499        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        // 100_000 deposit: spread 3_199 -> 319 bps -> claimed.
505        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        // The quote claims a 100_000 deposit, but we pass the real on-chain value
515        // (50_000). Spread is priced off the passed value: 50_000 - 49_500 = 500 =
516        // 100 bps, within the 150 bps ceiling -> claim. Pricing off the quote's
517        // 100_000 would give a 50_500 spread and decline, so a claim proves the
518        // passed value drives the gate.
519        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}