Skip to main content

fedimint_gateway_common/
lib.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::str::FromStr;
4use std::time::{Duration, SystemTime};
5
6use bitcoin::address::NetworkUnchecked;
7use bitcoin::hashes::sha256;
8use bitcoin::secp256k1::PublicKey;
9use bitcoin::{Address, Network, OutPoint};
10use clap::Subcommand;
11use envs::{
12    FM_LDK_ALIAS_ENV, FM_LND_MACAROON_ENV, FM_LND_PAYMENT_TIMEOUT_SECS_ENV, FM_LND_RPC_ADDR_ENV,
13    FM_LND_TIME_PREF_ENV, FM_LND_TLS_CERT_ENV, FM_PORT_LDK,
14};
15use fedimint_core::config::{FederationId, JsonClientConfig};
16use fedimint_core::encoding::{Decodable, Encodable};
17use fedimint_core::invite_code::InviteCode;
18use fedimint_core::util::{SafeUrl, get_average, get_median};
19use fedimint_core::{Amount, BitcoinAmountOrAll, secp256k1};
20use fedimint_eventlog::{EventKind, EventLogId, PersistedLogEntry, StructuredPaymentEvents};
21use fedimint_lnv2_common::gateway_api::PaymentFee;
22use fedimint_wallet_client::PegOutFees;
23use lightning::ln::msgs::SocketAddress;
24use lightning_invoice::Bolt11Invoice;
25use serde::de::Error as _;
26use serde::{Deserialize, Deserializer, Serialize, Serializer};
27
28pub mod envs;
29pub mod federation_status;
30
31pub use federation_status::*;
32
33pub const V1_API_ENDPOINT: &str = "v1";
34
35pub const ADDRESS_ENDPOINT: &str = "/address";
36pub const ADDRESS_RECHECK_ENDPOINT: &str = "/address_recheck";
37pub const BACKUP_ENDPOINT: &str = "/backup";
38pub const CONFIGURATION_ENDPOINT: &str = "/config";
39pub const CONNECT_FED_ENDPOINT: &str = "/connect_fed";
40pub const CREATE_BOLT11_INVOICE_FOR_OPERATOR_ENDPOINT: &str = "/create_bolt11_invoice_for_operator";
41pub const CREATE_BOLT12_OFFER_FOR_OPERATOR_ENDPOINT: &str = "/create_bolt12_offer_for_operator";
42pub const FEDERATION_STATUS_ENDPOINT: &str = "/federation_status";
43pub const GATEWAY_INFO_ENDPOINT: &str = "/info";
44pub const INVITE_CODES_ENDPOINT: &str = "/invite_codes";
45pub const GET_BALANCES_ENDPOINT: &str = "/balances";
46pub const GET_INVOICE_ENDPOINT: &str = "/get_invoice";
47pub const GET_LN_ONCHAIN_ADDRESS_ENDPOINT: &str = "/get_ln_onchain_address";
48pub const LEAVE_FED_ENDPOINT: &str = "/leave_fed";
49pub const LIST_CHANNELS_ENDPOINT: &str = "/list_channels";
50pub const LIST_TRANSACTIONS_ENDPOINT: &str = "/list_transactions";
51pub const MNEMONIC_ENDPOINT: &str = "/mnemonic";
52pub const CONNECT_PEER_ENDPOINT: &str = "/connect_peer";
53pub const OPEN_CHANNEL_ENDPOINT: &str = "/open_channel";
54pub const OPEN_CHANNEL_WITH_PUSH_ENDPOINT: &str = "/open_channel_with_push";
55pub const CLOSE_CHANNELS_WITH_PEER_ENDPOINT: &str = "/close_channels_with_peer";
56pub const PAY_INVOICE_FOR_OPERATOR_ENDPOINT: &str = "/pay_invoice_for_operator";
57pub const PAY_OFFER_FOR_OPERATOR_ENDPOINT: &str = "/pay_offer_for_operator";
58pub const PAYMENT_LOG_ENDPOINT: &str = "/payment_log";
59pub const PAYMENT_SUMMARY_ENDPOINT: &str = "/payment_summary";
60pub const PEGIN_FROM_ONCHAIN_ENDPOINT: &str = "/pegin_from_onchain";
61pub const RECEIVE_ECASH_ENDPOINT: &str = "/receive_ecash";
62pub const SET_CHANNEL_FEES_ENDPOINT: &str = "/set_channel_fees";
63pub const SET_FEES_ENDPOINT: &str = "/set_fees";
64pub const SET_PAYMENT_POLICY_ENDPOINT: &str = "/set_payment_policy";
65pub const STOP_ENDPOINT: &str = "/stop";
66pub const SEND_ONCHAIN_ENDPOINT: &str = "/send_onchain";
67pub const SPEND_ECASH_ENDPOINT: &str = "/spend_ecash";
68pub const WITHDRAW_ENDPOINT: &str = "/withdraw";
69pub const WITHDRAW_TO_ONCHAIN_ENDPOINT: &str = "/withdraw_to_onchain";
70
71pub const DEFAULT_LIGHTNING_PORT: u16 = 9735;
72
73#[derive(Debug, Serialize, Deserialize, Clone)]
74pub struct ConnectFedPayload {
75    pub invite_code: String,
76    pub use_tor: Option<bool>,
77    pub recover: Option<bool>,
78}
79
80#[derive(Debug, Serialize, Deserialize, Clone)]
81pub struct LeaveFedPayload {
82    pub federation_id: FederationId,
83}
84
85#[derive(Debug, Serialize, Deserialize)]
86pub struct InfoPayload;
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct BackupPayload {
90    pub federation_id: FederationId,
91}
92
93#[derive(Debug, Serialize, Deserialize, Clone)]
94pub struct ConfigPayload {
95    pub federation_id: Option<FederationId>,
96}
97
98#[derive(Debug, Serialize, Deserialize, Clone)]
99pub struct DepositAddressPayload {
100    pub federation_id: FederationId,
101}
102
103#[derive(Debug, Serialize, Deserialize, Clone)]
104pub struct PeginFromOnchainPayload {
105    pub federation_id: FederationId,
106    pub amount: BitcoinAmountOrAll,
107    pub fee_rate_sats_per_vbyte: u64,
108}
109
110#[derive(Debug, Serialize, Deserialize, Clone)]
111pub struct DepositAddressRecheckPayload {
112    pub address: Address<NetworkUnchecked>,
113    pub federation_id: FederationId,
114}
115
116#[derive(Debug, Serialize, Deserialize, Clone)]
117pub struct WithdrawPayload {
118    pub federation_id: FederationId,
119    pub amount: BitcoinAmountOrAll,
120    pub address: Address<NetworkUnchecked>,
121    /// When provided (from UI preview flow), uses these quoted fees.
122    /// When None, fetches current fees from the wallet.
123    #[serde(default)]
124    pub quoted_fees: Option<PegOutFees>,
125}
126
127#[derive(Debug, Serialize, Deserialize, Clone)]
128pub struct WithdrawToOnchainPayload {
129    pub federation_id: FederationId,
130    pub amount: BitcoinAmountOrAll,
131}
132
133#[derive(Debug, Serialize, Deserialize, Clone)]
134pub struct WithdrawResponse {
135    pub txid: bitcoin::Txid,
136    pub fees: PegOutFees,
137}
138
139#[derive(Debug, Serialize, Deserialize, Clone)]
140pub struct WithdrawPreviewPayload {
141    pub federation_id: FederationId,
142    pub amount: BitcoinAmountOrAll,
143    pub address: Address<NetworkUnchecked>,
144}
145
146#[derive(Debug, Serialize, Deserialize, Clone)]
147pub struct WithdrawPreviewResponse {
148    pub withdraw_amount: Amount,
149    pub address: String,
150    pub peg_out_fees: PegOutFees,
151    pub total_cost: Amount,
152    /// Estimated mint fees when withdrawing all. None for partial withdrawals.
153    #[serde(default)]
154    pub mint_fees: Option<Amount>,
155}
156
157/// Deprecated, unused, doesn't do anything
158///
159/// Only here for backward-compat reasons.
160#[allow(deprecated)]
161#[derive(Debug, Clone, Eq, PartialEq, Encodable, Decodable, Serialize, Deserialize)]
162pub enum ConnectorType {
163    Tcp,
164    Tor,
165}
166
167#[derive(Debug, Clone, Eq, PartialEq, Encodable, Decodable, Serialize, Deserialize)]
168pub struct FederationConfig {
169    pub invite_code: InviteCode,
170    // Unique integer identifier per-federation that is assigned when the gateways joins a
171    // federation.
172    #[serde(alias = "mint_channel_id")]
173    pub federation_index: u64,
174    pub lightning_fee: PaymentFee,
175    pub transaction_fee: PaymentFee,
176    /// Restrictions on the payments the gateway performs on behalf of this
177    /// federation's clients. Empty means every kind of payment is performed.
178    /// Changed with `set_payment_policy`.
179    ///
180    /// Gateways that predate this field do not send it and perform every kind
181    /// of payment, so it defaults to no restrictions when absent.
182    #[serde(default)]
183    pub payment_policies: BTreeSet<PaymentPolicy>,
184    #[allow(deprecated)] // only here for decoding backward-compat
185    pub _connector: ConnectorType,
186}
187
188impl FederationConfig {
189    /// Whether the gateway accepts incoming Lightning payments on behalf of
190    /// this federation's clients.
191    pub fn receive_enabled(&self) -> bool {
192        !self
193            .payment_policies
194            .contains(&PaymentPolicy::ReceivesDisabled)
195    }
196}
197
198/// A restriction on the payments the gateway performs on behalf of a
199/// federation's clients.
200#[derive(
201    Debug,
202    Clone,
203    Copy,
204    Eq,
205    PartialEq,
206    Ord,
207    PartialOrd,
208    Hash,
209    Encodable,
210    Decodable,
211    Serialize,
212    Deserialize,
213)]
214pub enum PaymentPolicy {
215    /// Do not accept incoming Lightning payments: refuse to create invoices
216    /// and fail back the payments of invoices that were already issued.
217    ReceivesDisabled,
218}
219
220/// Information about one of the feds we are connected to
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
222pub struct FederationInfo {
223    pub federation_id: FederationId,
224    pub federation_name: Option<String>,
225    pub balance_msat: Amount,
226    pub config: FederationConfig,
227    pub last_backup_time: Option<SystemTime>,
228}
229
230#[derive(Debug, Serialize, Deserialize, PartialEq)]
231pub struct GatewayInfo {
232    pub version_hash: String,
233    pub federations: Vec<FederationInfo>,
234    /// Mapping from short channel id to the federation id that it belongs to.
235    // TODO: Remove this alias once it no longer breaks backwards compatibility.
236    #[serde(alias = "channels")]
237    pub federation_fake_scids: Option<BTreeMap<u64, FederationId>>,
238    pub gateway_state: String,
239    pub lightning_info: LightningInfo,
240    pub lightning_mode: LightningMode,
241    pub registrations: BTreeMap<RegisteredProtocol, (SafeUrl, secp256k1::PublicKey)>,
242}
243
244#[derive(Debug, Serialize, Deserialize, PartialEq)]
245pub struct GatewayFedConfig {
246    pub federations: BTreeMap<FederationId, JsonClientConfig>,
247}
248
249#[derive(Debug, Serialize, Deserialize, Clone)]
250pub struct SetFeesPayload {
251    pub federation_id: Option<FederationId>,
252    pub lightning_base: Option<Amount>,
253    pub lightning_parts_per_million: Option<u64>,
254    pub transaction_base: Option<Amount>,
255    pub transaction_parts_per_million: Option<u64>,
256}
257
258/// Changes which payments the gateway performs on behalf of a federation's
259/// clients. Like `SetFeesPayload`, every setting is optional and only the ones
260/// present change, so further kinds of payments can be added later.
261#[derive(Debug, Serialize, Deserialize, Clone)]
262pub struct SetPaymentPolicyPayload {
263    /// The federation to change, or every connected federation when absent
264    pub federation_id: Option<FederationId>,
265    /// Whether to accept incoming Lightning payments for the federation's
266    /// clients. Turning this off refuses new invoices and fails back the
267    /// payments of invoices that were already issued.
268    pub receive_enabled: Option<bool>,
269}
270
271#[derive(Debug, Serialize, Deserialize, Clone)]
272pub struct CreateInvoiceForOperatorPayload {
273    pub amount_msats: u64,
274    pub expiry_secs: Option<u32>,
275    pub description: Option<String>,
276}
277
278#[derive(Debug, Serialize, Deserialize, Clone)]
279pub struct PayInvoiceForOperatorPayload {
280    pub invoice: Bolt11Invoice,
281}
282
283#[derive(Debug, Serialize, Deserialize, Clone)]
284pub struct SpendEcashPayload {
285    /// Federation id of the e-cash to spend
286    pub federation_id: FederationId,
287    /// The amount of e-cash to spend
288    pub amount: Amount,
289}
290
291#[derive(Debug, Serialize, Deserialize, Clone)]
292pub struct SpendEcashResponse {
293    /// OOBNotes.to_string() for v1, base32::encode_prefixed() for v2
294    pub notes: String,
295}
296
297#[derive(Debug, Serialize, Deserialize, Clone)]
298pub struct ReceiveEcashPayload {
299    /// Can be OOBNotes (v1) or ECash (v2)
300    pub notes: String,
301}
302
303#[derive(Debug, Serialize, Deserialize, Clone)]
304pub struct ReceiveEcashResponse {
305    pub amount: Amount,
306}
307
308#[derive(serde::Serialize, serde::Deserialize, Clone)]
309pub struct GatewayBalances {
310    pub onchain_balance_sats: u64,
311    pub lightning_balance_msats: u64,
312    pub ecash_balances: Vec<FederationBalanceInfo>,
313    pub inbound_lightning_liquidity_msats: u64,
314}
315
316#[derive(serde::Serialize, serde::Deserialize, Clone)]
317pub struct FederationBalanceInfo {
318    pub federation_id: FederationId,
319    pub ecash_balance_msats: Amount,
320}
321
322#[derive(Debug, Serialize, Deserialize, Clone)]
323pub struct MnemonicResponse {
324    pub mnemonic: Vec<String>,
325
326    // Legacy federations are federations that the gateway joined prior to v0.5.0
327    // and do not derive their secrets from the gateway's mnemonic. They also use
328    // a separate database from the gateway's db.
329    pub legacy_federations: Vec<FederationId>,
330}
331
332#[derive(Debug, Serialize, Deserialize, Clone)]
333pub struct PaymentLogPayload {
334    // The position in the log to stop querying. No events will be returned from after
335    // this `EventLogId`. If it is `None`, the last `EventLogId` is used.
336    pub end_position: Option<EventLogId>,
337
338    // The number of events to return
339    pub pagination_size: usize,
340
341    pub federation_id: FederationId,
342
343    /// Filter to only return events of these kinds. If empty, defaults to
344    /// `ALL_GATEWAY_EVENTS` (gateway payment-related events only, not all
345    /// events in the log).
346    ///
347    /// Note: returned event IDs may be non-contiguous because other internal
348    /// events (e.g. `tx-created`, `NoteCreated`) share the same ID space but
349    /// are filtered out.
350    pub event_kinds: Vec<EventKind>,
351}
352
353#[derive(Debug, Serialize, Deserialize, Clone)]
354pub struct PaymentLogResponse(pub Vec<PersistedLogEntry>);
355
356#[derive(Debug, Serialize, Deserialize, Clone)]
357pub struct PaymentSummaryResponse {
358    pub outgoing: PaymentStats,
359    pub incoming: PaymentStats,
360}
361
362#[derive(Debug, Serialize, Deserialize, Clone)]
363pub struct PaymentStats {
364    pub average_latency: Option<Duration>,
365    pub median_latency: Option<Duration>,
366    pub total_fees: Amount,
367    pub total_success: usize,
368    pub total_failure: usize,
369}
370
371impl PaymentStats {
372    /// Computes the payment statistics for the given structured payment events.
373    pub fn compute(events: &StructuredPaymentEvents) -> Self {
374        PaymentStats {
375            average_latency: get_average(&events.latencies_usecs).map(Duration::from_micros),
376            median_latency: get_median(&events.latencies_usecs).map(Duration::from_micros),
377            total_fees: Amount::from_msats(events.fees.iter().map(|a| a.msats).sum()),
378            total_success: events.latencies_usecs.len(),
379            total_failure: events.latencies_failure.len(),
380        }
381    }
382}
383
384#[derive(Debug, Serialize, Deserialize, Clone)]
385pub struct PaymentSummaryPayload {
386    pub start_millis: u64,
387    pub end_millis: u64,
388}
389
390#[derive(Serialize, Deserialize, Debug, Clone)]
391pub struct ChannelInfo {
392    pub remote_pubkey: secp256k1::PublicKey,
393    pub channel_size_sats: u64,
394    pub outbound_liquidity_sats: u64,
395    pub inbound_liquidity_sats: u64,
396    pub is_active: bool,
397    pub funding_outpoint: Option<OutPoint>,
398    pub remote_node_alias: Option<String>,
399    #[serde(default)]
400    pub remote_address: Option<String>,
401    /// The local-side base routing fee (msat) currently advertised for this
402    /// channel. `None` if the backend could not report a fee policy.
403    #[serde(default)]
404    pub base_fee_msat: Option<u64>,
405    /// The local-side proportional routing fee (parts per million) currently
406    /// advertised for this channel. `None` if the backend could not report a
407    /// fee policy.
408    #[serde(default)]
409    pub parts_per_million: Option<u64>,
410}
411
412#[derive(Debug, Serialize, Deserialize, Clone)]
413pub struct OpenChannelRequest {
414    pub pubkey: secp256k1::PublicKey,
415    pub host: String,
416    pub channel_size_sats: u64,
417    pub push_amount_sats: u64,
418    /// Feerate (sat/vB) for the channel-opening on-chain transaction. If
419    /// `None`, the Lightning backend picks a feerate. Not honored by all
420    /// backends (e.g. LDK manages its own fee estimation).
421    #[serde(default, deserialize_with = "empty_string_as_none")]
422    pub fee_rate_sats_per_vbyte: Option<u64>,
423    /// Base routing fee (msat) advertised for the opened channel. If `None`,
424    /// the backend's default policy is used.
425    #[serde(default, deserialize_with = "empty_string_as_none")]
426    pub base_fee_msat: Option<u64>,
427    /// Proportional routing fee (parts per million) advertised for the opened
428    /// channel. If `None`, the backend's default policy is used.
429    #[serde(default, deserialize_with = "empty_string_as_none")]
430    pub parts_per_million: Option<u64>,
431}
432
433#[derive(Debug, Serialize, Deserialize, Clone)]
434pub struct ConnectPeerRequest {
435    pub node_address: NodeAddress,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct NodeAddress {
440    pub pubkey: secp256k1::PublicKey,
441    pub address: SocketAddress,
442}
443
444impl NodeAddress {
445    pub fn host_with_port(&self) -> String {
446        self.address.to_string()
447    }
448}
449
450impl FromStr for NodeAddress {
451    type Err = String;
452
453    fn from_str(input: &str) -> Result<Self, Self::Err> {
454        let (pubkey, address) = input
455            .trim()
456            .split_once('@')
457            .ok_or_else(|| "Expected node address in pubkey@host[:port] format".to_string())?;
458        let pubkey = pubkey
459            .parse()
460            .map_err(|err| format!("Invalid peer public key: {err}"))?;
461        let address = address.trim();
462        if address.is_empty() {
463            return Err("Peer host must not be empty".to_string());
464        }
465        let address = if has_explicit_port(address) {
466            address.to_string()
467        } else {
468            format!("{address}:{DEFAULT_LIGHTNING_PORT}")
469        };
470        let address = SocketAddress::from_str(&address)
471            .map_err(|err| format!("Invalid peer address: {err}"))?;
472
473        Ok(NodeAddress { pubkey, address })
474    }
475}
476
477impl fmt::Display for NodeAddress {
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        let address = self.address.to_string();
480        if address.ends_with(&format!(":{DEFAULT_LIGHTNING_PORT}")) {
481            write!(
482                f,
483                "{}@{}",
484                self.pubkey,
485                &address[..address.len() - format!(":{DEFAULT_LIGHTNING_PORT}").len()]
486            )
487        } else {
488            write!(f, "{}@{}", self.pubkey, address)
489        }
490    }
491}
492
493fn has_explicit_port(address: &str) -> bool {
494    if address.starts_with('[') {
495        return address.contains("]:");
496    }
497
498    address.contains(':')
499}
500
501impl Serialize for NodeAddress {
502    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
503    where
504        S: Serializer,
505    {
506        serializer.serialize_str(&self.to_string())
507    }
508}
509
510impl<'de> Deserialize<'de> for NodeAddress {
511    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
512    where
513        D: Deserializer<'de>,
514    {
515        String::deserialize(deserializer)?
516            .parse()
517            .map_err(D::Error::custom)
518    }
519}
520
521#[derive(Debug, Serialize, Deserialize, Clone)]
522pub struct SetChannelFeesRequest {
523    /// Funding outpoint identifying the channel whose advertised routing fees
524    /// should be updated.
525    pub funding_outpoint: OutPoint,
526    /// New base routing fee in millisatoshis.
527    pub base_fee_msat: u64,
528    /// New proportional routing fee in parts per million.
529    pub parts_per_million: u64,
530}
531
532/// Helper for serde: deserializes missing values and empty form strings as
533/// `None`. Lets the same struct be used for JSON payloads and HTMX form
534/// submissions where numeric inputs may be left blank.
535fn empty_string_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
536where
537    D: serde::Deserializer<'de>,
538    T: std::str::FromStr + Deserialize<'de>,
539    T::Err: std::fmt::Display,
540{
541    #[derive(Deserialize)]
542    #[serde(untagged)]
543    enum StringOrT<T> {
544        String(String),
545        T(T),
546    }
547
548    match Option::<StringOrT<T>>::deserialize(deserializer)? {
549        None => Ok(None),
550        Some(StringOrT::T(value)) => Ok(Some(value)),
551        Some(StringOrT::String(s)) if s.trim().is_empty() => Ok(None),
552        Some(StringOrT::String(s)) => s
553            .trim()
554            .parse::<T>()
555            .map(Some)
556            .map_err(serde::de::Error::custom),
557    }
558}
559
560#[derive(Debug, Serialize, Deserialize, Clone)]
561pub struct SendOnchainRequest {
562    pub address: Address<NetworkUnchecked>,
563    pub amount: BitcoinAmountOrAll,
564    pub fee_rate_sats_per_vbyte: u64,
565}
566
567impl fmt::Display for SendOnchainRequest {
568    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569        write!(
570            f,
571            "SendOnchainRequest {{ address: {}, amount: {}, fee_rate_sats_per_vbyte: {} }}",
572            self.address.assume_checked_ref(),
573            self.amount,
574            self.fee_rate_sats_per_vbyte
575        )
576    }
577}
578
579#[derive(Debug, Serialize, Deserialize, Clone)]
580pub struct CloseChannelsWithPeerRequest {
581    pub pubkey: secp256k1::PublicKey,
582    #[serde(default)]
583    pub force: bool,
584    pub sats_per_vbyte: Option<u64>,
585}
586
587impl fmt::Display for CloseChannelsWithPeerRequest {
588    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
589        write!(
590            f,
591            "CloseChannelsWithPeerRequest {{ pubkey: {}, force: {}, sats_per_vbyte: {} }}",
592            self.pubkey,
593            self.force,
594            match self.sats_per_vbyte {
595                Some(sats) => sats.to_string(),
596                None => "None".to_string(),
597            }
598        )
599    }
600}
601
602#[derive(Debug, Serialize, Deserialize, Clone)]
603pub struct CloseChannelsWithPeerResponse {
604    pub num_channels_closed: u32,
605}
606
607#[derive(Debug, Serialize, Deserialize, Clone)]
608pub struct GetInvoiceRequest {
609    pub payment_hash: sha256::Hash,
610}
611
612#[derive(Debug, Serialize, Deserialize, Clone)]
613pub struct GetInvoiceResponse {
614    pub preimage: Option<String>,
615    pub payment_hash: Option<sha256::Hash>,
616    pub amount: Amount,
617    pub created_at: SystemTime,
618    pub status: PaymentStatus,
619}
620
621#[derive(Debug, Serialize, Deserialize, Clone)]
622pub struct ListTransactionsPayload {
623    pub start_secs: u64,
624    pub end_secs: u64,
625}
626
627#[derive(Debug, Serialize, Deserialize, Clone)]
628pub struct ListTransactionsResponse {
629    pub transactions: Vec<PaymentDetails>,
630}
631
632#[derive(Debug, Serialize, Deserialize, Clone)]
633pub struct PaymentDetails {
634    pub payment_hash: Option<sha256::Hash>,
635    pub preimage: Option<String>,
636    pub payment_kind: PaymentKind,
637    pub amount: Amount,
638    pub direction: PaymentDirection,
639    pub status: PaymentStatus,
640    pub timestamp_secs: u64,
641}
642
643#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
644pub enum PaymentKind {
645    Bolt11,
646    Bolt12Offer,
647    Bolt12Refund,
648    Onchain,
649}
650
651#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
652pub enum PaymentDirection {
653    Outbound,
654    Inbound,
655}
656
657#[derive(Debug, Serialize, Deserialize, Clone)]
658pub struct CreateOfferPayload {
659    pub amount: Option<Amount>,
660    pub description: Option<String>,
661    pub expiry_secs: Option<u32>,
662    pub quantity: Option<u64>,
663}
664
665#[derive(Debug, Serialize, Deserialize, Clone)]
666pub struct CreateOfferResponse {
667    pub offer: String,
668}
669
670#[derive(Debug, Serialize, Deserialize, Clone)]
671pub struct PayOfferPayload {
672    pub offer: String,
673    pub amount: Option<Amount>,
674    pub quantity: Option<u64>,
675    pub payer_note: Option<String>,
676}
677
678#[derive(Debug, Serialize, Deserialize, Clone)]
679pub struct PayOfferResponse {
680    pub preimage: String,
681}
682
683#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
684pub enum PaymentStatus {
685    Pending,
686    Succeeded,
687    Failed,
688}
689
690/// Default value for LND's `SendPaymentRequest::time_pref`. LND interprets this
691/// as a value in [`LND_TIME_PREF_MIN`, `LND_TIME_PREF_MAX`], where -1 optimizes
692/// purely for fees and 1 optimizes purely for reliability. We default to 0.5 to
693/// favor reliability while still considering fees.
694pub const LND_DEFAULT_TIME_PREF: f64 = 0.5;
695pub const LND_TIME_PREF_MIN: f64 = -1.0;
696pub const LND_TIME_PREF_MAX: f64 = 1.0;
697
698fn parse_lnd_time_pref(s: &str) -> Result<f64, String> {
699    let parsed: f64 = s.parse().map_err(|e| format!("invalid f64: {e}"))?;
700    if !parsed.is_finite() || !(LND_TIME_PREF_MIN..=LND_TIME_PREF_MAX).contains(&parsed) {
701        return Err(format!(
702            "must be a finite value in [{LND_TIME_PREF_MIN}, {LND_TIME_PREF_MAX}]"
703        ));
704    }
705    Ok(parsed)
706}
707
708/// Default value for LND's `SendPaymentRequest::timeout_seconds`, i.e. how long
709/// LND keeps trying to route an outgoing payment before giving up. This does
710/// not cancel an HTLC that is already in flight; it bounds pathfinding/retry.
711pub const LND_DEFAULT_PAYMENT_TIMEOUT_SECS: i32 = 180;
712pub const LND_PAYMENT_TIMEOUT_SECS_MIN: i32 = 1;
713pub const LND_PAYMENT_TIMEOUT_SECS_MAX: i32 = 600;
714
715fn parse_lnd_payment_timeout_secs(s: &str) -> Result<i32, String> {
716    let parsed: i32 = s.parse().map_err(|e| format!("invalid i32: {e}"))?;
717    if !(LND_PAYMENT_TIMEOUT_SECS_MIN..=LND_PAYMENT_TIMEOUT_SECS_MAX).contains(&parsed) {
718        return Err(format!(
719            "must be an integer in [{LND_PAYMENT_TIMEOUT_SECS_MIN}, {LND_PAYMENT_TIMEOUT_SECS_MAX}]"
720        ));
721    }
722    Ok(parsed)
723}
724
725#[derive(Debug, Clone, Subcommand, Serialize, Deserialize, PartialEq)]
726pub enum LightningMode {
727    #[clap(name = "lnd")]
728    Lnd {
729        /// LND RPC address
730        #[arg(long = "lnd-rpc-host", env = FM_LND_RPC_ADDR_ENV)]
731        lnd_rpc_addr: String,
732
733        /// LND TLS cert file path
734        #[arg(long = "lnd-tls-cert", env = FM_LND_TLS_CERT_ENV)]
735        lnd_tls_cert: String,
736
737        /// LND macaroon file path
738        #[arg(long = "lnd-macaroon", env = FM_LND_MACAROON_ENV)]
739        lnd_macaroon: String,
740
741        /// `time_pref` passed to LND `SendPaymentRequest`. -1.0 optimizes
742        /// purely for fees, 1.0 optimizes purely for reliability.
743        #[arg(
744            long = "lnd-time-pref",
745            env = FM_LND_TIME_PREF_ENV,
746            default_value_t = LND_DEFAULT_TIME_PREF,
747            value_parser = parse_lnd_time_pref,
748        )]
749        lnd_time_pref: f64,
750
751        /// How long (in seconds) LND keeps trying to route an outgoing payment
752        /// before giving up. Passed as `timeout_seconds` to LND
753        /// `SendPaymentRequest`. Must be in [1, 600].
754        #[arg(
755            long = "lnd-payment-timeout",
756            env = FM_LND_PAYMENT_TIMEOUT_SECS_ENV,
757            default_value_t = LND_DEFAULT_PAYMENT_TIMEOUT_SECS,
758            value_parser = parse_lnd_payment_timeout_secs,
759        )]
760        lnd_payment_timeout_secs: i32,
761    },
762    #[clap(name = "ldk")]
763    Ldk {
764        /// LDK lightning server port
765        #[arg(long = "ldk-lightning-port", env = FM_PORT_LDK)]
766        lightning_port: u16,
767
768        /// LDK's Alias
769        #[arg(long = "ldk-alias", env = FM_LDK_ALIAS_ENV)]
770        alias: String,
771    },
772}
773
774#[derive(Clone)]
775pub enum ChainSource {
776    Bitcoind {
777        username: String,
778        password: String,
779        server_url: SafeUrl,
780    },
781    Esplora {
782        server_url: SafeUrl,
783    },
784}
785
786impl fmt::Display for ChainSource {
787    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
788        match self {
789            ChainSource::Bitcoind {
790                username: _,
791                password: _,
792                server_url,
793            } => {
794                write!(f, "Bitcoind source with URL: {server_url}")
795            }
796            ChainSource::Esplora { server_url } => {
797                write!(f, "Esplora source with URL: {server_url}")
798            }
799        }
800    }
801}
802
803#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
804#[serde(rename_all = "snake_case")]
805pub enum LightningInfo {
806    Connected {
807        public_key: PublicKey,
808        alias: String,
809        network: Network,
810        block_height: u64,
811        synced_to_chain: bool,
812    },
813    NotConnected,
814}
815
816#[derive(
817    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Encodable, Decodable, Serialize, Deserialize,
818)]
819#[serde(rename_all = "snake_case")]
820pub enum RegisteredProtocol {
821    Http,
822    Iroh,
823}
824
825#[derive(Debug, Serialize, Deserialize, Clone)]
826pub struct SetMnemonicPayload {
827    pub words: Option<String>,
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    const NODE_PUBKEY: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
835
836    #[test]
837    fn node_address_defaults_lightning_port() {
838        let node_address: NodeAddress = format!("{NODE_PUBKEY}@example.com")
839            .parse()
840            .expect("valid node address");
841
842        assert_eq!(node_address.host_with_port(), "example.com:9735");
843        assert_eq!(
844            node_address.to_string(),
845            format!("{NODE_PUBKEY}@example.com")
846        );
847    }
848
849    #[test]
850    fn node_address_keeps_explicit_non_default_port() {
851        let node_address: NodeAddress = format!("{NODE_PUBKEY}@example.com:9736")
852            .parse()
853            .expect("valid node address");
854
855        assert_eq!(node_address.host_with_port(), "example.com:9736");
856        assert_eq!(
857            node_address.to_string(),
858            format!("{NODE_PUBKEY}@example.com:9736")
859        );
860    }
861
862    #[test]
863    fn node_address_handles_bracketed_ipv6_address() {
864        let node_address: NodeAddress = format!("{NODE_PUBKEY}@[::1]")
865            .parse()
866            .expect("valid node address");
867
868        assert_eq!(
869            node_address.host_with_port(),
870            "[0000:0000:0000:0000:0000:0000:0000:0001]:9735"
871        );
872        assert_eq!(
873            node_address.to_string(),
874            format!("{NODE_PUBKEY}@[0000:0000:0000:0000:0000:0000:0000:0001]")
875        );
876
877        let node_address: NodeAddress = format!("{NODE_PUBKEY}@[::1]:9736")
878            .parse()
879            .expect("valid node address");
880
881        assert_eq!(
882            node_address.host_with_port(),
883            "[0000:0000:0000:0000:0000:0000:0000:0001]:9736"
884        );
885        assert_eq!(
886            node_address.to_string(),
887            format!("{NODE_PUBKEY}@[0000:0000:0000:0000:0000:0000:0000:0001]:9736")
888        );
889    }
890
891    #[test]
892    fn node_address_serde_uses_display_format() {
893        let request = ConnectPeerRequest {
894            node_address: format!("{NODE_PUBKEY}@127.0.0.1:9735")
895                .parse()
896                .expect("valid node address"),
897        };
898
899        let json = serde_json::to_string(&request).expect("can serialize request");
900        assert_eq!(
901            json,
902            format!(r#"{{"node_address":"{NODE_PUBKEY}@127.0.0.1"}}"#)
903        );
904
905        let request: ConnectPeerRequest =
906            serde_json::from_str(&json).expect("can deserialize request");
907        assert_eq!(request.node_address.host_with_port(), "127.0.0.1:9735");
908    }
909}