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                let should_emit_event = self.storage.apply_payment_update(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                        should_emit_event,
52                    })
53                    .await;
54                Ok(ClaimDepositResponse { payment })
55            }
56            Err(e) => {
57                error!("Failed to claim deposit: {e:?}");
58                self.storage
59                    .update_deposit(
60                        detailed_utxo.txid.to_string(),
61                        detailed_utxo.vout,
62                        UpdateDepositPayload::ClaimError {
63                            error: e.clone().into(),
64                        },
65                    )
66                    .await?;
67                Err(e)
68            }
69        }
70    }
71
72    pub async fn refund_deposit(
73        &self,
74        request: RefundDepositRequest,
75    ) -> Result<RefundDepositResponse, SdkError> {
76        let detailed_utxo =
77            CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
78                .fetch_detailed_utxo(&request.txid, request.vout)
79                .await?;
80        let tx = self
81            .spark_wallet
82            .refund_static_deposit(
83                detailed_utxo.clone().tx,
84                Some(detailed_utxo.vout),
85                &request.destination_address,
86                request.fee.into(),
87            )
88            .await?;
89        let tx_hex = serialize(&tx).as_hex().to_string();
90        let tx_id = tx.compute_txid().as_raw_hash().to_string();
91
92        // Store the refund transaction details separately
93        self.storage
94            .update_deposit(
95                detailed_utxo.txid.to_string(),
96                detailed_utxo.vout,
97                UpdateDepositPayload::Refund {
98                    refund_tx: tx_hex.clone(),
99                    refund_txid: tx_id.clone(),
100                },
101            )
102            .await?;
103
104        self.chain_service
105            .broadcast_transaction(tx_hex.clone())
106            .await?;
107        Ok(RefundDepositResponse { tx_id, tx_hex })
108    }
109
110    #[allow(unused_variables)]
111    pub async fn list_unclaimed_deposits(
112        &self,
113        request: ListUnclaimedDepositsRequest,
114    ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
115        let deposits = self.storage.list_deposits().await?;
116        Ok(ListUnclaimedDepositsResponse { deposits })
117    }
118}
119
120impl BreezSdk {
121    /// Looks up the transfer produced by a static deposit claim, retrying
122    /// while the Spark operators have not yet indexed it. The SSP commits
123    /// the claim synchronously, but there is a brief window before the
124    /// transfer becomes queryable from the operators; transient query
125    /// errors are also retried. Returns the last error if every attempt
126    /// fails.
127    async fn lookup_claim_transfer_with_retry(
128        &self,
129        transfer_id: String,
130    ) -> Result<WalletTransfer, SdkError> {
131        let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
132        let mut last_error: Option<SdkError> = None;
133
134        for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
135            if attempt > 0 {
136                let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
137                    .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
138                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
139                trace!(
140                    "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
141                    attempt.saturating_add(1),
142                    CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
143                );
144            }
145
146            match self
147                .spark_wallet
148                .list_transfers(ListTransfersRequest {
149                    transfer_ids: vec![parsed_id.clone()],
150                    paging: None,
151                })
152                .await
153            {
154                Ok(mut resp) => {
155                    if let Some(transfer) = resp.items.pop() {
156                        return Ok(transfer);
157                    }
158                    last_error = None;
159                }
160                Err(e) => last_error = Some(e.into()),
161            }
162        }
163
164        Err(last_error
165            .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
166    }
167}