Skip to main content

breez_sdk_spark/sdk/payments/
mod.rs

1use spark_wallet::LightningReceivePayment;
2use tracing::instrument;
3
4use crate::{
5    ClaimHtlcPaymentRequest, ClaimHtlcPaymentResponse, FetchConversionLimitsRequest,
6    FetchConversionLimitsResponse, GetPaymentRequest, GetPaymentResponse,
7    RefundPendingConversionsResponse, WaitForPaymentIdentifier,
8    error::SdkError,
9    models::{
10        BuildUnsignedBatchPackageRequest, BuildUnsignedTransferPackageRequest, ListPaymentsRequest,
11        ListPaymentsResponse, Payment, PaymentRequest, PrepareSendBatchRequest,
12        PrepareSendBatchResponse, PrepareSendPaymentRequest, PrepareSendPaymentResponse,
13        PublishSignedTransferPackageRequest, PublishSignedTransferPackageResponse,
14        ReceivePaymentRequest, ReceivePaymentResponse, SendBatchRequest, SendBatchResponse,
15        SendPaymentRequest, SendPaymentResponse, UnsignedTransferPackage,
16    },
17    utils::payments::get_payment_with_conversion_details,
18};
19
20use super::BreezSdk;
21
22pub(in crate::sdk) mod client_signing;
23pub(in crate::sdk) mod conversion;
24mod polling;
25pub(in crate::sdk) mod prepare;
26mod receive;
27pub(in crate::sdk) mod send;
28pub(in crate::sdk) mod validation;
29
30#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
31#[allow(clippy::needless_pass_by_value)]
32impl BreezSdk {
33    pub async fn receive_payment(
34        &self,
35        request: ReceivePaymentRequest,
36    ) -> Result<ReceivePaymentResponse, SdkError> {
37        receive::receive_payment(self, request).await
38    }
39
40    pub async fn claim_htlc_payment(
41        &self,
42        request: ClaimHtlcPaymentRequest,
43    ) -> Result<ClaimHtlcPaymentResponse, SdkError> {
44        receive::claim_htlc_payment(self, request).await
45    }
46
47    pub async fn prepare_send_payment(
48        &self,
49        request: PrepareSendPaymentRequest,
50    ) -> Result<PrepareSendPaymentResponse, SdkError> {
51        // Cross-chain has its own request type (no parse step required) — early-dispatch
52        // before falling through to the generic `Input` path.
53        if let PaymentRequest::CrossChain {
54            ref address,
55            ref route,
56            max_slippage_bps,
57            target_overpay_bps,
58        } = request.payment_request
59        {
60            let amount = request.amount.ok_or(SdkError::InvalidInput(
61                "Amount is required for cross-chain sends".to_string(),
62            ))?;
63            return prepare::cross_chain::prepare(
64                self,
65                address,
66                route,
67                amount,
68                request.token_identifier.clone(),
69                request.conversion_options.clone(),
70                request.fee_policy.unwrap_or_default(),
71                max_slippage_bps,
72                target_overpay_bps,
73            )
74            .await;
75        }
76        prepare::prepare(self, request).await
77    }
78
79    #[instrument(
80        level = "info",
81        target = "breez_sdk_core::send_payment",
82        skip_all,
83        fields(payment_id = tracing::field::Empty),
84    )]
85    pub async fn send_payment(
86        &self,
87        request: SendPaymentRequest,
88    ) -> Result<SendPaymentResponse, SdkError> {
89        self.maybe_ensure_spark_private_mode_initialized().await?;
90        if let Some(key) = request.idempotency_key.as_deref() {
91            tracing::Span::current().record("payment_id", key);
92        }
93        Box::pin(send::orchestrate_send(self, request, false, None)).await
94    }
95
96    /// Prepares a send to several payees, all paid by one transaction.
97    ///
98    /// Each recipient is a Spark address or a Spark invoice, and one batch may
99    /// span several tokens. The response resolves every invoice into the asset
100    /// and amount it requests, and reports what the batch debits per asset.
101    ///
102    /// A batch pays tokens: sending sats to several payees at once is not
103    /// supported yet, so a recipient that resolves to sats is rejected.
104    ///
105    /// A batch that pays a Spark invoice is limited to a single token: the
106    /// operators reject a transaction that carries an invoice and pays more
107    /// than one. Send those as one batch per token.
108    pub async fn prepare_send_batch(
109        &self,
110        request: PrepareSendBatchRequest,
111    ) -> Result<PrepareSendBatchResponse, SdkError> {
112        prepare::batch::prepare(self, request).await
113    }
114
115    /// Sends the batch prepared by [`BreezSdk::prepare_send_batch`], returning
116    /// one payment per recipient in recipient order.
117    ///
118    /// Retrying after a failure that leaves the outcome unknown may pay twice:
119    /// a token transfer has no idempotency key, since the operator can only be
120    /// asked about a transaction by a hash that is computed while broadcasting.
121    /// Look for the batch with a `Token` payment details filter on the
122    /// transaction hash before sending it again.
123    #[instrument(level = "info", target = "breez_sdk_core::send_batch", skip_all)]
124    pub async fn send_batch(
125        &self,
126        request: SendBatchRequest,
127    ) -> Result<SendBatchResponse, SdkError> {
128        self.maybe_ensure_spark_private_mode_initialized().await?;
129        Box::pin(send::batch::send(self, request)).await
130    }
131
132    /// Builds the unsigned package for the batch prepared by
133    /// [`BreezSdk::prepare_send_batch`], for signing outside the SDK.
134    ///
135    /// Publish the signed package with
136    /// [`BreezSdk::publish_signed_transfer_package`], which returns every payment.
137    pub async fn build_unsigned_batch_package(
138        &self,
139        request: BuildUnsignedBatchPackageRequest,
140    ) -> Result<UnsignedTransferPackage, SdkError> {
141        Box::pin(client_signing::build_unsigned_batch_package(
142            self,
143            &request.prepare_response,
144        ))
145        .await
146    }
147
148    pub async fn build_unsigned_transfer_package(
149        &self,
150        request: BuildUnsignedTransferPackageRequest,
151    ) -> Result<UnsignedTransferPackage, SdkError> {
152        Box::pin(client_signing::build_unsigned_transfer_package(
153            self,
154            &request.prepare_response,
155            request.options.as_ref(),
156        ))
157        .await
158    }
159
160    #[instrument(
161        level = "info",
162        target = "breez_sdk_core::publish_signed_transfer_package",
163        skip_all
164    )]
165    pub async fn publish_signed_transfer_package(
166        &self,
167        request: PublishSignedTransferPackageRequest,
168    ) -> Result<PublishSignedTransferPackageResponse, SdkError> {
169        self.maybe_ensure_spark_private_mode_initialized().await?;
170        Box::pin(send::publish_signed_transfer_package(
171            self,
172            &request.signed_package,
173        ))
174        .await
175    }
176
177    pub async fn fetch_conversion_limits(
178        &self,
179        request: FetchConversionLimitsRequest,
180    ) -> Result<FetchConversionLimitsResponse, SdkError> {
181        self.token_converter
182            .fetch_limits(&request)
183            .await
184            .map_err(Into::into)
185    }
186
187    /// Runs one full pass of the pending-conversion refunder and returns how
188    /// many conversions were refunded, skipped (held back by a safety window),
189    /// or failed.
190    ///
191    /// The pass has two parts: refunding locally-marked failed conversions, and
192    /// reconciling against Flashnet's clawback-eligible listing to catch
193    /// conversions with no local marker (e.g. a storage write that never
194    /// landed). The SDK's periodic background schedule runs only the local
195    /// part; the reconcile runs at SDK init and on each call to this method, so
196    /// this is the explicit entry point for driving a full pass on demand.
197    pub async fn refund_pending_conversions(
198        &self,
199    ) -> Result<RefundPendingConversionsResponse, SdkError> {
200        self.token_converter
201            .refund_pending()
202            .await
203            .map_err(Into::into)
204    }
205
206    /// Lists payments from the storage with pagination
207    ///
208    /// This method provides direct access to the payment history stored in the database.
209    /// It returns payments in reverse chronological order (newest first).
210    ///
211    /// # Arguments
212    ///
213    /// * `request` - Contains pagination parameters (offset and limit)
214    ///
215    /// # Returns
216    ///
217    /// * `Ok(ListPaymentsResponse)` - Contains the list of payments if successful
218    /// * `Err(SdkError)` - If there was an error accessing the storage
219    pub async fn list_payments(
220        &self,
221        request: ListPaymentsRequest,
222    ) -> Result<ListPaymentsResponse, SdkError> {
223        use crate::utils::conversions::extract_conversion_info;
224        use crate::utils::payments::build_conversions;
225
226        let mut payments = self.storage.list_payments(request.into()).await?;
227
228        // Query child payments for payments that have conversion_details set (AMM)
229        let parent_ids: Vec<String> = payments
230            .iter()
231            .filter(|p| p.conversion_details.is_some())
232            .map(|p| p.id.clone())
233            .collect();
234
235        let related_payments_map = if parent_ids.is_empty() {
236            std::collections::HashMap::default()
237        } else {
238            self.storage.get_payments_by_parent_ids(parent_ids).await?
239        };
240
241        for payment in &mut payments {
242            let has_conversion_details = payment.conversion_details.is_some();
243            let has_crosschain_info = extract_conversion_info(payment.details.clone())
244                .is_some_and(|info| !matches!(info, crate::ConversionInfo::Amm { .. }));
245
246            if !has_conversion_details && !has_crosschain_info {
247                continue;
248            }
249
250            let child_payments = if has_conversion_details {
251                related_payments_map.get(&payment.id).map(Vec::as_slice)
252            } else {
253                None
254            };
255
256            let conversions = build_conversions(payment, child_payments);
257
258            if !conversions.is_empty() {
259                if let Some(ref mut cd) = payment.conversion_details {
260                    cd.conversions = conversions;
261                } else {
262                    let status = extract_conversion_info(payment.details.clone())
263                        .map_or(crate::ConversionStatus::Completed, |info| {
264                            info.status().clone()
265                        });
266                    payment.conversion_details = Some(crate::models::ConversionDetails {
267                        status,
268                        conversions,
269                    });
270                }
271            }
272        }
273
274        Ok(ListPaymentsResponse { payments })
275    }
276
277    pub async fn get_payment(
278        &self,
279        request: GetPaymentRequest,
280    ) -> Result<GetPaymentResponse, SdkError> {
281        let payment =
282            get_payment_with_conversion_details(request.payment_id, self.storage.clone()).await?;
283
284        Ok(GetPaymentResponse { payment })
285    }
286}
287
288// Private payment methods
289impl BreezSdk {
290    pub(crate) async fn receive_bolt11_invoice(
291        &self,
292        description: String,
293        amount_sats: Option<u64>,
294        expiry_secs: Option<u32>,
295        payment_hash: Option<String>,
296        receiver_identity_public_key: Option<String>,
297    ) -> Result<ReceivePaymentResponse, SdkError> {
298        receive::receive_bolt11_invoice(
299            self,
300            description,
301            amount_sats,
302            expiry_secs,
303            payment_hash,
304            receiver_identity_public_key,
305        )
306        .await
307    }
308
309    pub(crate) async fn receive_bolt11_invoice_inner(
310        &self,
311        description: String,
312        amount_sats: Option<u64>,
313        expiry_secs: Option<u32>,
314        payment_hash: Option<String>,
315        receiver_identity_public_key: Option<String>,
316    ) -> Result<LightningReceivePayment, SdkError> {
317        receive::receive_bolt11_invoice_inner(
318            self,
319            description,
320            amount_sats,
321            expiry_secs,
322            payment_hash,
323            receiver_identity_public_key,
324        )
325        .await
326    }
327
328    pub(crate) async fn wait_for_incoming_payment(
329        &self,
330        identifier: WaitForPaymentIdentifier,
331        completion_timeout_secs: u32,
332    ) -> Result<Payment, SdkError> {
333        polling::wait_for_incoming_payment(self, identifier, completion_timeout_secs).await
334    }
335
336    pub(crate) async fn finalize_payment(&self, payment: Payment) -> bool {
337        polling::finalize_payment(self, payment).await
338    }
339}