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