Skip to main content

lexe_api_core/models/
command.rs

1use std::collections::BTreeSet;
2
3use bitcoin::{address::NetworkUnchecked, bip32::Xpub};
4#[cfg(doc)]
5use lexe_common::root_seed::RootSeed;
6#[cfg(any(test, feature = "test-utils"))]
7use lexe_common::test_utils::arbitrary;
8use lexe_common::{
9    api::user::{NodePk, UserPk},
10    ln::{
11        amount::Amount,
12        balance::{LightningBalance, OnchainBalance},
13        channel::{LxChannelDetails, LxChannelId, LxUserChannelId},
14        hashes::Txid,
15        priority::ConfirmationPriority,
16        route::LxRoute,
17    },
18    ppm::Ppm,
19    time::TimestampMs,
20};
21use lexe_enclave::enclave::Measurement;
22use lexe_serde::hexstr_or_bytes;
23#[cfg(any(test, feature = "test-utils"))]
24use proptest_derive::Arbitrary;
25use serde::{Deserialize, Serialize};
26
27use crate::types::{
28    bounded_string::BoundedString,
29    invoice::Invoice,
30    offer::{MaxQuantity, Offer},
31    payments::{
32        ClientPaymentId, PaymentCreatedIndex, PaymentId, PaymentKind,
33        PaymentUpdatedIndex,
34    },
35    username::Username,
36};
37
38// --- General --- //
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct NodeInfoV1 {
42    pub version: semver::Version,
43    pub measurement: Measurement,
44    pub user_pk: UserPk,
45    pub node_pk: NodePk,
46    pub num_peers: usize,
47
48    pub num_usable_channels: usize,
49    pub num_channels: usize,
50    /// Our lightning channel balance
51    pub lightning_balance: LightningBalance,
52
53    /// Our on-chain wallet balance
54    pub onchain_balance: OnchainBalance,
55    /// The total # of UTXOs tracked by BDK.
56    pub num_utxos: usize,
57    /// The # of confirmed UTXOs tracked by BDK.
58    // TODO(max): LSP metrics should warn if this drops too low, as opening
59    // zeroconf with unconfirmed inputs risks double spending of channel funds.
60    pub num_confirmed_utxos: usize,
61    /// The # of unconfirmed UTXOs tracked by BDK.
62    pub num_unconfirmed_utxos: usize,
63
64    /// The channel manager's best synced block height.
65    pub best_block_height: u32,
66
67    /// The number of pending channel monitor updates.
68    /// If this isn't 0, it's likely that at least one channel is paused.
69    // TODO(max): This field is in the wrong place and should be removed.
70    // To my knowledge it is only used by integration tests (in a hacky way) to
71    // wait for a node to reach a quiescent state. The polling should be done
72    // inside the server handler rather than by the client in the test harness.
73    pub pending_monitor_updates: usize,
74}
75
76/// Information about the Lexe node.
77//
78// This is a cleaned-up version of [`NodeInfoV1`] with diagnostic fields
79// moved to [`DebugInfo`].
80#[derive(Debug, Serialize, Deserialize)]
81pub struct NodeInfo {
82    pub version: semver::Version,
83    pub measurement: Measurement,
84    pub user_pk: UserPk,
85    pub node_pk: NodePk,
86    pub num_peers: usize,
87
88    pub num_usable_channels: usize,
89    pub num_channels: usize,
90    /// Our lightning channel balance.
91    pub lightning_balance: LightningBalance,
92
93    /// Our on-chain wallet balance.
94    pub onchain_balance: OnchainBalance,
95
96    /// The channel manager's best synced block height.
97    pub best_block_height: u32,
98}
99
100impl From<NodeInfoV1> for NodeInfo {
101    fn from(v1: NodeInfoV1) -> Self {
102        Self {
103            version: v1.version,
104            measurement: v1.measurement,
105            user_pk: v1.user_pk,
106            node_pk: v1.node_pk,
107            num_peers: v1.num_peers,
108            num_usable_channels: v1.num_usable_channels,
109            num_channels: v1.num_channels,
110            lightning_balance: v1.lightning_balance,
111            onchain_balance: v1.onchain_balance,
112            best_block_height: v1.best_block_height,
113        }
114    }
115}
116
117/// Diagnostic information for debugging purposes.
118#[derive(Debug, Serialize, Deserialize)]
119pub struct DebugInfo {
120    /// Output descriptors for the on-chain wallet.
121    pub descriptors: OnchainDescriptors,
122    /// Legacy descriptors for wallets created <= node-v0.9.2.
123    /// `None` if the node doesn't have a legacy wallet.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub legacy_descriptors: Option<OnchainDescriptors>,
126
127    /// The total # of UTXOs tracked by BDK.
128    pub num_utxos: usize,
129    /// The # of confirmed UTXOs tracked by BDK.
130    // TODO(max): LSP metrics should warn if this drops too low, as opening
131    // zeroconf with unconfirmed inputs risks double spending of channel funds.
132    pub num_confirmed_utxos: usize,
133    /// The # of unconfirmed UTXOs tracked by BDK.
134    pub num_unconfirmed_utxos: usize,
135
136    /// The number of pending channel monitor updates.
137    /// If this isn't 0, it's likely that at least one channel is paused.
138    // TODO(max): This field is in the wrong place and should be removed.
139    // To my knowledge it is only used by integration tests (in a hacky way) to
140    // wait for a node to reach a quiescent state. The polling should be done
141    // inside the server handler rather than by the client in the test harness.
142    // This field is `Option` precisely so we can easily remove it later.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub pending_monitor_updates: Option<usize>,
145}
146
147/// BIP84 wpkh output descriptors for the on-chain wallet.
148///
149/// Descriptor strings include origin info and checksum. Example:
150/// "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/0/*)#dwvchw0k"
151///
152/// These are copy-pasteable into other wallets like Sparrow.
153#[derive(Clone, Debug, Serialize, Deserialize)]
154pub struct OnchainDescriptors {
155    /// BIP389 multipath descriptor for both keychains:
156    /// `wpkh([fp/84'/0'/0']xpub.../<0;1>/*)#checksum`
157    pub multipath_descriptor: String,
158
159    /// External (receive) keychain descriptor.
160    pub external_descriptor: String,
161
162    /// Internal (change) keychain descriptor.
163    pub internal_descriptor: String,
164
165    /// Account-level xpub at `m/84'/{coin}'/0'` for legacy tools.
166    pub account_xpub: Xpub,
167}
168
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170pub enum GDriveStatus {
171    Ok,
172    Error(String),
173    Disabled,
174}
175
176#[derive(Debug, Serialize, Deserialize)]
177pub struct BackupInfo {
178    pub gdrive_status: GDriveStatus,
179}
180
181/// Request to query which node enclaves need provisioning, given the client's
182/// trusted measurements.
183#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
184#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
185pub struct EnclavesToProvisionRequest {
186    /// The enclave measurements the client trusts.
187    /// Typically the 3 latest from releases.json.
188    pub trusted_measurements: BTreeSet<Measurement>,
189}
190
191/// The gateway→backend form of [`EnclavesToProvisionRequest`], carrying the
192/// `user_pk` that the gateway verified from the app's bearer auth token.
193#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
194#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
195pub struct EnclavesToProvisionForUser {
196    pub user_pk: UserPk,
197    /// The enclave measurements the client trusts.
198    pub trusted_measurements: BTreeSet<Measurement>,
199}
200
201#[derive(Debug, PartialEq, Serialize, Deserialize)]
202#[cfg_attr(test, derive(Arbitrary))]
203pub struct SetupGDrive {
204    /// The auth `code` which can used to obtain a set of GDrive credentials.
205    /// - Applicable only in staging/prod.
206    /// - If GDrive has not been setup, the node will acquire the full set of
207    ///   GDrive credentials and persist them (encrypted ofc) in Lexe's DB.
208    #[cfg_attr(test, proptest(strategy = "arbitrary::any_string()"))]
209    pub google_auth_code: String,
210
211    /// The password-encrypted [`RootSeed`] which can be backed up in
212    /// GDrive.
213    /// - Applicable only in staging/prod.
214    /// - If Drive backup is not setup, instance will back up this encrypted
215    ///   [`RootSeed`] in Google Drive. If a backup already exists, it is
216    ///   overwritten.
217    /// - We require the client to password-encrypt prior to sending the
218    ///   provision request to prevent leaking the length of the password. It
219    ///   also shifts the burden of running the 600K HMAC iterations from the
220    ///   provision instance to the mobile app.
221    #[serde(with = "hexstr_or_bytes")]
222    pub encrypted_seed: Vec<u8>,
223}
224// --- Channel Management --- //
225
226#[derive(Serialize, Deserialize)]
227pub struct ListChannelsResponse {
228    pub channels: Vec<LxChannelDetails>,
229}
230
231/// The information required for the user node to open a channel to the LSP.
232#[derive(Serialize, Deserialize)]
233pub struct OpenChannelRequest {
234    /// A user-provided id for this channel that's associated with the channel
235    /// throughout its whole lifetime, as the Lightning protocol channel id is
236    /// only known after negotiating the channel and creating the funding tx.
237    ///
238    /// This id is also used for idempotency. Retrying a request with the same
239    /// `user_channel_id` won't accidentally open another channel.
240    pub user_channel_id: LxUserChannelId,
241    /// The value of the channel we want to open.
242    pub value: Amount,
243}
244
245#[derive(Debug, Serialize, Deserialize)]
246pub struct OpenChannelResponse {
247    /// The Lightning protocol channel id of the newly created channel.
248    pub channel_id: LxChannelId,
249}
250
251#[derive(Serialize, Deserialize)]
252pub struct PreflightOpenChannelRequest {
253    /// The value of the channel we want to open.
254    pub value: Amount,
255}
256
257#[derive(Debug, Serialize, Deserialize)]
258pub struct PreflightOpenChannelResponse {
259    /// The estimated on-chain fee required to execute the channel open.
260    pub fee_estimate: Amount,
261}
262
263#[derive(Serialize, Deserialize)]
264pub struct CloseChannelRequest {
265    /// The id of the channel we want to close.
266    pub channel_id: LxChannelId,
267    /// Set to true if the channel should be force closed (unilateral).
268    /// Set to false if the channel should be cooperatively closed (bilateral).
269    pub force_close: bool,
270    /// The [`NodePk`] of our counterparty.
271    ///
272    /// If set to [`None`], the counterparty's [`NodePk`] will be determined by
273    /// calling [`list_channels`]. Setting this to [`Some`] allows
274    /// `close_channel` to avoid this relatively expensive [`Vec`] allocation.
275    ///
276    /// [`list_channels`]: lightning::ln::channelmanager::ChannelManager::list_channels
277    pub maybe_counterparty: Option<NodePk>,
278}
279
280pub type PreflightCloseChannelRequest = CloseChannelRequest;
281
282#[derive(Serialize, Deserialize)]
283pub struct PreflightCloseChannelResponse {
284    /// The estimated on-chain fee required to execute the channel close.
285    pub fee_estimate: Amount,
286}
287
288// --- Syncing and updating payments data --- //
289
290/// Upgradeable API struct for a [`PaymentId`].
291#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
292#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
293pub struct PaymentIdStruct {
294    /// The id of the payment to be fetched.
295    pub id: PaymentId,
296}
297
298/// An upgradeable version of [`Vec<PaymentId>`].
299#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
300#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
301pub struct VecPaymentId {
302    pub ids: Vec<PaymentId>,
303}
304
305/// Upgradeable API struct for a payment index.
306#[derive(Debug, PartialEq, Serialize, Deserialize)]
307#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
308pub struct PaymentCreatedIndexStruct {
309    /// The index of the payment to be fetched.
310    pub index: PaymentCreatedIndex,
311}
312
313/// Sync a batch of new payments to local storage.
314/// Results are returned in ascending `(created_at, payment_id)` order.
315#[derive(Debug, PartialEq, Serialize, Deserialize)]
316#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
317pub struct GetNewPayments {
318    /// Optional [`PaymentCreatedIndex`] at which the results should start,
319    /// exclusive. Payments with an index less than or equal to this will
320    /// not be returned.
321    pub start_index: Option<PaymentCreatedIndex>,
322    /// (Optional) the maximum number of results that can be returned.
323    pub limit: Option<u16>,
324}
325
326/// Get a batch of payments in ascending `(updated_at, payment_id)` order.
327#[derive(Debug, PartialEq, Serialize, Deserialize)]
328#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
329pub struct GetUpdatedPayments {
330    /// `(updated_at, id)` index at which the results should start, exclusive.
331    /// Payments with an index less than or equal to this will not be returned.
332    pub start_index: Option<PaymentUpdatedIndex>,
333    /// (Optional) the maximum number of results that can be returned.
334    pub limit: Option<u16>,
335}
336
337/// Get a batch of payment metadata in asc `(updated_at, payment_id)` order.
338#[derive(Debug, PartialEq, Serialize, Deserialize)]
339#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
340pub struct GetUpdatedPaymentMetadata {
341    /// `(updated_at, id)` index at which the results should start, exclusive.
342    /// Metadata with an index less than or equal to this will not be returned.
343    pub start_index: Option<PaymentUpdatedIndex>,
344    /// (Optional) the maximum number of results that can be returned.
345    pub limit: Option<u16>,
346}
347
348/// Upgradeable API struct for a list of [`PaymentCreatedIndex`]s.
349#[derive(Debug, PartialEq, Serialize, Deserialize)]
350#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
351pub struct PaymentCreatedIndexes {
352    /// The string-serialized [`PaymentCreatedIndex`]s of the payments to be
353    /// fetched. Typically, the ids passed here correspond to payments that
354    /// the mobile client currently has stored locally as "pending"; the
355    /// goal is to check whether any of these payments have been updated.
356    pub indexes: Vec<PaymentCreatedIndex>,
357}
358
359/// A request to update the personal note on a payment. Pass `None` to clear.
360#[derive(Clone, Serialize, Deserialize)]
361pub struct UpdatePersonalNote {
362    /// The index of the payment whose personal note should be updated.
363    // TODO(max): The server side only needs the `PaymentId`.
364    // This API should be changed to pass that instead.
365    pub index: PaymentCreatedIndex,
366    /// The updated note, or `None` to clear.
367    // compat: Alias added in node-v0.9.7
368    #[serde(rename = "note", alias = "personal_note")]
369    pub personal_note: Option<BoundedString>,
370}
371
372// --- BOLT11 Invoice Payments --- //
373
374/// The default [`PaymentKind`] for invoice-rail request endpoints.
375fn default_invoice_kind() -> PaymentKind {
376    PaymentKind::Invoice
377}
378
379#[derive(Serialize, Deserialize)]
380pub struct CreateInvoiceRequest {
381    pub expiry_secs: u32,
382
383    /// The amount to encode into the invoice.
384    pub amount: Option<Amount>,
385
386    /// The description to be encoded into the invoice.
387    ///
388    /// If `None`, the `description` field inside the invoice will be an empty
389    /// string (""), as lightning _requires_ a description (or description
390    /// hash) to be set.
391    /// NOTE: If both `description` and `description_hash` are set, node will
392    /// return an error.
393    pub description: Option<String>,
394
395    /// A 256-bit hash. Commonly a hash of a long description.
396    ///
397    /// This field is used to associate description longer than 639 bytes to
398    /// the invoice. Also known as '`h` tag in BOLT11'.
399    ///
400    /// This field is required to build invoices for the LNURL (LUD06)
401    /// receiving flow. Not used in other flows.
402    /// NOTE: If both `description` and `description_hash` are set, node will
403    /// return an error.
404    pub description_hash: Option<[u8; 32]>,
405
406    /// An optional message from the payer, stored with this inbound payment.
407    /// For LNURL-pay, set from the LUD-12 `comment`.
408    // compat: Alias added in node-v0.9.7
409    #[serde(rename = "payer_note", alias = "message")]
410    pub message: Option<BoundedString>,
411
412    /// An optional personal note for this invoice.
413    /// Added in `node-v0.9.10`
414    pub personal_note: Option<BoundedString>,
415
416    /// The [`PaymentKind`] to label this inbound invoice payment with.
417    /// `kind.rail()` must == `PaymentRail::Invoice` (or `kind` must be
418    /// unknown), otherwise the request is rejected.
419    ///
420    /// Defaults to [`PaymentKind::Invoice`] if not set.
421    // Added in `node-v0.9.11`
422    #[serde(default = "default_invoice_kind")]
423    pub kind: PaymentKind,
424
425    /// The partner's user_pk, if the partner is setting the fee for this
426    /// payment instead of using Lexe's default fees.
427    ///
428    /// This must be set in order for `partner_prop_fee` and `partner_base_fee`
429    /// to take effect.
430    // Added in `node-v0.9.6`
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub partner_pk: Option<UserPk>,
433
434    /// The partner-chosen proportional fee to charge on this payment.
435    /// If `partner_pk` is set, this must be set to [`Some`].
436    ///
437    /// Minimum: 5000 ppm (`LSP_USERNODE_SKIM_FEE`)
438    /// Maximum: 500,000 ppm (50%)
439    // Added in `node-v0.9.6`
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub partner_prop_fee: Option<Ppm>,
442
443    /// The partner-chosen base fee to charge on this payment.
444    ///
445    /// If this is set, the invoice `amount` must also be set.
446    // Added in `node-v0.9.6`
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub partner_base_fee: Option<Amount>,
449}
450
451impl Default for CreateInvoiceRequest {
452    fn default() -> Self {
453        Self {
454            expiry_secs: 0,
455            amount: None,
456            description: None,
457            description_hash: None,
458            message: None,
459            personal_note: None,
460            kind: default_invoice_kind(),
461            partner_pk: None,
462            partner_prop_fee: None,
463            partner_base_fee: None,
464        }
465    }
466}
467
468#[derive(Serialize, Deserialize)]
469pub struct CreateInvoiceResponse {
470    pub invoice: Invoice,
471    /// The [`PaymentCreatedIndex`] of the newly created invoice payment.
472    ///
473    /// Is always `Some` starting at `node-v0.8.10` and `lsp-v0.8.11`.
474    //
475    // TODO(max): Make non-Option once all servers are sufficiently upgraded.
476    pub created_index: Option<PaymentCreatedIndex>,
477}
478
479#[derive(Serialize, Deserialize)]
480pub struct PayInvoiceRequest {
481    /// The invoice we want to pay.
482    pub invoice: Invoice,
483    /// Specifies the amount we will pay if the invoice to be paid is
484    /// amountless. This field must be [`Some`] for amountless invoices.
485    pub fallback_amount: Option<Amount>,
486    /// An optional message to persist with this outbound payment. For
487    /// LNURL-pay, this is the LUD-12 `comment` sent during invoice
488    /// negotiation.
489    // compat: Alias added in node-v0.9.7
490    #[serde(rename = "payer_note", alias = "message")]
491    pub message: Option<BoundedString>,
492    /// An optional personal note for this payment, useful if the
493    /// receiver-provided description is insufficient.
494    // compat: Alias added in node-v0.9.7
495    #[serde(rename = "note", alias = "personal_note")]
496    pub personal_note: Option<BoundedString>,
497    /// The [`PaymentKind`] to label this outbound invoice payment with.
498    /// `kind.rail()` must == `PaymentRail::Invoice` (or `kind` must be
499    /// unknown), otherwise the request is rejected.
500    ///
501    /// Defaults to [`PaymentKind::Invoice`] if not set.
502    // Added in `node-v0.9.11`
503    #[serde(default = "default_invoice_kind")]
504    pub kind: PaymentKind,
505}
506
507#[derive(Serialize, Deserialize)]
508pub struct PayInvoiceResponse {
509    /// When the node registered this payment.
510    /// Used in the [`PaymentCreatedIndex`].
511    pub created_at: TimestampMs,
512}
513
514#[derive(Serialize, Deserialize)]
515pub struct PreflightPayInvoiceRequest {
516    /// The invoice we want to pay.
517    pub invoice: Invoice,
518    /// Specifies the amount we will pay if the invoice to be paid is
519    /// amountless. This field must be [`Some`] for amountless invoices.
520    pub fallback_amount: Option<Amount>,
521    /// The [`PaymentKind`] the real payment will be labeled with.
522    // Added in `node-v0.9.11`
523    #[serde(default = "default_invoice_kind")]
524    pub kind: PaymentKind,
525}
526
527#[derive(Serialize, Deserialize)]
528pub struct PreflightPayInvoiceResponse {
529    /// The total amount to-be-paid for the pre-flighted [`Invoice`],
530    /// excluding the fees.
531    ///
532    /// This value may be different from the value originally requested if
533    /// we had to reach `htlc_minimum_msat` for some intermediate hops.
534    pub amount: Amount,
535    /// The total amount of fees to-be-paid for the pre-flighted [`Invoice`].
536    pub fees: Amount,
537    /// The route this invoice will be paid over.
538    // Added in node,lsp-v0.7.8
539    // TODO(max): We don't actually pay over this route.
540    pub route: LxRoute,
541}
542
543// --- BOLT12 Offer payments --- //
544
545#[derive(Default, Serialize, Deserialize)]
546pub struct CreateOfferRequest {
547    /// The description to be encoded into the invoice.
548    ///
549    /// If `None`, the `description` field inside the invoice will be an empty
550    /// string (""), as lightning _requires_ a description to be set.
551    pub description: Option<BoundedString>,
552
553    /// The `min_amount` we're requesting for payments using this offer.
554    ///
555    /// If `None`, the offer is variable amount and the payer can choose any
556    /// value.
557    ///
558    /// If `Some`, the offer amount is lower-bounded and the payer must pay
559    /// this value or higher (per item, see `max_quantity`). The offer amount
560    /// must be a non-zero value if set.
561    // Renamed in node-v0.9.4
562    #[serde(alias = "amount")]
563    pub min_amount: Option<Amount>,
564
565    /// An optional expiration for the offer, in seconds from now.
566    pub expiry_secs: Option<u32>,
567
568    /// The max number of items that can be purchased in any one payment for
569    /// the offer.
570    ///
571    /// NOTE: this is not related to single-use vs reusable offers.
572    ///                                                                        
573    /// The expected amount paid for this offer is `offer.min_amount *
574    /// quantity`, where `offer.min_amount` is the value per item and
575    /// `quantity` is the number of items chosen _by the payer_. The
576    /// payer's chosen `quantity` must be in the range: `0 < quantity <=
577    /// offer.max_quantity`.
578    ///
579    /// If `None`, defaults to `MaxQuantity::ONE`, i.e., the expected paid
580    /// `amount` is just `offer.amount`.
581    pub max_quantity: Option<MaxQuantity>,
582
583    /// The issuer of the offer.
584    ///
585    /// If `Some`, offer will encode the string. Bolt12 spec expects this tring
586    /// to be a domain or a `user@domain` address.
587    /// If `None`, offer issuer will encode "lexe.app" as the issuer.
588    pub issuer: Option<BoundedString>,
589    //
590    // TODO(phlip9): add a `single_use` field to the offer request? right now
591    // all offers are reusable.
592}
593
594#[derive(Serialize, Deserialize)]
595pub struct CreateOfferResponse {
596    pub offer: Offer,
597}
598
599#[derive(Serialize, Deserialize)]
600pub struct PreflightPayOfferRequest {
601    /// The user-provided idempotency id for this payment.
602    pub cid: ClientPaymentId,
603    /// The offer we want to pay.
604    pub offer: Offer,
605    /// Specifies the amount we will pay. If the offer specifies a minimum
606    /// amount, `amount` should satisfy that minimum.
607    // Renamed and made non-optional in node-v0.9.4
608    // The old `fallback_amount = None` is technically valid and incompatible
609    // but rare, due to offers not setting amounts often
610    #[serde(alias = "fallback_amount")]
611    pub amount: Amount,
612}
613
614#[derive(Serialize, Deserialize)]
615pub struct PreflightPayOfferResponse {
616    /// The total amount to-be-paid for the pre-flighted [`Offer`],
617    /// excluding the fees.
618    ///
619    /// This value may be different from the value originally requested if
620    /// we had to reach `htlc_minimum_msat` for some intermediate hops.
621    pub amount: Amount,
622    /// The total amount of fees to-be-paid for the pre-flighted [`Offer`].
623    ///
624    /// Since we only approximate the route atm, we likely underestimate the
625    /// actual fee.
626    pub fees: Amount,
627    /// The route this offer will be paid over.
628    ///
629    /// Because we don't yet fetch the actual BOLT 12 invoice during preflight,
630    /// this route is only an approximation of the final route (we can only
631    /// route to the last public node before the offer's blinded path begins).
632    // Added in node,lsp-v0.7.8
633    // TODO(max): We don't actually pay over this route.
634    pub route: LxRoute,
635}
636
637/// The default [`PaymentKind`] for offer-rail request endpoints.
638fn default_offer_kind() -> PaymentKind {
639    PaymentKind::Offer
640}
641
642#[derive(Serialize, Deserialize)]
643pub struct PayOfferRequest {
644    /// The user-provided idempotency id for this payment.
645    pub cid: ClientPaymentId,
646    /// The offer we want to pay.
647    pub offer: Offer,
648    /// Specifies the amount we will pay. If the offer specifies a minimum
649    /// amount, `amount` should satisfy that minimum.
650    // Renamed and made non-optional in node-v0.9.4
651    // The old `fallback_amount = None` is technically valid and incompatible
652    // but rare, due to offers not setting amounts often
653    #[serde(alias = "fallback_amount")]
654    pub amount: Amount,
655    /// An optional BOLT 12 `payer_note` included with the invoice request and
656    /// visible to the recipient.
657    // compat: Alias added in node-v0.9.7
658    #[serde(rename = "payer_note", alias = "message")]
659    pub message: Option<BoundedString>,
660    /// An optional personal note for this payment, useful if the
661    /// receiver-provided description is insufficient.
662    // compat: Alias added in node-v0.9.7
663    #[serde(rename = "note", alias = "personal_note")]
664    pub personal_note: Option<BoundedString>,
665    /// The [`PaymentKind`] to label this outbound offer payment with.
666    /// `kind.rail()` must == `PaymentRail::Offer` (or `kind` must be unknown),
667    /// otherwise the request is rejected.
668    ///
669    /// Defaults to [`PaymentKind::Offer`] if not set.
670    // Added in `node-v0.9.11`
671    #[serde(default = "default_offer_kind")]
672    pub kind: PaymentKind,
673}
674
675#[derive(Serialize, Deserialize)]
676pub struct PayOfferResponse {
677    /// When the node registered this payment. Used in the
678    /// [`PaymentCreatedIndex`].
679    pub created_at: TimestampMs,
680}
681
682// --- On-chain payments --- //
683
684#[derive(Debug, PartialEq, Serialize, Deserialize)]
685#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
686pub struct GetAddressResponse {
687    #[cfg_attr(
688        any(test, feature = "test-utils"),
689        proptest(strategy = "arbitrary::any_mainnet_addr_unchecked()")
690    )]
691    pub addr: bitcoin::Address<NetworkUnchecked>,
692}
693
694#[derive(Serialize, Deserialize)]
695#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary, Debug))]
696pub struct PayOnchainRequest {
697    /// The identifier to use for this payment.
698    pub cid: ClientPaymentId,
699    /// The address we want to send funds to.
700    #[cfg_attr(
701        any(test, feature = "test-utils"),
702        proptest(strategy = "arbitrary::any_mainnet_addr_unchecked()")
703    )]
704    pub address: bitcoin::Address<NetworkUnchecked>,
705    /// How much Bitcoin we want to send.
706    pub amount: Amount,
707    /// How quickly we want our transaction to be confirmed.
708    /// The higher the priority, the more fees we will pay.
709    // See LexeEsplora for the conversion to the target number of blocks
710    pub priority: ConfirmationPriority,
711    /// An optional personal note for this payment.
712    // compat: Alias added in node-v0.9.7
713    #[serde(rename = "note", alias = "personal_note")]
714    pub personal_note: Option<BoundedString>,
715}
716
717#[derive(Serialize, Deserialize)]
718pub struct PayOnchainResponse {
719    /// When the node registered this payment. Used in the
720    /// [`PaymentCreatedIndex`].
721    pub created_at: TimestampMs,
722    /// The Bitcoin txid for the transaction we just submitted to the mempool.
723    pub txid: Txid,
724}
725
726#[derive(Debug, PartialEq, Serialize, Deserialize)]
727pub struct PreflightPayOnchainRequest {
728    /// The address we want to send funds to.
729    pub address: bitcoin::Address<NetworkUnchecked>,
730    /// How much Bitcoin we want to send.
731    pub amount: Amount,
732}
733
734#[derive(Serialize, Deserialize)]
735pub struct PreflightPayOnchainResponse {
736    /// Corresponds with [`ConfirmationPriority::High`]
737    ///
738    /// The high estimate is optional--we don't want to block the user from
739    /// sending if they only have enough for a normal tx fee.
740    pub high: Option<FeeEstimate>,
741    /// Corresponds with [`ConfirmationPriority::Normal`]
742    pub normal: FeeEstimate,
743    /// Corresponds with [`ConfirmationPriority::Background`]
744    pub background: FeeEstimate,
745}
746
747#[derive(Serialize, Deserialize)]
748pub struct FeeEstimate {
749    /// The fee amount estimate.
750    pub amount: Amount,
751}
752
753// --- Sync --- //
754
755#[derive(Serialize, Deserialize)]
756pub struct ResyncRequest {
757    /// If true, the LSP will full sync the BDK wallet and do a normal LDK
758    /// sync.
759    pub full_sync: bool,
760}
761
762// --- Username --- //
763
764/// Upserts the user's custom human Bitcoin address.
765#[derive(Debug, PartialEq, Serialize, Deserialize)]
766#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
767pub struct UpsertCustomHumanBitcoinAddress {
768    /// Username for BIP-353 and LNURL.
769    pub username: Username,
770    /// Offer to be used to fetch invoices on BIP-353.
771    pub offer: Offer,
772}
773
774/// Claims a generated human Bitcoin address.
775///
776/// This endpoint is used during node initialization to claim an auto-generated
777/// human Bitcoin address. The address will have `is_primary: false` and
778/// `is_generated: true`.
779#[derive(Debug, PartialEq, Serialize, Deserialize)]
780#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
781pub struct ClaimGeneratedHumanBitcoinAddress {
782    /// Offer to be used to fetch invoices on BIP-353.
783    pub offer: Offer,
784    /// The username to claim. This must be the username returned by
785    /// `get_generated_username`.
786    pub username: Username,
787}
788
789/// Response for `get_generated_username` endpoint.
790#[derive(Debug, PartialEq, Serialize, Deserialize)]
791#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
792pub struct GetGeneratedUsernameResponse {
793    /// The generated username that can be used for claiming an HBA.
794    pub username: Username,
795    /// Whether this user already has a claimed generated HBA.
796    /// If true, the caller should skip calling
797    /// `claim_generated_human_bitcoin_address`.
798    pub already_claimed: bool,
799}
800
801/// Response for `get_human_bitcoin_address`.
802#[derive(Debug, PartialEq, Serialize, Deserialize)]
803#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
804pub struct GetHumanBitcoinAddressResponse {
805    /// The user's active HBA, or `None` if they haven't claimed any yet.
806    pub hba: Option<ActiveHumanBitcoinAddress>,
807}
808
809/// Response for `upsert_custom_human_bitcoin_address`.
810///
811/// Unlike [`GetHumanBitcoinAddressResponse`], the HBA is never absent here: a
812/// successful upsert always yields the primary custom HBA it just set.
813#[derive(Debug, PartialEq, Serialize, Deserialize)]
814#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
815pub struct UpsertHumanBitcoinAddressResponse {
816    /// The active HBA that was just claimed, renamed, or refreshed.
817    pub hba: ActiveHumanBitcoinAddress,
818}
819
820/// The user's active Human Bitcoin Address, paired with whether they may
821/// currently change it. This is what `get`/`upsert_*_human_bitcoin_address`
822/// serve back to the app.
823#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
824#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
825pub struct ActiveHumanBitcoinAddress {
826    /// The active HBA itself.
827    pub hba: HumanBitcoinAddress,
828    /// Whether the user can currently claim a *different* custom username.
829    /// `true` when the user has no active custom HBA (never claimed one, or
830    /// theirs has lapsed), is within the 24h grace period, or is past the
831    /// 90-day freeze; `false` while frozen with an active custom HBA.
832    pub updatable: bool,
833}
834
835/// The user's active Human Bitcoin Address: the active (non-expired) primary
836/// custom HBA if present, otherwise the generated fallback.
837/// Aliases are never returned here.
838#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
839#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
840pub struct HumanBitcoinAddress {
841    /// Username for BIP-353 and LNURL.
842    pub username: Username,
843    /// Offer for fetching invoices on BIP-353.
844    pub offer: Offer,
845    /// The last time this Human Bitcoin Address was updated.
846    pub updated_at: TimestampMs,
847    /// When this Human Bitcoin Address expires, if applicable.
848    ///
849    /// Custom HBAs may or may not expire; generated fallbacks never expire.
850    pub expires_at: Option<TimestampMs>,
851    /// Whether this is the user's generated fallback HBA.
852    pub is_generated: bool,
853}
854
855/// The legacy flat HBA shape returned by the deprecated v1
856/// `*_human_bitcoin_address` endpoints, built from the v2 types above.
857#[derive(Debug, PartialEq, Serialize, Deserialize)]
858#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
859pub struct HumanBitcoinAddressV1 {
860    /// Current username for BIP-353 and LNURL.
861    pub username: Option<Username>,
862    /// Current offer for fetching invoices on BIP-353.
863    pub offer: Option<Offer>,
864    /// Last time the human Bitcoin address was updated.
865    pub updated_at: Option<TimestampMs>,
866    /// Whether the human Bitcoin address can be updated. Always `true` for
867    /// generated addresses; for claimed addresses, depends on time-based
868    /// freeze rules.
869    pub updatable: bool,
870}
871
872impl From<GetHumanBitcoinAddressResponse> for HumanBitcoinAddressV1 {
873    fn from(resp: GetHumanBitcoinAddressResponse) -> Self {
874        match resp.hba {
875            Some(active) => Self::from(active),
876            None => Self {
877                username: None,
878                offer: None,
879                updated_at: None,
880                // No active HBA ⇒ the user has never claimed a custom one,
881                // so they are always free to claim one now.
882                updatable: true,
883            },
884        }
885    }
886}
887
888impl From<UpsertHumanBitcoinAddressResponse> for HumanBitcoinAddressV1 {
889    fn from(resp: UpsertHumanBitcoinAddressResponse) -> Self {
890        Self::from(resp.hba)
891    }
892}
893
894impl From<ActiveHumanBitcoinAddress> for HumanBitcoinAddressV1 {
895    fn from(active: ActiveHumanBitcoinAddress) -> Self {
896        Self {
897            username: Some(active.hba.username),
898            offer: Some(active.hba.offer),
899            updated_at: Some(active.hba.updated_at),
900            updatable: active.updatable,
901        }
902    }
903}
904
905#[cfg(any(test, feature = "test-utils"))]
906mod arbitrary_impl {
907    use proptest::{
908        arbitrary::{Arbitrary, any},
909        strategy::{BoxedStrategy, Strategy},
910    };
911
912    use super::*;
913
914    impl Arbitrary for PreflightPayOnchainRequest {
915        type Parameters = ();
916        type Strategy = BoxedStrategy<Self>;
917
918        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
919            (arbitrary::any_mainnet_addr_unchecked(), any::<Amount>())
920                .prop_map(|(address, amount)| Self { address, amount })
921                .boxed()
922        }
923    }
924}
925
926#[cfg(test)]
927mod test {
928    use std::str::FromStr;
929
930    use lexe_common::test_utils::roundtrip;
931
932    use super::*;
933
934    #[test]
935    fn preflight_pay_onchain_roundtrip() {
936        roundtrip::query_string_roundtrip_proptest::<PreflightPayOnchainRequest>(
937        );
938    }
939
940    #[test]
941    fn payment_id_struct_roundtrip() {
942        roundtrip::query_string_roundtrip_proptest::<PaymentIdStruct>();
943    }
944
945    #[test]
946    fn payment_index_struct_roundtrip() {
947        roundtrip::query_string_roundtrip_proptest::<PaymentCreatedIndexStruct>(
948        );
949    }
950
951    #[test]
952    fn get_new_payments_roundtrip() {
953        roundtrip::query_string_roundtrip_proptest::<GetNewPayments>();
954    }
955
956    #[test]
957    fn payment_indexes_roundtrip() {
958        // This is serialized as JSON, not query strings.
959        roundtrip::json_value_roundtrip_proptest::<PaymentCreatedIndexes>();
960    }
961
962    #[test]
963    fn get_address_response_roundtrip() {
964        roundtrip::json_value_roundtrip_proptest::<GetAddressResponse>();
965    }
966
967    #[test]
968    fn setup_gdrive_request_roundtrip() {
969        roundtrip::json_string_roundtrip_proptest::<SetupGDrive>();
970    }
971
972    #[test]
973    fn upsert_custom_human_bitcoin_address_roundtrip() {
974        roundtrip::json_value_roundtrip_proptest::<
975            UpsertCustomHumanBitcoinAddress,
976        >();
977    }
978
979    #[test]
980    fn claim_generated_human_bitcoin_address_request_roundtrip() {
981        roundtrip::json_value_roundtrip_proptest::<
982            ClaimGeneratedHumanBitcoinAddress,
983        >();
984    }
985
986    #[test]
987    fn get_generated_username_response_roundtrip() {
988        roundtrip::json_value_roundtrip_proptest::<GetGeneratedUsernameResponse>(
989        );
990    }
991
992    #[test]
993    fn human_bitcoin_address_response_roundtrip() {
994        roundtrip::json_value_roundtrip_proptest::<HumanBitcoinAddressV1>();
995    }
996
997    #[test]
998    fn get_human_bitcoin_address_response_roundtrip() {
999        roundtrip::json_value_roundtrip_proptest::<
1000            GetHumanBitcoinAddressResponse,
1001        >();
1002    }
1003
1004    #[test]
1005    fn upsert_human_bitcoin_address_response_roundtrip() {
1006        roundtrip::json_value_roundtrip_proptest::<
1007            UpsertHumanBitcoinAddressResponse,
1008        >();
1009    }
1010
1011    /// Sanity check the `DebugInfo` serialization against a hard-coded string.
1012    #[test]
1013    fn debug_info_serialization() {
1014        // Account-level xpub (from BDK tests)
1015        let account_xpub = Xpub::from_str(
1016            "xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA"
1017        ).unwrap();
1018
1019        // Descriptors with real checksums (computed via miniscript)
1020        let descriptor = "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/<0;1>/*)#c8v4zjyh".to_owned();
1021        let external_descriptor = "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/0/*)#dwvchw0k".to_owned();
1022        let internal_descriptor = "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/1/*)#u6fe2mlw".to_owned();
1023
1024        let debug_info = DebugInfo {
1025            descriptors: OnchainDescriptors {
1026                multipath_descriptor: descriptor.clone(),
1027                external_descriptor: external_descriptor.clone(),
1028                internal_descriptor: internal_descriptor.clone(),
1029                account_xpub,
1030            },
1031            legacy_descriptors: None,
1032            num_utxos: 5,
1033            num_confirmed_utxos: 3,
1034            num_unconfirmed_utxos: 2,
1035            pending_monitor_updates: Some(0),
1036        };
1037
1038        // Serialize and check against expected JSON.
1039        // NOTE: Do NOT remove this raw string check. We're sanity-checking how
1040        // it looks in serialized form.
1041        let json = serde_json::to_string_pretty(&debug_info).unwrap();
1042        let expected = r#"{
1043  "descriptors": {
1044    "multipath_descriptor": "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/<0;1>/*)#c8v4zjyh",
1045    "external_descriptor": "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/0/*)#dwvchw0k",
1046    "internal_descriptor": "wpkh([be83839f/84'/0'/0']xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA/1/*)#u6fe2mlw",
1047    "account_xpub": "xpub6DCQ1YcqvZtSwGWMrwHELPehjWV3f2MGZ69yBADTxFEUAoLwb5Mp5GniQK6tTp3AgbngVz9zEFbBJUPVnkG7LFYt8QMTfbrNqs6FNEwAPKA"
1048  },
1049  "num_utxos": 5,
1050  "num_confirmed_utxos": 3,
1051  "num_unconfirmed_utxos": 2,
1052  "pending_monitor_updates": 0
1053}"#;
1054        assert_eq!(json, expected);
1055
1056        // Verify deserialization roundtrips
1057        let back: DebugInfo = serde_json::from_str(&json).unwrap();
1058        assert_eq!(back.num_utxos, 5);
1059        assert_eq!(back.num_confirmed_utxos, 3);
1060        assert_eq!(back.num_unconfirmed_utxos, 2);
1061        assert_eq!(back.descriptors.multipath_descriptor, descriptor);
1062        assert_eq!(back.descriptors.external_descriptor, external_descriptor);
1063        assert_eq!(back.descriptors.internal_descriptor, internal_descriptor);
1064        assert_eq!(
1065            back.descriptors.account_xpub,
1066            debug_info.descriptors.account_xpub
1067        );
1068        assert!(back.legacy_descriptors.is_none());
1069        assert_eq!(back.pending_monitor_updates, Some(0));
1070    }
1071}