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