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::{ListTransfersRequest, TransferId, WalletTransfer};
6use tracing::{error, trace};
7
8use crate::{
9    ClaimDepositRequest, ClaimDepositResponse, ListUnclaimedDepositsRequest,
10    ListUnclaimedDepositsResponse, RefundDepositRequest, RefundDepositResponse, error::SdkError,
11    models::Payment, persist::UpdateDepositPayload, sdk::RuntimeEvent,
12    utils::utxo_fetcher::CachedUtxoFetcher,
13};
14
15use super::BreezSdk;
16
17// Retry parameters for looking up the transfer created by a static deposit
18// claim while it propagates across Spark operators.
19const CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS: u32 = 3;
20const CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS: u64 = 500;
21
22#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
23#[allow(clippy::needless_pass_by_value)]
24impl BreezSdk {
25    pub async fn claim_deposit(
26        &self,
27        request: ClaimDepositRequest,
28    ) -> Result<ClaimDepositResponse, SdkError> {
29        self.maybe_ensure_spark_private_mode_initialized().await?;
30        let detailed_utxo =
31            CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
32                .fetch_detailed_utxo(&request.txid, request.vout)
33                .await?;
34
35        let max_fee = request
36            .max_fee
37            .or(self.config.max_deposit_claim_fee.clone());
38        match self.claim_utxo(&detailed_utxo, max_fee).await {
39            Ok(transfer_id) => {
40                let transfer = self.lookup_claim_transfer_with_retry(transfer_id).await?;
41                let payment: Payment = transfer.try_into()?;
42                // Insert the payment before returning so callers that
43                // immediately list payments see the claim.
44                self.storage.insert_payment(payment.clone()).await?;
45                self.storage
46                    .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
47                    .await?;
48                self.event_emitter
49                    .emit_runtime_event(RuntimeEvent::DepositClaimed {
50                        payment: Box::new(payment.clone()),
51                    })
52                    .await;
53                Ok(ClaimDepositResponse { payment })
54            }
55            Err(e) => {
56                error!("Failed to claim deposit: {e:?}");
57                self.storage
58                    .update_deposit(
59                        detailed_utxo.txid.to_string(),
60                        detailed_utxo.vout,
61                        UpdateDepositPayload::ClaimError {
62                            error: e.clone().into(),
63                        },
64                    )
65                    .await?;
66                Err(e)
67            }
68        }
69    }
70
71    pub async fn refund_deposit(
72        &self,
73        request: RefundDepositRequest,
74    ) -> Result<RefundDepositResponse, SdkError> {
75        let detailed_utxo =
76            CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
77                .fetch_detailed_utxo(&request.txid, request.vout)
78                .await?;
79        let tx = self
80            .spark_wallet
81            .refund_static_deposit(
82                detailed_utxo.clone().tx,
83                Some(detailed_utxo.vout),
84                &request.destination_address,
85                request.fee.into(),
86            )
87            .await?;
88        let tx_hex = serialize(&tx).as_hex().to_string();
89        let tx_id = tx.compute_txid().as_raw_hash().to_string();
90
91        // Store the refund transaction details separately
92        self.storage
93            .update_deposit(
94                detailed_utxo.txid.to_string(),
95                detailed_utxo.vout,
96                UpdateDepositPayload::Refund {
97                    refund_tx: tx_hex.clone(),
98                    refund_txid: tx_id.clone(),
99                },
100            )
101            .await?;
102
103        self.chain_service
104            .broadcast_transaction(tx_hex.clone())
105            .await?;
106        Ok(RefundDepositResponse { tx_id, tx_hex })
107    }
108
109    #[allow(unused_variables)]
110    pub async fn list_unclaimed_deposits(
111        &self,
112        request: ListUnclaimedDepositsRequest,
113    ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
114        let deposits = self.storage.list_deposits().await?;
115        Ok(ListUnclaimedDepositsResponse { deposits })
116    }
117}
118
119impl BreezSdk {
120    /// Looks up the transfer produced by a static deposit claim, retrying
121    /// while the Spark operators have not yet indexed it. The SSP commits
122    /// the claim synchronously, but there is a brief window before the
123    /// transfer becomes queryable from the operators; transient query
124    /// errors are also retried. Returns the last error if every attempt
125    /// fails.
126    async fn lookup_claim_transfer_with_retry(
127        &self,
128        transfer_id: String,
129    ) -> Result<WalletTransfer, SdkError> {
130        let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
131        let mut last_error: Option<SdkError> = None;
132
133        for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
134            if attempt > 0 {
135                let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
136                    .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
137                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
138                trace!(
139                    "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
140                    attempt.saturating_add(1),
141                    CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
142                );
143            }
144
145            match self
146                .spark_wallet
147                .list_transfers(ListTransfersRequest {
148                    transfer_ids: vec![parsed_id.clone()],
149                    paging: None,
150                })
151                .await
152            {
153                Ok(mut resp) => {
154                    if let Some(transfer) = resp.items.pop() {
155                        return Ok(transfer);
156                    }
157                    last_error = None;
158                }
159                Err(e) => last_error = Some(e.into()),
160            }
161        }
162
163        Err(last_error
164            .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
165    }
166}