diff --git a/.github/workflows/ldk-server-rabbitmq-integration.yml b/.github/workflows/ldk-server-rabbitmq-integration.yml new file mode 100644 index 000000000..b1390e328 --- /dev/null +++ b/.github/workflows/ldk-server-rabbitmq-integration.yml @@ -0,0 +1,34 @@ +name: LDK Server - RabbitMQ Integration Tests + +on: [ push, pull_request ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + runs-on: ubuntu-latest + + services: + rabbitmq: + image: rabbitmq:3 + env: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - 5672:5672 + options: >- + --health-cmd "rabbitmqctl node_health_check" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run RabbitMQ integration tests + run: cargo test -p ldk-server --features integration-tests-events-rabbitmq --verbose --color=always -- --nocapture + env: + RUST_BACKTRACE: 1 diff --git a/.github/workflows/ldk-server.yml b/.github/workflows/ldk-server.yml new file mode 100644 index 000000000..e1d5551ce --- /dev/null +++ b/.github/workflows/ldk-server.yml @@ -0,0 +1,43 @@ +name: CI Checks - LDK Server + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + strategy: + matrix: + platform: [ + ubuntu-latest, + macos-latest, + ] + toolchain: [ + stable, + beta, + 1.85.0, # MSRV, same as ldk-node + ] + include: + - toolchain: 1.85.0 + msrv: true + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout source code + uses: actions/checkout@v3 + - name: Install Rust ${{ matrix.toolchain }} toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} + if [ "${{ matrix.msrv }}" = "true" ]; then rustup component add clippy; fi + - name: Build ldk-server crates on Rust ${{ matrix.toolchain }} + run: cargo build -p ldk-server -p ldk-server-cli -p ldk-server-client -p ldk-server-protos --verbose --color always + - name: Check clippy on ldk-server if on msrv + if: matrix.msrv + run: cargo clippy -p ldk-server --all-features -- -D warnings + - name: Test ldk-server crates on Rust ${{ matrix.toolchain }} + run: cargo test -p ldk-server -p ldk-server-cli -p ldk-server-client -p ldk-server-protos + - name: Cargo check release on Rust ${{ matrix.toolchain }} + run: cargo check --release -p ldk-server -p ldk-server-cli -p ldk-server-client -p ldk-server-protos + - name: Cargo check doc on Rust ${{ matrix.toolchain }} + run: cargo doc --release -p ldk-server -p ldk-server-cli -p ldk-server-client -p ldk-server-protos diff --git a/Cargo.toml b/Cargo.toml index 6996f59f0..8ccaf5e61 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,12 @@ +[workspace] +resolver = "2" +members = [ + "ldk-server/ldk-server", + "ldk-server/ldk-server-cli", + "ldk-server/ldk-server-client", + "ldk-server/ldk-server-protos", +] + [package] name = "ldk-node" version = "0.8.0+git" @@ -110,6 +119,18 @@ uniffi = { version = "0.28.3", features = ["build"], optional = true } [profile.release] panic = "abort" +[profile.release.package.ldk-server] +opt-level = 3 + +[profile.release.package.ldk-server-cli] +opt-level = 3 + +[profile.release.package.ldk-server-client] +opt-level = 3 + +[profile.release.package.ldk-server-protos] +opt-level = 3 + [profile.dev] panic = "abort" diff --git a/ldk-server/.gitignore b/ldk-server/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/ldk-server/.gitignore @@ -0,0 +1 @@ +/target diff --git a/ldk-server/README.md b/ldk-server/README.md new file mode 100644 index 000000000..8497da579 --- /dev/null +++ b/ldk-server/README.md @@ -0,0 +1,72 @@ +# LDK Server + +**LDK Server** is a fully-functional Lightning node in daemon form, built on top of +[LDK Node](https://github.com/lightningdevkit/ldk-node), which itself provides a powerful abstraction over the +[Lightning Development Kit (LDK)](https://github.com/lightningdevkit/rust-lightning) and uses a built-in +[Bitcoin Development Kit (BDK)](https://bitcoindevkit.org/) wallet. + +The primary goal of LDK Server is to provide an efficient, stable, and API-first solution for deploying and managing +a Lightning Network node. With its streamlined setup, LDK Server enables users to easily set up, configure, and run +a Lightning node while exposing a robust, language-agnostic API via [Protocol Buffers (Protobuf)](https://protobuf.dev/). + +### Features + +- **Out-of-the-Box Lightning Node**: + - Deploy a Lightning Network node with minimal configuration, no coding required. + +- **API-First Design**: + - Exposes a well-defined API using Protobuf, allowing seamless integration with HTTP-clients or applications. + +- **Powered by LDK**: + - Built on top of LDK-Node, leveraging the modular, reliable, and high-performance architecture of LDK. + +- **Effortless Integration**: + - Ideal for embedding Lightning functionality into payment processors, self-hosted nodes, custodial wallets, or other Lightning-enabled + applications. + +### Project Status + +🚧 **Work in Progress**: +- **APIs Under Development**: Expect breaking changes as the project evolves. +- **Potential Bugs and Inconsistencies**: While progress is being made toward stability, unexpected behavior may occur. +- **Improved Logging and Error Handling Coming Soon**: Current error handling is rudimentary (specially for CLI), and usability improvements are actively being worked on. +- **Pending Testing**: Not tested, hence don't use it for production! + +We welcome your feedback and contributions to help shape the future of LDK Server! + + +### Configuration +Refer `./ldk-server/ldk-server-config.toml` to see available configuration options. + +### Building +``` +git clone https://github.com/lightningdevkit/ldk-server.git +cargo build +``` + +### Running +``` +cargo run --bin ldk-server ./ldk-server/ldk-server-config.toml +``` + +Interact with the node using CLI: +``` +ldk-server-cli -b localhost:3002 --api-key your-secret-api-key --tls-cert /path/to/tls_cert.pem onchain-receive # To generate onchain-receive address. +ldk-server-cli -b localhost:3002 --api-key your-secret-api-key --tls-cert /path/to/tls_cert.pem help # To print help/available commands. +``` + +### Shell Completions + +The CLI supports generating shell completions for Bash, Zsh, Fish, Elvish, and PowerShell. + +Add completions to your shell config: +```bash +# Bash (add to ~/.bashrc) +eval "$(ldk-server-cli completions bash)" + +# Zsh (add to ~/.zshrc) +eval "$(ldk-server-cli completions zsh)" + +# Fish (add to ~/.config/fish/config.fish) +ldk-server-cli completions fish | source +``` diff --git a/ldk-server/ldk-server-cli/Cargo.toml b/ldk-server/ldk-server-cli/Cargo.toml new file mode 100644 index 000000000..5d006001a --- /dev/null +++ b/ldk-server/ldk-server-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ldk-server-cli" +version = "0.1.0" +edition = "2021" + +[dependencies] +ldk-server-client = { path = "../ldk-server-client", features = ["serde"] } +clap = { version = "4.0.5", default-features = false, features = ["derive", "std", "error-context", "suggestions", "help"] } +clap_complete = { version = "4.0", default-features = false } +hex-conservative = { version = "0.2", default-features = false, features = ["std"] } +tokio = { version = "1.38.0", default-features = false, features = ["rt-multi-thread", "macros"] } +serde = "1.0" +serde_json = "1.0" +toml = { version = "0.8", default-features = false, features = ["parse"] } diff --git a/ldk-server/ldk-server-cli/src/config.rs b/ldk-server/ldk-server-cli/src/config.rs new file mode 100644 index 000000000..49f84cff4 --- /dev/null +++ b/ldk-server/ldk-server-cli/src/config.rs @@ -0,0 +1,82 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const DEFAULT_CERT_FILE: &str = "tls.crt"; +const API_KEY_FILE: &str = "api_key"; + +pub fn get_default_data_dir() -> Option { + #[cfg(target_os = "macos")] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join("Library/Application Support/ldk-server")) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(|appdata| PathBuf::from(appdata).join("ldk-server")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join(".ldk-server")) + } +} + +pub fn get_default_config_path() -> Option { + get_default_data_dir().map(|dir| dir.join(DEFAULT_CONFIG_FILE)) +} + +pub fn get_default_cert_path() -> Option { + get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE)) +} + +pub fn get_default_api_key_path(network: &str) -> Option { + get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub node: NodeConfig, + pub tls: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TlsConfig { + pub cert_path: Option, +} + +#[derive(Debug, Deserialize)] +pub struct NodeConfig { + pub rest_service_address: String, + network: String, +} + +impl Config { + pub fn network(&self) -> Result { + match self.node.network.as_str() { + "bitcoin" | "mainnet" => Ok("bitcoin".to_string()), + "testnet" => Ok("testnet".to_string()), + "testnet4" => Ok("testnet4".to_string()), + "signet" => Ok("signet".to_string()), + "regtest" => Ok("regtest".to_string()), + other => Err(format!("Unsupported network: {other}")), + } + } +} + +pub fn load_config(path: &PathBuf) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?; + toml::from_str(&contents) + .map_err(|e| format!("Failed to parse config file '{}': {}", path.display(), e)) +} diff --git a/ldk-server/ldk-server-cli/src/main.rs b/ldk-server/ldk-server-cli/src/main.rs new file mode 100644 index 000000000..4e571b3d9 --- /dev/null +++ b/ldk-server/ldk-server-cli/src/main.rs @@ -0,0 +1,806 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::path::PathBuf; + +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::{generate, Shell}; +use config::{ + get_default_api_key_path, get_default_cert_path, get_default_config_path, load_config, +}; +use hex_conservative::DisplayHex; +use ldk_server_client::client::LdkServerClient; +use ldk_server_client::error::LdkServerError; +use ldk_server_client::error::LdkServerErrorCode::{ + AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, +}; +use ldk_server_client::ldk_server_protos::api::{ + Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse, + Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, + CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, + ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, + GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, + OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, + OpenChannelRequest, OpenChannelResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, + SpliceOutResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, +}; +use ldk_server_client::ldk_server_protos::types::{ + bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken, + RouteParametersConfig, +}; +use serde::Serialize; +use types::{CliListForwardedPaymentsResponse, CliListPaymentsResponse, CliPaginatedResponse}; + +mod config; +mod types; + +// Having these default values as constants in the Proto file and +// importing/reusing them here might be better, but Proto3 removed +// the ability to set default values. +const DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA: u32 = 1008; +const DEFAULT_MAX_PATH_COUNT: u32 = 10; +const DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF: u32 = 2; +const DEFAULT_EXPIRY_SECS: u32 = 86_400; + +#[derive(Parser, Debug)] +#[command( + name = "ldk-server-cli", + version, + about = "CLI for interacting with an LDK Server node", + override_usage = "ldk-server-cli [OPTIONS] " +)] +struct Cli { + #[arg(short, long, help = "Base URL of the server. If not provided, reads from config file")] + base_url: Option, + + #[arg( + short, + long, + help = "API key for authentication. Defaults by reading ~/.ldk-server/[network]/api_key" + )] + api_key: Option, + + #[arg( + short, + long, + help = "Path to the server's TLS certificate file (PEM format). Defaults to ~/.ldk-server/tls.crt" + )] + tls_cert: Option, + + #[arg(short, long, help = "Path to config file. Defaults to ~/.ldk-server/config.toml")] + config: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + #[command(about = "Retrieve the latest node info like node_id, current_best_block, etc")] + GetNodeInfo, + #[command(about = "Retrieve an overview of all known balances")] + GetBalances, + #[command(about = "Retrieve a new on-chain funding address")] + OnchainReceive, + #[command(about = "Send an on-chain payment to the given address")] + OnchainSend { + #[arg(short, long, help = "The address to send coins to")] + address: String, + #[arg( + long, + help = "The amount in satoshis to send. Will respect any on-chain reserve needed for anchor channels" + )] + amount_sats: Option, + #[arg( + long, + help = "Send full balance to the address. Warning: will not retain on-chain reserves for anchor channels" + )] + send_all: Option, + #[arg( + long, + help = "Fee rate in satoshis per virtual byte. If not set, a reasonable estimate will be used" + )] + fee_rate_sat_per_vb: Option, + }, + #[command(about = "Create a BOLT11 invoice to receive a payment")] + Bolt11Receive { + #[arg(short, long, help = "Description to attach along with the invoice")] + description: Option, + #[arg( + long, + help = "SHA-256 hash of the description (hex). Use instead of description for longer text" + )] + description_hash: Option, + #[arg(short, long, help = "Invoice expiry time in seconds (default: 86400)")] + expiry_secs: Option, + #[arg( + long, + help = "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" + )] + amount_msat: Option, + }, + #[command(about = "Pay a BOLT11 invoice")] + Bolt11Send { + #[arg(short, long, help = "A BOLT11 invoice for a payment within the Lightning Network")] + invoice: String, + #[arg(long, help = "Amount in millisatoshis. Required when paying a zero-amount invoice")] + amount_msat: Option, + #[arg( + long, + help = "Maximum total fees in millisatoshis that may accrue during route finding. Defaults to 1% of payment + 50 sats" + )] + max_total_routing_fee_msat: Option, + #[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")] + max_total_cltv_expiry_delta: Option, + #[arg( + long, + help = "Maximum number of paths that may be used by MPP payments (default: 10)" + )] + max_path_count: Option, + #[arg( + long, + help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)" + )] + max_channel_saturation_power_of_half: Option, + }, + #[command(about = "Return a BOLT12 offer for receiving payments")] + Bolt12Receive { + #[arg(short, long, help = "Description to attach along with the offer")] + description: String, + #[arg( + long, + help = "Amount in millisatoshis to request. If unset, a variable-amount offer is returned" + )] + amount_msat: Option, + #[arg(long, help = "Offer expiry time in seconds")] + expiry_secs: Option, + #[arg(long, help = "Number of items requested. Can only be set for fixed-amount offers")] + quantity: Option, + }, + #[command(about = "Send a payment for a BOLT12 offer")] + Bolt12Send { + #[arg(short, long, help = "A BOLT12 offer for a payment within the Lightning Network")] + offer: String, + #[arg(long, help = "Amount in millisatoshis. Required when paying a zero-amount offer")] + amount_msat: Option, + #[arg(short, long, help = "Number of items requested")] + quantity: Option, + #[arg( + short, + long, + help = "Note to include for the payee. Will be seen by recipient and reflected back in the invoice" + )] + payer_note: Option, + #[arg( + long, + help = "Maximum total fees, in millisatoshi, that may accrue during route finding, Defaults to 1% of the payment amount + 50 sats" + )] + max_total_routing_fee_msat: Option, + #[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")] + max_total_cltv_expiry_delta: Option, + #[arg( + long, + help = "Maximum number of paths that may be used by MPP payments (default: 10)" + )] + max_path_count: Option, + #[arg( + long, + help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)" + )] + max_channel_saturation_power_of_half: Option, + }, + #[command(about = "Cooperatively close the channel specified by the given channel ID")] + CloseChannel { + #[arg(short, long, help = "The local user_channel_id of this channel")] + user_channel_id: String, + #[arg( + short, + long, + help = "The hex-encoded public key of the node to close a channel with" + )] + counterparty_node_id: String, + }, + #[command(about = "Force close the channel specified by the given channel ID")] + ForceCloseChannel { + #[arg(short, long, help = "The local user_channel_id of this channel")] + user_channel_id: String, + #[arg( + short, + long, + help = "The hex-encoded public key of the node to close a channel with" + )] + counterparty_node_id: String, + #[arg(long, help = "The reason for force-closing, defaults to \"\"")] + force_close_reason: Option, + }, + #[command(about = "Create a new outbound channel to the given remote node")] + OpenChannel { + #[arg(short, long, help = "The hex-encoded public key of the node to open a channel with")] + node_pubkey: String, + #[arg( + short, + long, + help = "Address to connect to remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" + )] + address: String, + #[arg(long, help = "The amount of satoshis to commit to the channel")] + channel_amount_sats: u64, + #[arg( + long, + help = "Amount of satoshis to push to the remote side as part of the initial commitment state" + )] + push_to_counterparty_msat: Option, + #[arg(long, help = "Whether the channel should be public")] + announce_channel: bool, + // Channel config options + #[arg( + long, + help = "Amount (in millionths of a satoshi) charged per satoshi for payments forwarded outbound over the channel. This can be updated by using update-channel-config." + )] + forwarding_fee_proportional_millionths: Option, + #[arg( + long, + help = "Amount (in milli-satoshi) charged for payments forwarded outbound over the channel, in excess of forwarding_fee_proportional_millionths. This can be updated by using update-channel-config." + )] + forwarding_fee_base_msat: Option, + #[arg( + long, + help = "The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded over the channel. This can be updated by using update-channel-config." + )] + cltv_expiry_delta: Option, + }, + #[command( + about = "Increase the channel balance by the given amount, funds will come from the node's on-chain wallet" + )] + SpliceIn { + #[arg(short, long, help = "The local user_channel_id of the channel")] + user_channel_id: String, + #[arg(short, long, help = "The hex-encoded public key of the channel's counterparty node")] + counterparty_node_id: String, + #[arg(long, help = "The amount of sats to splice into the channel")] + splice_amount_sats: u64, + }, + #[command(about = "Decrease the channel balance by the given amount")] + SpliceOut { + #[arg(short, long, help = "The local user_channel_id of this channel")] + user_channel_id: String, + #[arg(short, long, help = "The hex-encoded public key of the channel's counterparty node")] + counterparty_node_id: String, + #[arg(long, help = "The amount of sats to splice out of the channel")] + splice_amount_sats: u64, + #[arg( + short, + long, + help = "Bitcoin address to send the spliced-out funds. If not set, uses the node's on-chain wallet" + )] + address: Option, + }, + #[command(about = "Return a list of known channels")] + ListChannels, + #[command(about = "Retrieve list of all payments")] + ListPayments { + #[arg(short, long)] + #[arg( + help = "Fetch at least this many payments by iterating through multiple pages. Returns combined results with the last page token. If not provided, returns only a single page." + )] + number_of_payments: Option, + #[arg(long)] + #[arg(help = "Page token to continue from a previous page (format: token:index)")] + page_token: Option, + }, + #[command(about = "Get details of a specific payment by its payment ID")] + GetPaymentDetails { + #[arg(short, long, help = "The payment ID in hex-encoded form")] + payment_id: String, + }, + #[command(about = "Retrieves list of all forwarded payments")] + ListForwardedPayments { + #[arg( + short, + long, + help = "Fetch at least this many forwarded payments by iterating through multiple pages. Returns combined results with the last page token. If not provided, returns only a single page." + )] + number_of_payments: Option, + #[arg(long, help = "Page token to continue from a previous page (format: token:index)")] + page_token: Option, + }, + UpdateChannelConfig { + #[arg(short, long, help = "The local user_channel_id of this channel")] + user_channel_id: String, + #[arg( + short, + long, + help = "The hex-encoded public key of the counterparty node to update channel config with" + )] + counterparty_node_id: String, + #[arg( + long, + help = "Amount (in millionths of a satoshi) charged per satoshi for payments forwarded outbound over the channel. This can be updated by using update-channel-config." + )] + forwarding_fee_proportional_millionths: Option, + #[arg( + long, + help = "Amount (in milli-satoshi) charged for payments forwarded outbound over the channel, in excess of forwarding_fee_proportional_millionths. This can be updated by using update-channel-config." + )] + forwarding_fee_base_msat: Option, + #[arg( + long, + help = "The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded over the channel." + )] + cltv_expiry_delta: Option, + }, + #[command(about = "Connect to a peer on the Lightning Network without opening a channel")] + ConnectPeer { + #[arg(short, long, help = "The hex-encoded public key of the node to connect to")] + node_pubkey: String, + #[arg( + short, + long, + help = "Address to connect to remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" + )] + address: String, + #[arg( + long, + default_value_t = false, + help = "Whether to persist the connection for automatic reconnection on restart" + )] + persist: bool, + }, + #[command(about = "Generate shell completions for the CLI")] + Completions { + #[arg( + value_enum, + help = "The shell to generate completions for (bash, zsh, fish, powershell, elvish)" + )] + shell: Shell, + }, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + // short-circuit if generating completions + if let Commands::Completions { shell } = cli.command { + generate(shell, &mut Cli::command(), "ldk-server-cli", &mut std::io::stdout()); + return; + } + + let config_path = cli.config.map(PathBuf::from).or_else(get_default_config_path); + let config = config_path.as_ref().and_then(|p| load_config(p).ok()); + + // Get API key from argument, then from api_key file + let api_key = cli + .api_key + .or_else(|| { + // Try to read from api_key file based on network (file contains raw bytes) + let network = config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); + get_default_api_key_path(&network) + .and_then(|path| std::fs::read(&path).ok()) + .map(|bytes| bytes.to_lower_hex_string()) + }) + .unwrap_or_else(|| { + eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at ~/.ldk-server/[network]/api_key"); + std::process::exit(1); + }); + + // Get base URL from argument then from config file + let base_url = + cli.base_url.or_else(|| config.as_ref().map(|c| c.node.rest_service_address.clone())) + .unwrap_or_else(|| { + eprintln!("Base URL not provided. Use --base-url or ensure config file exists at ~/.ldk-server/config.toml"); + std::process::exit(1); + }); + + // Get TLS cert path from argument, then from config file, then try default location + let tls_cert_path = cli.tls_cert.map(PathBuf::from).or_else(|| { + config + .as_ref() + .and_then(|c| c.tls.as_ref().and_then(|t| t.cert_path.as_ref().map(PathBuf::from))) + .or_else(get_default_cert_path) + }) + .unwrap_or_else(|| { + eprintln!("TLS cert path not provided. Use --tls-cert or ensure config file exists at ~/.ldk-server/config.toml"); + std::process::exit(1); + }); + + let server_cert_pem = std::fs::read(&tls_cert_path).unwrap_or_else(|e| { + eprintln!("Failed to read server certificate file '{}': {}", tls_cert_path.display(), e); + std::process::exit(1); + }); + + let client = LdkServerClient::new(base_url, api_key, &server_cert_pem).unwrap_or_else(|e| { + eprintln!("Failed to create client: {e}"); + std::process::exit(1); + }); + + match cli.command { + Commands::GetNodeInfo => { + handle_response_result::<_, GetNodeInfoResponse>( + client.get_node_info(GetNodeInfoRequest {}).await, + ); + }, + Commands::GetBalances => { + handle_response_result::<_, GetBalancesResponse>( + client.get_balances(GetBalancesRequest {}).await, + ); + }, + Commands::OnchainReceive => { + handle_response_result::<_, OnchainReceiveResponse>( + client.onchain_receive(OnchainReceiveRequest {}).await, + ); + }, + Commands::OnchainSend { address, amount_sats, send_all, fee_rate_sat_per_vb } => { + handle_response_result::<_, OnchainSendResponse>( + client + .onchain_send(OnchainSendRequest { + address, + amount_sats, + send_all, + fee_rate_sat_per_vb, + }) + .await, + ); + }, + Commands::Bolt11Receive { description, description_hash, expiry_secs, amount_msat } => { + let invoice_description = match (description, description_hash) { + (Some(desc), None) => Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Direct(desc)), + }), + (None, Some(hash)) => Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Hash(hash)), + }), + (Some(_), Some(_)) => { + handle_error(LdkServerError::new( + InternalError, + "Only one of description or description_hash can be set.".to_string(), + )); + }, + (None, None) => None, + }; + + let expiry_secs = expiry_secs.unwrap_or(DEFAULT_EXPIRY_SECS); + let request = + Bolt11ReceiveRequest { description: invoice_description, expiry_secs, amount_msat }; + + handle_response_result::<_, Bolt11ReceiveResponse>( + client.bolt11_receive(request).await, + ); + }, + Commands::Bolt11Send { + invoice, + amount_msat, + max_total_routing_fee_msat, + max_total_cltv_expiry_delta, + max_path_count, + max_channel_saturation_power_of_half, + } => { + let route_parameters = RouteParametersConfig { + max_total_routing_fee_msat, + max_total_cltv_expiry_delta: max_total_cltv_expiry_delta + .unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA), + max_path_count: max_path_count.unwrap_or(DEFAULT_MAX_PATH_COUNT), + max_channel_saturation_power_of_half: max_channel_saturation_power_of_half + .unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF), + }; + handle_response_result::<_, Bolt11SendResponse>( + client + .bolt11_send(Bolt11SendRequest { + invoice, + amount_msat, + route_parameters: Some(route_parameters), + }) + .await, + ); + }, + Commands::Bolt12Receive { description, amount_msat, expiry_secs, quantity } => { + handle_response_result::<_, Bolt12ReceiveResponse>( + client + .bolt12_receive(Bolt12ReceiveRequest { + description, + amount_msat, + expiry_secs, + quantity, + }) + .await, + ); + }, + Commands::Bolt12Send { + offer, + amount_msat, + quantity, + payer_note, + max_total_routing_fee_msat, + max_total_cltv_expiry_delta, + max_path_count, + max_channel_saturation_power_of_half, + } => { + let route_parameters = RouteParametersConfig { + max_total_routing_fee_msat, + max_total_cltv_expiry_delta: max_total_cltv_expiry_delta + .unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA), + max_path_count: max_path_count.unwrap_or(DEFAULT_MAX_PATH_COUNT), + max_channel_saturation_power_of_half: max_channel_saturation_power_of_half + .unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF), + }; + + handle_response_result::<_, Bolt12SendResponse>( + client + .bolt12_send(Bolt12SendRequest { + offer, + amount_msat, + quantity, + payer_note, + route_parameters: Some(route_parameters), + }) + .await, + ); + }, + Commands::CloseChannel { user_channel_id, counterparty_node_id } => { + handle_response_result::<_, CloseChannelResponse>( + client + .close_channel(CloseChannelRequest { user_channel_id, counterparty_node_id }) + .await, + ); + }, + Commands::ForceCloseChannel { + user_channel_id, + counterparty_node_id, + force_close_reason, + } => { + handle_response_result::<_, ForceCloseChannelResponse>( + client + .force_close_channel(ForceCloseChannelRequest { + user_channel_id, + counterparty_node_id, + force_close_reason, + }) + .await, + ); + }, + Commands::OpenChannel { + node_pubkey, + address, + channel_amount_sats, + push_to_counterparty_msat, + announce_channel, + forwarding_fee_proportional_millionths, + forwarding_fee_base_msat, + cltv_expiry_delta, + } => { + let channel_config = build_open_channel_config( + forwarding_fee_proportional_millionths, + forwarding_fee_base_msat, + cltv_expiry_delta, + ); + + handle_response_result::<_, OpenChannelResponse>( + client + .open_channel(OpenChannelRequest { + node_pubkey, + address, + channel_amount_sats, + push_to_counterparty_msat, + channel_config, + announce_channel, + }) + .await, + ); + }, + Commands::SpliceIn { user_channel_id, counterparty_node_id, splice_amount_sats } => { + handle_response_result::<_, SpliceInResponse>( + client + .splice_in(SpliceInRequest { + user_channel_id, + counterparty_node_id, + splice_amount_sats, + }) + .await, + ); + }, + Commands::SpliceOut { + user_channel_id, + counterparty_node_id, + address, + splice_amount_sats, + } => { + handle_response_result::<_, SpliceOutResponse>( + client + .splice_out(SpliceOutRequest { + user_channel_id, + counterparty_node_id, + address, + splice_amount_sats, + }) + .await, + ); + }, + Commands::ListChannels => { + handle_response_result::<_, ListChannelsResponse>( + client.list_channels(ListChannelsRequest {}).await, + ); + }, + Commands::ListPayments { number_of_payments, page_token } => { + let page_token = page_token + .map(|token_str| parse_page_token(&token_str).unwrap_or_else(|e| handle_error(e))); + + handle_response_result::<_, CliListPaymentsResponse>( + fetch_paginated( + number_of_payments, + page_token, + |pt| client.list_payments(ListPaymentsRequest { page_token: pt }), + |r| (r.payments, r.next_page_token), + ) + .await, + ); + }, + Commands::GetPaymentDetails { payment_id } => { + handle_response_result::<_, GetPaymentDetailsResponse>( + client.get_payment_details(GetPaymentDetailsRequest { payment_id }).await, + ); + }, + Commands::ListForwardedPayments { number_of_payments, page_token } => { + let page_token = page_token + .map(|token_str| parse_page_token(&token_str).unwrap_or_else(|e| handle_error(e))); + + handle_response_result::<_, CliListForwardedPaymentsResponse>( + fetch_paginated( + number_of_payments, + page_token, + |pt| { + client.list_forwarded_payments(ListForwardedPaymentsRequest { + page_token: pt, + }) + }, + |r| (r.forwarded_payments, r.next_page_token), + ) + .await, + ); + }, + Commands::UpdateChannelConfig { + user_channel_id, + counterparty_node_id, + forwarding_fee_proportional_millionths, + forwarding_fee_base_msat, + cltv_expiry_delta, + } => { + let channel_config = ChannelConfig { + forwarding_fee_proportional_millionths, + forwarding_fee_base_msat, + cltv_expiry_delta, + force_close_avoidance_max_fee_satoshis: None, + accept_underpaying_htlcs: None, + max_dust_htlc_exposure: None, + }; + + handle_response_result::<_, UpdateChannelConfigResponse>( + client + .update_channel_config(UpdateChannelConfigRequest { + user_channel_id, + counterparty_node_id, + channel_config: Some(channel_config), + }) + .await, + ); + }, + Commands::ConnectPeer { node_pubkey, address, persist } => { + handle_response_result::<_, ConnectPeerResponse>( + client.connect_peer(ConnectPeerRequest { node_pubkey, address, persist }).await, + ); + }, + Commands::Completions { .. } => unreachable!("Handled above"), + } +} + +fn build_open_channel_config( + forwarding_fee_proportional_millionths: Option, forwarding_fee_base_msat: Option, + cltv_expiry_delta: Option, +) -> Option { + // Only create a config if at least one field is set + if forwarding_fee_proportional_millionths.is_none() + && forwarding_fee_base_msat.is_none() + && cltv_expiry_delta.is_none() + { + return None; + } + + Some(ChannelConfig { + forwarding_fee_proportional_millionths, + forwarding_fee_base_msat, + cltv_expiry_delta, + force_close_avoidance_max_fee_satoshis: None, + accept_underpaying_htlcs: None, + max_dust_htlc_exposure: None, + }) +} + +async fn fetch_paginated( + target_count: Option, initial_page_token: Option, + fetch_page: impl Fn(Option) -> Fut, + extract: impl Fn(R) -> (Vec, Option), +) -> Result, LdkServerError> +where + Fut: std::future::Future>, +{ + match target_count { + Some(count) => { + let mut items = Vec::with_capacity(count as usize); + let mut page_token = initial_page_token; + let mut next_page_token; + + loop { + let response = fetch_page(page_token).await?; + let (new_items, new_next_page_token) = extract(response); + items.extend(new_items); + next_page_token = new_next_page_token; + + if items.len() >= count as usize || next_page_token.is_none() { + break; + } + page_token = next_page_token; + } + + Ok(CliPaginatedResponse::new(items, next_page_token)) + }, + None => { + let response = fetch_page(initial_page_token).await?; + let (items, next_page_token) = extract(response); + Ok(CliPaginatedResponse::new(items, next_page_token)) + }, + } +} + +fn handle_response_result(response: Result) +where + Rs: Into, + Js: Serialize + std::fmt::Debug, +{ + match response { + Ok(response) => { + let json_response: Js = response.into(); + match serde_json::to_string_pretty(&json_response) { + Ok(json) => println!("{json}"), + Err(e) => { + eprintln!("Error serializing response ({json_response:?}) to JSON: {e}"); + std::process::exit(1); + }, + } + }, + Err(e) => { + handle_error(e); + }, + } +} + +fn parse_page_token(token_str: &str) -> Result { + let parts: Vec<&str> = token_str.split(':').collect(); + if parts.len() != 2 { + return Err(LdkServerError::new( + InternalError, + "Page token must be in format 'token:index'".to_string(), + )); + } + let index = parts[1] + .parse::() + .map_err(|_| LdkServerError::new(InternalError, "Invalid page token index".to_string()))?; + Ok(PageToken { token: parts[0].to_string(), index }) +} + +fn handle_error(e: LdkServerError) -> ! { + let error_type = match e.error_code { + InvalidRequestError => "Invalid Request", + AuthError => "Authentication Error", + LightningError => "Lightning Error", + InternalServerError => "Internal Server Error", + InternalError => "Internal Error", + }; + eprintln!("Error ({}): {}", error_type, e.message); + std::process::exit(1); // Exit with status code 1 on error. +} diff --git a/ldk-server/ldk-server-cli/src/types.rs b/ldk-server/ldk-server-cli/src/types.rs new file mode 100644 index 000000000..e3c708d51 --- /dev/null +++ b/ldk-server/ldk-server-cli/src/types.rs @@ -0,0 +1,41 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! CLI-specific type wrappers for API responses. +//! +//! This file contains wrapper types that customize the serialization format +//! of API responses for CLI output. These wrappers ensure that the CLI's output +//! format matches what users expect and what the CLI can parse back as input. + +use ldk_server_client::ldk_server_protos::types::{ForwardedPayment, PageToken, Payment}; +use serde::Serialize; + +/// CLI-specific wrapper for paginated responses that formats the page token +/// as "token:idx" instead of a JSON object. +#[derive(Debug, Clone, Serialize)] +pub struct CliPaginatedResponse { + /// List of items. + pub list: Vec, + /// Next page token formatted as "token:idx", or None if no more pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +impl CliPaginatedResponse { + pub fn new(list: Vec, next_page_token: Option) -> Self { + Self { list, next_page_token: next_page_token.map(format_page_token) } + } +} + +pub type CliListPaymentsResponse = CliPaginatedResponse; +pub type CliListForwardedPaymentsResponse = CliPaginatedResponse; + +fn format_page_token(token: PageToken) -> String { + format!("{}:{}", token.token, token.index) +} diff --git a/ldk-server/ldk-server-client/Cargo.toml b/ldk-server/ldk-server-client/Cargo.toml new file mode 100644 index 000000000..13916fa3f --- /dev/null +++ b/ldk-server/ldk-server-client/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ldk-server-client" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +serde = ["ldk-server-protos/serde"] + +[dependencies] +ldk-server-protos = { path = "../ldk-server-protos" } +reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] } +prost = { version = "0.11.6", default-features = false, features = ["std", "prost-derive"] } +bitcoin_hashes = "0.14" diff --git a/ldk-server/ldk-server-client/src/client.rs b/ldk-server/ldk-server-client/src/client.rs new file mode 100644 index 000000000..0d137db19 --- /dev/null +++ b/ldk-server/ldk-server-client/src/client.rs @@ -0,0 +1,312 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use bitcoin_hashes::hmac::{Hmac, HmacEngine}; +use bitcoin_hashes::{sha256, Hash, HashEngine}; +use ldk_server_protos::api::{ + Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse, + Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, + CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, + ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, + GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, + ListForwardedPaymentsResponse, ListPaymentsRequest, ListPaymentsResponse, + OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, + OpenChannelRequest, OpenChannelResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, + SpliceOutResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, +}; +use ldk_server_protos::endpoints::{ + BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, + CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, + GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, + LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SPLICE_IN_PATH, + SPLICE_OUT_PATH, UPDATE_CHANNEL_CONFIG_PATH, +}; +use ldk_server_protos::error::{ErrorCode, ErrorResponse}; +use prost::Message; +use reqwest::header::CONTENT_TYPE; +use reqwest::{Certificate, Client}; + +use crate::error::LdkServerError; +use crate::error::LdkServerErrorCode::{ + AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, +}; + +const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; + +/// Client to access a hosted instance of LDK Server. +/// +/// The client requires the server's TLS certificate to be provided for verification. +/// This certificate can be found at `/tls.crt` after the +/// server generates it on first startup. +#[derive(Clone)] +pub struct LdkServerClient { + base_url: String, + client: Client, + api_key: String, +} + +impl LdkServerClient { + /// Constructs a [`LdkServerClient`] using `base_url` as the ldk-server endpoint. + /// + /// `base_url` should not include the scheme, e.g., `localhost:3000`. + /// `api_key` is used for HMAC-based authentication. + /// `server_cert_pem` is the server's TLS certificate in PEM format. This can be + /// found at `/tls.crt` after the server starts. + pub fn new(base_url: String, api_key: String, server_cert_pem: &[u8]) -> Result { + let cert = Certificate::from_pem(server_cert_pem) + .map_err(|e| format!("Failed to parse server certificate: {e}"))?; + + let client = Client::builder() + .add_root_certificate(cert) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + + Ok(Self { base_url, client, api_key }) + } + + /// Computes the HMAC-SHA256 authentication header value. + /// Format: "HMAC :" + fn compute_auth_header(&self, body: &[u8]) -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time should be after Unix epoch") + .as_secs(); + + // Compute HMAC-SHA256(api_key, timestamp_bytes || body) + let mut hmac_engine: HmacEngine = HmacEngine::new(self.api_key.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let hmac_result = Hmac::::from_engine(hmac_engine); + + format!("HMAC {}:{}", timestamp, hmac_result) + } + + /// Retrieve the latest node info like `node_id`, `current_best_block` etc. + /// For API contract/usage, refer to docs for [`GetNodeInfoRequest`] and [`GetNodeInfoResponse`]. + pub async fn get_node_info( + &self, request: GetNodeInfoRequest, + ) -> Result { + let url = format!("https://{}/{GET_NODE_INFO_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieves an overview of all known balances. + /// For API contract/usage, refer to docs for [`GetBalancesRequest`] and [`GetBalancesResponse`]. + pub async fn get_balances( + &self, request: GetBalancesRequest, + ) -> Result { + let url = format!("https://{}/{GET_BALANCES_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieve a new on-chain funding address. + /// For API contract/usage, refer to docs for [`OnchainReceiveRequest`] and [`OnchainReceiveResponse`]. + pub async fn onchain_receive( + &self, request: OnchainReceiveRequest, + ) -> Result { + let url = format!("https://{}/{ONCHAIN_RECEIVE_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Send an on-chain payment to the given address. + /// For API contract/usage, refer to docs for [`OnchainSendRequest`] and [`OnchainSendResponse`]. + pub async fn onchain_send( + &self, request: OnchainSendRequest, + ) -> Result { + let url = format!("https://{}/{ONCHAIN_SEND_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieve a new BOLT11 payable invoice. + /// For API contract/usage, refer to docs for [`Bolt11ReceiveRequest`] and [`Bolt11ReceiveResponse`]. + pub async fn bolt11_receive( + &self, request: Bolt11ReceiveRequest, + ) -> Result { + let url = format!("https://{}/{BOLT11_RECEIVE_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Send a payment for a BOLT11 invoice. + /// For API contract/usage, refer to docs for [`Bolt11SendRequest`] and [`Bolt11SendResponse`]. + pub async fn bolt11_send( + &self, request: Bolt11SendRequest, + ) -> Result { + let url = format!("https://{}/{BOLT11_SEND_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieve a new BOLT11 payable offer. + /// For API contract/usage, refer to docs for [`Bolt12ReceiveRequest`] and [`Bolt12ReceiveResponse`]. + pub async fn bolt12_receive( + &self, request: Bolt12ReceiveRequest, + ) -> Result { + let url = format!("https://{}/{BOLT12_RECEIVE_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Send a payment for a BOLT12 offer. + /// For API contract/usage, refer to docs for [`Bolt12SendRequest`] and [`Bolt12SendResponse`]. + pub async fn bolt12_send( + &self, request: Bolt12SendRequest, + ) -> Result { + let url = format!("https://{}/{BOLT12_SEND_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Creates a new outbound channel. + /// For API contract/usage, refer to docs for [`OpenChannelRequest`] and [`OpenChannelResponse`]. + pub async fn open_channel( + &self, request: OpenChannelRequest, + ) -> Result { + let url = format!("https://{}/{OPEN_CHANNEL_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Splices funds into the channel specified by given request. + /// For API contract/usage, refer to docs for [`SpliceInRequest`] and [`SpliceInResponse`]. + pub async fn splice_in( + &self, request: SpliceInRequest, + ) -> Result { + let url = format!("https://{}/{SPLICE_IN_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Splices funds out of the channel specified by given request. + /// For API contract/usage, refer to docs for [`SpliceOutRequest`] and [`SpliceOutResponse`]. + pub async fn splice_out( + &self, request: SpliceOutRequest, + ) -> Result { + let url = format!("https://{}/{SPLICE_OUT_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Closes the channel specified by given request. + /// For API contract/usage, refer to docs for [`CloseChannelRequest`] and [`CloseChannelResponse`]. + pub async fn close_channel( + &self, request: CloseChannelRequest, + ) -> Result { + let url = format!("https://{}/{CLOSE_CHANNEL_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Force closes the channel specified by given request. + /// For API contract/usage, refer to docs for [`ForceCloseChannelRequest`] and [`ForceCloseChannelResponse`]. + pub async fn force_close_channel( + &self, request: ForceCloseChannelRequest, + ) -> Result { + let url = format!("https://{}/{FORCE_CLOSE_CHANNEL_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieves list of known channels. + /// For API contract/usage, refer to docs for [`ListChannelsRequest`] and [`ListChannelsResponse`]. + pub async fn list_channels( + &self, request: ListChannelsRequest, + ) -> Result { + let url = format!("https://{}/{LIST_CHANNELS_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieves list of all payments sent or received by us. + /// For API contract/usage, refer to docs for [`ListPaymentsRequest`] and [`ListPaymentsResponse`]. + pub async fn list_payments( + &self, request: ListPaymentsRequest, + ) -> Result { + let url = format!("https://{}/{LIST_PAYMENTS_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Updates the config for a previously opened channel. + /// For API contract/usage, refer to docs for [`UpdateChannelConfigRequest`] and [`UpdateChannelConfigResponse`]. + pub async fn update_channel_config( + &self, request: UpdateChannelConfigRequest, + ) -> Result { + let url = format!("https://{}/{UPDATE_CHANNEL_CONFIG_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieves payment details for a given payment id. + /// For API contract/usage, refer to docs for [`GetPaymentDetailsRequest`] and [`GetPaymentDetailsResponse`]. + pub async fn get_payment_details( + &self, request: GetPaymentDetailsRequest, + ) -> Result { + let url = format!("https://{}/{GET_PAYMENT_DETAILS_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieves list of all forwarded payments. + /// For API contract/usage, refer to docs for [`ListForwardedPaymentsRequest`] and [`ListForwardedPaymentsResponse`]. + pub async fn list_forwarded_payments( + &self, request: ListForwardedPaymentsRequest, + ) -> Result { + let url = format!("https://{}/{LIST_FORWARDED_PAYMENTS_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Connect to a peer on the Lightning Network. + /// For API contract/usage, refer to docs for [`ConnectPeerRequest`] and [`ConnectPeerResponse`]. + pub async fn connect_peer( + &self, request: ConnectPeerRequest, + ) -> Result { + let url = format!("https://{}/{CONNECT_PEER_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + async fn post_request( + &self, request: &Rq, url: &str, + ) -> Result { + let request_body = request.encode_to_vec(); + let auth_header = self.compute_auth_header(&request_body); + let response_raw = self + .client + .post(url) + .header(CONTENT_TYPE, APPLICATION_OCTET_STREAM) + .header("X-Auth", auth_header) + .body(request_body) + .send() + .await + .map_err(|e| { + LdkServerError::new(InternalError, format!("HTTP request failed: {}", e)) + })?; + + let status = response_raw.status(); + let payload = response_raw.bytes().await.map_err(|e| { + LdkServerError::new(InternalError, format!("Failed to read response body: {}", e)) + })?; + + if status.is_success() { + Ok(Rs::decode(&payload[..]).map_err(|e| { + LdkServerError::new( + InternalError, + format!("Failed to decode success response: {}", e), + ) + })?) + } else { + let error_response = ErrorResponse::decode(&payload[..]).map_err(|e| { + LdkServerError::new( + InternalError, + format!("Failed to decode error response (status {}): {}", status, e), + ) + })?; + + let error_code = match ErrorCode::from_i32(error_response.error_code) { + Some(ErrorCode::InvalidRequestError) => InvalidRequestError, + Some(ErrorCode::AuthError) => AuthError, + Some(ErrorCode::LightningError) => LightningError, + Some(ErrorCode::InternalServerError) => InternalServerError, + Some(ErrorCode::UnknownError) | None => InternalError, + }; + + Err(LdkServerError::new(error_code, error_response.message)) + } + } +} diff --git a/ldk-server/ldk-server-client/src/error.rs b/ldk-server/ldk-server-client/src/error.rs new file mode 100644 index 000000000..67cba37d7 --- /dev/null +++ b/ldk-server/ldk-server-client/src/error.rs @@ -0,0 +1,71 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fmt; + +/// Represents an error returned by the LDK server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LdkServerError { + /// The error message containing a generic description of the error condition in English. + /// It is intended for a human audience only and should not be parsed to extract any information + /// programmatically. Client-side code may use it for logging only. + pub message: String, + + /// The error code uniquely identifying an error condition. + /// It is meant to be read and understood programmatically by code that detects/handles errors by + /// type. + pub error_code: LdkServerErrorCode, +} + +impl LdkServerError { + /// Creates a new [`LdkServerError`] with the given error code and message. + pub fn new(error_code: LdkServerErrorCode, message: impl Into) -> Self { + Self { error_code, message: message.into() } + } +} + +impl std::error::Error for LdkServerError {} + +impl fmt::Display for LdkServerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error: [{}]: {}", self.error_code, self.message) + } +} + +/// Defines error codes for categorizing LDK server errors. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LdkServerErrorCode { + /// Please refer to [`ldk_server_protos::error::ErrorCode::InvalidRequestError`]. + InvalidRequestError, + + /// Please refer to [`ldk_server_protos::error::ErrorCode::AuthError`]. + AuthError, + + /// Please refer to [`ldk_server_protos::error::ErrorCode::LightningError`]. + LightningError, + + /// Please refer to [`ldk_server_protos::error::ErrorCode::InternalServerError`]. + InternalServerError, + + /// There is an unknown error, it could be a client-side bug, unrecognized error-code, network error + /// or something else. + InternalError, +} + +impl fmt::Display for LdkServerErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LdkServerErrorCode::InvalidRequestError => write!(f, "InvalidRequestError"), + LdkServerErrorCode::AuthError => write!(f, "AuthError"), + LdkServerErrorCode::LightningError => write!(f, "LightningError"), + LdkServerErrorCode::InternalServerError => write!(f, "InternalServerError"), + LdkServerErrorCode::InternalError => write!(f, "InternalError"), + } + } +} diff --git a/ldk-server/ldk-server-client/src/lib.rs b/ldk-server/ldk-server-client/src/lib.rs new file mode 100644 index 000000000..098c7087f --- /dev/null +++ b/ldk-server/ldk-server-client/src/lib.rs @@ -0,0 +1,23 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Client-side library to interact with LDK Server. + +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(rustdoc::private_intra_doc_links)] +#![deny(missing_docs)] + +/// Implements a ldk-ldk-server-client ([`client::LdkServerClient`]) to access a hosted instance of LDK Server. +pub mod client; + +/// Implements the error type ([`error::LdkServerError`]) returned on interacting with [`client::LdkServerClient`] +pub mod error; + +/// Request/Response structs required for interacting with the ldk-ldk-server-client. +pub use ldk_server_protos; diff --git a/ldk-server/ldk-server-protos/Cargo.toml b/ldk-server/ldk-server-protos/Cargo.toml new file mode 100644 index 000000000..c971d6f52 --- /dev/null +++ b/ldk-server/ldk-server-protos/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ldk-server-protos" +version = "0.1.0" +edition = "2021" + +build = "build.rs" + +# We use a cfg instead of a feature for genproto to prevent it from being +# enabled with --all-features. Proto generation is a developer-only tool that +# requires external dependencies (protoc) and shouldn't be triggered accidentally. +# This lint configuration tells Cargo that genproto is an expected custom cfg. +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(genproto)'] } + +[features] +default = [] +serde = ["dep:serde", "dep:bytes"] + +[dependencies] +prost = { version = "0.11.6", default-features = false, features = ["std", "prost-derive"] } +serde = { version = "1.0", features = ["derive"], optional = true } +bytes = { version = "1", features = ["serde"], optional = true } + +[target.'cfg(genproto)'.build-dependencies] +prost-build = { version = "0.11.6", default-features = false } diff --git a/ldk-server/ldk-server-protos/build.rs b/ldk-server/ldk-server-protos/build.rs new file mode 100644 index 000000000..32b9f6b01 --- /dev/null +++ b/ldk-server/ldk-server-protos/build.rs @@ -0,0 +1,50 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +#[cfg(genproto)] +extern crate prost_build; + +#[cfg(genproto)] +use std::{env, fs, path::Path}; + +/// To generate updated proto objects, run `RUSTFLAGS="--cfg genproto" cargo build` +fn main() { + #[cfg(genproto)] + generate_protos(); +} + +#[cfg(genproto)] +fn generate_protos() { + prost_build::Config::new() + .bytes(&["."]) + .type_attribute( + ".", + "#[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]", + ) + .type_attribute(".", "#[cfg_attr(feature = \"serde\", serde(rename_all = \"snake_case\"))]") + .compile_protos( + &[ + "src/proto/api.proto", + "src/proto/types.proto", + "src/proto/events.proto", + "src/proto/error.proto", + ], + &["src/proto/"], + ) + .expect("protobuf compilation failed"); + println!("OUT_DIR: {}", &env::var("OUT_DIR").unwrap()); + let from_path = Path::new(&env::var("OUT_DIR").unwrap()).join("api.rs"); + fs::copy(from_path, "src/api.rs").unwrap(); + let from_path = Path::new(&env::var("OUT_DIR").unwrap()).join("types.rs"); + fs::copy(from_path, "src/types.rs").unwrap(); + let from_path = Path::new(&env::var("OUT_DIR").unwrap()).join("events.rs"); + fs::copy(from_path, "src/events.rs").unwrap(); + let from_path = Path::new(&env::var("OUT_DIR").unwrap()).join("error.rs"); + fs::copy(from_path, "src/error.rs").unwrap(); +} diff --git a/ldk-server/ldk-server-protos/src/api.rs b/ldk-server/ldk-server-protos/src/api.rs new file mode 100644 index 000000000..1f1bbbeab --- /dev/null +++ b/ldk-server/ldk-server-protos/src/api.rs @@ -0,0 +1,628 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +/// Retrieve the latest node info like `node_id`, `current_best_block` etc. +/// See more: +/// - +/// - +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetNodeInfoRequest {} +/// The response `content` for the `GetNodeInfo` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetNodeInfoResponse { + /// The hex-encoded `node-id` or public key for our own lightning node. + #[prost(string, tag = "1")] + pub node_id: ::prost::alloc::string::String, + /// The best block to which our Lightning wallet is currently synced. + /// + /// Should be always set, will never be `None`. + #[prost(message, optional, tag = "3")] + pub current_best_block: ::core::option::Option, + /// The timestamp, in seconds since start of the UNIX epoch, when we last successfully synced our Lightning wallet to + /// the chain tip. + /// + /// Will be `None` if the wallet hasn't been synced yet. + #[prost(uint64, optional, tag = "4")] + pub latest_lightning_wallet_sync_timestamp: ::core::option::Option, + /// The timestamp, in seconds since start of the UNIX epoch, when we last successfully synced our on-chain + /// wallet to the chain tip. + /// + /// Will be `None` if the wallet hasn’t been synced since the node was initialized. + #[prost(uint64, optional, tag = "5")] + pub latest_onchain_wallet_sync_timestamp: ::core::option::Option, + /// The timestamp, in seconds since start of the UNIX epoch, when we last successfully update our fee rate cache. + /// + /// Will be `None` if the cache hasn’t been updated since the node was initialized. + #[prost(uint64, optional, tag = "6")] + pub latest_fee_rate_cache_update_timestamp: ::core::option::Option, + /// The timestamp, in seconds since start of the UNIX epoch, when the last rapid gossip sync (RGS) snapshot we + /// successfully applied was generated. + /// + /// Will be `None` if RGS isn’t configured or the snapshot hasn’t been updated since the node was initialized. + #[prost(uint64, optional, tag = "7")] + pub latest_rgs_snapshot_timestamp: ::core::option::Option, + /// The timestamp, in seconds since start of the UNIX epoch, when we last broadcasted a node announcement. + /// + /// Will be `None` if we have no public channels or we haven’t broadcasted since the node was initialized. + #[prost(uint64, optional, tag = "8")] + pub latest_node_announcement_broadcast_timestamp: ::core::option::Option, +} +/// Retrieve a new on-chain funding address. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OnchainReceiveRequest {} +/// The response `content` for the `OnchainReceive` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OnchainReceiveResponse { + /// A Bitcoin on-chain address. + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, +} +/// Send an on-chain payment to the given address. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OnchainSendRequest { + /// The address to send coins to. + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, + /// The amount in satoshis to send. + /// While sending the specified amount, we will respect any on-chain reserve we need to keep, + /// i.e., won't allow to cut into `total_anchor_channels_reserve_sats`. + /// See more: + #[prost(uint64, optional, tag = "2")] + pub amount_sats: ::core::option::Option, + /// If set, the amount_sats field should be unset. + /// It indicates that node will send full balance to the specified address. + /// + /// Please note that when send_all is used this operation will **not** retain any on-chain reserves, + /// which might be potentially dangerous if you have open Anchor channels for which you can't trust + /// the counterparty to spend the Anchor output after channel closure. + /// See more: + #[prost(bool, optional, tag = "3")] + pub send_all: ::core::option::Option, + /// If `fee_rate_sat_per_vb` is set it will be used on the resulting transaction. Otherwise we'll retrieve + /// a reasonable estimate from BitcoinD. + #[prost(uint64, optional, tag = "4")] + pub fee_rate_sat_per_vb: ::core::option::Option, +} +/// The response `content` for the `OnchainSend` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OnchainSendResponse { + /// The transaction ID of the broadcasted transaction. + #[prost(string, tag = "1")] + pub txid: ::prost::alloc::string::String, +} +/// Return a BOLT11 payable invoice that can be used to request and receive a payment +/// for the given amount, if specified. +/// The inbound payment will be automatically claimed upon arrival. +/// See more: +/// - +/// - +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11ReceiveRequest { + /// The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount invoice is returned. + #[prost(uint64, optional, tag = "1")] + pub amount_msat: ::core::option::Option, + /// An optional description to attach along with the invoice. + /// Will be set in the description field of the encoded payment request. + #[prost(message, optional, tag = "2")] + pub description: ::core::option::Option, + /// Invoice expiry time in seconds. + #[prost(uint32, tag = "3")] + pub expiry_secs: u32, +} +/// The response `content` for the `Bolt11Receive` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11ReceiveResponse { + /// An invoice for a payment within the Lightning Network. + /// With the details of the invoice, the sender has all the data necessary to send a payment + /// to the recipient. + #[prost(string, tag = "1")] + pub invoice: ::prost::alloc::string::String, +} +/// Send a payment for a BOLT11 invoice. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11SendRequest { + /// An invoice for a payment within the Lightning Network. + #[prost(string, tag = "1")] + pub invoice: ::prost::alloc::string::String, + /// Set this field when paying a so-called "zero-amount" invoice, i.e., an invoice that leaves the + /// amount paid to be determined by the user. + /// This operation will fail if the amount specified is less than the value required by the given invoice. + #[prost(uint64, optional, tag = "2")] + pub amount_msat: ::core::option::Option, + /// Configuration options for payment routing and pathfinding. + #[prost(message, optional, tag = "3")] + pub route_parameters: ::core::option::Option, +} +/// The response `content` for the `Bolt11Send` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11SendResponse { + /// An identifier used to uniquely identify a payment in hex-encoded form. + #[prost(string, tag = "1")] + pub payment_id: ::prost::alloc::string::String, +} +/// Returns a BOLT12 offer for the given amount, if specified. +/// +/// See more: +/// - +/// - +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt12ReceiveRequest { + /// An optional description to attach along with the offer. + /// Will be set in the description field of the encoded offer. + #[prost(string, tag = "1")] + pub description: ::prost::alloc::string::String, + /// The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount offer is returned. + #[prost(uint64, optional, tag = "2")] + pub amount_msat: ::core::option::Option, + /// Offer expiry time in seconds. + #[prost(uint32, optional, tag = "3")] + pub expiry_secs: ::core::option::Option, + /// If set, it represents the number of items requested, can only be set for fixed-amount offers. + #[prost(uint64, optional, tag = "4")] + pub quantity: ::core::option::Option, +} +/// The response `content` for the `Bolt12Receive` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt12ReceiveResponse { + /// An offer for a payment within the Lightning Network. + /// With the details of the offer, the sender has all the data necessary to send a payment + /// to the recipient. + #[prost(string, tag = "1")] + pub offer: ::prost::alloc::string::String, +} +/// Send a payment for a BOLT12 offer. +/// See more: +/// - +/// - +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt12SendRequest { + /// An offer for a payment within the Lightning Network. + #[prost(string, tag = "1")] + pub offer: ::prost::alloc::string::String, + /// Set this field when paying a so-called "zero-amount" offer, i.e., an offer that leaves the + /// amount paid to be determined by the user. + /// This operation will fail if the amount specified is less than the value required by the given offer. + #[prost(uint64, optional, tag = "2")] + pub amount_msat: ::core::option::Option, + /// If set, it represents the number of items requested. + #[prost(uint64, optional, tag = "3")] + pub quantity: ::core::option::Option, + /// If set, it will be seen by the recipient and reflected back in the invoice. + #[prost(string, optional, tag = "4")] + pub payer_note: ::core::option::Option<::prost::alloc::string::String>, + /// Configuration options for payment routing and pathfinding. + #[prost(message, optional, tag = "5")] + pub route_parameters: ::core::option::Option, +} +/// The response `content` for the `Bolt12Send` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt12SendResponse { + /// An identifier used to uniquely identify a payment in hex-encoded form. + #[prost(string, tag = "1")] + pub payment_id: ::prost::alloc::string::String, +} +/// Creates a new outbound channel to the given remote node. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OpenChannelRequest { + /// The hex-encoded public key of the node to open a channel with. + #[prost(string, tag = "1")] + pub node_pubkey: ::prost::alloc::string::String, + /// An address which can be used to connect to a remote peer. + /// It can be of type IPv4:port, IPv6:port, OnionV3:port or hostname:port + #[prost(string, tag = "2")] + pub address: ::prost::alloc::string::String, + /// The amount of satoshis the caller is willing to commit to the channel. + #[prost(uint64, tag = "3")] + pub channel_amount_sats: u64, + /// The amount of satoshis to push to the remote side as part of the initial commitment state. + #[prost(uint64, optional, tag = "4")] + pub push_to_counterparty_msat: ::core::option::Option, + /// The channel configuration to be used for opening this channel. If unset, default ChannelConfig is used. + #[prost(message, optional, tag = "5")] + pub channel_config: ::core::option::Option, + /// Whether the channel should be public. + #[prost(bool, tag = "6")] + pub announce_channel: bool, +} +/// The response `content` for the `OpenChannel` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OpenChannelResponse { + /// The local channel id of the created channel that user can use to refer to channel. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, +} +/// Increases the channel balance by the given amount. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SpliceInRequest { + /// The local `user_channel_id` of the channel. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, + /// The hex-encoded public key of the channel's counterparty node. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount of sats to splice into the channel. + #[prost(uint64, tag = "3")] + pub splice_amount_sats: u64, +} +/// The response `content` for the `SpliceIn` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SpliceInResponse {} +/// Decreases the channel balance by the given amount. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SpliceOutRequest { + /// The local `user_channel_id` of this channel. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, + /// The hex-encoded public key of the channel's counterparty node. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// A Bitcoin on-chain address to send the spliced-out funds. + /// + /// If not set, an address from the node's on-chain wallet will be used. + #[prost(string, optional, tag = "3")] + pub address: ::core::option::Option<::prost::alloc::string::String>, + /// The amount of sats to splice out of the channel. + #[prost(uint64, tag = "4")] + pub splice_amount_sats: u64, +} +/// The response `content` for the `SpliceOut` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SpliceOutResponse { + /// The Bitcoin on-chain address where the funds will be sent. + #[prost(string, tag = "1")] + pub address: ::prost::alloc::string::String, +} +/// Update the config for a previously opened channel. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpdateChannelConfigRequest { + /// The local `user_channel_id` of this channel. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, + /// The hex-encoded public key of the counterparty node to update channel config with. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The updated channel configuration settings for a channel. + #[prost(message, optional, tag = "3")] + pub channel_config: ::core::option::Option, +} +/// The response `content` for the `UpdateChannelConfig` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpdateChannelConfigResponse {} +/// Closes the channel specified by given request. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CloseChannelRequest { + /// The local `user_channel_id` of this channel. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, + /// The hex-encoded public key of the node to close a channel with. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, +} +/// The response `content` for the `CloseChannel` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CloseChannelResponse {} +/// Force closes the channel specified by given request. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ForceCloseChannelRequest { + /// The local `user_channel_id` of this channel. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, + /// The hex-encoded public key of the node to close a channel with. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The reason for force-closing. + #[prost(string, optional, tag = "3")] + pub force_close_reason: ::core::option::Option<::prost::alloc::string::String>, +} +/// The response `content` for the `ForceCloseChannel` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ForceCloseChannelResponse {} +/// Returns a list of known channels. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListChannelsRequest {} +/// The response `content` for the `ListChannels` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListChannelsResponse { + /// List of channels. + #[prost(message, repeated, tag = "1")] + pub channels: ::prost::alloc::vec::Vec, +} +/// Returns payment details for a given payment_id. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPaymentDetailsRequest { + /// An identifier used to uniquely identify a payment in hex-encoded form. + #[prost(string, tag = "1")] + pub payment_id: ::prost::alloc::string::String, +} +/// The response `content` for the `GetPaymentDetails` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPaymentDetailsResponse { + /// Represents a payment. + /// Will be `None` if payment doesn't exist. + #[prost(message, optional, tag = "1")] + pub payment: ::core::option::Option, +} +/// Retrieves list of all payments. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListPaymentsRequest { + /// `page_token` is a pagination token. + /// + /// To query for the first page, `page_token` must not be specified. + /// + /// For subsequent pages, use the value that was returned as `next_page_token` in the previous + /// page's response. + #[prost(message, optional, tag = "1")] + pub page_token: ::core::option::Option, +} +/// The response `content` for the `ListPayments` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListPaymentsResponse { + /// List of payments. + #[prost(message, repeated, tag = "1")] + pub payments: ::prost::alloc::vec::Vec, + /// `next_page_token` is a pagination token, used to retrieve the next page of results. + /// Use this value to query for next-page of paginated operation, by specifying + /// this value as the `page_token` in the next request. + /// + /// If `next_page_token` is `None`, then the "last page" of results has been processed and + /// there is no more data to be retrieved. + /// + /// If `next_page_token` is not `None`, it does not necessarily mean that there is more data in the + /// result set. The only way to know when you have reached the end of the result set is when + /// `next_page_token` is `None`. + /// + /// **Caution**: Clients must not assume a specific number of records to be present in a page for + /// paginated response. + #[prost(message, optional, tag = "2")] + pub next_page_token: ::core::option::Option, +} +/// Retrieves list of all forwarded payments. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListForwardedPaymentsRequest { + /// `page_token` is a pagination token. + /// + /// To query for the first page, `page_token` must not be specified. + /// + /// For subsequent pages, use the value that was returned as `next_page_token` in the previous + /// page's response. + #[prost(message, optional, tag = "1")] + pub page_token: ::core::option::Option, +} +/// The response `content` for the `ListForwardedPayments` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListForwardedPaymentsResponse { + /// List of forwarded payments. + #[prost(message, repeated, tag = "1")] + pub forwarded_payments: ::prost::alloc::vec::Vec, + /// `next_page_token` is a pagination token, used to retrieve the next page of results. + /// Use this value to query for next-page of paginated operation, by specifying + /// this value as the `page_token` in the next request. + /// + /// If `next_page_token` is `None`, then the "last page" of results has been processed and + /// there is no more data to be retrieved. + /// + /// If `next_page_token` is not `None`, it does not necessarily mean that there is more data in the + /// result set. The only way to know when you have reached the end of the result set is when + /// `next_page_token` is `None`. + /// + /// **Caution**: Clients must not assume a specific number of records to be present in a page for + /// paginated response. + #[prost(message, optional, tag = "2")] + pub next_page_token: ::core::option::Option, +} +/// Retrieves an overview of all known balances. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetBalancesRequest {} +/// The response `content` for the `GetBalances` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetBalancesResponse { + /// The total balance of our on-chain wallet. + #[prost(uint64, tag = "1")] + pub total_onchain_balance_sats: u64, + /// The currently spendable balance of our on-chain wallet. + /// + /// This includes any sufficiently confirmed funds, minus `total_anchor_channels_reserve_sats`. + #[prost(uint64, tag = "2")] + pub spendable_onchain_balance_sats: u64, + /// The share of our total balance that we retain as an emergency reserve to (hopefully) be + /// able to spend the Anchor outputs when one of our channels is closed. + #[prost(uint64, tag = "3")] + pub total_anchor_channels_reserve_sats: u64, + /// The total balance that we would be able to claim across all our Lightning channels. + /// + /// Note this excludes balances that we are unsure if we are able to claim (e.g., as we are + /// waiting for a preimage or for a timeout to expire). These balances will however be included + /// as `MaybePreimageClaimableHTLC` and `MaybeTimeoutClaimableHTLC` in `lightning_balances`. + #[prost(uint64, tag = "4")] + pub total_lightning_balance_sats: u64, + /// A detailed list of all known Lightning balances that would be claimable on channel closure. + /// + /// Note that less than the listed amounts are spendable over lightning as further reserve + /// restrictions apply. Please refer to `Channel::outbound_capacity_msat` and + /// Channel::next_outbound_htlc_limit_msat as returned by `ListChannels` + /// for a better approximation of the spendable amounts. + #[prost(message, repeated, tag = "5")] + pub lightning_balances: ::prost::alloc::vec::Vec, + /// A detailed list of balances currently being swept from the Lightning to the on-chain + /// wallet. + /// + /// These are balances resulting from channel closures that may have been encumbered by a + /// delay, but are now being claimed and useable once sufficiently confirmed on-chain. + /// + /// Note that, depending on the sync status of the wallets, swept balances listed here might or + /// might not already be accounted for in `total_onchain_balance_sats`. + #[prost(message, repeated, tag = "6")] + pub pending_balances_from_channel_closures: + ::prost::alloc::vec::Vec, +} +/// Connect to a peer on the Lightning Network. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ConnectPeerRequest { + /// The hex-encoded public key of the node to connect to. + #[prost(string, tag = "1")] + pub node_pubkey: ::prost::alloc::string::String, + /// An address which can be used to connect to a remote peer. + /// It can be of type IPv4:port, IPv6:port, OnionV3:port or hostname:port + #[prost(string, tag = "2")] + pub address: ::prost::alloc::string::String, + /// Whether to persist the peer connection, i.e., whether the peer will be re-connected on + /// restart. + #[prost(bool, tag = "3")] + pub persist: bool, +} +/// The response `content` for the `ConnectPeer` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ConnectPeerResponse {} diff --git a/ldk-server/ldk-server-protos/src/endpoints.rs b/ldk-server/ldk-server-protos/src/endpoints.rs new file mode 100644 index 000000000..606da0dbe --- /dev/null +++ b/ldk-server/ldk-server-protos/src/endpoints.rs @@ -0,0 +1,28 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub const GET_NODE_INFO_PATH: &str = "GetNodeInfo"; +pub const GET_BALANCES_PATH: &str = "GetBalances"; +pub const ONCHAIN_RECEIVE_PATH: &str = "OnchainReceive"; +pub const ONCHAIN_SEND_PATH: &str = "OnchainSend"; +pub const BOLT11_RECEIVE_PATH: &str = "Bolt11Receive"; +pub const BOLT11_SEND_PATH: &str = "Bolt11Send"; +pub const BOLT12_RECEIVE_PATH: &str = "Bolt12Receive"; +pub const BOLT12_SEND_PATH: &str = "Bolt12Send"; +pub const OPEN_CHANNEL_PATH: &str = "OpenChannel"; +pub const SPLICE_IN_PATH: &str = "SpliceIn"; +pub const SPLICE_OUT_PATH: &str = "SpliceOut"; +pub const CLOSE_CHANNEL_PATH: &str = "CloseChannel"; +pub const FORCE_CLOSE_CHANNEL_PATH: &str = "ForceCloseChannel"; +pub const LIST_CHANNELS_PATH: &str = "ListChannels"; +pub const LIST_PAYMENTS_PATH: &str = "ListPayments"; +pub const LIST_FORWARDED_PAYMENTS_PATH: &str = "ListForwardedPayments"; +pub const UPDATE_CHANNEL_CONFIG_PATH: &str = "UpdateChannelConfig"; +pub const GET_PAYMENT_DETAILS_PATH: &str = "GetPaymentDetails"; +pub const CONNECT_PEER_PATH: &str = "ConnectPeer"; diff --git a/ldk-server/ldk-server-protos/src/error.rs b/ldk-server/ldk-server-protos/src/error.rs new file mode 100644 index 000000000..41e73ee42 --- /dev/null +++ b/ldk-server/ldk-server-protos/src/error.rs @@ -0,0 +1,79 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +/// When HttpStatusCode is not ok (200), the response `content` contains a serialized `ErrorResponse` +/// with the relevant ErrorCode and `message` +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ErrorResponse { + /// The error message containing a generic description of the error condition in English. + /// It is intended for a human audience only and should not be parsed to extract any information + /// programmatically. Client-side code may use it for logging only. + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, + /// The error code uniquely identifying an error condition. + /// It is meant to be read and understood programmatically by code that detects/handles errors by + /// type. + /// + /// **Caution**: If a new type of `error_code` is introduced in the `ErrorCode` enum, `error_code` field will be set to + /// `UnknownError`. + #[prost(enumeration = "ErrorCode", tag = "2")] + pub error_code: i32, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ErrorCode { + /// Will never be used as `error_code` by server. + /// + /// **Caution**: If a new type of `error_code` is introduced in the `ErrorCode` enum, `error_code` field will be set to + /// `UnknownError`. + UnknownError = 0, + /// Used in the following cases: + /// - The request was missing a required argument. + /// - The specified argument was invalid, incomplete or in the wrong format. + /// - The request body of api cannot be deserialized into corresponding protobuf object. + /// - The request does not follow api contract. + InvalidRequestError = 1, + /// Used when authentication fails or in case of an unauthorized request. + AuthError = 2, + /// Used to represent an error while doing a Lightning operation. + LightningError = 3, + /// Used when an internal server error occurred. The client is probably at no fault. + InternalServerError = 4, +} +impl ErrorCode { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + ErrorCode::UnknownError => "UNKNOWN_ERROR", + ErrorCode::InvalidRequestError => "INVALID_REQUEST_ERROR", + ErrorCode::AuthError => "AUTH_ERROR", + ErrorCode::LightningError => "LIGHTNING_ERROR", + ErrorCode::InternalServerError => "INTERNAL_SERVER_ERROR", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "UNKNOWN_ERROR" => Some(Self::UnknownError), + "INVALID_REQUEST_ERROR" => Some(Self::InvalidRequestError), + "AUTH_ERROR" => Some(Self::AuthError), + "LIGHTNING_ERROR" => Some(Self::LightningError), + "INTERNAL_SERVER_ERROR" => Some(Self::InternalServerError), + _ => None, + } + } +} diff --git a/ldk-server/ldk-server-protos/src/events.rs b/ldk-server/ldk-server-protos/src/events.rs new file mode 100644 index 000000000..08a605bed --- /dev/null +++ b/ldk-server/ldk-server-protos/src/events.rs @@ -0,0 +1,74 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +/// EventEnvelope wraps different event types in a single message to be used by EventPublisher. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EventEnvelope { + #[prost(oneof = "event_envelope::Event", tags = "2, 3, 4, 6")] + pub event: ::core::option::Option, +} +/// Nested message and enum types in `EventEnvelope`. +pub mod event_envelope { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Event { + #[prost(message, tag = "2")] + PaymentReceived(super::PaymentReceived), + #[prost(message, tag = "3")] + PaymentSuccessful(super::PaymentSuccessful), + #[prost(message, tag = "4")] + PaymentFailed(super::PaymentFailed), + #[prost(message, tag = "6")] + PaymentForwarded(super::PaymentForwarded), + } +} +/// PaymentReceived indicates a payment has been received. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaymentReceived { + /// The payment details for the payment in event. + #[prost(message, optional, tag = "1")] + pub payment: ::core::option::Option, +} +/// PaymentSuccessful indicates a sent payment was successful. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaymentSuccessful { + /// The payment details for the payment in event. + #[prost(message, optional, tag = "1")] + pub payment: ::core::option::Option, +} +/// PaymentFailed indicates a sent payment has failed. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaymentFailed { + /// The payment details for the payment in event. + #[prost(message, optional, tag = "1")] + pub payment: ::core::option::Option, +} +/// PaymentForwarded indicates a payment was forwarded through the node. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaymentForwarded { + #[prost(message, optional, tag = "1")] + pub forwarded_payment: ::core::option::Option, +} diff --git a/ldk-server/ldk-server-protos/src/lib.rs b/ldk-server/ldk-server-protos/src/lib.rs new file mode 100644 index 000000000..24a735256 --- /dev/null +++ b/ldk-server/ldk-server-protos/src/lib.rs @@ -0,0 +1,14 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub mod api; +pub mod endpoints; +pub mod error; +pub mod events; +pub mod types; diff --git a/ldk-server/ldk-server-protos/src/proto/api.proto b/ldk-server/ldk-server-protos/src/proto/api.proto new file mode 100644 index 000000000..be29f8f87 --- /dev/null +++ b/ldk-server/ldk-server-protos/src/proto/api.proto @@ -0,0 +1,501 @@ +syntax = "proto3"; +package api; + +import 'types.proto'; + +// Retrieve the latest node info like `node_id`, `current_best_block` etc. +// See more: +// - https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.node_id +// - https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.status +message GetNodeInfoRequest { +} + +// The response `content` for the `GetNodeInfo` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message GetNodeInfoResponse { + + // The hex-encoded `node-id` or public key for our own lightning node. + string node_id = 1; + + // The best block to which our Lightning wallet is currently synced. + // + // Should be always set, will never be `None`. + types.BestBlock current_best_block = 3; + + // The timestamp, in seconds since start of the UNIX epoch, when we last successfully synced our Lightning wallet to + // the chain tip. + // + // Will be `None` if the wallet hasn't been synced yet. + optional uint64 latest_lightning_wallet_sync_timestamp = 4; + + // The timestamp, in seconds since start of the UNIX epoch, when we last successfully synced our on-chain + // wallet to the chain tip. + // + // Will be `None` if the wallet hasn’t been synced since the node was initialized. + optional uint64 latest_onchain_wallet_sync_timestamp = 5; + + // The timestamp, in seconds since start of the UNIX epoch, when we last successfully update our fee rate cache. + // + // Will be `None` if the cache hasn’t been updated since the node was initialized. + optional uint64 latest_fee_rate_cache_update_timestamp = 6; + + // The timestamp, in seconds since start of the UNIX epoch, when the last rapid gossip sync (RGS) snapshot we + // successfully applied was generated. + // + // Will be `None` if RGS isn’t configured or the snapshot hasn’t been updated since the node was initialized. + optional uint64 latest_rgs_snapshot_timestamp = 7; + + // The timestamp, in seconds since start of the UNIX epoch, when we last broadcasted a node announcement. + // + // Will be `None` if we have no public channels or we haven’t broadcasted since the node was initialized. + optional uint64 latest_node_announcement_broadcast_timestamp = 8; +} + +// Retrieve a new on-chain funding address. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.OnchainPayment.html#method.new_address +message OnchainReceiveRequest { +} + +// The response `content` for the `OnchainReceive` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.. +message OnchainReceiveResponse { + + // A Bitcoin on-chain address. + string address = 1; +} + +// Send an on-chain payment to the given address. +message OnchainSendRequest { + + // The address to send coins to. + string address = 1; + + // The amount in satoshis to send. + // While sending the specified amount, we will respect any on-chain reserve we need to keep, + // i.e., won't allow to cut into `total_anchor_channels_reserve_sats`. + // See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.OnchainPayment.html#method.send_to_address + optional uint64 amount_sats = 2; + + // If set, the amount_sats field should be unset. + // It indicates that node will send full balance to the specified address. + // + // Please note that when send_all is used this operation will **not** retain any on-chain reserves, + // which might be potentially dangerous if you have open Anchor channels for which you can't trust + // the counterparty to spend the Anchor output after channel closure. + // See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.OnchainPayment.html#method.send_all_to_address + optional bool send_all = 3; + + // If `fee_rate_sat_per_vb` is set it will be used on the resulting transaction. Otherwise we'll retrieve + // a reasonable estimate from BitcoinD. + optional uint64 fee_rate_sat_per_vb = 4; +} + +// The response `content` for the `OnchainSend` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message OnchainSendResponse { + + // The transaction ID of the broadcasted transaction. + string txid = 1; +} + +// Return a BOLT11 payable invoice that can be used to request and receive a payment +// for the given amount, if specified. +// The inbound payment will be automatically claimed upon arrival. +// See more: +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.receive +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.receive_variable_amount +message Bolt11ReceiveRequest { + + // The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount invoice is returned. + optional uint64 amount_msat = 1; + + // An optional description to attach along with the invoice. + // Will be set in the description field of the encoded payment request. + types.Bolt11InvoiceDescription description = 2; + + // Invoice expiry time in seconds. + uint32 expiry_secs = 3; +} + +// The response `content` for the `Bolt11Receive` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt11ReceiveResponse { + + // An invoice for a payment within the Lightning Network. + // With the details of the invoice, the sender has all the data necessary to send a payment + // to the recipient. + string invoice = 1; +} + +// Send a payment for a BOLT11 invoice. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.send +message Bolt11SendRequest { + + // An invoice for a payment within the Lightning Network. + string invoice = 1; + + // Set this field when paying a so-called "zero-amount" invoice, i.e., an invoice that leaves the + // amount paid to be determined by the user. + // This operation will fail if the amount specified is less than the value required by the given invoice. + optional uint64 amount_msat = 2; + + // Configuration options for payment routing and pathfinding. + optional types.RouteParametersConfig route_parameters = 3; + +} + +// The response `content` for the `Bolt11Send` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt11SendResponse { + + // An identifier used to uniquely identify a payment in hex-encoded form. + string payment_id = 1; +} + +// Returns a BOLT12 offer for the given amount, if specified. +// +// See more: +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.receive_variable_amount +message Bolt12ReceiveRequest { + + // An optional description to attach along with the offer. + // Will be set in the description field of the encoded offer. + string description = 1; + + // The amount in millisatoshi to send. If unset, a "zero-amount" or variable-amount offer is returned. + optional uint64 amount_msat = 2; + + // Offer expiry time in seconds. + optional uint32 expiry_secs = 3; + + // If set, it represents the number of items requested, can only be set for fixed-amount offers. + optional uint64 quantity = 4; +} + +// The response `content` for the `Bolt12Receive` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt12ReceiveResponse { + + // An offer for a payment within the Lightning Network. + // With the details of the offer, the sender has all the data necessary to send a payment + // to the recipient. + string offer = 1; +} + +// Send a payment for a BOLT12 offer. +// See more: +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.send +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt12Payment.html#method.send_using_amount +message Bolt12SendRequest { + + // An offer for a payment within the Lightning Network. + string offer = 1; + + // Set this field when paying a so-called "zero-amount" offer, i.e., an offer that leaves the + // amount paid to be determined by the user. + // This operation will fail if the amount specified is less than the value required by the given offer. + optional uint64 amount_msat = 2; + + // If set, it represents the number of items requested. + optional uint64 quantity = 3; + + // If set, it will be seen by the recipient and reflected back in the invoice. + optional string payer_note = 4; + + // Configuration options for payment routing and pathfinding. + optional types.RouteParametersConfig route_parameters = 5; +} + +// The response `content` for the `Bolt12Send` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt12SendResponse { + + // An identifier used to uniquely identify a payment in hex-encoded form. + string payment_id = 1; +} + +// Creates a new outbound channel to the given remote node. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.connect_open_channel +message OpenChannelRequest { + + // The hex-encoded public key of the node to open a channel with. + string node_pubkey = 1; + + // An address which can be used to connect to a remote peer. + // It can be of type IPv4:port, IPv6:port, OnionV3:port or hostname:port + string address = 2; + + // The amount of satoshis the caller is willing to commit to the channel. + uint64 channel_amount_sats = 3; + + // The amount of satoshis to push to the remote side as part of the initial commitment state. + optional uint64 push_to_counterparty_msat = 4; + + // The channel configuration to be used for opening this channel. If unset, default ChannelConfig is used. + optional types.ChannelConfig channel_config = 5; + + // Whether the channel should be public. + bool announce_channel = 6; +} + +// The response `content` for the `OpenChannel` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message OpenChannelResponse { + + // The local channel id of the created channel that user can use to refer to channel. + string user_channel_id = 1; +} + +// Increases the channel balance by the given amount. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.splice_in +message SpliceInRequest { + + // The local `user_channel_id` of the channel. + string user_channel_id = 1; + + // The hex-encoded public key of the channel's counterparty node. + string counterparty_node_id = 2; + + // The amount of sats to splice into the channel. + uint64 splice_amount_sats = 3; +} + +// The response `content` for the `SpliceIn` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message SpliceInResponse {} + +// Decreases the channel balance by the given amount. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.splice_out +message SpliceOutRequest { + + // The local `user_channel_id` of this channel. + string user_channel_id = 1; + + // The hex-encoded public key of the channel's counterparty node. + string counterparty_node_id = 2; + + // A Bitcoin on-chain address to send the spliced-out funds. + // + // If not set, an address from the node's on-chain wallet will be used. + optional string address = 3; + + // The amount of sats to splice out of the channel. + uint64 splice_amount_sats = 4; +} + +// The response `content` for the `SpliceOut` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message SpliceOutResponse { + + // The Bitcoin on-chain address where the funds will be sent. + string address = 1; +} + +// Update the config for a previously opened channel. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.update_channel_config +message UpdateChannelConfigRequest { + + // The local `user_channel_id` of this channel. + string user_channel_id = 1; + + // The hex-encoded public key of the counterparty node to update channel config with. + string counterparty_node_id = 2; + + // The updated channel configuration settings for a channel. + types.ChannelConfig channel_config = 3; +} + +// The response `content` for the `UpdateChannelConfig` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message UpdateChannelConfigResponse { +} + +// Closes the channel specified by given request. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.close_channel +message CloseChannelRequest { + + // The local `user_channel_id` of this channel. + string user_channel_id = 1; + + // The hex-encoded public key of the node to close a channel with. + string counterparty_node_id = 2; +} + +// The response `content` for the `CloseChannel` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message CloseChannelResponse {} + +// Force closes the channel specified by given request. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.force_close_channel +message ForceCloseChannelRequest { + // The local `user_channel_id` of this channel. + string user_channel_id = 1; + // The hex-encoded public key of the node to close a channel with. + string counterparty_node_id = 2; + // The reason for force-closing. + optional string force_close_reason = 3; +} + +// The response `content` for the `ForceCloseChannel` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message ForceCloseChannelResponse {} + +// Returns a list of known channels. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.list_channels +message ListChannelsRequest {} + +// The response `content` for the `ListChannels` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message ListChannelsResponse { + + // List of channels. + repeated types.Channel channels = 1; +} + +// Returns payment details for a given payment_id. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.payment +message GetPaymentDetailsRequest { + // An identifier used to uniquely identify a payment in hex-encoded form. + string payment_id = 1; +} + +// The response `content` for the `GetPaymentDetails` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message GetPaymentDetailsResponse { + // Represents a payment. + // Will be `None` if payment doesn't exist. + types.Payment payment = 1; +} + +// Retrieves list of all payments. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.list_payments +message ListPaymentsRequest { + // `page_token` is a pagination token. + // + // To query for the first page, `page_token` must not be specified. + // + // For subsequent pages, use the value that was returned as `next_page_token` in the previous + // page's response. + optional types.PageToken page_token = 1; +} + +// The response `content` for the `ListPayments` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message ListPaymentsResponse { + // List of payments. + repeated types.Payment payments = 1; + + // `next_page_token` is a pagination token, used to retrieve the next page of results. + // Use this value to query for next-page of paginated operation, by specifying + // this value as the `page_token` in the next request. + // + // If `next_page_token` is `None`, then the "last page" of results has been processed and + // there is no more data to be retrieved. + // + // If `next_page_token` is not `None`, it does not necessarily mean that there is more data in the + // result set. The only way to know when you have reached the end of the result set is when + // `next_page_token` is `None`. + // + // **Caution**: Clients must not assume a specific number of records to be present in a page for + // paginated response. + optional types.PageToken next_page_token = 2; +} + +// Retrieves list of all forwarded payments. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.Event.html#variant.PaymentForwarded +message ListForwardedPaymentsRequest { + // `page_token` is a pagination token. + // + // To query for the first page, `page_token` must not be specified. + // + // For subsequent pages, use the value that was returned as `next_page_token` in the previous + // page's response. + optional types.PageToken page_token = 1; +} + +// The response `content` for the `ListForwardedPayments` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message ListForwardedPaymentsResponse { + // List of forwarded payments. + repeated types.ForwardedPayment forwarded_payments = 1; + + // `next_page_token` is a pagination token, used to retrieve the next page of results. + // Use this value to query for next-page of paginated operation, by specifying + // this value as the `page_token` in the next request. + // + // If `next_page_token` is `None`, then the "last page" of results has been processed and + // there is no more data to be retrieved. + // + // If `next_page_token` is not `None`, it does not necessarily mean that there is more data in the + // result set. The only way to know when you have reached the end of the result set is when + // `next_page_token` is `None`. + // + // **Caution**: Clients must not assume a specific number of records to be present in a page for + // paginated response. + optional types.PageToken next_page_token = 2; +} + +// Retrieves an overview of all known balances. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.list_balances +message GetBalancesRequest {} + +// The response `content` for the `GetBalances` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message GetBalancesResponse { + // The total balance of our on-chain wallet. + uint64 total_onchain_balance_sats = 1; + + // The currently spendable balance of our on-chain wallet. + // + // This includes any sufficiently confirmed funds, minus `total_anchor_channels_reserve_sats`. + uint64 spendable_onchain_balance_sats = 2; + + // The share of our total balance that we retain as an emergency reserve to (hopefully) be + // able to spend the Anchor outputs when one of our channels is closed. + uint64 total_anchor_channels_reserve_sats = 3; + + // The total balance that we would be able to claim across all our Lightning channels. + // + // Note this excludes balances that we are unsure if we are able to claim (e.g., as we are + // waiting for a preimage or for a timeout to expire). These balances will however be included + // as `MaybePreimageClaimableHTLC` and `MaybeTimeoutClaimableHTLC` in `lightning_balances`. + uint64 total_lightning_balance_sats = 4; + + // A detailed list of all known Lightning balances that would be claimable on channel closure. + // + // Note that less than the listed amounts are spendable over lightning as further reserve + // restrictions apply. Please refer to `Channel::outbound_capacity_msat` and + // Channel::next_outbound_htlc_limit_msat as returned by `ListChannels` + // for a better approximation of the spendable amounts. + repeated types.LightningBalance lightning_balances = 5; + + // A detailed list of balances currently being swept from the Lightning to the on-chain + // wallet. + // + // These are balances resulting from channel closures that may have been encumbered by a + // delay, but are now being claimed and useable once sufficiently confirmed on-chain. + // + // Note that, depending on the sync status of the wallets, swept balances listed here might or + // might not already be accounted for in `total_onchain_balance_sats`. + repeated types.PendingSweepBalance pending_balances_from_channel_closures = 6; +} + +// Connect to a peer on the Lightning Network. +// See more: https://docs.rs/ldk-node/latest/ldk_node/struct.Node.html#method.connect +message ConnectPeerRequest { + // The hex-encoded public key of the node to connect to. + string node_pubkey = 1; + + // An address which can be used to connect to a remote peer. + // It can be of type IPv4:port, IPv6:port, OnionV3:port or hostname:port + string address = 2; + + // Whether to persist the peer connection, i.e., whether the peer will be re-connected on + // restart. + bool persist = 3; +} + +// The response `content` for the `ConnectPeer` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message ConnectPeerResponse {} diff --git a/ldk-server/ldk-server-protos/src/proto/error.proto b/ldk-server/ldk-server-protos/src/proto/error.proto new file mode 100644 index 000000000..c5a75d7dd --- /dev/null +++ b/ldk-server/ldk-server-protos/src/proto/error.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; +package error; + +// When HttpStatusCode is not ok (200), the response `content` contains a serialized `ErrorResponse` +// with the relevant ErrorCode and `message` +message ErrorResponse { + + // The error message containing a generic description of the error condition in English. + // It is intended for a human audience only and should not be parsed to extract any information + // programmatically. Client-side code may use it for logging only. + string message = 1; + + // The error code uniquely identifying an error condition. + // It is meant to be read and understood programmatically by code that detects/handles errors by + // type. + // + // **Caution**: If a new type of `error_code` is introduced in the `ErrorCode` enum, `error_code` field will be set to + // `UnknownError`. + ErrorCode error_code = 2; +} + +enum ErrorCode { + + // Will never be used as `error_code` by server. + // + // **Caution**: If a new type of `error_code` is introduced in the `ErrorCode` enum, `error_code` field will be set to + // `UnknownError`. + UNKNOWN_ERROR = 0; + + // Used in the following cases: + // - The request was missing a required argument. + // - The specified argument was invalid, incomplete or in the wrong format. + // - The request body of api cannot be deserialized into corresponding protobuf object. + // - The request does not follow api contract. + INVALID_REQUEST_ERROR = 1; + + // Used when authentication fails or in case of an unauthorized request. + AUTH_ERROR = 2; + + // Used to represent an error while doing a Lightning operation. + LIGHTNING_ERROR = 3; + + // Used when an internal server error occurred. The client is probably at no fault. + INTERNAL_SERVER_ERROR = 4; +} diff --git a/ldk-server/ldk-server-protos/src/proto/events.proto b/ldk-server/ldk-server-protos/src/proto/events.proto new file mode 100644 index 000000000..19d2f5c8d --- /dev/null +++ b/ldk-server/ldk-server-protos/src/proto/events.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; +import "types.proto"; +package events; + +// EventEnvelope wraps different event types in a single message to be used by EventPublisher. +message EventEnvelope { + oneof event { + PaymentReceived payment_received = 2; + PaymentSuccessful payment_successful = 3; + PaymentFailed payment_failed = 4; + PaymentForwarded payment_forwarded = 6; + } +} + +// PaymentReceived indicates a payment has been received. +message PaymentReceived { + // The payment details for the payment in event. + types.Payment payment = 1; +} + +// PaymentSuccessful indicates a sent payment was successful. +message PaymentSuccessful { + // The payment details for the payment in event. + types.Payment payment = 1; +} + +// PaymentFailed indicates a sent payment has failed. +message PaymentFailed { + // The payment details for the payment in event. + types.Payment payment = 1; +} + +// PaymentForwarded indicates a payment was forwarded through the node. +message PaymentForwarded { + types.ForwardedPayment forwarded_payment = 1; +} diff --git a/ldk-server/ldk-server-protos/src/proto/types.proto b/ldk-server/ldk-server-protos/src/proto/types.proto new file mode 100644 index 000000000..730616921 --- /dev/null +++ b/ldk-server/ldk-server-protos/src/proto/types.proto @@ -0,0 +1,706 @@ +syntax = "proto3"; +package types; + +// Represents a payment. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.PaymentDetails.html +message Payment { + // An identifier used to uniquely identify a payment in hex-encoded form. + string id = 1; + + // The kind of the payment. + PaymentKind kind = 2; + + // The amount transferred. + optional uint64 amount_msat = 3; + + // The fees that were paid for this payment. + // + // For Lightning payments, this will only be updated for outbound payments once they + // succeeded. + optional uint64 fee_paid_msat = 7; + + // The direction of the payment. + PaymentDirection direction = 4; + + // The status of the payment. + PaymentStatus status = 5; + + // The timestamp, in seconds since start of the UNIX epoch, when this entry was last updated. + uint64 latest_update_timestamp = 6; +} + +message PaymentKind { + oneof kind { + Onchain onchain = 1; + Bolt11 bolt11 = 2; + Bolt11Jit bolt11_jit = 3; + Bolt12Offer bolt12_offer = 4; + Bolt12Refund bolt12_refund = 5; + Spontaneous spontaneous = 6; + } +} + +// Represents an on-chain payment. +message Onchain { + // The transaction identifier of this payment. + string txid = 1; + + // The confirmation status of this payment. + ConfirmationStatus status = 2; +} + +message ConfirmationStatus { + oneof status { + Confirmed confirmed = 1; + Unconfirmed unconfirmed = 2; + } +} + +// The on-chain transaction is confirmed in the best chain. +message Confirmed { + // The hex representation of hash of the block in which the transaction was confirmed. + string block_hash = 1; + + // The height under which the block was confirmed. + uint32 height = 2; + + // The timestamp, in seconds since start of the UNIX epoch, when this entry was last updated. + uint64 timestamp = 3; +} + +// The on-chain transaction is unconfirmed. +message Unconfirmed {} + +// Represents a BOLT 11 payment. +message Bolt11 { + // The payment hash, i.e., the hash of the preimage. + string hash = 1; + + // The pre-image used by the payment. + optional string preimage = 2; + + // The secret used by the payment. + optional bytes secret = 3; +} + +// Represents a BOLT 11 payment intended to open an LSPS 2 just-in-time channel. +message Bolt11Jit { + // The payment hash, i.e., the hash of the preimage. + string hash = 1; + + // The pre-image used by the payment. + optional string preimage = 2; + + // The secret used by the payment. + optional bytes secret = 3; + + // Limits applying to how much fee we allow an LSP to deduct from the payment amount. + // + // Allowing them to deduct this fee from the first inbound payment will pay for the LSP’s channel opening fees. + // + // See [`LdkChannelConfig::accept_underpaying_htlcs`](https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.accept_underpaying_htlcs) + // for more information. + LSPFeeLimits lsp_fee_limits = 4; + + // The value, in thousands of a satoshi, that was deducted from this payment as an extra + // fee taken by our channel counterparty. + // + // Will only be `Some` once we received the payment. + optional uint64 counterparty_skimmed_fee_msat = 5; +} + +// Represents a BOLT 12 ‘offer’ payment, i.e., a payment for an Offer. +message Bolt12Offer { + // The payment hash, i.e., the hash of the preimage. + optional string hash = 1; + + // The pre-image used by the payment. + optional string preimage = 2; + + // The secret used by the payment. + optional bytes secret = 3; + + // The hex-encoded ID of the offer this payment is for. + string offer_id = 4; + + // The payer's note for the payment. + // Truncated to [PAYER_NOTE_LIMIT](https://docs.rs/lightning/latest/lightning/offers/invoice_request/constant.PAYER_NOTE_LIMIT.html). + // + // **Caution**: The `payer_note` field may come from an untrusted source. To prevent potential misuse, + // all non-printable characters will be sanitized and replaced with safe characters. + optional string payer_note = 5; + + // The quantity of an item requested in the offer. + optional uint64 quantity = 6; +} + +// Represents a BOLT 12 ‘refund’ payment, i.e., a payment for a Refund. +message Bolt12Refund { + // The payment hash, i.e., the hash of the preimage. + optional string hash = 1; + + // The pre-image used by the payment. + optional string preimage = 2; + + // The secret used by the payment. + optional bytes secret = 3; + + // The payer's note for the payment. + // Truncated to [PAYER_NOTE_LIMIT](https://docs.rs/lightning/latest/lightning/offers/invoice_request/constant.PAYER_NOTE_LIMIT.html). + // + // **Caution**: The `payer_note` field may come from an untrusted source. To prevent potential misuse, + // all non-printable characters will be sanitized and replaced with safe characters. + optional string payer_note = 5; + + // The quantity of an item requested in the offer. + optional uint64 quantity = 6; + +} + +// Represents a spontaneous (“keysend”) payment. +message Spontaneous { + // The payment hash, i.e., the hash of the preimage. + string hash = 1; + + // The pre-image used by the payment. + optional string preimage = 2; +} + +// Limits applying to how much fee we allow an LSP to deduct from the payment amount. +// See [`LdkChannelConfig::accept_underpaying_htlcs`] for more information. +// +// [`LdkChannelConfig::accept_underpaying_htlcs`]: lightning::util::config::ChannelConfig::accept_underpaying_htlcs +message LSPFeeLimits { + // The maximal total amount we allow any configured LSP withhold from us when forwarding the + // payment. + optional uint64 max_total_opening_fee_msat = 1; + + // The maximal proportional fee, in parts-per-million millisatoshi, we allow any configured + // LSP withhold from us when forwarding the payment. + optional uint64 max_proportional_opening_fee_ppm_msat = 2; +} + +// Represents the direction of a payment. +enum PaymentDirection { + // The payment is inbound. + INBOUND = 0; + + // The payment is outbound. + OUTBOUND = 1; +} + +// Represents the current status of a payment. +enum PaymentStatus { + // The payment is still pending. + PENDING = 0; + + // The payment succeeded. + SUCCEEDED = 1; + + // The payment failed. + FAILED = 2; +} + +// A forwarded payment through our node. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.Event.html#variant.PaymentForwarded +message ForwardedPayment{ + // The channel id of the incoming channel between the previous node and us. + string prev_channel_id = 1; + + // The channel id of the outgoing channel between the next node and us. + string next_channel_id = 2; + + // The `user_channel_id` of the incoming channel between the previous node and us. + string prev_user_channel_id = 3; + + // The node id of the previous node. + string prev_node_id = 9; + + // The node id of the next node. + string next_node_id = 10; + + // The `user_channel_id` of the outgoing channel between the next node and us. + // This will be `None` if the payment was settled via an on-chain transaction. + // See the caveat described for the `total_fee_earned_msat` field. + optional string next_user_channel_id = 4; + + // The total fee, in milli-satoshis, which was earned as a result of the payment. + // + // Note that if we force-closed the channel over which we forwarded an HTLC while the HTLC was pending, the amount the + // next hop claimed will have been rounded down to the nearest whole satoshi. Thus, the fee calculated here may be + // higher than expected as we still claimed the full value in millisatoshis from the source. + // In this case, `claim_from_onchain_tx` will be set. + // + // If the channel which sent us the payment has been force-closed, we will claim the funds via an on-chain transaction. + // In that case we do not yet know the on-chain transaction fees which we will spend and will instead set this to `None`. + optional uint64 total_fee_earned_msat = 5; + + // The share of the total fee, in milli-satoshis, which was withheld in addition to the forwarding fee. + // This will only be set if we forwarded an intercepted HTLC with less than the expected amount. This means our + // counterparty accepted to receive less than the invoice amount. + // + // The caveat described above the `total_fee_earned_msat` field applies here as well. + optional uint64 skimmed_fee_msat = 6; + + // If this is true, the forwarded HTLC was claimed by our counterparty via an on-chain transaction. + bool claim_from_onchain_tx = 7; + + // The final amount forwarded, in milli-satoshis, after the fee is deducted. + // + // The caveat described above the `total_fee_earned_msat` field applies here as well. + optional uint64 outbound_amount_forwarded_msat = 8; + +} + +message Channel { + // The channel ID (prior to funding transaction generation, this is a random 32-byte + // identifier, afterwards this is the transaction ID of the funding transaction XOR the + // funding transaction output). + // + // Note that this means this value is *not* persistent - it can change once during the + // lifetime of the channel. + string channel_id = 1; + + // The node ID of our the channel's remote counterparty. + string counterparty_node_id = 2; + + // The channel's funding transaction output, if we've negotiated the funding transaction with + // our counterparty already. + optional OutPoint funding_txo = 3; + + // The hex-encoded local `user_channel_id` of this channel. + string user_channel_id = 4; + + // The value, in satoshis, that must always be held as a reserve in the channel for us. This + // value ensures that if we broadcast a revoked state, our counterparty can punish us by + // claiming at least this value on chain. + // + // This value is not included in [`outbound_capacity_msat`] as it can never be spent. + // + // This value will be `None` for outbound channels until the counterparty accepts the channel. + optional uint64 unspendable_punishment_reserve = 5; + + // The value, in satoshis, of this channel as it appears in the funding output. + uint64 channel_value_sats = 6; + + // The currently negotiated fee rate denominated in satoshi per 1000 weight units, + // which is applied to commitment and HTLC transactions. + uint32 feerate_sat_per_1000_weight = 7; + + // The available outbound capacity for sending HTLCs to the remote peer. + // + // The amount does not include any pending HTLCs which are not yet resolved (and, thus, whose + // balance is not available for inclusion in new outbound HTLCs). This further does not include + // any pending outgoing HTLCs which are awaiting some other resolution to be sent. + uint64 outbound_capacity_msat = 8; + + // The available outbound capacity for sending HTLCs to the remote peer. + // + // The amount does not include any pending HTLCs which are not yet resolved + // (and, thus, whose balance is not available for inclusion in new inbound HTLCs). This further + // does not include any pending outgoing HTLCs which are awaiting some other resolution to be + // sent. + uint64 inbound_capacity_msat = 9; + + // The number of required confirmations on the funding transactions before the funding is + // considered "locked". The amount is selected by the channel fundee. + // + // The value will be `None` for outbound channels until the counterparty accepts the channel. + optional uint32 confirmations_required = 10; + + // The current number of confirmations on the funding transaction. + optional uint32 confirmations = 11; + + // Is `true` if the channel was initiated (and therefore funded) by us. + bool is_outbound = 12; + + // Is `true` if both parties have exchanged `channel_ready` messages, and the channel is + // not currently being shut down. Both parties exchange `channel_ready` messages upon + // independently verifying that the required confirmations count provided by + // `confirmations_required` has been reached. + bool is_channel_ready = 13; + + // Is `true` if the channel (a) `channel_ready` messages have been exchanged, (b) the + // peer is connected, and (c) the channel is not currently negotiating shutdown. + // + // This is a strict superset of `is_channel_ready`. + bool is_usable = 14; + + // Is `true` if this channel is (or will be) publicly-announced + bool is_announced = 15; + + // Set of configurable parameters set by self that affect channel operation. + ChannelConfig channel_config = 16; + + // The available outbound capacity for sending a single HTLC to the remote peer. This is + // similar to `outbound_capacity_msat` but it may be further restricted by + // the current state and per-HTLC limit(s). This is intended for use when routing, allowing us + // to use a limit as close as possible to the HTLC limit we can currently send. + uint64 next_outbound_htlc_limit_msat = 17; + + // The minimum value for sending a single HTLC to the remote peer. This is the equivalent of + // `next_outbound_htlc_limit_msat` but represents a lower-bound, rather than + // an upper-bound. This is intended for use when routing, allowing us to ensure we pick a + // route which is valid. + uint64 next_outbound_htlc_minimum_msat = 18; + + // The number of blocks (after our commitment transaction confirms) that we will need to wait + // until we can claim our funds after we force-close the channel. During this time our + // counterparty is allowed to punish us if we broadcasted a stale state. If our counterparty + // force-closes the channel and broadcasts a commitment transaction we do not have to wait any + // time to claim our non-HTLC-encumbered funds. + // + // This value will be `None` for outbound channels until the counterparty accepts the channel. + optional uint32 force_close_spend_delay = 19; + + // The smallest value HTLC (in msat) the remote peer will accept, for this channel. + // + // This field is only `None` before we have received either the `OpenChannel` or + // `AcceptChannel` message from the remote peer. + optional uint64 counterparty_outbound_htlc_minimum_msat = 20; + + // The largest value HTLC (in msat) the remote peer currently will accept, for this channel. + optional uint64 counterparty_outbound_htlc_maximum_msat = 21; + + // The value, in satoshis, that must always be held in the channel for our counterparty. This + // value ensures that if our counterparty broadcasts a revoked state, we can punish them by + // claiming at least this value on chain. + // + // This value is not included in `inbound_capacity_msat` as it can never be spent. + uint64 counterparty_unspendable_punishment_reserve = 22; + + // Base routing fee in millisatoshis. + optional uint32 counterparty_forwarding_info_fee_base_msat = 23; + + // Proportional fee, in millionths of a satoshi the channel will charge per transferred satoshi. + optional uint32 counterparty_forwarding_info_fee_proportional_millionths = 24; + + // The minimum difference in CLTV expiry between an ingoing HTLC and its outgoing counterpart, + // such that the outgoing HTLC is forwardable to this counterparty. + optional uint32 counterparty_forwarding_info_cltv_expiry_delta = 25; +} + +// ChannelConfig represents the configuration settings for a channel in a Lightning Network node. +// See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html +message ChannelConfig { + // Amount (in millionths of a satoshi) charged per satoshi for payments forwarded outbound + // over the channel. + // See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.forwarding_fee_proportional_millionths + optional uint32 forwarding_fee_proportional_millionths = 1; + + // Amount (in milli-satoshi) charged for payments forwarded outbound over the channel, + // in excess of forwarding_fee_proportional_millionths. + // See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.forwarding_fee_base_msat + optional uint32 forwarding_fee_base_msat = 2; + + // The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded + // over the channel this config applies to. + // See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.cltv_expiry_delta + optional uint32 cltv_expiry_delta = 3; + + // The maximum additional fee we’re willing to pay to avoid waiting for the counterparty’s + // to_self_delay to reclaim funds. + // See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.force_close_avoidance_max_fee_satoshis + optional uint64 force_close_avoidance_max_fee_satoshis = 4; + + // If set, allows this channel’s counterparty to skim an additional fee off this node’s + // inbound HTLCs. Useful for liquidity providers to offload on-chain channel costs to end users. + // See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.accept_underpaying_htlcs + optional bool accept_underpaying_htlcs = 5; + + // Limit our total exposure to potential loss to on-chain fees on close, including + // in-flight HTLCs which are burned to fees as they are too small to claim on-chain + // and fees on commitment transaction(s) broadcasted by our counterparty in excess of + // our own fee estimate. + // See more: https://docs.rs/lightning/latest/lightning/util/config/struct.ChannelConfig.html#structfield.max_dust_htlc_exposure + oneof max_dust_htlc_exposure { + + // This sets a fixed limit on the total dust exposure in millisatoshis. + // See more: https://docs.rs/lightning/latest/lightning/util/config/enum.MaxDustHTLCExposure.html#variant.FixedLimitMsat + uint64 fixed_limit_msat = 6; + + // This sets a multiplier on the ConfirmationTarget::OnChainSweep feerate (in sats/KW) to determine the maximum allowed dust exposure. + // See more: https://docs.rs/lightning/latest/lightning/util/config/enum.MaxDustHTLCExposure.html#variant.FeeRateMultiplier + uint64 fee_rate_multiplier = 7; + } +} + +// Represent a transaction outpoint. +message OutPoint { + // The referenced transaction's txid. + string txid = 1; + + // The index of the referenced output in its transaction's vout. + uint32 vout = 2; +} + +message BestBlock { + // The block’s hash + string block_hash = 1; + + // The height at which the block was confirmed. + uint32 height = 2; +} + +// Details about the status of a known Lightning balance. +message LightningBalance { + oneof balance_type { + ClaimableOnChannelClose claimable_on_channel_close = 1; + ClaimableAwaitingConfirmations claimable_awaiting_confirmations = 2; + ContentiousClaimable contentious_claimable = 3; + MaybeTimeoutClaimableHTLC maybe_timeout_claimable_htlc = 4; + MaybePreimageClaimableHTLC maybe_preimage_claimable_htlc = 5; + CounterpartyRevokedOutputClaimable counterparty_revoked_output_claimable = 6; + } +} + +// The channel is not yet closed (or the commitment or closing transaction has not yet appeared in a block). +// The given balance is claimable (less on-chain fees) if the channel is force-closed now. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.LightningBalance.html#variant.ClaimableOnChannelClose +message ClaimableOnChannelClose { + // The identifier of the channel this balance belongs to. + string channel_id = 1; + + // The identifier of our channel counterparty. + string counterparty_node_id = 2; + + // The amount available to claim, in satoshis, excluding the on-chain fees which will be required to do so. + uint64 amount_satoshis = 3; + + // The transaction fee we pay for the closing commitment transaction. + // This amount is not included in the `amount_satoshis` value. + // + // Note that if this channel is inbound (and thus our counterparty pays the commitment transaction fee) this value + // will be zero. + uint64 transaction_fee_satoshis = 4; + + // The amount of millisatoshis which has been burned to fees from HTLCs which are outbound from us and are related to + // a payment which was sent by us. This is the sum of the millisatoshis part of all HTLCs which are otherwise + // represented by `LightningBalance::MaybeTimeoutClaimableHTLC` with their + // `LightningBalance::MaybeTimeoutClaimableHTLC::outbound_payment` flag set, as well as any dust HTLCs which would + // otherwise be represented the same. + // + // This amount (rounded up to a whole satoshi value) will not be included in `amount_satoshis`. + uint64 outbound_payment_htlc_rounded_msat = 5; + + // The amount of millisatoshis which has been burned to fees from HTLCs which are outbound from us and are related to + // a forwarded HTLC. This is the sum of the millisatoshis part of all HTLCs which are otherwise represented by + // `LightningBalance::MaybeTimeoutClaimableHTLC` with their `LightningBalance::MaybeTimeoutClaimableHTLC::outbound_payment` + // flag not set, as well as any dust HTLCs which would otherwise be represented the same. + // + // This amount (rounded up to a whole satoshi value) will not be included in `amount_satoshis`. + uint64 outbound_forwarded_htlc_rounded_msat = 6; + + // The amount of millisatoshis which has been burned to fees from HTLCs which are inbound to us and for which we know + // the preimage. This is the sum of the millisatoshis part of all HTLCs which would be represented by + // `LightningBalance::ContentiousClaimable` on channel close, but whose current value is included in `amount_satoshis`, + // as well as any dust HTLCs which would otherwise be represented the same. + // + // This amount (rounded up to a whole satoshi value) will not be included in `amount_satoshis`. + uint64 inbound_claiming_htlc_rounded_msat = 7; + + // The amount of millisatoshis which has been burned to fees from HTLCs which are inbound to us and for which we do + // not know the preimage. This is the sum of the millisatoshis part of all HTLCs which would be represented by + // `LightningBalance::MaybePreimageClaimableHTLC` on channel close, as well as any dust HTLCs which would otherwise be + // represented the same. + // + // This amount (rounded up to a whole satoshi value) will not be included in the counterparty’s `amount_satoshis`. + uint64 inbound_htlc_rounded_msat = 8; +} + +// The channel has been closed, and the given balance is ours but awaiting confirmations until we consider it spendable. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.LightningBalance.html#variant.ClaimableAwaitingConfirmations +message ClaimableAwaitingConfirmations { + // The identifier of the channel this balance belongs to. + string channel_id = 1; + + // The identifier of our channel counterparty. + string counterparty_node_id = 2; + + // The amount available to claim, in satoshis, possibly excluding the on-chain fees which were spent in broadcasting + // the transaction. + uint64 amount_satoshis = 3; + + // The height at which we start tracking it as `SpendableOutput`. + uint32 confirmation_height = 4; +} + +// The channel has been closed, and the given balance should be ours but awaiting spending transaction confirmation. +// If the spending transaction does not confirm in time, it is possible our counterparty can take the funds by +// broadcasting an HTLC timeout on-chain. +// +// Once the spending transaction confirms, before it has reached enough confirmations to be considered safe from chain +// reorganizations, the balance will instead be provided via `LightningBalance::ClaimableAwaitingConfirmations`. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.LightningBalance.html#variant.ContentiousClaimable +message ContentiousClaimable { + // The identifier of the channel this balance belongs to. + string channel_id = 1; + + // The identifier of our channel counterparty. + string counterparty_node_id = 2; + + // The amount available to claim, in satoshis, excluding the on-chain fees which were spent in broadcasting + // the transaction. + uint64 amount_satoshis = 3; + + // The height at which the counterparty may be able to claim the balance if we have not done so. + uint32 timeout_height = 4; + + // The payment hash that locks this HTLC. + string payment_hash = 5; + + // The preimage that can be used to claim this HTLC. + string payment_preimage = 6; +} + +// HTLCs which we sent to our counterparty which are claimable after a timeout (less on-chain fees) if the counterparty +// does not know the preimage for the HTLCs. These are somewhat likely to be claimed by our counterparty before we do. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.LightningBalance.html#variant.MaybeTimeoutClaimableHTLC +message MaybeTimeoutClaimableHTLC { + // The identifier of the channel this balance belongs to. + string channel_id = 1; + + // The identifier of our channel counterparty. + string counterparty_node_id = 2; + + // The amount available to claim, in satoshis, excluding the on-chain fees which were spent in broadcasting + // the transaction. + uint64 amount_satoshis = 3; + + // The height at which we will be able to claim the balance if our counterparty has not done so. + uint32 claimable_height = 4; + + // The payment hash whose preimage our counterparty needs to claim this HTLC. + string payment_hash = 5; + + // Indicates whether this HTLC represents a payment which was sent outbound from us. + bool outbound_payment = 6; +} + +// HTLCs which we received from our counterparty which are claimable with a preimage which we do not currently have. +// This will only be claimable if we receive the preimage from the node to which we forwarded this HTLC before the +// timeout. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.LightningBalance.html#variant.MaybePreimageClaimableHTLC +message MaybePreimageClaimableHTLC { + // The identifier of the channel this balance belongs to. + string channel_id = 1; + + // The identifier of our channel counterparty. + string counterparty_node_id = 2; + + // The amount available to claim, in satoshis, excluding the on-chain fees which were spent in broadcasting + // the transaction. + uint64 amount_satoshis = 3; + + // The height at which our counterparty will be able to claim the balance if we have not yet received the preimage and + // claimed it ourselves. + uint32 expiry_height = 4; + + // The payment hash whose preimage we need to claim this HTLC. + string payment_hash = 5; +} +// The channel has been closed, and our counterparty broadcasted a revoked commitment transaction. +// +// Thus, we’re able to claim all outputs in the commitment transaction, one of which has the following amount. +// +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.LightningBalance.html#variant.CounterpartyRevokedOutputClaimable +message CounterpartyRevokedOutputClaimable { + // The identifier of the channel this balance belongs to. + string channel_id = 1; + + // The identifier of our channel counterparty. + string counterparty_node_id = 2; + + // The amount, in satoshis, of the output which we can claim. + uint64 amount_satoshis = 3; +} + +// Details about the status of a known balance currently being swept to our on-chain wallet. +message PendingSweepBalance { + oneof balance_type { + PendingBroadcast pending_broadcast = 1; + BroadcastAwaitingConfirmation broadcast_awaiting_confirmation = 2; + AwaitingThresholdConfirmations awaiting_threshold_confirmations = 3; + } +} + +// The spendable output is about to be swept, but a spending transaction has yet to be generated and broadcast. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.PendingSweepBalance.html#variant.PendingBroadcast +message PendingBroadcast { + // The identifier of the channel this balance belongs to. + optional string channel_id = 1; + + // The amount, in satoshis, of the output being swept. + uint64 amount_satoshis = 2; +} + +// A spending transaction has been generated and broadcast and is awaiting confirmation on-chain. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.PendingSweepBalance.html#variant.BroadcastAwaitingConfirmation +message BroadcastAwaitingConfirmation { + // The identifier of the channel this balance belongs to. + optional string channel_id = 1; + + // The best height when we last broadcast a transaction spending the output being swept. + uint32 latest_broadcast_height = 2; + + // The identifier of the transaction spending the swept output we last broadcast. + string latest_spending_txid = 3; + + // The amount, in satoshis, of the output being swept. + uint64 amount_satoshis = 4; +} + +// A spending transaction has been confirmed on-chain and is awaiting threshold confirmations. +// +// It will be considered irrevocably confirmed after reaching `ANTI_REORG_DELAY`. +// See more: https://docs.rs/ldk-node/latest/ldk_node/enum.PendingSweepBalance.html#variant.AwaitingThresholdConfirmations +message AwaitingThresholdConfirmations { + // The identifier of the channel this balance belongs to. + optional string channel_id = 1; + + // The identifier of the confirmed transaction spending the swept output. + string latest_spending_txid = 2; + + // The hash of the block in which the spending transaction was confirmed. + string confirmation_hash = 3; + + // The height at which the spending transaction was confirmed. + uint32 confirmation_height = 4; + + // The amount, in satoshis, of the output being swept. + uint64 amount_satoshis = 5; +} + +// Token used to determine start of next page in paginated APIs. +message PageToken { + string token = 1; + int64 index = 2; +} + +message Bolt11InvoiceDescription { + oneof kind { + string direct = 1; + string hash = 2; + } +} + +// Configuration options for payment routing and pathfinding. +// See https://docs.rs/lightning/0.2.0/lightning/routing/router/struct.RouteParametersConfig.html for more details on each field. +message RouteParametersConfig { + // The maximum total fees, in millisatoshi, that may accrue during route finding. + // Defaults to 1% of the payment amount + 50 sats + optional uint64 max_total_routing_fee_msat = 1; + + // The maximum total CLTV delta we accept for the route. + // Defaults to 1008. + uint32 max_total_cltv_expiry_delta = 2; + + // The maximum number of paths that may be used by (MPP) payments. + // Defaults to 10. + uint32 max_path_count = 3; + + // Selects the maximum share of a channel's total capacity which will be + // sent over a channel, as a power of 1/2. + // Default value: 2 + uint32 max_channel_saturation_power_of_half = 4; +} diff --git a/ldk-server/ldk-server-protos/src/types.rs b/ldk-server/ldk-server-protos/src/types.rs new file mode 100644 index 000000000..238f4b5d1 --- /dev/null +++ b/ldk-server/ldk-server-protos/src/types.rs @@ -0,0 +1,953 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +/// Represents a payment. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Payment { + /// An identifier used to uniquely identify a payment in hex-encoded form. + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// The kind of the payment. + #[prost(message, optional, tag = "2")] + pub kind: ::core::option::Option, + /// The amount transferred. + #[prost(uint64, optional, tag = "3")] + pub amount_msat: ::core::option::Option, + /// The fees that were paid for this payment. + /// + /// For Lightning payments, this will only be updated for outbound payments once they + /// succeeded. + #[prost(uint64, optional, tag = "7")] + pub fee_paid_msat: ::core::option::Option, + /// The direction of the payment. + #[prost(enumeration = "PaymentDirection", tag = "4")] + pub direction: i32, + /// The status of the payment. + #[prost(enumeration = "PaymentStatus", tag = "5")] + pub status: i32, + /// The timestamp, in seconds since start of the UNIX epoch, when this entry was last updated. + #[prost(uint64, tag = "6")] + pub latest_update_timestamp: u64, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaymentKind { + #[prost(oneof = "payment_kind::Kind", tags = "1, 2, 3, 4, 5, 6")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `PaymentKind`. +pub mod payment_kind { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Kind { + #[prost(message, tag = "1")] + Onchain(super::Onchain), + #[prost(message, tag = "2")] + Bolt11(super::Bolt11), + #[prost(message, tag = "3")] + Bolt11Jit(super::Bolt11Jit), + #[prost(message, tag = "4")] + Bolt12Offer(super::Bolt12Offer), + #[prost(message, tag = "5")] + Bolt12Refund(super::Bolt12Refund), + #[prost(message, tag = "6")] + Spontaneous(super::Spontaneous), + } +} +/// Represents an on-chain payment. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Onchain { + /// The transaction identifier of this payment. + #[prost(string, tag = "1")] + pub txid: ::prost::alloc::string::String, + /// The confirmation status of this payment. + #[prost(message, optional, tag = "2")] + pub status: ::core::option::Option, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ConfirmationStatus { + #[prost(oneof = "confirmation_status::Status", tags = "1, 2")] + pub status: ::core::option::Option, +} +/// Nested message and enum types in `ConfirmationStatus`. +pub mod confirmation_status { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Status { + #[prost(message, tag = "1")] + Confirmed(super::Confirmed), + #[prost(message, tag = "2")] + Unconfirmed(super::Unconfirmed), + } +} +/// The on-chain transaction is confirmed in the best chain. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Confirmed { + /// The hex representation of hash of the block in which the transaction was confirmed. + #[prost(string, tag = "1")] + pub block_hash: ::prost::alloc::string::String, + /// The height under which the block was confirmed. + #[prost(uint32, tag = "2")] + pub height: u32, + /// The timestamp, in seconds since start of the UNIX epoch, when this entry was last updated. + #[prost(uint64, tag = "3")] + pub timestamp: u64, +} +/// The on-chain transaction is unconfirmed. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Unconfirmed {} +/// Represents a BOLT 11 payment. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11 { + /// The payment hash, i.e., the hash of the preimage. + #[prost(string, tag = "1")] + pub hash: ::prost::alloc::string::String, + /// The pre-image used by the payment. + #[prost(string, optional, tag = "2")] + pub preimage: ::core::option::Option<::prost::alloc::string::String>, + /// The secret used by the payment. + #[prost(bytes = "bytes", optional, tag = "3")] + pub secret: ::core::option::Option<::prost::bytes::Bytes>, +} +/// Represents a BOLT 11 payment intended to open an LSPS 2 just-in-time channel. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11Jit { + /// The payment hash, i.e., the hash of the preimage. + #[prost(string, tag = "1")] + pub hash: ::prost::alloc::string::String, + /// The pre-image used by the payment. + #[prost(string, optional, tag = "2")] + pub preimage: ::core::option::Option<::prost::alloc::string::String>, + /// The secret used by the payment. + #[prost(bytes = "bytes", optional, tag = "3")] + pub secret: ::core::option::Option<::prost::bytes::Bytes>, + /// Limits applying to how much fee we allow an LSP to deduct from the payment amount. + /// + /// Allowing them to deduct this fee from the first inbound payment will pay for the LSP’s channel opening fees. + /// + /// See \[`LdkChannelConfig::accept_underpaying_htlcs`\]() + /// for more information. + #[prost(message, optional, tag = "4")] + pub lsp_fee_limits: ::core::option::Option, + /// The value, in thousands of a satoshi, that was deducted from this payment as an extra + /// fee taken by our channel counterparty. + /// + /// Will only be `Some` once we received the payment. + #[prost(uint64, optional, tag = "5")] + pub counterparty_skimmed_fee_msat: ::core::option::Option, +} +/// Represents a BOLT 12 ‘offer’ payment, i.e., a payment for an Offer. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt12Offer { + /// The payment hash, i.e., the hash of the preimage. + #[prost(string, optional, tag = "1")] + pub hash: ::core::option::Option<::prost::alloc::string::String>, + /// The pre-image used by the payment. + #[prost(string, optional, tag = "2")] + pub preimage: ::core::option::Option<::prost::alloc::string::String>, + /// The secret used by the payment. + #[prost(bytes = "bytes", optional, tag = "3")] + pub secret: ::core::option::Option<::prost::bytes::Bytes>, + /// The hex-encoded ID of the offer this payment is for. + #[prost(string, tag = "4")] + pub offer_id: ::prost::alloc::string::String, + /// The payer's note for the payment. + /// Truncated to \[PAYER_NOTE_LIMIT\](). + /// + /// **Caution**: The `payer_note` field may come from an untrusted source. To prevent potential misuse, + /// all non-printable characters will be sanitized and replaced with safe characters. + #[prost(string, optional, tag = "5")] + pub payer_note: ::core::option::Option<::prost::alloc::string::String>, + /// The quantity of an item requested in the offer. + #[prost(uint64, optional, tag = "6")] + pub quantity: ::core::option::Option, +} +/// Represents a BOLT 12 ‘refund’ payment, i.e., a payment for a Refund. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt12Refund { + /// The payment hash, i.e., the hash of the preimage. + #[prost(string, optional, tag = "1")] + pub hash: ::core::option::Option<::prost::alloc::string::String>, + /// The pre-image used by the payment. + #[prost(string, optional, tag = "2")] + pub preimage: ::core::option::Option<::prost::alloc::string::String>, + /// The secret used by the payment. + #[prost(bytes = "bytes", optional, tag = "3")] + pub secret: ::core::option::Option<::prost::bytes::Bytes>, + /// The payer's note for the payment. + /// Truncated to \[PAYER_NOTE_LIMIT\](). + /// + /// **Caution**: The `payer_note` field may come from an untrusted source. To prevent potential misuse, + /// all non-printable characters will be sanitized and replaced with safe characters. + #[prost(string, optional, tag = "5")] + pub payer_note: ::core::option::Option<::prost::alloc::string::String>, + /// The quantity of an item requested in the offer. + #[prost(uint64, optional, tag = "6")] + pub quantity: ::core::option::Option, +} +/// Represents a spontaneous (“keysend”) payment. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Spontaneous { + /// The payment hash, i.e., the hash of the preimage. + #[prost(string, tag = "1")] + pub hash: ::prost::alloc::string::String, + /// The pre-image used by the payment. + #[prost(string, optional, tag = "2")] + pub preimage: ::core::option::Option<::prost::alloc::string::String>, +} +/// Limits applying to how much fee we allow an LSP to deduct from the payment amount. +/// See \[`LdkChannelConfig::accept_underpaying_htlcs`\] for more information. +/// +/// \[`LdkChannelConfig::accept_underpaying_htlcs`\]: lightning::util::config::ChannelConfig::accept_underpaying_htlcs +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LspFeeLimits { + /// The maximal total amount we allow any configured LSP withhold from us when forwarding the + /// payment. + #[prost(uint64, optional, tag = "1")] + pub max_total_opening_fee_msat: ::core::option::Option, + /// The maximal proportional fee, in parts-per-million millisatoshi, we allow any configured + /// LSP withhold from us when forwarding the payment. + #[prost(uint64, optional, tag = "2")] + pub max_proportional_opening_fee_ppm_msat: ::core::option::Option, +} +/// A forwarded payment through our node. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ForwardedPayment { + /// The channel id of the incoming channel between the previous node and us. + #[prost(string, tag = "1")] + pub prev_channel_id: ::prost::alloc::string::String, + /// The channel id of the outgoing channel between the next node and us. + #[prost(string, tag = "2")] + pub next_channel_id: ::prost::alloc::string::String, + /// The `user_channel_id` of the incoming channel between the previous node and us. + #[prost(string, tag = "3")] + pub prev_user_channel_id: ::prost::alloc::string::String, + /// The node id of the previous node. + #[prost(string, tag = "9")] + pub prev_node_id: ::prost::alloc::string::String, + /// The node id of the next node. + #[prost(string, tag = "10")] + pub next_node_id: ::prost::alloc::string::String, + /// The `user_channel_id` of the outgoing channel between the next node and us. + /// This will be `None` if the payment was settled via an on-chain transaction. + /// See the caveat described for the `total_fee_earned_msat` field. + #[prost(string, optional, tag = "4")] + pub next_user_channel_id: ::core::option::Option<::prost::alloc::string::String>, + /// The total fee, in milli-satoshis, which was earned as a result of the payment. + /// + /// Note that if we force-closed the channel over which we forwarded an HTLC while the HTLC was pending, the amount the + /// next hop claimed will have been rounded down to the nearest whole satoshi. Thus, the fee calculated here may be + /// higher than expected as we still claimed the full value in millisatoshis from the source. + /// In this case, `claim_from_onchain_tx` will be set. + /// + /// If the channel which sent us the payment has been force-closed, we will claim the funds via an on-chain transaction. + /// In that case we do not yet know the on-chain transaction fees which we will spend and will instead set this to `None`. + #[prost(uint64, optional, tag = "5")] + pub total_fee_earned_msat: ::core::option::Option, + /// The share of the total fee, in milli-satoshis, which was withheld in addition to the forwarding fee. + /// This will only be set if we forwarded an intercepted HTLC with less than the expected amount. This means our + /// counterparty accepted to receive less than the invoice amount. + /// + /// The caveat described above the `total_fee_earned_msat` field applies here as well. + #[prost(uint64, optional, tag = "6")] + pub skimmed_fee_msat: ::core::option::Option, + /// If this is true, the forwarded HTLC was claimed by our counterparty via an on-chain transaction. + #[prost(bool, tag = "7")] + pub claim_from_onchain_tx: bool, + /// The final amount forwarded, in milli-satoshis, after the fee is deducted. + /// + /// The caveat described above the `total_fee_earned_msat` field applies here as well. + #[prost(uint64, optional, tag = "8")] + pub outbound_amount_forwarded_msat: ::core::option::Option, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Channel { + /// The channel ID (prior to funding transaction generation, this is a random 32-byte + /// identifier, afterwards this is the transaction ID of the funding transaction XOR the + /// funding transaction output). + /// + /// Note that this means this value is *not* persistent - it can change once during the + /// lifetime of the channel. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The node ID of our the channel's remote counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The channel's funding transaction output, if we've negotiated the funding transaction with + /// our counterparty already. + #[prost(message, optional, tag = "3")] + pub funding_txo: ::core::option::Option, + /// The hex-encoded local `user_channel_id` of this channel. + #[prost(string, tag = "4")] + pub user_channel_id: ::prost::alloc::string::String, + /// The value, in satoshis, that must always be held as a reserve in the channel for us. This + /// value ensures that if we broadcast a revoked state, our counterparty can punish us by + /// claiming at least this value on chain. + /// + /// This value is not included in \[`outbound_capacity_msat`\] as it can never be spent. + /// + /// This value will be `None` for outbound channels until the counterparty accepts the channel. + #[prost(uint64, optional, tag = "5")] + pub unspendable_punishment_reserve: ::core::option::Option, + /// The value, in satoshis, of this channel as it appears in the funding output. + #[prost(uint64, tag = "6")] + pub channel_value_sats: u64, + /// The currently negotiated fee rate denominated in satoshi per 1000 weight units, + /// which is applied to commitment and HTLC transactions. + #[prost(uint32, tag = "7")] + pub feerate_sat_per_1000_weight: u32, + /// The available outbound capacity for sending HTLCs to the remote peer. + /// + /// The amount does not include any pending HTLCs which are not yet resolved (and, thus, whose + /// balance is not available for inclusion in new outbound HTLCs). This further does not include + /// any pending outgoing HTLCs which are awaiting some other resolution to be sent. + #[prost(uint64, tag = "8")] + pub outbound_capacity_msat: u64, + /// The available outbound capacity for sending HTLCs to the remote peer. + /// + /// The amount does not include any pending HTLCs which are not yet resolved + /// (and, thus, whose balance is not available for inclusion in new inbound HTLCs). This further + /// does not include any pending outgoing HTLCs which are awaiting some other resolution to be + /// sent. + #[prost(uint64, tag = "9")] + pub inbound_capacity_msat: u64, + /// The number of required confirmations on the funding transactions before the funding is + /// considered "locked". The amount is selected by the channel fundee. + /// + /// The value will be `None` for outbound channels until the counterparty accepts the channel. + #[prost(uint32, optional, tag = "10")] + pub confirmations_required: ::core::option::Option, + /// The current number of confirmations on the funding transaction. + #[prost(uint32, optional, tag = "11")] + pub confirmations: ::core::option::Option, + /// Is `true` if the channel was initiated (and therefore funded) by us. + #[prost(bool, tag = "12")] + pub is_outbound: bool, + /// Is `true` if both parties have exchanged `channel_ready` messages, and the channel is + /// not currently being shut down. Both parties exchange `channel_ready` messages upon + /// independently verifying that the required confirmations count provided by + /// `confirmations_required` has been reached. + #[prost(bool, tag = "13")] + pub is_channel_ready: bool, + /// Is `true` if the channel (a) `channel_ready` messages have been exchanged, (b) the + /// peer is connected, and (c) the channel is not currently negotiating shutdown. + /// + /// This is a strict superset of `is_channel_ready`. + #[prost(bool, tag = "14")] + pub is_usable: bool, + /// Is `true` if this channel is (or will be) publicly-announced + #[prost(bool, tag = "15")] + pub is_announced: bool, + /// Set of configurable parameters set by self that affect channel operation. + #[prost(message, optional, tag = "16")] + pub channel_config: ::core::option::Option, + /// The available outbound capacity for sending a single HTLC to the remote peer. This is + /// similar to `outbound_capacity_msat` but it may be further restricted by + /// the current state and per-HTLC limit(s). This is intended for use when routing, allowing us + /// to use a limit as close as possible to the HTLC limit we can currently send. + #[prost(uint64, tag = "17")] + pub next_outbound_htlc_limit_msat: u64, + /// The minimum value for sending a single HTLC to the remote peer. This is the equivalent of + /// `next_outbound_htlc_limit_msat` but represents a lower-bound, rather than + /// an upper-bound. This is intended for use when routing, allowing us to ensure we pick a + /// route which is valid. + #[prost(uint64, tag = "18")] + pub next_outbound_htlc_minimum_msat: u64, + /// The number of blocks (after our commitment transaction confirms) that we will need to wait + /// until we can claim our funds after we force-close the channel. During this time our + /// counterparty is allowed to punish us if we broadcasted a stale state. If our counterparty + /// force-closes the channel and broadcasts a commitment transaction we do not have to wait any + /// time to claim our non-HTLC-encumbered funds. + /// + /// This value will be `None` for outbound channels until the counterparty accepts the channel. + #[prost(uint32, optional, tag = "19")] + pub force_close_spend_delay: ::core::option::Option, + /// The smallest value HTLC (in msat) the remote peer will accept, for this channel. + /// + /// This field is only `None` before we have received either the `OpenChannel` or + /// `AcceptChannel` message from the remote peer. + #[prost(uint64, optional, tag = "20")] + pub counterparty_outbound_htlc_minimum_msat: ::core::option::Option, + /// The largest value HTLC (in msat) the remote peer currently will accept, for this channel. + #[prost(uint64, optional, tag = "21")] + pub counterparty_outbound_htlc_maximum_msat: ::core::option::Option, + /// The value, in satoshis, that must always be held in the channel for our counterparty. This + /// value ensures that if our counterparty broadcasts a revoked state, we can punish them by + /// claiming at least this value on chain. + /// + /// This value is not included in `inbound_capacity_msat` as it can never be spent. + #[prost(uint64, tag = "22")] + pub counterparty_unspendable_punishment_reserve: u64, + /// Base routing fee in millisatoshis. + #[prost(uint32, optional, tag = "23")] + pub counterparty_forwarding_info_fee_base_msat: ::core::option::Option, + /// Proportional fee, in millionths of a satoshi the channel will charge per transferred satoshi. + #[prost(uint32, optional, tag = "24")] + pub counterparty_forwarding_info_fee_proportional_millionths: ::core::option::Option, + /// The minimum difference in CLTV expiry between an ingoing HTLC and its outgoing counterpart, + /// such that the outgoing HTLC is forwardable to this counterparty. + #[prost(uint32, optional, tag = "25")] + pub counterparty_forwarding_info_cltv_expiry_delta: ::core::option::Option, +} +/// ChannelConfig represents the configuration settings for a channel in a Lightning Network node. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ChannelConfig { + /// Amount (in millionths of a satoshi) charged per satoshi for payments forwarded outbound + /// over the channel. + /// See more: + #[prost(uint32, optional, tag = "1")] + pub forwarding_fee_proportional_millionths: ::core::option::Option, + /// Amount (in milli-satoshi) charged for payments forwarded outbound over the channel, + /// in excess of forwarding_fee_proportional_millionths. + /// See more: + #[prost(uint32, optional, tag = "2")] + pub forwarding_fee_base_msat: ::core::option::Option, + /// The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded + /// over the channel this config applies to. + /// See more: + #[prost(uint32, optional, tag = "3")] + pub cltv_expiry_delta: ::core::option::Option, + /// The maximum additional fee we’re willing to pay to avoid waiting for the counterparty’s + /// to_self_delay to reclaim funds. + /// See more: + #[prost(uint64, optional, tag = "4")] + pub force_close_avoidance_max_fee_satoshis: ::core::option::Option, + /// If set, allows this channel’s counterparty to skim an additional fee off this node’s + /// inbound HTLCs. Useful for liquidity providers to offload on-chain channel costs to end users. + /// See more: + #[prost(bool, optional, tag = "5")] + pub accept_underpaying_htlcs: ::core::option::Option, + /// Limit our total exposure to potential loss to on-chain fees on close, including + /// in-flight HTLCs which are burned to fees as they are too small to claim on-chain + /// and fees on commitment transaction(s) broadcasted by our counterparty in excess of + /// our own fee estimate. + /// See more: + #[prost(oneof = "channel_config::MaxDustHtlcExposure", tags = "6, 7")] + pub max_dust_htlc_exposure: ::core::option::Option, +} +/// Nested message and enum types in `ChannelConfig`. +pub mod channel_config { + /// Limit our total exposure to potential loss to on-chain fees on close, including + /// in-flight HTLCs which are burned to fees as they are too small to claim on-chain + /// and fees on commitment transaction(s) broadcasted by our counterparty in excess of + /// our own fee estimate. + /// See more: + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum MaxDustHtlcExposure { + /// This sets a fixed limit on the total dust exposure in millisatoshis. + /// See more: + #[prost(uint64, tag = "6")] + FixedLimitMsat(u64), + /// This sets a multiplier on the ConfirmationTarget::OnChainSweep feerate (in sats/KW) to determine the maximum allowed dust exposure. + /// See more: + #[prost(uint64, tag = "7")] + FeeRateMultiplier(u64), + } +} +/// Represent a transaction outpoint. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OutPoint { + /// The referenced transaction's txid. + #[prost(string, tag = "1")] + pub txid: ::prost::alloc::string::String, + /// The index of the referenced output in its transaction's vout. + #[prost(uint32, tag = "2")] + pub vout: u32, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct BestBlock { + /// The block’s hash + #[prost(string, tag = "1")] + pub block_hash: ::prost::alloc::string::String, + /// The height at which the block was confirmed. + #[prost(uint32, tag = "2")] + pub height: u32, +} +/// Details about the status of a known Lightning balance. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LightningBalance { + #[prost(oneof = "lightning_balance::BalanceType", tags = "1, 2, 3, 4, 5, 6")] + pub balance_type: ::core::option::Option, +} +/// Nested message and enum types in `LightningBalance`. +pub mod lightning_balance { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum BalanceType { + #[prost(message, tag = "1")] + ClaimableOnChannelClose(super::ClaimableOnChannelClose), + #[prost(message, tag = "2")] + ClaimableAwaitingConfirmations(super::ClaimableAwaitingConfirmations), + #[prost(message, tag = "3")] + ContentiousClaimable(super::ContentiousClaimable), + #[prost(message, tag = "4")] + MaybeTimeoutClaimableHtlc(super::MaybeTimeoutClaimableHtlc), + #[prost(message, tag = "5")] + MaybePreimageClaimableHtlc(super::MaybePreimageClaimableHtlc), + #[prost(message, tag = "6")] + CounterpartyRevokedOutputClaimable(super::CounterpartyRevokedOutputClaimable), + } +} +/// The channel is not yet closed (or the commitment or closing transaction has not yet appeared in a block). +/// The given balance is claimable (less on-chain fees) if the channel is force-closed now. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ClaimableOnChannelClose { + /// The identifier of the channel this balance belongs to. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The identifier of our channel counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount available to claim, in satoshis, excluding the on-chain fees which will be required to do so. + #[prost(uint64, tag = "3")] + pub amount_satoshis: u64, + /// The transaction fee we pay for the closing commitment transaction. + /// This amount is not included in the `amount_satoshis` value. + /// + /// Note that if this channel is inbound (and thus our counterparty pays the commitment transaction fee) this value + /// will be zero. + #[prost(uint64, tag = "4")] + pub transaction_fee_satoshis: u64, + /// The amount of millisatoshis which has been burned to fees from HTLCs which are outbound from us and are related to + /// a payment which was sent by us. This is the sum of the millisatoshis part of all HTLCs which are otherwise + /// represented by `LightningBalance::MaybeTimeoutClaimableHTLC` with their + /// `LightningBalance::MaybeTimeoutClaimableHTLC::outbound_payment` flag set, as well as any dust HTLCs which would + /// otherwise be represented the same. + /// + /// This amount (rounded up to a whole satoshi value) will not be included in `amount_satoshis`. + #[prost(uint64, tag = "5")] + pub outbound_payment_htlc_rounded_msat: u64, + /// The amount of millisatoshis which has been burned to fees from HTLCs which are outbound from us and are related to + /// a forwarded HTLC. This is the sum of the millisatoshis part of all HTLCs which are otherwise represented by + /// `LightningBalance::MaybeTimeoutClaimableHTLC` with their `LightningBalance::MaybeTimeoutClaimableHTLC::outbound_payment` + /// flag not set, as well as any dust HTLCs which would otherwise be represented the same. + /// + /// This amount (rounded up to a whole satoshi value) will not be included in `amount_satoshis`. + #[prost(uint64, tag = "6")] + pub outbound_forwarded_htlc_rounded_msat: u64, + /// The amount of millisatoshis which has been burned to fees from HTLCs which are inbound to us and for which we know + /// the preimage. This is the sum of the millisatoshis part of all HTLCs which would be represented by + /// `LightningBalance::ContentiousClaimable` on channel close, but whose current value is included in `amount_satoshis`, + /// as well as any dust HTLCs which would otherwise be represented the same. + /// + /// This amount (rounded up to a whole satoshi value) will not be included in `amount_satoshis`. + #[prost(uint64, tag = "7")] + pub inbound_claiming_htlc_rounded_msat: u64, + /// The amount of millisatoshis which has been burned to fees from HTLCs which are inbound to us and for which we do + /// not know the preimage. This is the sum of the millisatoshis part of all HTLCs which would be represented by + /// `LightningBalance::MaybePreimageClaimableHTLC` on channel close, as well as any dust HTLCs which would otherwise be + /// represented the same. + /// + /// This amount (rounded up to a whole satoshi value) will not be included in the counterparty’s `amount_satoshis`. + #[prost(uint64, tag = "8")] + pub inbound_htlc_rounded_msat: u64, +} +/// The channel has been closed, and the given balance is ours but awaiting confirmations until we consider it spendable. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ClaimableAwaitingConfirmations { + /// The identifier of the channel this balance belongs to. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The identifier of our channel counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount available to claim, in satoshis, possibly excluding the on-chain fees which were spent in broadcasting + /// the transaction. + #[prost(uint64, tag = "3")] + pub amount_satoshis: u64, + /// The height at which we start tracking it as `SpendableOutput`. + #[prost(uint32, tag = "4")] + pub confirmation_height: u32, +} +/// The channel has been closed, and the given balance should be ours but awaiting spending transaction confirmation. +/// If the spending transaction does not confirm in time, it is possible our counterparty can take the funds by +/// broadcasting an HTLC timeout on-chain. +/// +/// Once the spending transaction confirms, before it has reached enough confirmations to be considered safe from chain +/// reorganizations, the balance will instead be provided via `LightningBalance::ClaimableAwaitingConfirmations`. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ContentiousClaimable { + /// The identifier of the channel this balance belongs to. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The identifier of our channel counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount available to claim, in satoshis, excluding the on-chain fees which were spent in broadcasting + /// the transaction. + #[prost(uint64, tag = "3")] + pub amount_satoshis: u64, + /// The height at which the counterparty may be able to claim the balance if we have not done so. + #[prost(uint32, tag = "4")] + pub timeout_height: u32, + /// The payment hash that locks this HTLC. + #[prost(string, tag = "5")] + pub payment_hash: ::prost::alloc::string::String, + /// The preimage that can be used to claim this HTLC. + #[prost(string, tag = "6")] + pub payment_preimage: ::prost::alloc::string::String, +} +/// HTLCs which we sent to our counterparty which are claimable after a timeout (less on-chain fees) if the counterparty +/// does not know the preimage for the HTLCs. These are somewhat likely to be claimed by our counterparty before we do. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MaybeTimeoutClaimableHtlc { + /// The identifier of the channel this balance belongs to. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The identifier of our channel counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount available to claim, in satoshis, excluding the on-chain fees which were spent in broadcasting + /// the transaction. + #[prost(uint64, tag = "3")] + pub amount_satoshis: u64, + /// The height at which we will be able to claim the balance if our counterparty has not done so. + #[prost(uint32, tag = "4")] + pub claimable_height: u32, + /// The payment hash whose preimage our counterparty needs to claim this HTLC. + #[prost(string, tag = "5")] + pub payment_hash: ::prost::alloc::string::String, + /// Indicates whether this HTLC represents a payment which was sent outbound from us. + #[prost(bool, tag = "6")] + pub outbound_payment: bool, +} +/// HTLCs which we received from our counterparty which are claimable with a preimage which we do not currently have. +/// This will only be claimable if we receive the preimage from the node to which we forwarded this HTLC before the +/// timeout. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MaybePreimageClaimableHtlc { + /// The identifier of the channel this balance belongs to. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The identifier of our channel counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount available to claim, in satoshis, excluding the on-chain fees which were spent in broadcasting + /// the transaction. + #[prost(uint64, tag = "3")] + pub amount_satoshis: u64, + /// The height at which our counterparty will be able to claim the balance if we have not yet received the preimage and + /// claimed it ourselves. + #[prost(uint32, tag = "4")] + pub expiry_height: u32, + /// The payment hash whose preimage we need to claim this HTLC. + #[prost(string, tag = "5")] + pub payment_hash: ::prost::alloc::string::String, +} +/// The channel has been closed, and our counterparty broadcasted a revoked commitment transaction. +/// +/// Thus, we’re able to claim all outputs in the commitment transaction, one of which has the following amount. +/// +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CounterpartyRevokedOutputClaimable { + /// The identifier of the channel this balance belongs to. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// The identifier of our channel counterparty. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The amount, in satoshis, of the output which we can claim. + #[prost(uint64, tag = "3")] + pub amount_satoshis: u64, +} +/// Details about the status of a known balance currently being swept to our on-chain wallet. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PendingSweepBalance { + #[prost(oneof = "pending_sweep_balance::BalanceType", tags = "1, 2, 3")] + pub balance_type: ::core::option::Option, +} +/// Nested message and enum types in `PendingSweepBalance`. +pub mod pending_sweep_balance { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum BalanceType { + #[prost(message, tag = "1")] + PendingBroadcast(super::PendingBroadcast), + #[prost(message, tag = "2")] + BroadcastAwaitingConfirmation(super::BroadcastAwaitingConfirmation), + #[prost(message, tag = "3")] + AwaitingThresholdConfirmations(super::AwaitingThresholdConfirmations), + } +} +/// The spendable output is about to be swept, but a spending transaction has yet to be generated and broadcast. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PendingBroadcast { + /// The identifier of the channel this balance belongs to. + #[prost(string, optional, tag = "1")] + pub channel_id: ::core::option::Option<::prost::alloc::string::String>, + /// The amount, in satoshis, of the output being swept. + #[prost(uint64, tag = "2")] + pub amount_satoshis: u64, +} +/// A spending transaction has been generated and broadcast and is awaiting confirmation on-chain. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct BroadcastAwaitingConfirmation { + /// The identifier of the channel this balance belongs to. + #[prost(string, optional, tag = "1")] + pub channel_id: ::core::option::Option<::prost::alloc::string::String>, + /// The best height when we last broadcast a transaction spending the output being swept. + #[prost(uint32, tag = "2")] + pub latest_broadcast_height: u32, + /// The identifier of the transaction spending the swept output we last broadcast. + #[prost(string, tag = "3")] + pub latest_spending_txid: ::prost::alloc::string::String, + /// The amount, in satoshis, of the output being swept. + #[prost(uint64, tag = "4")] + pub amount_satoshis: u64, +} +/// A spending transaction has been confirmed on-chain and is awaiting threshold confirmations. +/// +/// It will be considered irrevocably confirmed after reaching `ANTI_REORG_DELAY`. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AwaitingThresholdConfirmations { + /// The identifier of the channel this balance belongs to. + #[prost(string, optional, tag = "1")] + pub channel_id: ::core::option::Option<::prost::alloc::string::String>, + /// The identifier of the confirmed transaction spending the swept output. + #[prost(string, tag = "2")] + pub latest_spending_txid: ::prost::alloc::string::String, + /// The hash of the block in which the spending transaction was confirmed. + #[prost(string, tag = "3")] + pub confirmation_hash: ::prost::alloc::string::String, + /// The height at which the spending transaction was confirmed. + #[prost(uint32, tag = "4")] + pub confirmation_height: u32, + /// The amount, in satoshis, of the output being swept. + #[prost(uint64, tag = "5")] + pub amount_satoshis: u64, +} +/// Token used to determine start of next page in paginated APIs. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PageToken { + #[prost(string, tag = "1")] + pub token: ::prost::alloc::string::String, + #[prost(int64, tag = "2")] + pub index: i64, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Bolt11InvoiceDescription { + #[prost(oneof = "bolt11_invoice_description::Kind", tags = "1, 2")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `Bolt11InvoiceDescription`. +pub mod bolt11_invoice_description { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Kind { + #[prost(string, tag = "1")] + Direct(::prost::alloc::string::String), + #[prost(string, tag = "2")] + Hash(::prost::alloc::string::String), + } +} +/// Configuration options for payment routing and pathfinding. +/// See for more details on each field. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RouteParametersConfig { + /// The maximum total fees, in millisatoshi, that may accrue during route finding. + /// Defaults to 1% of the payment amount + 50 sats + #[prost(uint64, optional, tag = "1")] + pub max_total_routing_fee_msat: ::core::option::Option, + /// The maximum total CLTV delta we accept for the route. + /// Defaults to 1008. + #[prost(uint32, tag = "2")] + pub max_total_cltv_expiry_delta: u32, + /// The maximum number of paths that may be used by (MPP) payments. + /// Defaults to 10. + #[prost(uint32, tag = "3")] + pub max_path_count: u32, + /// Selects the maximum share of a channel's total capacity which will be + /// sent over a channel, as a power of 1/2. + /// Default value: 2 + #[prost(uint32, tag = "4")] + pub max_channel_saturation_power_of_half: u32, +} +/// Represents the direction of a payment. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum PaymentDirection { + /// The payment is inbound. + Inbound = 0, + /// The payment is outbound. + Outbound = 1, +} +impl PaymentDirection { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + PaymentDirection::Inbound => "INBOUND", + PaymentDirection::Outbound => "OUTBOUND", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "INBOUND" => Some(Self::Inbound), + "OUTBOUND" => Some(Self::Outbound), + _ => None, + } + } +} +/// Represents the current status of a payment. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum PaymentStatus { + /// The payment is still pending. + Pending = 0, + /// The payment succeeded. + Succeeded = 1, + /// The payment failed. + Failed = 2, +} +impl PaymentStatus { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + PaymentStatus::Pending => "PENDING", + PaymentStatus::Succeeded => "SUCCEEDED", + PaymentStatus::Failed => "FAILED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PENDING" => Some(Self::Pending), + "SUCCEEDED" => Some(Self::Succeeded), + "FAILED" => Some(Self::Failed), + _ => None, + } + } +} diff --git a/ldk-server/ldk-server/Cargo.toml b/ldk-server/ldk-server/Cargo.toml new file mode 100644 index 000000000..0d2fcec8d --- /dev/null +++ b/ldk-server/ldk-server/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "ldk-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +ldk-node = { path = "../.." } +serde = { version = "1.0.203", default-features = false, features = ["derive"] } +hyper = { version = "1", default-features = false, features = ["server", "http1"] } +http-body-util = { version = "0.1", default-features = false } +hyper-util = { version = "0.1", default-features = false, features = ["server-graceful"] } +tokio = { version = "1.38.0", default-features = false, features = ["time", "signal", "rt-multi-thread"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +rcgen = { version = "0.13", default-features = false, features = ["ring"] } +prost = { version = "0.11.6", default-features = false, features = ["std"] } +ldk-server-protos = { path = "../ldk-server-protos" } +bytes = { version = "1.4.0", default-features = false } +hex = { package = "hex-conservative", version = "0.2.1", default-features = false } +rusqlite = { version = "0.31.0", features = ["bundled"] } +rand = { version = "0.8.5", default-features = false } +async-trait = { version = "0.1.85", default-features = false } +toml = { version = "0.8.9", default-features = false, features = ["parse"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +log = "0.4.28" +base64 = { version = "0.21", default-features = false, features = ["std"] } + +# Required for RabittMQ based EventPublisher. Only enabled for `events-rabbitmq` feature. +lapin = { version = "2.4.0", features = ["rustls"], default-features = false, optional = true } + +[features] +default = [] +events-rabbitmq = ["dep:lapin"] + +# Experimental Features. +experimental-lsps2-support = [] + +# Feature-flags related to integration tests. +integration-tests-events-rabbitmq = ["events-rabbitmq"] + +[dev-dependencies] +futures-util = "0.3.31" diff --git a/ldk-server/ldk-server/ldk-server-config.toml b/ldk-server/ldk-server/ldk-server-config.toml new file mode 100644 index 000000000..062ca08ab --- /dev/null +++ b/ldk-server/ldk-server/ldk-server-config.toml @@ -0,0 +1,79 @@ +# Lightning node settings +[node] +network = "regtest" # Bitcoin network to use +listening_addresses = ["localhost:3001"] # Lightning node listening addresses +announcement_addresses = ["54.3.7.81:3001"] # Lightning node announcement addresses +rest_service_address = "127.0.0.1:3002" # LDK Server REST address +alias = "ldk_server" # Lightning node alias + +# Storage settings +[storage.disk] +dir_path = "/tmp/ldk-server/" # Path for LDK and BDK data persistence, optional, defaults to ~/.ldk-server/ + +[log] +level = "Debug" # Log level (Error, Warn, Info, Debug, Trace) +file = "/tmp/ldk-server/ldk-server.log" # Log file path + +[tls] +#cert_path = "/path/to/tls.crt" # Path to TLS certificate, by default uses dir_path/tls.crt +#key_path = "/path/to/tls.key" # Path to TLS private key, by default uses dir_path/tls.key +hosts = ["example.com"] # Allowed hosts for TLS, will always include "localhost" and "127.0.0.1" + +# Must set one of bitcoind, electrum, or esplora + +# Bitcoin Core settings +[bitcoind] +rpc_address = "127.0.0.1:18444" # RPC endpoint +rpc_user = "polaruser" # RPC username +rpc_password = "polarpass" # RPC password + +# Electrum settings +[electrum] +server_url = "ssl://electrum.blockstream.info:50002" # Electrum endpoint +# server_url = "tcp://electrum.blockstream.info:50001" + +# Esplora settings +[esplora] +server_url = "https://mempool.space/api" # Esplora endpoint + +# RabbitMQ settings (only required if using events-rabbitmq feature) +[rabbitmq] +connection_string = "" # RabbitMQ connection string +exchange_name = "" + +# Experimental LSPS2 Service Support +# CAUTION: LSPS2 support is highly experimental and for testing purposes only. +[liquidity.lsps2_service] +# Indicates whether the LSPS service will be announced via the gossip network. +advertise_service = false + +# The fee we withhold for the channel open from the initial payment. +channel_opening_fee_ppm = 1000 # 0.1% fee + +# The proportional overprovisioning for the channel. +channel_over_provisioning_ppm = 500000 # 50% extra capacity + +# The minimum fee required for opening a channel. +min_channel_opening_fee_msat = 10000000 # 10,000 satoshis + +# The minimum number of blocks after confirmation we promise to keep the channel open. +min_channel_lifetime = 4320 # ~30 days + +# The maximum number of blocks that the client is allowed to set its `to_self_delay` parameter. +max_client_to_self_delay = 1440 # ~10 days + +# The minimum payment size that we will accept when opening a channel. +min_payment_size_msat = 10000000 # 10,000 satoshis + +# The maximum payment size that we will accept when opening a channel. +max_payment_size_msat = 25000000000 # 0.25 BTC + +# Use the 'client-trusts-LSP' trust model. +# When set, the service will delay broadcasting the JIT channel's +# funding transaction until the client claimed sufficient HTLC parts to pay for the channel open. +client_trusts_lsp = false + +# Optional token for clients (uncomment and set if required) +## A token we may require to be sent by the clients. +## If set, only requests matching this token will be accepted. (uncomment and set if required) +# require_token = "" diff --git a/ldk-server/ldk-server/src/api/bolt11_receive.rs b/ldk-server/ldk-server/src/api/bolt11_receive.rs new file mode 100644 index 000000000..a5e3bed5b --- /dev/null +++ b/ldk-server/ldk-server/src/api/bolt11_receive.rs @@ -0,0 +1,32 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_protos::api::{Bolt11ReceiveRequest, Bolt11ReceiveResponse}; + +use crate::api::error::LdkServerError; +use crate::service::Context; +use crate::util::proto_adapter::proto_to_bolt11_description; + +pub(crate) fn handle_bolt11_receive_request( + context: Context, request: Bolt11ReceiveRequest, +) -> Result { + let description = proto_to_bolt11_description(request.description)?; + let invoice = match request.amount_msat { + Some(amount_msat) => { + context.node.bolt11_payment().receive(amount_msat, &description, request.expiry_secs)? + }, + None => context + .node + .bolt11_payment() + .receive_variable_amount(&description, request.expiry_secs)?, + }; + + let response = Bolt11ReceiveResponse { invoice: invoice.to_string() }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/bolt11_send.rs b/ldk-server/ldk-server/src/api/bolt11_send.rs new file mode 100644 index 000000000..917e3230e --- /dev/null +++ b/ldk-server/ldk-server/src/api/bolt11_send.rs @@ -0,0 +1,63 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::lightning::routing::router::RouteParametersConfig; +use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_server_protos::api::{Bolt11SendRequest, Bolt11SendResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_bolt11_send_request( + context: Context, request: Bolt11SendRequest, +) -> Result { + let invoice = Bolt11Invoice::from_str(request.invoice.as_str()) + .map_err(|_| ldk_node::NodeError::InvalidInvoice)?; + + let route_parameters = match request.route_parameters { + Some(params) => { + let max_path_count: u8 = params.max_path_count.try_into().map_err(|_| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid max_path_count, must be between 0 and {}", u8::MAX), + ) + })?; + let max_channel_saturation_power_of_half: u8 = + params.max_channel_saturation_power_of_half.try_into().map_err(|_| { + LdkServerError::new( + InvalidRequestError, + format!( + "Invalid max_channel_saturation_power_of_half, must be between 0 and {}", + u8::MAX + ), + ) + })?; + Some(RouteParametersConfig { + max_total_routing_fee_msat: params.max_total_routing_fee_msat, + max_total_cltv_expiry_delta: params.max_total_cltv_expiry_delta, + max_path_count, + max_channel_saturation_power_of_half, + }) + }, + None => None, + }; + + let payment_id = match request.amount_msat { + None => context.node.bolt11_payment().send(&invoice, route_parameters), + Some(amount_msat) => { + context.node.bolt11_payment().send_using_amount(&invoice, amount_msat, route_parameters) + }, + }?; + + let response = Bolt11SendResponse { payment_id: payment_id.to_string() }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/bolt12_receive.rs b/ldk-server/ldk-server/src/api/bolt12_receive.rs new file mode 100644 index 000000000..cc8875a8d --- /dev/null +++ b/ldk-server/ldk-server/src/api/bolt12_receive.rs @@ -0,0 +1,33 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_protos::api::{Bolt12ReceiveRequest, Bolt12ReceiveResponse}; + +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_bolt12_receive_request( + context: Context, request: Bolt12ReceiveRequest, +) -> Result { + let offer = match request.amount_msat { + Some(amount_msat) => context.node.bolt12_payment().receive( + amount_msat, + &request.description, + request.expiry_secs, + request.quantity, + )?, + None => context + .node + .bolt12_payment() + .receive_variable_amount(&request.description, request.expiry_secs)?, + }; + + let response = Bolt12ReceiveResponse { offer: offer.to_string() }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/bolt12_send.rs b/ldk-server/ldk-server/src/api/bolt12_send.rs new file mode 100644 index 000000000..34535a04a --- /dev/null +++ b/ldk-server/ldk-server/src/api/bolt12_send.rs @@ -0,0 +1,72 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::lightning::offers::offer::Offer; +use ldk_node::lightning::routing::router::RouteParametersConfig; +use ldk_server_protos::api::{Bolt12SendRequest, Bolt12SendResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_bolt12_send_request( + context: Context, request: Bolt12SendRequest, +) -> Result { + let offer = + Offer::from_str(request.offer.as_str()).map_err(|_| ldk_node::NodeError::InvalidOffer)?; + + let route_parameters = match request.route_parameters { + Some(params) => { + let max_path_count = params.max_path_count.try_into().map_err(|_| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid max_path_count, must be between 0 and {}", u8::MAX), + ) + })?; + let max_channel_saturation_power_of_half = + params.max_channel_saturation_power_of_half.try_into().map_err(|_| { + LdkServerError::new( + InvalidRequestError, + format!( + "Invalid max_channel_saturation_power_of_half, must be between 0 and {}", + u8::MAX + ), + ) + })?; + Some(RouteParametersConfig { + max_total_routing_fee_msat: params.max_total_routing_fee_msat, + max_total_cltv_expiry_delta: params.max_total_cltv_expiry_delta, + max_path_count, + max_channel_saturation_power_of_half, + }) + }, + None => None, + }; + + let payment_id = match request.amount_msat { + None => context.node.bolt12_payment().send( + &offer, + request.quantity, + request.payer_note, + route_parameters, + ), + Some(amount_msat) => context.node.bolt12_payment().send_using_amount( + &offer, + amount_msat, + request.quantity, + request.payer_note, + route_parameters, + ), + }?; + + let response = Bolt12SendResponse { payment_id: payment_id.to_string() }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/close_channel.rs b/ldk-server/ldk-server/src/api/close_channel.rs new file mode 100644 index 000000000..5ae4070b6 --- /dev/null +++ b/ldk-server/ldk-server/src/api/close_channel.rs @@ -0,0 +1,62 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::UserChannelId; +use ldk_server_protos::api::{ + CloseChannelRequest, CloseChannelResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, +}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_close_channel_request( + context: Context, request: CloseChannelRequest, +) -> Result { + let user_channel_id = parse_user_channel_id(&request.user_channel_id)?; + let counterparty_node_id = parse_counterparty_node_id(&request.counterparty_node_id)?; + + context.node.close_channel(&user_channel_id, counterparty_node_id)?; + + Ok(CloseChannelResponse {}) +} + +pub(crate) fn handle_force_close_channel_request( + context: Context, request: ForceCloseChannelRequest, +) -> Result { + let user_channel_id = parse_user_channel_id(&request.user_channel_id)?; + let counterparty_node_id = parse_counterparty_node_id(&request.counterparty_node_id)?; + + context.node.force_close_channel( + &user_channel_id, + counterparty_node_id, + request.force_close_reason, + )?; + + Ok(ForceCloseChannelResponse {}) +} + +fn parse_user_channel_id(id: &str) -> Result { + let parsed = id.parse::().map_err(|_| { + LdkServerError::new(InvalidRequestError, "Invalid UserChannelId.".to_string()) + })?; + Ok(UserChannelId(parsed)) +} + +fn parse_counterparty_node_id(id: &str) -> Result { + PublicKey::from_str(id).map_err(|e| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid counterparty node ID, error: {}", e), + ) + }) +} diff --git a/ldk-server/ldk-server/src/api/connect_peer.rs b/ldk-server/ldk-server/src/api/connect_peer.rs new file mode 100644 index 000000000..d3fac3a02 --- /dev/null +++ b/ldk-server/ldk-server/src/api/connect_peer.rs @@ -0,0 +1,30 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_server_protos::api::{ConnectPeerRequest, ConnectPeerResponse}; + +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_connect_peer( + context: Context, request: ConnectPeerRequest, +) -> Result { + let node_id = PublicKey::from_str(&request.node_pubkey) + .map_err(|_| ldk_node::NodeError::InvalidPublicKey)?; + let address = SocketAddress::from_str(&request.address) + .map_err(|_| ldk_node::NodeError::InvalidSocketAddress)?; + + context.node.connect(node_id, address, request.persist)?; + + Ok(ConnectPeerResponse {}) +} diff --git a/ldk-server/ldk-server/src/api/error.rs b/ldk-server/ldk-server/src/api/error.rs new file mode 100644 index 000000000..9117c025c --- /dev/null +++ b/ldk-server/ldk-server/src/api/error.rs @@ -0,0 +1,133 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fmt; + +use ldk_node::NodeError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LdkServerError { + // The error message containing a generic description of the error condition in English. + // It is intended for a human audience only and should not be parsed to extract any information + // programmatically. Client-side code may use it for logging only. + pub(crate) message: String, + + // The error code uniquely identifying an error condition. + // It is meant to be read and understood programmatically by code that detects/handles errors by + // type. + pub(crate) error_code: LdkServerErrorCode, +} + +impl LdkServerError { + pub(crate) fn new(error_code: LdkServerErrorCode, message: impl Into) -> Self { + Self { error_code, message: message.into() } + } +} + +impl std::error::Error for LdkServerError {} + +impl fmt::Display for LdkServerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error: [{}]: {}", self.error_code, self.message) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(clippy::enum_variant_names)] +pub(crate) enum LdkServerErrorCode { + /// Please refer to [`protos::error::ErrorCode::InvalidRequestError`]. + InvalidRequestError, + + /// Please refer to [`protos::error::ErrorCode::AuthError`]. + AuthError, + + /// Please refer to [`protos::error::ErrorCode::LightningError`]. + LightningError, + + /// Please refer to [`protos::error::ErrorCode::InternalServerError`]. + InternalServerError, +} + +impl fmt::Display for LdkServerErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LdkServerErrorCode::InvalidRequestError => write!(f, "InvalidRequestError"), + LdkServerErrorCode::AuthError => write!(f, "AuthError"), + LdkServerErrorCode::LightningError => write!(f, "LightningError"), + LdkServerErrorCode::InternalServerError => write!(f, "InternalServerError"), + } + } +} + +impl From for LdkServerError { + fn from(error: NodeError) -> Self { + let (message, error_code) = match error { + NodeError::InvalidAddress + | NodeError::InvalidSocketAddress + | NodeError::InvalidPublicKey + | NodeError::InvalidSecretKey + | NodeError::InvalidOfferId + | NodeError::InvalidNodeId + | NodeError::InvalidPaymentId + | NodeError::InvalidPaymentHash + | NodeError::InvalidPaymentPreimage + | NodeError::InvalidPaymentSecret + | NodeError::InvalidAmount + | NodeError::InvalidInvoice + | NodeError::InvalidOffer + | NodeError::InvalidRefund + | NodeError::InvalidChannelId + | NodeError::InvalidNetwork + | NodeError::InvalidUri + | NodeError::InvalidQuantity + | NodeError::InvalidNodeAlias + | NodeError::InvalidDateTime + | NodeError::InvalidFeeRate + | NodeError::UriParameterParsingFailed + | NodeError::InvalidBlindedPaths + | NodeError::AsyncPaymentServicesDisabled => { + (error.to_string(), LdkServerErrorCode::InvalidRequestError) + }, + NodeError::ConnectionFailed + | NodeError::InvoiceCreationFailed + | NodeError::InvoiceRequestCreationFailed + | NodeError::OfferCreationFailed + | NodeError::RefundCreationFailed + | NodeError::PaymentSendingFailed + | NodeError::InvalidCustomTlvs + | NodeError::ProbeSendingFailed + | NodeError::ChannelCreationFailed + | NodeError::ChannelClosingFailed + | NodeError::ChannelSplicingFailed + | NodeError::ChannelConfigUpdateFailed + | NodeError::DuplicatePayment + | NodeError::InsufficientFunds + | NodeError::UnsupportedCurrency + | NodeError::HrnParsingFailed + | NodeError::LiquidityFeeTooHigh => (error.to_string(), LdkServerErrorCode::LightningError), + NodeError::AlreadyRunning + | NodeError::NotRunning + | NodeError::PersistenceFailed + | NodeError::FeerateEstimationUpdateFailed + | NodeError::FeerateEstimationUpdateTimeout + | NodeError::WalletOperationFailed + | NodeError::WalletOperationTimeout + | NodeError::GossipUpdateFailed + | NodeError::GossipUpdateTimeout + | NodeError::LiquiditySourceUnavailable + | NodeError::LiquidityRequestFailed + | NodeError::OnchainTxCreationFailed + | NodeError::OnchainTxSigningFailed + | NodeError::TxSyncFailed + | NodeError::InvalidScriptPubKey + | NodeError::TxSyncTimeout => (error.to_string(), LdkServerErrorCode::InternalServerError), + }; + LdkServerError::new(error_code, message) + } +} diff --git a/ldk-server/ldk-server/src/api/get_balances.rs b/ldk-server/ldk-server/src/api/get_balances.rs new file mode 100644 index 000000000..82ab69b77 --- /dev/null +++ b/ldk-server/ldk-server/src/api/get_balances.rs @@ -0,0 +1,38 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_protos::api::{GetBalancesRequest, GetBalancesResponse}; + +use crate::api::error::LdkServerError; +use crate::service::Context; +use crate::util::proto_adapter::{lightning_balance_to_proto, pending_sweep_balance_to_proto}; + +pub(crate) fn handle_get_balances_request( + context: Context, _request: GetBalancesRequest, +) -> Result { + let balance_details = context.node.list_balances(); + + let response = GetBalancesResponse { + total_onchain_balance_sats: balance_details.total_onchain_balance_sats, + spendable_onchain_balance_sats: balance_details.spendable_onchain_balance_sats, + total_anchor_channels_reserve_sats: balance_details.total_anchor_channels_reserve_sats, + total_lightning_balance_sats: balance_details.total_lightning_balance_sats, + lightning_balances: balance_details + .lightning_balances + .into_iter() + .map(lightning_balance_to_proto) + .collect(), + pending_balances_from_channel_closures: balance_details + .pending_balances_from_channel_closures + .into_iter() + .map(pending_sweep_balance_to_proto) + .collect(), + }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/get_node_info.rs b/ldk-server/ldk-server/src/api/get_node_info.rs new file mode 100644 index 000000000..8599cf208 --- /dev/null +++ b/ldk-server/ldk-server/src/api/get_node_info.rs @@ -0,0 +1,37 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_protos::api::{GetNodeInfoRequest, GetNodeInfoResponse}; +use ldk_server_protos::types::BestBlock; + +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_get_node_info_request( + context: Context, _request: GetNodeInfoRequest, +) -> Result { + let node_status = context.node.status(); + + let best_block = BestBlock { + block_hash: node_status.current_best_block.block_hash.to_string(), + height: node_status.current_best_block.height, + }; + + let response = GetNodeInfoResponse { + node_id: context.node.node_id().to_string(), + current_best_block: Some(best_block), + latest_lightning_wallet_sync_timestamp: node_status.latest_lightning_wallet_sync_timestamp, + latest_onchain_wallet_sync_timestamp: node_status.latest_onchain_wallet_sync_timestamp, + latest_fee_rate_cache_update_timestamp: node_status.latest_fee_rate_cache_update_timestamp, + latest_rgs_snapshot_timestamp: node_status.latest_rgs_snapshot_timestamp, + latest_node_announcement_broadcast_timestamp: node_status + .latest_node_announcement_broadcast_timestamp, + }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/get_payment_details.rs b/ldk-server/ldk-server/src/api/get_payment_details.rs new file mode 100644 index 000000000..1d7b266a4 --- /dev/null +++ b/ldk-server/ldk-server/src/api/get_payment_details.rs @@ -0,0 +1,35 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use hex::FromHex; +use ldk_node::lightning::ln::channelmanager::PaymentId; +use ldk_server_protos::api::{GetPaymentDetailsRequest, GetPaymentDetailsResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; +use crate::util::proto_adapter::payment_to_proto; + +pub(crate) fn handle_get_payment_details_request( + context: Context, request: GetPaymentDetailsRequest, +) -> Result { + let payment_id_bytes = + <[u8; PaymentId::LENGTH]>::from_hex(&request.payment_id).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid payment_id, must be a {}-byte hex-string.", PaymentId::LENGTH), + ) + })?; + + let payment_details = context.node.payment(&PaymentId(payment_id_bytes)); + + let response = GetPaymentDetailsResponse { payment: payment_details.map(payment_to_proto) }; + + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/list_channels.rs b/ldk-server/ldk-server/src/api/list_channels.rs new file mode 100644 index 000000000..84d1584b9 --- /dev/null +++ b/ldk-server/ldk-server/src/api/list_channels.rs @@ -0,0 +1,23 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_protos::api::{ListChannelsRequest, ListChannelsResponse}; + +use crate::api::error::LdkServerError; +use crate::service::Context; +use crate::util::proto_adapter::channel_to_proto; + +pub(crate) fn handle_list_channels_request( + context: Context, _request: ListChannelsRequest, +) -> Result { + let channels = context.node.list_channels().into_iter().map(channel_to_proto).collect(); + + let response = ListChannelsResponse { channels }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/list_forwarded_payments.rs b/ldk-server/ldk-server/src/api/list_forwarded_payments.rs new file mode 100644 index 000000000..78ce3dc4e --- /dev/null +++ b/ldk-server/ldk-server/src/api/list_forwarded_payments.rs @@ -0,0 +1,73 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use bytes::Bytes; +use ldk_server_protos::api::{ListForwardedPaymentsRequest, ListForwardedPaymentsResponse}; +use ldk_server_protos::types::{ForwardedPayment, PageToken}; +use prost::Message; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InternalServerError; +use crate::io::persist::{ + FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::service::Context; + +pub(crate) fn handle_list_forwarded_payments_request( + context: Context, request: ListForwardedPaymentsRequest, +) -> Result { + let page_token = request.page_token.map(|p| (p.token, p.index)); + let list_response = context + .paginated_kv_store + .list( + FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, + page_token, + ) + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to list forwarded payments: {}", e), + ) + })?; + + let mut forwarded_payments: Vec = + Vec::with_capacity(list_response.keys.len()); + for key in list_response.keys { + let forwarded_payment_bytes = context + .paginated_kv_store + .read( + FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + ) + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to read forwarded payment data: {}", e), + ) + })?; + let forwarded_payment = ForwardedPayment::decode(Bytes::from(forwarded_payment_bytes)) + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to decode forwarded payment: {}", e), + ) + })?; + forwarded_payments.push(forwarded_payment); + } + let response = ListForwardedPaymentsResponse { + forwarded_payments, + next_page_token: list_response + .next_page_token + .map(|(token, index)| PageToken { token, index }), + }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/list_payments.rs b/ldk-server/ldk-server/src/api/list_payments.rs new file mode 100644 index 000000000..fbaf7c557 --- /dev/null +++ b/ldk-server/ldk-server/src/api/list_payments.rs @@ -0,0 +1,64 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use bytes::Bytes; +use ldk_server_protos::api::{ListPaymentsRequest, ListPaymentsResponse}; +use ldk_server_protos::types::{PageToken, Payment}; +use prost::Message; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InternalServerError; +use crate::io::persist::{ + PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::service::Context; + +pub(crate) fn handle_list_payments_request( + context: Context, request: ListPaymentsRequest, +) -> Result { + let page_token = request.page_token.map(|p| (p.token, p.index)); + let list_response = context + .paginated_kv_store + .list( + PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, + page_token, + ) + .map_err(|e| { + LdkServerError::new(InternalServerError, format!("Failed to list payments: {}", e)) + })?; + + let mut payments: Vec = Vec::with_capacity(list_response.keys.len()); + for key in list_response.keys { + let payment_bytes = context + .paginated_kv_store + .read( + PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + ) + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to read payment data: {}", e), + ) + })?; + let payment = Payment::decode(Bytes::from(payment_bytes)).map_err(|e| { + LdkServerError::new(InternalServerError, format!("Failed to decode payment: {}", e)) + })?; + payments.push(payment); + } + let response = ListPaymentsResponse { + payments, + next_page_token: list_response + .next_page_token + .map(|(token, index)| PageToken { token, index }), + }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/mod.rs b/ldk-server/ldk-server/src/api/mod.rs new file mode 100644 index 000000000..1152f8b34 --- /dev/null +++ b/ldk-server/ldk-server/src/api/mod.rs @@ -0,0 +1,77 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; +use ldk_server_protos::types::channel_config::MaxDustHtlcExposure; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; + +pub(crate) mod bolt11_receive; +pub(crate) mod bolt11_send; +pub(crate) mod bolt12_receive; +pub(crate) mod bolt12_send; +pub(crate) mod close_channel; +pub(crate) mod connect_peer; +pub(crate) mod error; +pub(crate) mod get_balances; +pub(crate) mod get_node_info; +pub(crate) mod get_payment_details; +pub(crate) mod list_channels; +pub(crate) mod list_forwarded_payments; +pub(crate) mod list_payments; +pub(crate) mod onchain_receive; +pub(crate) mod onchain_send; +pub(crate) mod open_channel; +pub(crate) mod splice_channel; +pub(crate) mod update_channel_config; + +pub(crate) fn build_channel_config_from_proto( + default_config: ChannelConfig, proto_channel_config: ldk_server_protos::types::ChannelConfig, +) -> Result { + let max_dust_htlc_exposure = proto_channel_config + .max_dust_htlc_exposure + .map(|max_dust_htlc_exposure| match max_dust_htlc_exposure { + MaxDustHtlcExposure::FixedLimitMsat(limit_msat) => { + MaxDustHTLCExposure::FixedLimit { limit_msat } + }, + MaxDustHtlcExposure::FeeRateMultiplier(multiplier) => { + MaxDustHTLCExposure::FeeRateMultiplier { multiplier } + }, + }) + .unwrap_or(default_config.max_dust_htlc_exposure); + + let cltv_expiry_delta = match proto_channel_config.cltv_expiry_delta { + Some(c) => Some(u16::try_from(c).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid cltv_expiry_delta, must be between 0 and {}", u16::MAX), + ) + })?), + None => None, + } + .unwrap_or(default_config.cltv_expiry_delta); + + Ok(ChannelConfig { + forwarding_fee_proportional_millionths: proto_channel_config + .forwarding_fee_proportional_millionths + .unwrap_or(default_config.forwarding_fee_proportional_millionths), + forwarding_fee_base_msat: proto_channel_config + .forwarding_fee_base_msat + .unwrap_or(default_config.forwarding_fee_base_msat), + cltv_expiry_delta, + max_dust_htlc_exposure, + force_close_avoidance_max_fee_satoshis: proto_channel_config + .force_close_avoidance_max_fee_satoshis + .unwrap_or(default_config.force_close_avoidance_max_fee_satoshis), + accept_underpaying_htlcs: proto_channel_config + .accept_underpaying_htlcs + .unwrap_or(default_config.accept_underpaying_htlcs), + }) +} diff --git a/ldk-server/ldk-server/src/api/onchain_receive.rs b/ldk-server/ldk-server/src/api/onchain_receive.rs new file mode 100644 index 000000000..cad2837ed --- /dev/null +++ b/ldk-server/ldk-server/src/api/onchain_receive.rs @@ -0,0 +1,22 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_protos::api::{OnchainReceiveRequest, OnchainReceiveResponse}; + +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_onchain_receive_request( + context: Context, _request: OnchainReceiveRequest, +) -> Result { + let response = OnchainReceiveResponse { + address: context.node.onchain_payment().new_address()?.to_string(), + }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/onchain_send.rs b/ldk-server/ldk-server/src/api/onchain_send.rs new file mode 100644 index 000000000..6eb1d63af --- /dev/null +++ b/ldk-server/ldk-server/src/api/onchain_send.rs @@ -0,0 +1,50 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::bitcoin::{Address, FeeRate}; +use ldk_server_protos::api::{OnchainSendRequest, OnchainSendResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_onchain_send_request( + context: Context, request: OnchainSendRequest, +) -> Result { + let address = Address::from_str(&request.address) + .map_err(|_| ldk_node::NodeError::InvalidAddress)? + .require_network(context.node.config().network) + .map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Address is not valid for the configured network.".to_string(), + ) + })?; + + let fee_rate = request.fee_rate_sat_per_vb.and_then(FeeRate::from_sat_per_vb); + let txid = match (request.amount_sats, request.send_all) { + (Some(amount_sats), None) => { + context.node.onchain_payment().send_to_address(&address, amount_sats, fee_rate)? + }, + // Retain existing api behaviour to not retain reserves on `send_all_to_address`. + (None, Some(true)) => { + context.node.onchain_payment().send_all_to_address(&address, false, fee_rate)? + }, + _ => { + return Err(LdkServerError::new( + InvalidRequestError, + "Must specify either `send_all` or `amount_sats`, but not both or neither", + )) + }, + }; + let response = OnchainSendResponse { txid: txid.to_string() }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/open_channel.rs b/ldk-server/ldk-server/src/api/open_channel.rs new file mode 100644 index 000000000..6c470b71c --- /dev/null +++ b/ldk-server/ldk-server/src/api/open_channel.rs @@ -0,0 +1,54 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::config::ChannelConfig; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_server_protos::api::{OpenChannelRequest, OpenChannelResponse}; + +use crate::api::build_channel_config_from_proto; +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_open_channel( + context: Context, request: OpenChannelRequest, +) -> Result { + let node_id = PublicKey::from_str(&request.node_pubkey) + .map_err(|_| ldk_node::NodeError::InvalidPublicKey)?; + let address = SocketAddress::from_str(&request.address) + .map_err(|_| ldk_node::NodeError::InvalidSocketAddress)?; + + let channel_config = request + .channel_config + .map(|proto_config| build_channel_config_from_proto(ChannelConfig::default(), proto_config)) + .transpose()?; + + let user_channel_id = if request.announce_channel { + context.node.open_announced_channel( + node_id, + address, + request.channel_amount_sats, + request.push_to_counterparty_msat, + channel_config, + )? + } else { + context.node.open_channel( + node_id, + address, + request.channel_amount_sats, + request.push_to_counterparty_msat, + channel_config, + )? + }; + + let response = OpenChannelResponse { user_channel_id: user_channel_id.0.to_string() }; + Ok(response) +} diff --git a/ldk-server/ldk-server/src/api/splice_channel.rs b/ldk-server/ldk-server/src/api/splice_channel.rs new file mode 100644 index 000000000..5d6edfecd --- /dev/null +++ b/ldk-server/ldk-server/src/api/splice_channel.rs @@ -0,0 +1,79 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Address; +use ldk_node::UserChannelId; +use ldk_server_protos::api::{ + SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, +}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_splice_in_request( + context: Context, request: SpliceInRequest, +) -> Result { + let user_channel_id = parse_user_channel_id(&request.user_channel_id)?; + let counterparty_node_id = parse_counterparty_node_id(&request.counterparty_node_id)?; + + context.node.splice_in(&user_channel_id, counterparty_node_id, request.splice_amount_sats)?; + + Ok(SpliceInResponse {}) +} + +pub(crate) fn handle_splice_out_request( + context: Context, request: SpliceOutRequest, +) -> Result { + let user_channel_id = parse_user_channel_id(&request.user_channel_id)?; + let counterparty_node_id = parse_counterparty_node_id(&request.counterparty_node_id)?; + + let address = request + .address + .map(|address| { + Address::from_str(&address) + .and_then(|address| address.require_network(context.node.config().network)) + .map_err(|_| ldk_node::NodeError::InvalidAddress) + }) + .unwrap_or_else(|| context.node.onchain_payment().new_address()) + .map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Address is not valid for the configured network.".to_string(), + ) + })?; + + context.node.splice_out( + &user_channel_id, + counterparty_node_id, + &address, + request.splice_amount_sats, + )?; + + Ok(SpliceOutResponse { address: address.to_string() }) +} + +fn parse_user_channel_id(id: &str) -> Result { + let parsed = id.parse::().map_err(|_| { + LdkServerError::new(InvalidRequestError, "Invalid UserChannelId.".to_string()) + })?; + Ok(UserChannelId(parsed)) +} + +fn parse_counterparty_node_id(id: &str) -> Result { + PublicKey::from_str(id).map_err(|e| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid counterparty node ID, error: {}", e), + ) + }) +} diff --git a/ldk-server/ldk-server/src/api/update_channel_config.rs b/ldk-server/ldk-server/src/api/update_channel_config.rs new file mode 100644 index 000000000..780374c5d --- /dev/null +++ b/ldk-server/ldk-server/src/api/update_channel_config.rs @@ -0,0 +1,66 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::str::FromStr; + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::UserChannelId; +use ldk_server_protos::api::{UpdateChannelConfigRequest, UpdateChannelConfigResponse}; + +use crate::api::build_channel_config_from_proto; +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::{InvalidRequestError, LightningError}; +use crate::service::Context; + +pub(crate) fn handle_update_channel_config_request( + context: Context, request: UpdateChannelConfigRequest, +) -> Result { + let user_channel_id: u128 = request + .user_channel_id + .parse::() + .map_err(|_| LdkServerError::new(InvalidRequestError, "Invalid UserChannelId."))?; + + // FIXME: Use ldk/ldk-node's partial config update api. + let current_config = context + .node + .list_channels() + .into_iter() + .find(|c| c.user_channel_id.0 == user_channel_id) + .ok_or_else(|| { + LdkServerError::new(InvalidRequestError, "Channel not found for given user_channel_id.") + })? + .config; + + let updated_channel_config = build_channel_config_from_proto( + current_config, + request.channel_config.ok_or_else(|| { + LdkServerError::new(InvalidRequestError, "Channel config must be provided.") + })?, + )?; + + let counterparty_node_id = PublicKey::from_str(&request.counterparty_node_id).map_err(|e| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid counterparty node id, error {}", e), + ) + })?; + + context + .node + .update_channel_config( + &UserChannelId(user_channel_id), + counterparty_node_id, + updated_channel_config, + ) + .map_err(|e| { + LdkServerError::new(LightningError, format!("Failed to update channel config: {}", e)) + })?; + + Ok(UpdateChannelConfigResponse {}) +} diff --git a/ldk-server/ldk-server/src/io/events/event_publisher.rs b/ldk-server/ldk-server/src/io/events/event_publisher.rs new file mode 100644 index 000000000..308bde257 --- /dev/null +++ b/ldk-server/ldk-server/src/io/events/event_publisher.rs @@ -0,0 +1,68 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use async_trait::async_trait; +use ldk_server_protos::events::EventEnvelope; + +use crate::api::error::LdkServerError; + +/// A trait for publishing events or notifications from the LDK Server. +/// +/// Implementors of this trait define how events are sent to various messaging +/// systems. It provides a consistent, asynchronous interface for event publishing, while allowing +/// each implementation to manage its own initialization and configuration, typically sourced from +/// the `ldk-server.config` file. A no-op implementation is included by default, +/// with specific implementations enabled via feature flags. +/// +/// Events are represented as [`EventEnvelope`] messages, which are Protocol Buffers +/// ([protobuf](https://protobuf.dev/)) objects defined in [`ldk_server_protos::events`]. +/// These events are serialized to bytes by the publisher before transmission, and consumers can +/// deserialize them using the protobuf definitions. +/// +/// The underlying messaging system is expected to support durably buffered events, +/// enabling easy decoupling between the LDK Server and event consumers. +#[async_trait] +pub trait EventPublisher: Send + Sync { + /// Publishes an event to the underlying messaging system. + /// + /// # Arguments + /// * `event` - The event message to publish, provided as an [`EventEnvelope`] + /// defined in [`ldk_server_protos::events`]. Implementors must serialize + /// the whole [`EventEnvelope`] to bytes before publishing. + /// + /// In order to ensure no events are lost, implementors of this trait must publish events + /// durably to underlying messaging system. An event is considered published when + /// [`EventPublisher::publish`] returns `Ok(())`, thus implementors MUST durably persist/publish events *before* + /// returning `Ok(())`. + /// + /// # Errors + /// May return an [`LdkServerErrorCode::InternalServerError`] if the event cannot be published, + /// such as due to network failures, misconfiguration, or transport-specific issues. + /// If event publishing fails, the LDK Server will retry publishing the event indefinitely, which + /// may degrade performance until the underlying messaging system is operational again. + /// + /// [`LdkServerErrorCode::InternalServerError`]: crate::api::error::LdkServerErrorCode + async fn publish(&self, event: EventEnvelope) -> Result<(), LdkServerError>; +} + +/// A no-op implementation of the [`EventPublisher`] trait. +#[cfg(not(feature = "events-rabbitmq"))] +pub(crate) struct NoopEventPublisher; + +#[async_trait] +#[cfg(not(feature = "events-rabbitmq"))] +impl EventPublisher for NoopEventPublisher { + /// Publishes an event to a no-op sink, effectively discarding it. + /// + /// This implementation does nothing and always returns `Ok(())`, serving as a + /// default when no messaging system is configured. + async fn publish(&self, _event: EventEnvelope) -> Result<(), LdkServerError> { + Ok(()) + } +} diff --git a/ldk-server/ldk-server/src/io/events/mod.rs b/ldk-server/ldk-server/src/io/events/mod.rs new file mode 100644 index 000000000..f944b7e30 --- /dev/null +++ b/ldk-server/ldk-server/src/io/events/mod.rs @@ -0,0 +1,25 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub(crate) mod event_publisher; + +#[cfg(feature = "events-rabbitmq")] +pub(crate) mod rabbitmq; + +use ldk_server_protos::events::event_envelope; + +/// Event variant to event name mapping. +pub(crate) fn get_event_name(event: &event_envelope::Event) -> &'static str { + match event { + event_envelope::Event::PaymentReceived(_) => "PaymentReceived", + event_envelope::Event::PaymentSuccessful(_) => "PaymentSuccessful", + event_envelope::Event::PaymentFailed(_) => "PaymentFailed", + event_envelope::Event::PaymentForwarded(_) => "PaymentForwarded", + } +} diff --git a/ldk-server/ldk-server/src/io/events/rabbitmq/mod.rs b/ldk-server/ldk-server/src/io/events/rabbitmq/mod.rs new file mode 100644 index 000000000..a20b5bd96 --- /dev/null +++ b/ldk-server/ldk-server/src/io/events/rabbitmq/mod.rs @@ -0,0 +1,244 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::sync::Arc; + +use ::prost::Message; +use async_trait::async_trait; +use lapin::options::{BasicPublishOptions, ConfirmSelectOptions, ExchangeDeclareOptions}; +use lapin::types::FieldTable; +use lapin::{ + BasicProperties, Channel, Connection, ConnectionProperties, ConnectionState, ExchangeKind, +}; +use ldk_server_protos::events::EventEnvelope; +use tokio::sync::Mutex; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InternalServerError; +use crate::io::events::event_publisher::EventPublisher; + +/// A RabbitMQ-based implementation of the EventPublisher trait. +pub struct RabbitMqEventPublisher { + /// The RabbitMQ connection, used for reconnection logic. + connection: Arc>>, + /// The RabbitMQ channel used for publishing events. + channel: Arc>>, + /// Configuration details, including connection string and exchange name. + config: RabbitMqConfig, +} + +/// Configuration for the RabbitMQ event publisher. +#[derive(Debug, Clone)] +pub struct RabbitMqConfig { + pub connection_string: String, + pub exchange_name: String, +} + +/// Delivery mode for persistent messages (written to disk). +const DELIVERY_MODE_PERSISTENT: u8 = 2; + +impl RabbitMqEventPublisher { + /// Creates a new RabbitMqEventPublisher instance. + pub fn new(config: RabbitMqConfig) -> Self { + Self { connection: Arc::new(Mutex::new(None)), channel: Arc::new(Mutex::new(None)), config } + } + + async fn connect(config: &RabbitMqConfig) -> Result<(Connection, Channel), LdkServerError> { + let conn = Connection::connect(&config.connection_string, ConnectionProperties::default()) + .await + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to connect to RabbitMQ: {}", e), + ) + })?; + + let channel = conn.create_channel().await.map_err(|e| { + LdkServerError::new(InternalServerError, format!("Failed to create channel: {}", e)) + })?; + + channel.confirm_select(ConfirmSelectOptions::default()).await.map_err(|e| { + LdkServerError::new(InternalServerError, format!("Failed to enable confirms: {}", e)) + })?; + + channel + .exchange_declare( + &config.exchange_name, + ExchangeKind::Fanout, + ExchangeDeclareOptions { durable: true, ..Default::default() }, + FieldTable::default(), + ) + .await + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to declare exchange: {}", e), + ) + })?; + + Ok((conn, channel)) + } + + async fn ensure_connected(&self) -> Result<(), LdkServerError> { + { + let connection = self.connection.lock().await; + if let Some(connection) = &*connection { + if connection.status().state() == ConnectionState::Connected { + return Ok(()); + } + } + } + + // Connection is not alive, attempt reconnecting. + let (connection, channel) = Self::connect(&self.config) + .await + .map_err(|e| LdkServerError::new(InternalServerError, e.to_string()))?; + *self.connection.lock().await = Some(connection); + *self.channel.lock().await = Some(channel); + Ok(()) + } +} + +#[async_trait] +impl EventPublisher for RabbitMqEventPublisher { + /// Publishes an event to RabbitMQ. + /// + /// The event is published to a fanout exchange with persistent delivery mode, + /// and the method waits for confirmation from RabbitMQ to ensure durability. + async fn publish(&self, event: EventEnvelope) -> Result<(), LdkServerError> { + // Ensure connection is alive before proceeding + self.ensure_connected().await?; + + let channel_guard = self.channel.lock().await; + let channel = channel_guard.as_ref().ok_or_else(|| { + LdkServerError::new(InternalServerError, "Channel not initialized".to_string()) + })?; + + // Publish the event with persistent delivery mode + let confirm = channel + .basic_publish( + &self.config.exchange_name, + "", // Empty routing key should be used for fanout exchange, since it is ignored. + BasicPublishOptions::default(), + &event.encode_to_vec(), + BasicProperties::default().with_delivery_mode(DELIVERY_MODE_PERSISTENT), + ) + .await + .map_err(|e| { + LdkServerError::new( + InternalServerError, + format!("Failed to publish event, error: {}", e), + ) + })?; + + let confirmation = confirm.await.map_err(|e| { + LdkServerError::new(InternalServerError, format!("Failed to get confirmation: {}", e)) + })?; + + match confirmation { + lapin::publisher_confirm::Confirmation::Ack(_) => Ok(()), + lapin::publisher_confirm::Confirmation::Nack(_) => Err(LdkServerError::new( + InternalServerError, + "Message not acknowledged".to_string(), + )), + _ => { + Err(LdkServerError::new(InternalServerError, "Unexpected confirmation".to_string())) + }, + } + } +} + +#[cfg(test)] +#[cfg(feature = "integration-tests-events-rabbitmq")] +mod integration_tests_events_rabbitmq { + use std::io; + use std::time::Duration; + + use futures_util::stream::StreamExt; + use lapin::options::{ + BasicAckOptions, BasicConsumeOptions, QueueBindOptions, QueueDeclareOptions, + }; + use lapin::types::FieldTable; + use lapin::{Channel, Connection}; + use ldk_server_protos::events::event_envelope::Event; + use ldk_server_protos::events::PaymentForwarded; + use tokio; + + use super::*; + #[tokio::test] + async fn test_publish_and_consume_event() { + let config = RabbitMqConfig { + connection_string: "amqp://guest:guest@localhost:5672/%2f".to_string(), + exchange_name: "test_exchange".to_string(), + }; + + let publisher = RabbitMqEventPublisher::new(config.clone()); + + let conn = Connection::connect(&config.connection_string, ConnectionProperties::default()) + .await + .expect("Failed make rabbitmq connection"); + let channel = conn.create_channel().await.expect("Failed to create rabbitmq channel"); + + let queue_name = "test_queue"; + setup_queue(&queue_name, &channel, &config).await; + + let event = + EventEnvelope { event: Some(Event::PaymentForwarded(PaymentForwarded::default())) }; + publisher.publish(event.clone()).await.expect("Failed to publish event"); + + consume_event(&queue_name, &channel, &event).await.expect("Failed to consume event"); + } + + async fn setup_queue(queue_name: &str, channel: &Channel, config: &RabbitMqConfig) { + channel + .queue_declare(queue_name, QueueDeclareOptions::default(), FieldTable::default()) + .await + .unwrap(); + channel + .exchange_declare( + &config.exchange_name, + ExchangeKind::Fanout, + ExchangeDeclareOptions { durable: true, ..Default::default() }, + FieldTable::default(), + ) + .await + .unwrap(); + + channel + .queue_bind( + queue_name, + &config.exchange_name, + "", + QueueBindOptions::default(), + FieldTable::default(), + ) + .await + .unwrap(); + } + + async fn consume_event( + queue_name: &str, channel: &Channel, expected_event: &EventEnvelope, + ) -> io::Result<()> { + let mut consumer = channel + .basic_consume( + queue_name, + "test_consumer", + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await + .unwrap(); + let delivery = + tokio::time::timeout(Duration::from_secs(10), consumer.next()).await?.unwrap().unwrap(); + let received_event = EventEnvelope::decode(&*delivery.data)?; + assert_eq!(received_event, *expected_event, "Event mismatch"); + channel.basic_ack(delivery.delivery_tag, BasicAckOptions::default()).await.unwrap(); + Ok(()) + } +} diff --git a/ldk-server/ldk-server/src/io/mod.rs b/ldk-server/ldk-server/src/io/mod.rs new file mode 100644 index 000000000..ab1df5f66 --- /dev/null +++ b/ldk-server/ldk-server/src/io/mod.rs @@ -0,0 +1,12 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub(crate) mod events; +pub(crate) mod persist; +pub(crate) mod utils; diff --git a/ldk-server/ldk-server/src/io/persist/mod.rs b/ldk-server/ldk-server/src/io/persist/mod.rs new file mode 100644 index 000000000..6c01795bc --- /dev/null +++ b/ldk-server/ldk-server/src/io/persist/mod.rs @@ -0,0 +1,19 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub(crate) mod paginated_kv_store; +pub(crate) mod sqlite_store; + +/// The forwarded payments will be persisted under this prefix. +pub(crate) const FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE: &str = "forwarded_payments"; +pub(crate) const FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + +/// The payments will be persisted under this prefix. +pub(crate) const PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; +pub(crate) const PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; diff --git a/ldk-server/ldk-server/src/io/persist/paginated_kv_store.rs b/ldk-server/ldk-server/src/io/persist/paginated_kv_store.rs new file mode 100644 index 000000000..7a7f66b82 --- /dev/null +++ b/ldk-server/ldk-server/src/io/persist/paginated_kv_store.rs @@ -0,0 +1,93 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::io; + +/// Provides an interface that allows storage and retrieval of persisted values that are associated +/// with given keys, with support for pagination with time-based ordering. +/// +/// In order to avoid collisions, the key space is segmented based on the given `primary_namespace`s +/// and `secondary_namespace`s. Implementations of this trait are free to handle them in different +/// ways, as long as per-namespace key uniqueness is asserted. +/// +/// Keys and namespaces are required to be valid ASCII strings in the range of +/// [`KVSTORE_NAMESPACE_KEY_ALPHABET`] and no longer than [`KVSTORE_NAMESPACE_KEY_MAX_LEN`]. Empty +/// primary namespaces and secondary namespaces (`""`) are considered valid; however, if +/// `primary_namespace` is empty, `secondary_namespace` must also be empty. This means that concerns +/// should always be separated by primary namespace first, before secondary namespaces are used. +/// While the number of primary namespaces will be relatively small and determined at compile time, +/// there may be many secondary namespaces per primary namespace. Note that per-namespace uniqueness +/// needs to also hold for keys *and* namespaces in any given namespace, i.e., conflicts between keys +/// and equally named primary or secondary namespaces must be avoided. +/// +/// **Note:** This trait extends the functionality of [`KVStore`] by adding support for +/// paginated listing of keys based on a monotonic counter or logical timestamp. This is useful +/// when dealing with a large number of keys that cannot be efficiently retrieved all at once. +/// +/// See also [`KVStore`]. +/// +/// [`KVStore`]: ldk_node::lightning::util::persist::KVStore +/// [`KVSTORE_NAMESPACE_KEY_ALPHABET`]: ldk_node::lightning::util::persist::KVSTORE_NAMESPACE_KEY_ALPHABET +/// [`KVSTORE_NAMESPACE_KEY_MAX_LEN`]: ldk_node::lightning::util::persist::KVSTORE_NAMESPACE_KEY_MAX_LEN +pub trait PaginatedKVStore: Send + Sync { + /// Returns the data stored for the given `primary_namespace`, `secondary_namespace`, and `key`. + /// + /// Returns an [`ErrorKind::NotFound`] if the given `key` could not be found in the given + /// `primary_namespace` and `secondary_namespace`. + /// + /// [`ErrorKind::NotFound`]: io::ErrorKind::NotFound + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result, io::Error>; + + /// Persists the given data under the given `key` with an associated `time`. + /// + /// The `time` parameter is a `i64` representing a monotonic counter or logical timestamp. + /// It is used to track the order of keys for list operations. Implementations should store the + /// `time` value and use it for ordering in the `list` method. + /// + /// Will create the given `primary_namespace` and `secondary_namespace` if not already present + /// in the store. + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, time: i64, buf: &[u8], + ) -> Result<(), io::Error>; + + /// Returns a paginated list of keys that are stored under the given `secondary_namespace` in + /// `primary_namespace`, ordered in descending order of `time`. + /// + /// The `list` method returns the latest records first, based on the `time` associated with each key. + /// Pagination is controlled by the `next_page_token`, which is an `Option<(String, i64)>` + /// used to determine the starting point for the next page of results. If `next_page_token` is `None`, + /// the listing starts from the most recent entry. The `next_page_token` in the returned + /// [`ListResponse`] can be used to fetch the next page of results. + /// + /// Implementations should ensure that keys are returned in descending order of `time` and that + /// pagination tokens are correctly managed. + /// + /// Returns an empty list if `primary_namespace` or `secondary_namespace` is unknown or if + /// there are no more keys to return. + /// + /// [`ListResponse`]: struct.ListResponse.html + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + next_page_token: Option<(String, i64)>, + ) -> Result; +} + +/// Represents the response from a paginated `list` operation. +/// +/// Contains the list of keys and an optional `next_page_token` that can be used to retrieve the +/// next set of keys. +pub struct ListResponse { + /// A vector of keys, ordered in descending order of `time`. + pub keys: Vec, + + /// A token that can be used to retrieve the next set of keys. + pub next_page_token: Option<(String, i64)>, +} diff --git a/ldk-server/ldk-server/src/io/persist/sqlite_store/mod.rs b/ldk-server/ldk-server/src/io/persist/sqlite_store/mod.rs new file mode 100644 index 000000000..a52ffa46d --- /dev/null +++ b/ldk-server/ldk-server/src/io/persist/sqlite_store/mod.rs @@ -0,0 +1,384 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::{fs, io}; + +use ldk_node::lightning::types::string::PrintableString; +use rusqlite::{named_params, Connection}; + +use crate::io::persist::paginated_kv_store::{ListResponse, PaginatedKVStore}; +use crate::io::utils::check_namespace_key_validity; + +/// The default database file name. +pub const DEFAULT_SQLITE_DB_FILE_NAME: &str = "ldk_server_data.sqlite"; + +/// The default table in which we store all the paginated data. +pub const DEFAULT_PAGINATED_KV_TABLE_NAME: &str = "ldk_paginated_data"; + +// The current SQLite `user_version`, which we can use if we'd ever need to do a schema migration. +const SCHEMA_USER_VERSION: u16 = 1; + +// The maximum number of keys retrieved per page in paginated list operation. +const LIST_KEYS_MAX_PAGE_SIZE: i32 = 100; + +pub struct SqliteStore { + connection: Arc>, + paginated_kv_table_name: String, +} + +impl SqliteStore { + /// Constructs a new [`SqliteStore`]. + /// + /// If not already existing, a new SQLite database will be created in the given `data_dir` under the + /// given `db_file_name` (or the default to [`DEFAULT_SQLITE_DB_FILE_NAME`] if set to `None`). + /// + /// Similarly, the given `paginated_kv_table_name` will be used or default to [`DEFAULT_PAGINATED_KV_TABLE_NAME`]. + pub fn new( + data_dir: PathBuf, db_file_name: Option, paginated_kv_table_name: Option, + ) -> io::Result { + let db_file_name = db_file_name.unwrap_or(DEFAULT_SQLITE_DB_FILE_NAME.to_string()); + let paginated_kv_table_name = + paginated_kv_table_name.unwrap_or(DEFAULT_PAGINATED_KV_TABLE_NAME.to_string()); + + fs::create_dir_all(data_dir.clone()).map_err(|e| { + let msg = format!( + "Failed to create database destination directory {}: {}", + data_dir.display(), + e + ); + io::Error::other(msg) + })?; + let mut db_file_path = data_dir; + db_file_path.push(db_file_name); + + let connection = Connection::open(db_file_path.clone()).map_err(|e| { + let msg = + format!("Failed to open/create database file {}: {}", db_file_path.display(), e); + io::Error::other(msg) + })?; + + let sql = "SELECT user_version FROM pragma_user_version".to_string(); + let version_res: u16 = connection.query_row(&sql, [], |row| row.get(0)).unwrap(); + + if version_res == 0 { + // New database, set our SCHEMA_USER_VERSION and continue + connection + .pragma( + Some(rusqlite::DatabaseName::Main), + "user_version", + SCHEMA_USER_VERSION, + |_| Ok(()), + ) + .map_err(|e| { + let msg = format!("Failed to set PRAGMA user_version: {}", e); + io::Error::other(msg) + })?; + } else if version_res > SCHEMA_USER_VERSION { + let msg = format!( + "Failed to open database: incompatible schema version {}. Expected: {}", + version_res, SCHEMA_USER_VERSION + ); + return Err(io::Error::other(msg)); + } + + let create_paginated_kv_table_sql = format!( + "CREATE TABLE IF NOT EXISTS {} ( + primary_namespace TEXT NOT NULL, + secondary_namespace TEXT DEFAULT \"\" NOT NULL, + key TEXT NOT NULL CHECK (key <> ''), + creation_time INTEGER NOT NULL, + value BLOB, PRIMARY KEY ( primary_namespace, secondary_namespace, key ) + );", + paginated_kv_table_name + ); + + connection.execute(&create_paginated_kv_table_sql, []).map_err(|e| { + let msg = format!("Failed to create table {}: {}", paginated_kv_table_name, e); + io::Error::other(msg) + })?; + + let index_creation_time_sql = format!( + "CREATE INDEX IF NOT EXISTS idx_creation_time ON {} (creation_time);", + paginated_kv_table_name + ); + + connection.execute(&index_creation_time_sql, []).map_err(|e| { + let msg = format!( + "Failed to create index on creation_time, table {}: {}", + paginated_kv_table_name, e + ); + io::Error::other(msg) + })?; + + let connection = Arc::new(Mutex::new(connection)); + Ok(Self { connection, paginated_kv_table_name }) + } + + fn read_internal( + &self, kv_table_name: &str, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + check_namespace_key_validity(primary_namespace, secondary_namespace, Some(key), "read")?; + + let locked_conn = self.connection.lock().unwrap(); + let sql = + format!("SELECT value FROM {} WHERE primary_namespace=:primary_namespace AND secondary_namespace=:secondary_namespace AND key=:key;", + kv_table_name); + + let mut stmt = locked_conn.prepare_cached(&sql).map_err(|e| { + let msg = format!("Failed to prepare statement: {}", e); + io::Error::other(msg) + })?; + + let res = stmt + .query_row( + named_params! { + ":primary_namespace": primary_namespace, + ":secondary_namespace": secondary_namespace, + ":key": key, + }, + |row| row.get(0), + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + let msg = format!( + "Failed to read as key could not be found: {}/{}/{}", + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key) + ); + io::Error::new(io::ErrorKind::NotFound, msg) + }, + e => { + let msg = format!( + "Failed to read from key {}/{}/{}: {}", + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key), + e + ); + io::Error::other(msg) + }, + })?; + Ok(res) + } +} + +impl PaginatedKVStore for SqliteStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + self.read_internal( + &self.paginated_kv_table_name, + primary_namespace, + secondary_namespace, + key, + ) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, time: i64, buf: &[u8], + ) -> io::Result<()> { + check_namespace_key_validity(primary_namespace, secondary_namespace, Some(key), "write")?; + + let locked_conn = self.connection.lock().unwrap(); + + let sql = format!( + "INSERT INTO {} (primary_namespace, secondary_namespace, key, creation_time, value) + VALUES (:primary_namespace, :secondary_namespace, :key, :creation_time, :value) + ON CONFLICT(primary_namespace, secondary_namespace, key) + DO UPDATE SET value = excluded.value;", + self.paginated_kv_table_name + ); + + let mut stmt = locked_conn.prepare_cached(&sql).map_err(|e| { + let msg = format!("Failed to prepare statement: {}", e); + io::Error::other(msg) + })?; + + stmt.execute(named_params! { + ":primary_namespace": primary_namespace, + ":secondary_namespace": secondary_namespace, + ":key": key, + ":creation_time": time, + ":value": buf, + }) + .map(|_| ()) + .map_err(|e| { + let msg = format!( + "Failed to write to key {}/{}/{}: {}", + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key), + e + ); + io::Error::other(msg) + }) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option<(String, i64)>, + ) -> io::Result { + check_namespace_key_validity(primary_namespace, secondary_namespace, None, "list")?; + + let locked_conn = self.connection.lock().unwrap(); + + let sql = format!( + "SELECT key, creation_time FROM {} WHERE primary_namespace=:primary_namespace AND secondary_namespace=:secondary_namespace \ + AND ( creation_time < :creation_time_token OR (creation_time = :creation_time_token AND key > :key_token) ) \ + ORDER BY creation_time DESC, key ASC LIMIT :page_size", + self.paginated_kv_table_name + ); + + let mut stmt = locked_conn.prepare_cached(&sql).map_err(|e| { + let msg = format!("Failed to prepare statement: {}", e); + io::Error::other(msg) + })?; + + let mut keys: Vec = Vec::new(); + let page_token = page_token.unwrap_or(("".to_string(), i64::MAX)); + + let rows_iter = stmt + .query_map( + named_params! { + ":primary_namespace": primary_namespace, + ":secondary_namespace": secondary_namespace, + ":key_token": page_token.0, + ":creation_time_token": page_token.1, + ":page_size": LIST_KEYS_MAX_PAGE_SIZE, + }, + |row| { + let key: String = row.get(0)?; + let creation_time: i64 = row.get(1)?; + Ok((key, creation_time)) + }, + ) + .map_err(|e| { + let msg = format!("Failed to retrieve queried rows: {}", e); + io::Error::other(msg) + })?; + + let mut last_creation_time: Option = None; + for r in rows_iter { + let (k, ct) = r.map_err(|e| { + let msg = format!("Failed to retrieve queried rows: {}", e); + io::Error::other(msg) + })?; + keys.push(k); + last_creation_time = Some(ct); + } + + let last_key = keys.last().cloned(); + let next_page_token = if let (Some(k), Some(ct)) = (last_key, last_creation_time) { + Some((k, ct)) + } else { + None + }; + + Ok(ListResponse { keys, next_page_token }) + } +} + +#[cfg(test)] +mod tests { + use std::panic::RefUnwindSafe; + + use ldk_node::lightning::util::persist::KVSTORE_NAMESPACE_KEY_MAX_LEN; + use rand::distributions::Alphanumeric; + use rand::{thread_rng, Rng}; + + use super::*; + + #[test] + fn read_write_remove_list_persist() { + let mut temp_path = random_storage_path(); + temp_path.push("read_write_remove_list_persist"); + let store = SqliteStore::new( + temp_path, + Some("test_db".to_string()), + Some("test_table".to_string()), + ) + .unwrap(); + do_read_write_remove_list_persist(&store); + } + + pub(crate) fn random_storage_path() -> PathBuf { + let mut temp_path = std::env::temp_dir(); + let mut rng = thread_rng(); + let rand_dir: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); + temp_path.push(rand_dir); + temp_path + } + + pub(crate) fn do_read_write_remove_list_persist( + kv_store: &K, + ) { + let data = [42u8; 32]; + + let primary_namespace = "testspace"; + let secondary_namespace = "testsubspace"; + let testkey = "testkey_0"; + + let list_all_keys = |primary_namespace: &str, secondary_namespace: &str| -> Vec { + let mut all_keys = Vec::new(); + let mut page_token = None; + loop { + let list_response = + kv_store.list(primary_namespace, secondary_namespace, page_token).unwrap(); + assert!(list_response.keys.len() <= LIST_KEYS_MAX_PAGE_SIZE as usize); + all_keys.extend(list_response.keys); + if list_response.next_page_token.is_none() { + break; + } + page_token = list_response.next_page_token; + } + all_keys + }; + + // Test the basic KVStore operations. + for i in 0..110 { + kv_store + .write(primary_namespace, secondary_namespace, &format!("testkey_{}", i), 0, &data) + .unwrap(); + } + + // Test empty primary/secondary namespaces are allowed, but not empty primary namespace and non-empty + // secondary primary_namespace, and not empty key. + kv_store.write("", "", testkey, 0, &data).unwrap(); + let res = + std::panic::catch_unwind(|| kv_store.write("", secondary_namespace, testkey, 0, &data)); + assert!(res.is_err()); + let res = std::panic::catch_unwind(|| { + kv_store.write(primary_namespace, secondary_namespace, "", 0, &data) + }); + assert!(res.is_err()); + + let listed_keys = list_all_keys(primary_namespace, secondary_namespace); + assert_eq!(listed_keys.len(), 110); + assert_eq!(listed_keys[0], testkey); + + let read_data = kv_store.read(primary_namespace, secondary_namespace, testkey).unwrap(); + assert_eq!(data, &*read_data); + + // Ensure we have no issue operating with primary_namespace/secondary_namespace/key being KVSTORE_NAMESPACE_KEY_MAX_LEN + let max_chars: String = "A".repeat(KVSTORE_NAMESPACE_KEY_MAX_LEN); + kv_store.write(&max_chars, &max_chars, &max_chars, 0, &data).unwrap(); + + println!("{:?}", listed_keys); + + let listed_keys = list_all_keys(&max_chars, &max_chars); + assert_eq!(listed_keys.len(), 1); + assert_eq!(listed_keys[0], max_chars); + + let read_data = kv_store.read(&max_chars, &max_chars, &max_chars).unwrap(); + assert_eq!(data, &*read_data); + } +} diff --git a/ldk-server/ldk-server/src/io/utils.rs b/ldk-server/ldk-server/src/io/utils.rs new file mode 100644 index 000000000..d3571cc8e --- /dev/null +++ b/ldk-server/ldk-server/src/io/utils.rs @@ -0,0 +1,108 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_node::lightning::types::string::PrintableString; +use ldk_node::lightning::util::persist::{ + KVSTORE_NAMESPACE_KEY_ALPHABET, KVSTORE_NAMESPACE_KEY_MAX_LEN, +}; + +pub(crate) fn check_namespace_key_validity( + primary_namespace: &str, secondary_namespace: &str, key: Option<&str>, operation: &str, +) -> Result<(), std::io::Error> { + if let Some(key) = key { + if key.is_empty() { + debug_assert!( + false, + "Failed to {} {}/{}/{}: key may not be empty.", + operation, + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key) + ); + let msg = format!( + "Failed to {} {}/{}/{}: key may not be empty.", + operation, + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key) + ); + return Err(std::io::Error::other(msg)); + } + + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + debug_assert!(false, + "Failed to {} {}/{}/{}: primary namespace may not be empty if a non-empty secondary namespace is given.", + operation, + PrintableString(primary_namespace), PrintableString(secondary_namespace), PrintableString(key) + ); + let msg = format!( + "Failed to {} {}/{}/{}: primary namespace may not be empty if a non-empty secondary namespace is given.", operation, + PrintableString(primary_namespace), PrintableString(secondary_namespace), PrintableString(key) + ); + return Err(std::io::Error::other(msg)); + } + + if !is_valid_kvstore_str(primary_namespace) + || !is_valid_kvstore_str(secondary_namespace) + || !is_valid_kvstore_str(key) + { + debug_assert!( + false, + "Failed to {} {}/{}/{}: primary namespace, secondary namespace, and key must be valid.", + operation, + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key) + ); + let msg = format!( + "Failed to {} {}/{}/{}: primary namespace, secondary namespace, and key must be valid.", + operation, + PrintableString(primary_namespace), + PrintableString(secondary_namespace), + PrintableString(key) + ); + return Err(std::io::Error::other(msg)); + } + } else { + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + debug_assert!(false, + "Failed to {} {}/{}: primary namespace may not be empty if a non-empty secondary namespace is given.", + operation, PrintableString(primary_namespace), PrintableString(secondary_namespace) + ); + let msg = format!( + "Failed to {} {}/{}: primary namespace may not be empty if a non-empty secondary namespace is given.", + operation, PrintableString(primary_namespace), PrintableString(secondary_namespace) + ); + return Err(std::io::Error::other(msg)); + } + if !is_valid_kvstore_str(primary_namespace) || !is_valid_kvstore_str(secondary_namespace) { + debug_assert!( + false, + "Failed to {} {}/{}: primary namespace and secondary namespace must be valid.", + operation, + PrintableString(primary_namespace), + PrintableString(secondary_namespace) + ); + let msg = format!( + "Failed to {} {}/{}: primary namespace and secondary namespace must be valid.", + operation, + PrintableString(primary_namespace), + PrintableString(secondary_namespace) + ); + return Err(std::io::Error::other(msg)); + } + } + + Ok(()) +} + +pub(crate) fn is_valid_kvstore_str(key: &str) -> bool { + key.len() <= KVSTORE_NAMESPACE_KEY_MAX_LEN + && key.chars().all(|c| KVSTORE_NAMESPACE_KEY_ALPHABET.contains(c)) +} diff --git a/ldk-server/ldk-server/src/main.rs b/ldk-server/ldk-server/src/main.rs new file mode 100644 index 000000000..ba0731c9a --- /dev/null +++ b/ldk-server/ldk-server/src/main.rs @@ -0,0 +1,566 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +mod api; +mod io; +mod service; +mod util; + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use hex::DisplayHex; +use hyper::server::conn::http1; +use hyper_util::rt::TokioIo; +use ldk_node::bitcoin::Network; +use ldk_node::config::Config; +use ldk_node::entropy::NodeEntropy; +use ldk_node::lightning::ln::channelmanager::PaymentId; +use ldk_node::{Builder, Event, Node}; +use ldk_server_protos::events; +use ldk_server_protos::events::{event_envelope, EventEnvelope}; +use ldk_server_protos::types::Payment; +use log::{debug, error, info}; +use prost::Message; +use rand::Rng; +use tokio::net::TcpListener; +use tokio::select; +use tokio::signal::unix::SignalKind; + +use crate::io::events::event_publisher::EventPublisher; +use crate::io::events::get_event_name; +#[cfg(feature = "events-rabbitmq")] +use crate::io::events::rabbitmq::{RabbitMqConfig, RabbitMqEventPublisher}; +use crate::io::persist::paginated_kv_store::PaginatedKVStore; +use crate::io::persist::sqlite_store::SqliteStore; +use crate::io::persist::{ + FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::service::NodeService; +use crate::util::config::{load_config, ChainSource}; +use crate::util::logger::ServerLogger; +use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto}; +use crate::util::tls::get_or_generate_tls_config; + +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const API_KEY_FILE: &str = "api_key"; + +fn get_default_data_dir() -> Option { + #[cfg(target_os = "macos")] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join("Library/Application Support/ldk-server")) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(|appdata| PathBuf::from(appdata).join("ldk-server")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + #[allow(deprecated)] // todo can remove once we update MSRV to 1.87+ + std::env::home_dir().map(|home| home.join(".ldk-server")) + } +} + +fn get_default_config_path() -> Option { + get_default_data_dir().map(|data_dir| data_dir.join(DEFAULT_CONFIG_FILE)) +} + +const USAGE_GUIDE: &str = "Usage: ldk-server [config_path] + +If no config path is provided, ldk-server will look for a config file at: + Linux: ~/.ldk-server/config.toml + macOS: ~/Library/Application Support/ldk-server/config.toml + Windows: %APPDATA%\\ldk-server\\config.toml"; + +fn main() { + let args: Vec = std::env::args().collect(); + + let config_path: PathBuf = if args.len() < 2 { + match get_default_config_path() { + Some(path) => path, + None => { + eprintln!("Unable to determine home directory for default config path."); + eprintln!("{USAGE_GUIDE}"); + std::process::exit(-1); + }, + } + } else { + let arg = args[1].as_str(); + if arg == "-h" || arg == "--help" { + println!("{USAGE_GUIDE}"); + std::process::exit(0); + } + PathBuf::from(arg) + }; + + if fs::File::open(&config_path).is_err() { + eprintln!("Unable to access configuration file: {}", config_path.display()); + std::process::exit(-1); + } + + let mut ldk_node_config = Config::default(); + let config_file = match load_config(&config_path) { + Ok(config) => config, + Err(e) => { + eprintln!("Invalid configuration file: {}", e); + std::process::exit(-1); + }, + }; + + let storage_dir: PathBuf = match config_file.storage_dir_path { + None => { + let default = get_default_data_dir(); + match default { + Some(path) => { + info!("No storage_dir_path configured, defaulting to {}", path.display()); + path + }, + None => { + eprintln!("Unable to determine home directory for default storage path."); + std::process::exit(-1); + }, + } + }, + Some(configured_path) => PathBuf::from(configured_path), + }; + + let network_dir: PathBuf = match config_file.network { + Network::Bitcoin => storage_dir.join("bitcoin"), + Network::Testnet => storage_dir.join("testnet"), + Network::Testnet4 => storage_dir.join("testnet4"), + Network::Signet => storage_dir.join("signet"), + Network::Regtest => storage_dir.join("regtest"), + }; + + let log_file_path = config_file.log_file_path.map(PathBuf::from).unwrap_or_else(|| { + let mut default_log_path = network_dir.clone(); + default_log_path.push("ldk-server.log"); + default_log_path + }); + + if log_file_path == storage_dir || log_file_path == network_dir { + eprintln!("Log file path cannot be the same as storage directory path."); + std::process::exit(-1); + } + + let logger = match ServerLogger::init(config_file.log_level, &log_file_path) { + Ok(logger) => logger, + Err(e) => { + eprintln!("Failed to initialize logger: {e}"); + std::process::exit(-1); + }, + }; + + let api_key = match load_or_generate_api_key(&network_dir) { + Ok(key) => key, + Err(e) => { + eprintln!("Failed to load or generate API key: {e}"); + std::process::exit(-1); + }, + }; + + ldk_node_config.storage_dir_path = network_dir.to_str().unwrap().to_string(); + ldk_node_config.listening_addresses = config_file.listening_addrs; + ldk_node_config.announcement_addresses = config_file.announcement_addrs; + ldk_node_config.network = config_file.network; + + let mut builder = Builder::from_config(ldk_node_config); + builder.set_log_facade_logger(); + + if let Some(alias) = config_file.alias { + if let Err(e) = builder.set_node_alias(alias.to_string()) { + error!("Failed to set node alias: {e}"); + std::process::exit(-1); + } + } + + match config_file.chain_source { + ChainSource::Rpc { rpc_address, rpc_user, rpc_password } => { + builder.set_chain_source_bitcoind_rpc( + rpc_address.ip().to_string(), + rpc_address.port(), + rpc_user, + rpc_password, + ); + }, + ChainSource::Electrum { server_url } => { + builder.set_chain_source_electrum(server_url, None); + }, + ChainSource::Esplora { server_url } => { + builder.set_chain_source_esplora(server_url, None); + }, + } + + // LSPS2 support is highly experimental and for testing purposes only. + #[cfg(feature = "experimental-lsps2-support")] + builder.set_liquidity_provider_lsps2( + config_file.lsps2_service_config.expect("Missing liquidity.lsps2_server config"), + ); + + let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(runtime) => Arc::new(runtime), + Err(e) => { + error!("Failed to setup tokio runtime: {e}"); + std::process::exit(-1); + }, + }; + + builder.set_runtime(runtime.handle().clone()); + + let seed_path = storage_dir.join("keys_seed").to_str().unwrap().to_string(); + let node_entropy = match NodeEntropy::from_seed_path(seed_path) { + Ok(entropy) => entropy, + Err(e) => { + error!("Failed to load or generate seed: {e}"); + std::process::exit(-1); + }, + }; + + let node = match builder.build(node_entropy) { + Ok(node) => Arc::new(node), + Err(e) => { + error!("Failed to build LDK Node: {e}"); + std::process::exit(-1); + }, + }; + + let paginated_store: Arc = + Arc::new(match SqliteStore::new(network_dir.clone(), None, None) { + Ok(store) => store, + Err(e) => { + error!("Failed to create SqliteStore: {e:?}"); + std::process::exit(-1); + }, + }); + + #[cfg(not(feature = "events-rabbitmq"))] + let event_publisher: Arc = + Arc::new(crate::io::events::event_publisher::NoopEventPublisher); + + #[cfg(feature = "events-rabbitmq")] + let event_publisher: Arc = { + let rabbitmq_config = RabbitMqConfig { + connection_string: config_file.rabbitmq_connection_string, + exchange_name: config_file.rabbitmq_exchange_name, + }; + Arc::new(RabbitMqEventPublisher::new(rabbitmq_config)) + }; + + info!("Starting up..."); + match node.start() { + Ok(()) => {}, + Err(e) => { + error!("Failed to start up LDK Node: {e}"); + std::process::exit(-1); + }, + } + + info!( + "CONNECTION_STRING: {}@{}", + node.node_id(), + node.config().listening_addresses.as_ref().unwrap().first().unwrap() + ); + + runtime.block_on(async { + // Register SIGHUP handler for log rotation + let mut sighup_stream = match tokio::signal::unix::signal(SignalKind::hangup()) { + Ok(stream) => stream, + Err(e) => { + error!("Failed to register SIGHUP handler: {e}"); + std::process::exit(-1); + } + }; + + let mut sigterm_stream = match tokio::signal::unix::signal(SignalKind::terminate()) { + Ok(stream) => stream, + Err(e) => { + error!("Failed to register for SIGTERM stream: {e}"); + std::process::exit(-1); + } + }; + let event_node = Arc::clone(&node); + let rest_svc_listener = TcpListener::bind(config_file.rest_service_addr) + .await + .expect("Failed to bind listening port"); + + let server_config = match get_or_generate_tls_config( + config_file.tls_config, + storage_dir.to_str().unwrap(), + ) { + Ok(config) => config, + Err(e) => { + error!("Failed to set up TLS: {e}"); + std::process::exit(-1); + } + }; + let tls_acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + info!("TLS enabled for REST service on {}", config_file.rest_service_addr); + + loop { + select! { + event = event_node.next_event_async() => { + match event { + Event::ChannelPending { channel_id, counterparty_node_id, .. } => { + info!( + "CHANNEL_PENDING: {} from counterparty {}", + channel_id, counterparty_node_id + ); + if let Err(e) = event_node.event_handled() { + error!("Failed to mark event as handled: {e}"); + } + }, + Event::ChannelReady { channel_id, counterparty_node_id, .. } => { + info!( + "CHANNEL_READY: {} from counterparty {:?}", + channel_id, counterparty_node_id + ); + if let Err(e) = event_node.event_handled() { + error!("Failed to mark event as handled: {e}"); + } + }, + Event::PaymentReceived { payment_id, payment_hash, amount_msat, .. } => { + info!( + "PAYMENT_RECEIVED: with id {:?}, hash {}, amount_msat {}", + payment_id, payment_hash, amount_msat + ); + let payment_id = payment_id.expect("PaymentId expected for ldk-server >=0.1"); + + publish_event_and_upsert_payment(&payment_id, + |payment_ref| event_envelope::Event::PaymentReceived(events::PaymentReceived { + payment: Some(payment_ref.clone()), + }), + &event_node, + Arc::clone(&event_publisher), + Arc::clone(&paginated_store)).await; + }, + Event::PaymentSuccessful {payment_id, ..} => { + let payment_id = payment_id.expect("PaymentId expected for ldk-server >=0.1"); + + publish_event_and_upsert_payment(&payment_id, + |payment_ref| event_envelope::Event::PaymentSuccessful(events::PaymentSuccessful { + payment: Some(payment_ref.clone()), + }), + &event_node, + Arc::clone(&event_publisher), + Arc::clone(&paginated_store)).await; + }, + Event::PaymentFailed {payment_id, ..} => { + let payment_id = payment_id.expect("PaymentId expected for ldk-server >=0.1"); + + publish_event_and_upsert_payment(&payment_id, + |payment_ref| event_envelope::Event::PaymentFailed(events::PaymentFailed { + payment: Some(payment_ref.clone()), + }), + &event_node, + Arc::clone(&event_publisher), + Arc::clone(&paginated_store)).await; + }, + Event::PaymentClaimable {payment_id, ..} => { + if let Some(payment_details) = event_node.payment(&payment_id) { + let payment = payment_to_proto(payment_details); + upsert_payment_details(&event_node, Arc::clone(&paginated_store), &payment); + } else { + error!("Unable to find payment with paymentId: {payment_id}"); + } + }, + Event::PaymentForwarded { + prev_channel_id, + next_channel_id, + prev_user_channel_id, + next_user_channel_id, + prev_node_id, + next_node_id, + total_fee_earned_msat, + skimmed_fee_msat, + claim_from_onchain_tx, + outbound_amount_forwarded_msat + } => { + + info!("PAYMENT_FORWARDED: with outbound_amount_forwarded_msat {}, total_fee_earned_msat: {}, inbound channel: {}, outbound channel: {}", + outbound_amount_forwarded_msat.unwrap_or(0), total_fee_earned_msat.unwrap_or(0), prev_channel_id, next_channel_id + ); + + let forwarded_payment = forwarded_payment_to_proto( + prev_channel_id, + next_channel_id, + prev_user_channel_id, + next_user_channel_id, + prev_node_id, + next_node_id, + total_fee_earned_msat, + skimmed_fee_msat, + claim_from_onchain_tx, + outbound_amount_forwarded_msat + ); + + // We don't expose this payment-id to the user, it is a temporary measure to generate + // some unique identifiers until we have forwarded-payment-id available in ldk. + // Currently, this is the expected user handling behaviour for forwarded payments. + let mut forwarded_payment_id = [0u8;32]; + rand::thread_rng().fill(&mut forwarded_payment_id); + + let forwarded_payment_creation_time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time must be > 1970").as_secs() as i64; + + match event_publisher.publish(EventEnvelope { + event: Some(event_envelope::Event::PaymentForwarded(events::PaymentForwarded { + forwarded_payment: Some(forwarded_payment.clone()), + })), + }).await { + Ok(_) => {}, + Err(e) => { + error!("Failed to publish 'PaymentForwarded' event: {}", e); + continue; + } + }; + + match paginated_store.write(FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE,FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, + &forwarded_payment_id.to_lower_hex_string(), + forwarded_payment_creation_time, + &forwarded_payment.encode_to_vec(), + ) { + Ok(_) => { + if let Err(e) = event_node.event_handled() { + error!("Failed to mark event as handled: {e}"); + } + } + Err(e) => { + error!("Failed to write forwarded payment to persistence: {}", e); + } + } + }, + _ => { + if let Err(e) = event_node.event_handled() { + error!("Failed to mark event as handled: {e}"); + } + }, + } + }, + res = rest_svc_listener.accept() => { + match res { + Ok((stream, _)) => { + let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone()); + let acceptor = tls_acceptor.clone(); + runtime.spawn(async move { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + let io_stream = TokioIo::new(tls_stream); + if let Err(err) = http1::Builder::new().serve_connection(io_stream, node_service).await { + error!("Failed to serve TLS connection: {err}"); + } + }, + Err(e) => error!("TLS handshake failed: {e}"), + } + }); + }, + Err(e) => error!("Failed to accept connection: {}", e), + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received CTRL-C, shutting down.."); + break; + } + _ = sighup_stream.recv() => { + if let Err(e) = logger.reopen() { + error!("Failed to reopen log file on SIGHUP: {e}"); + } + } + _ = sigterm_stream.recv() => { + info!("Received SIGTERM, shutting down.."); + break; + } + } + } + }); + + node.stop().expect("Shutdown should always succeed."); + info!("Shutdown complete.."); +} + +async fn publish_event_and_upsert_payment( + payment_id: &PaymentId, payment_to_event: fn(&Payment) -> event_envelope::Event, + event_node: &Node, event_publisher: Arc, + paginated_store: Arc, +) { + if let Some(payment_details) = event_node.payment(payment_id) { + let payment = payment_to_proto(payment_details); + + let event = payment_to_event(&payment); + let event_name = get_event_name(&event); + match event_publisher.publish(EventEnvelope { event: Some(event) }).await { + Ok(_) => {}, + Err(e) => { + error!("Failed to publish '{event_name}' event, : {e}"); + return; + }, + }; + + upsert_payment_details(event_node, Arc::clone(&paginated_store), &payment); + } else { + error!("Unable to find payment with paymentId: {payment_id}"); + } +} + +fn upsert_payment_details( + event_node: &Node, paginated_store: Arc, payment: &Payment, +) { + let time = + SystemTime::now().duration_since(UNIX_EPOCH).expect("Time must be > 1970").as_secs() as i64; + + match paginated_store.write( + PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, + &payment.id, + time, + &payment.encode_to_vec(), + ) { + Ok(_) => { + if let Err(e) = event_node.event_handled() { + error!("Failed to mark event as handled: {e}"); + } + }, + Err(e) => { + error!("Failed to write payment to persistence: {e}"); + }, + } +} + +/// Loads the API key from a file, or generates a new one if it doesn't exist. +/// The API key file is stored with 0400 permissions (read-only for owner). +fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result { + let api_key_path = storage_dir.join(API_KEY_FILE); + + if api_key_path.exists() { + let key_bytes = fs::read(&api_key_path)?; + Ok(key_bytes.to_lower_hex_string()) + } else { + // Ensure the storage directory exists + fs::create_dir_all(storage_dir)?; + + // Generate a 32-byte random API key + let mut rng = rand::thread_rng(); + let mut key_bytes = [0u8; 32]; + rng.fill(&mut key_bytes); + + // Write the raw bytes to the file + fs::write(&api_key_path, key_bytes)?; + + // Set permissions to 0400 (read-only for owner) + let permissions = fs::Permissions::from_mode(0o400); + fs::set_permissions(&api_key_path, permissions)?; + + debug!("Generated new API key at {}", api_key_path.display()); + Ok(key_bytes.to_lower_hex_string()) + } +} diff --git a/ldk-server/ldk-server/src/service.rs b/ldk-server/ldk-server/src/service.rs new file mode 100644 index 000000000..0f0509450 --- /dev/null +++ b/ldk-server/ldk-server/src/service.rs @@ -0,0 +1,487 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::{Bytes, Incoming}; +use hyper::service::Service; +use hyper::{Request, Response, StatusCode}; +use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; +use ldk_node::bitcoin::hashes::{sha256, Hash, HashEngine}; +use ldk_node::Node; +use ldk_server_protos::endpoints::{ + BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, + CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, + GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, + LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SPLICE_IN_PATH, + SPLICE_OUT_PATH, UPDATE_CHANNEL_CONFIG_PATH, +}; +use prost::Message; + +use crate::api::bolt11_receive::handle_bolt11_receive_request; +use crate::api::bolt11_send::handle_bolt11_send_request; +use crate::api::bolt12_receive::handle_bolt12_receive_request; +use crate::api::bolt12_send::handle_bolt12_send_request; +use crate::api::close_channel::{handle_close_channel_request, handle_force_close_channel_request}; +use crate::api::connect_peer::handle_connect_peer; +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::{AuthError, InvalidRequestError}; +use crate::api::get_balances::handle_get_balances_request; +use crate::api::get_node_info::handle_get_node_info_request; +use crate::api::get_payment_details::handle_get_payment_details_request; +use crate::api::list_channels::handle_list_channels_request; +use crate::api::list_forwarded_payments::handle_list_forwarded_payments_request; +use crate::api::list_payments::handle_list_payments_request; +use crate::api::onchain_receive::handle_onchain_receive_request; +use crate::api::onchain_send::handle_onchain_send_request; +use crate::api::open_channel::handle_open_channel; +use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_request}; +use crate::api::update_channel_config::handle_update_channel_config_request; +use crate::io::persist::paginated_kv_store::PaginatedKVStore; +use crate::util::proto_adapter::to_error_response; + +// Maximum request body size: 10 MB +// This prevents memory exhaustion from large requests +const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; + +#[derive(Clone)] +pub struct NodeService { + node: Arc, + paginated_kv_store: Arc, + api_key: String, +} + +impl NodeService { + pub(crate) fn new( + node: Arc, paginated_kv_store: Arc, api_key: String, + ) -> Self { + Self { node, paginated_kv_store, api_key } + } +} + +// Maximum allowed time difference between client timestamp and server time (1 minute) +const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; + +#[derive(Debug, Clone)] +pub(crate) struct AuthParams { + timestamp: u64, + hmac_hex: String, +} + +/// Extracts authentication parameters from request headers. +/// Returns (timestamp, hmac_hex) if valid format, or error. +fn extract_auth_params(req: &Request) -> Result { + let auth_header = req + .headers() + .get("X-Auth") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| LdkServerError::new(AuthError, "Missing X-Auth header"))?; + + // Format: "HMAC :" + let auth_data = auth_header + .strip_prefix("HMAC ") + .ok_or_else(|| LdkServerError::new(AuthError, "Invalid X-Auth header format"))?; + + let (timestamp_str, hmac_hex) = auth_data + .split_once(':') + .ok_or_else(|| LdkServerError::new(AuthError, "Invalid X-Auth header format"))?; + + let timestamp = timestamp_str + .parse::() + .map_err(|_| LdkServerError::new(AuthError, "Invalid timestamp in X-Auth header"))?; + + // validate hmac_hex is valid hex + if hmac_hex.len() != 64 || !hmac_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(LdkServerError::new(AuthError, "Invalid HMAC in X-Auth header")); + } + + Ok(AuthParams { timestamp, hmac_hex: hmac_hex.to_string() }) +} + +/// Validates the HMAC authentication after the request body has been read. +fn validate_hmac_auth( + timestamp: u64, provided_hmac_hex: &str, body: &[u8], api_key: &str, +) -> Result<(), LdkServerError> { + // Validate timestamp is within acceptable window + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| LdkServerError::new(AuthError, "System time error"))? + .as_secs(); + + let time_diff = now.abs_diff(timestamp); + if time_diff > AUTH_TIMESTAMP_TOLERANCE_SECS { + return Err(LdkServerError::new(AuthError, "Request timestamp expired")); + } + + // Compute expected HMAC: HMAC-SHA256(api_key, timestamp_bytes || body) + let mut hmac_engine: HmacEngine = HmacEngine::new(api_key.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let expected_hmac = Hmac::::from_engine(hmac_engine); + + // Compare HMACs (constant-time comparison via Hash equality) + let expected_hex = expected_hmac.to_string(); + if expected_hex != provided_hmac_hex { + return Err(LdkServerError::new(AuthError, "Invalid credentials")); + } + + Ok(()) +} + +pub(crate) struct Context { + pub(crate) node: Arc, + pub(crate) paginated_kv_store: Arc, +} + +impl Service> for NodeService { + type Response = Response>; + type Error = hyper::Error; + type Future = Pin> + Send>>; + + fn call(&self, req: Request) -> Self::Future { + // Extract auth params from headers (validation happens after body is read) + let auth_params = match extract_auth_params(&req) { + Ok(params) => params, + Err(e) => { + let (error_response, status_code) = to_error_response(e); + return Box::pin(async move { + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()) + }); + }, + }; + + let context = Context { + node: Arc::clone(&self.node), + paginated_kv_store: Arc::clone(&self.paginated_kv_store), + }; + let api_key = self.api_key.clone(); + + // Exclude '/' from path pattern matching. + match &req.uri().path()[1..] { + GET_NODE_INFO_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_get_node_info_request, + )), + GET_BALANCES_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_get_balances_request, + )), + ONCHAIN_RECEIVE_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_onchain_receive_request, + )), + ONCHAIN_SEND_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_onchain_send_request, + )), + BOLT11_RECEIVE_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt11_receive_request, + )), + BOLT11_SEND_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt11_send_request, + )), + BOLT12_RECEIVE_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt12_receive_request, + )), + BOLT12_SEND_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt12_send_request, + )), + OPEN_CHANNEL_PATH => { + Box::pin(handle_request(context, req, auth_params, api_key, handle_open_channel)) + }, + SPLICE_IN_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_splice_in_request, + )), + SPLICE_OUT_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_splice_out_request, + )), + CLOSE_CHANNEL_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_close_channel_request, + )), + FORCE_CLOSE_CHANNEL_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_force_close_channel_request, + )), + LIST_CHANNELS_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_list_channels_request, + )), + UPDATE_CHANNEL_CONFIG_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_update_channel_config_request, + )), + GET_PAYMENT_DETAILS_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_get_payment_details_request, + )), + LIST_PAYMENTS_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_list_payments_request, + )), + LIST_FORWARDED_PAYMENTS_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_list_forwarded_payments_request, + )), + CONNECT_PEER_PATH => { + Box::pin(handle_request(context, req, auth_params, api_key, handle_connect_peer)) + }, + path => { + let error = format!("Unknown request: {}", path).into_bytes(); + Box::pin(async { + Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from(error))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()) + }) + }, + } + } +} + +async fn handle_request< + T: Message + Default, + R: Message, + F: Fn(Context, T) -> Result, +>( + context: Context, request: Request, auth_params: AuthParams, api_key: String, + handler: F, +) -> Result<>>::Response, hyper::Error> { + // Limit the size of the request body to prevent abuse + let limited_body = Limited::new(request.into_body(), MAX_BODY_SIZE); + let bytes = match limited_body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + let (error_response, status_code) = to_error_response(LdkServerError::new( + InvalidRequestError, + "Request body too large or failed to read.", + )); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()); + }, + }; + + // Validate HMAC authentication with the request body + if let Err(e) = + validate_hmac_auth(auth_params.timestamp, &auth_params.hmac_hex, &bytes, &api_key) + { + let (error_response, status_code) = to_error_response(e); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()); + } + + match T::decode(bytes) { + Ok(request) => match handler(context, request) { + Ok(response) => Ok(Response::builder() + .body(Full::new(Bytes::from(response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()), + Err(e) => { + let (error_response, status_code) = to_error_response(e); + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()) + }, + }, + Err(_) => { + let (error_response, status_code) = + to_error_response(LdkServerError::new(InvalidRequestError, "Malformed request.")); + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()) + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compute_hmac(api_key: &str, timestamp: u64, body: &[u8]) -> String { + let mut hmac_engine: HmacEngine = HmacEngine::new(api_key.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + Hmac::::from_engine(hmac_engine).to_string() + } + + fn create_test_request(auth_header: Option) -> Request<()> { + let mut builder = Request::builder(); + if let Some(header) = auth_header { + builder = builder.header("X-Auth", header); + } + builder.body(()).unwrap() + } + + #[test] + fn test_extract_auth_params_success() { + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let hmac = "8f5a33c2c68fb253899a588308fd13dcaf162d2788966a1fb6cc3aa2e0c51a93"; + let auth_header = format!("HMAC {timestamp}:{hmac}"); + + let req = create_test_request(Some(auth_header)); + + let result = extract_auth_params(&req); + assert!(result.is_ok()); + let AuthParams { timestamp: ts, hmac_hex } = result.unwrap(); + assert_eq!(ts, timestamp); + assert_eq!(hmac_hex, hmac); + } + + #[test] + fn test_extract_auth_params_missing_header() { + let req = create_test_request(None); + + let result = extract_auth_params(&req); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_extract_auth_params_invalid_format() { + // Missing "HMAC " prefix + let req = create_test_request(Some("12345:deadbeef".to_string())); + + let result = extract_auth_params(&req); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_hmac_auth_success() { + let api_key = "test_api_key".to_string(); + let body = b"test request body"; + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let hmac = compute_hmac(&api_key, timestamp, body); + + let result = validate_hmac_auth(timestamp, &hmac, body, &api_key); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_hmac_auth_wrong_key() { + let api_key = "test_api_key".to_string(); + let body = b"test request body"; + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + // Compute HMAC with wrong key + let hmac = compute_hmac("wrong_key", timestamp, body); + + let result = validate_hmac_auth(timestamp, &hmac, body, &api_key); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_hmac_auth_expired_timestamp() { + let api_key = "test_api_key".to_string(); + let body = b"test request body"; + // Use a timestamp from 10 minutes ago + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + - 600; + let hmac = compute_hmac(&api_key, timestamp, body); + + let result = validate_hmac_auth(timestamp, &hmac, body, &api_key); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_hmac_auth_tampered_body() { + let api_key = "test_api_key".to_string(); + let original_body = b"test request body"; + let tampered_body = b"tampered body"; + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + // Compute HMAC with original body + let hmac = compute_hmac(&api_key, timestamp, original_body); + + // Try to validate with tampered body + let result = validate_hmac_auth(timestamp, &hmac, tampered_body, &api_key); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } +} diff --git a/ldk-server/ldk-server/src/util/config.rs b/ldk-server/ldk-server/src/util/config.rs new file mode 100644 index 000000000..3824a82d5 --- /dev/null +++ b/ldk-server/ldk-server/src/util/config.rs @@ -0,0 +1,591 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::net::SocketAddr; +use std::path::Path; +use std::str::FromStr; +use std::{fs, io}; + +use ldk_node::bitcoin::Network; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::lightning::routing::gossip::NodeAlias; +use ldk_node::liquidity::LSPS2ServiceConfig; +use log::LevelFilter; +use serde::{Deserialize, Serialize}; + +/// Configuration for LDK Server. +#[derive(Debug)] +pub struct Config { + pub listening_addrs: Option>, + pub announcement_addrs: Option>, + pub alias: Option, + pub network: Network, + pub tls_config: Option, + pub rest_service_addr: SocketAddr, + pub storage_dir_path: Option, + pub chain_source: ChainSource, + pub rabbitmq_connection_string: String, + pub rabbitmq_exchange_name: String, + pub lsps2_service_config: Option, + pub log_level: LevelFilter, + pub log_file_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsConfig { + pub cert_path: Option, + pub key_path: Option, + pub hosts: Vec, +} + +#[derive(Debug)] +pub enum ChainSource { + Rpc { rpc_address: SocketAddr, rpc_user: String, rpc_password: String }, + Electrum { server_url: String }, + Esplora { server_url: String }, +} + +impl TryFrom for Config { + type Error = io::Error; + + fn try_from(toml_config: TomlConfig) -> io::Result { + let listening_addrs = toml_config + .node + .listening_addresses + .map(|addresses| { + addresses + .into_iter() + .map(|addr| { + SocketAddress::from_str(&addr).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid listening address configured: {}", e), + ) + }) + }) + .collect() + }) + .transpose()?; + let announcement_addrs = toml_config + .node + .announcement_addresses + .map(|addresses| { + addresses + .into_iter() + .map(|addr| { + SocketAddress::from_str(&addr).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid announcement address configured: {}", e), + ) + }) + }) + .collect() + }) + .transpose()?; + let rest_service_addr = SocketAddr::from_str(&toml_config.node.rest_service_address) + .map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid rest service address configured: {}", e), + ) + })?; + let chain_source = match (toml_config.esplora, toml_config.electrum, toml_config.bitcoind) { + (Some(EsploraConfig { server_url }), None, None) => ChainSource::Esplora { server_url }, + (None, Some(ElectrumConfig { server_url }), None) => { + ChainSource::Electrum { server_url } + }, + (None, None, Some(BitcoindConfig { rpc_address, rpc_user, rpc_password })) => { + let rpc_address = SocketAddr::from_str(&rpc_address).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid bitcoind RPC address configured: {}", e), + ) + })?; + ChainSource::Rpc { rpc_address, rpc_user, rpc_password } + }, + (None, None, None) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "At least one chain source must be set, either esplora, electrum, or bitcoind" + .to_string(), + )) + }, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Must set a single chain source, multiple were configured".to_string(), + )) + }, + }; + + let alias = if let Some(alias_str) = toml_config.node.alias { + let mut bytes = [0u8; 32]; + let alias_bytes = alias_str.trim().as_bytes(); + if alias_bytes.len() > 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "node.alias must be at most 32 bytes long.".to_string(), + )); + } + bytes[..alias_bytes.len()].copy_from_slice(alias_bytes); + Some(NodeAlias(bytes)) + } else { + None + }; + + let log_level = toml_config + .log + .as_ref() + .and_then(|log_config| log_config.level.as_ref()) + .map(|level_str| { + LevelFilter::from_str(level_str).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid log level configured: {}", e), + ) + }) + }) + .transpose()? + .unwrap_or(LevelFilter::Debug); + + let (rabbitmq_connection_string, rabbitmq_exchange_name) = { + let rabbitmq = toml_config.rabbitmq.unwrap_or(RabbitmqConfig { + connection_string: String::new(), + exchange_name: String::new(), + }); + #[cfg(feature = "events-rabbitmq")] + if rabbitmq.connection_string.is_empty() || rabbitmq.exchange_name.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Both `rabbitmq.connection_string` and `rabbitmq.exchange_name` must be configured if enabling `events-rabbitmq` feature.".to_string(), + )); + } + (rabbitmq.connection_string, rabbitmq.exchange_name) + }; + + #[cfg(not(feature = "experimental-lsps2-support"))] + let lsps2_service_config: Option = None; + #[cfg(feature = "experimental-lsps2-support")] + let lsps2_service_config = Some(toml_config.liquidity + .and_then(|l| l.lsps2_service) + .ok_or_else(|| io::Error::new( + io::ErrorKind::InvalidInput, + "`liquidity.lsps2_service` must be defined in config if enabling `experimental-lsps2-support` feature." + ))? + .into()); + + let tls_config = toml_config.tls.map(|tls| TlsConfig { + cert_path: tls.cert_path, + key_path: tls.key_path, + hosts: tls.hosts.unwrap_or_default(), + }); + + Ok(Config { + listening_addrs, + announcement_addrs, + network: toml_config.node.network, + alias, + rest_service_addr, + storage_dir_path: toml_config.storage.and_then(|s| s.disk.and_then(|d| d.dir_path)), + chain_source, + rabbitmq_connection_string, + rabbitmq_exchange_name, + lsps2_service_config, + log_level, + log_file_path: toml_config.log.and_then(|l| l.file), + tls_config, + }) + } +} + +/// Configuration loaded from a TOML file. +#[derive(Deserialize, Serialize)] +pub struct TomlConfig { + node: NodeConfig, + storage: Option, + bitcoind: Option, + electrum: Option, + esplora: Option, + rabbitmq: Option, + liquidity: Option, + log: Option, + tls: Option, +} + +#[derive(Deserialize, Serialize)] +struct NodeConfig { + network: Network, + listening_addresses: Option>, + announcement_addresses: Option>, + rest_service_address: String, + alias: Option, +} + +#[derive(Deserialize, Serialize)] +struct StorageConfig { + disk: Option, +} + +#[derive(Deserialize, Serialize)] +struct DiskConfig { + dir_path: Option, +} + +#[derive(Deserialize, Serialize)] +struct BitcoindConfig { + rpc_address: String, + rpc_user: String, + rpc_password: String, +} + +#[derive(Deserialize, Serialize)] +struct ElectrumConfig { + server_url: String, +} + +#[derive(Deserialize, Serialize)] +struct EsploraConfig { + server_url: String, +} + +#[derive(Deserialize, Serialize)] +struct LogConfig { + level: Option, + file: Option, +} + +#[derive(Deserialize, Serialize)] +struct RabbitmqConfig { + connection_string: String, + exchange_name: String, +} + +#[derive(Deserialize, Serialize)] +struct TomlTlsConfig { + cert_path: Option, + key_path: Option, + hosts: Option>, +} + +#[derive(Deserialize, Serialize)] +struct LiquidityConfig { + lsps2_service: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +struct LSPS2ServiceTomlConfig { + advertise_service: bool, + channel_opening_fee_ppm: u32, + channel_over_provisioning_ppm: u32, + min_channel_opening_fee_msat: u64, + min_channel_lifetime: u32, + max_client_to_self_delay: u32, + min_payment_size_msat: u64, + max_payment_size_msat: u64, + client_trusts_lsp: bool, + require_token: Option, +} + +impl From for LSPS2ServiceConfig { + fn from(val: LSPS2ServiceTomlConfig) -> Self { + let LSPS2ServiceTomlConfig { + advertise_service, + channel_opening_fee_ppm, + channel_over_provisioning_ppm, + min_channel_opening_fee_msat, + min_channel_lifetime, + max_client_to_self_delay, + min_payment_size_msat, + max_payment_size_msat, + client_trusts_lsp, + require_token, + } = val; + + Self { + advertise_service, + channel_opening_fee_ppm, + channel_over_provisioning_ppm, + min_channel_opening_fee_msat, + min_channel_lifetime, + min_payment_size_msat, + max_client_to_self_delay, + max_payment_size_msat, + client_trusts_lsp, + require_token, + } + } +} + +/// Loads the configuration from a TOML file at the given path. +pub fn load_config>(config_path: P) -> io::Result { + let file_contents = fs::read_to_string(config_path.as_ref()).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to read config file '{}': {}", config_path.as_ref().display(), e), + ) + })?; + + let toml_config: TomlConfig = toml::from_str(&file_contents).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Config file contains invalid TOML format: {}", e), + ) + })?; + Config::try_from(toml_config) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use ldk_node::bitcoin::Network; + use ldk_node::lightning::ln::msgs::SocketAddress; + + use super::*; + + #[test] + fn test_read_toml_config_from_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "config.toml"; + + let toml_config = r#" + [node] + network = "regtest" + listening_addresses = ["localhost:3001"] + announcement_addresses = ["54.3.7.81:3001"] + rest_service_address = "127.0.0.1:3002" + alias = "LDK Server" + + [tls] + cert_path = "/path/to/tls.crt" + key_path = "/path/to/tls.key" + hosts = ["example.com", "ldk-server.local"] + + [storage.disk] + dir_path = "/tmp" + + [log] + level = "Trace" + file = "/var/log/ldk-server.log" + + [esplora] + server_url = "https://mempool.space/api" + + [rabbitmq] + connection_string = "rabbitmq_connection_string" + exchange_name = "rabbitmq_exchange_name" + + [liquidity.lsps2_service] + advertise_service = false + channel_opening_fee_ppm = 1000 # 0.1% fee + channel_over_provisioning_ppm = 500000 # 50% extra capacity + min_channel_opening_fee_msat = 10000000 # 10,000 satoshis + min_channel_lifetime = 4320 # ~30 days + max_client_to_self_delay = 1440 # ~10 days + min_payment_size_msat = 10000000 # 10,000 satoshis + max_payment_size_msat = 25000000000 # 0.25 BTC + client_trusts_lsp = true + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + + let mut bytes = [0u8; 32]; + let alias = "LDK Server"; + bytes[..alias.len()].copy_from_slice(alias.as_bytes()); + + let config = load_config(storage_path.join(config_file_name)).unwrap(); + let expected = Config { + listening_addrs: Some(vec![SocketAddress::from_str("localhost:3001").unwrap()]), + announcement_addrs: Some(vec![SocketAddress::from_str("54.3.7.81:3001").unwrap()]), + alias: Some(NodeAlias(bytes)), + network: Network::Regtest, + rest_service_addr: SocketAddr::from_str("127.0.0.1:3002").unwrap(), + storage_dir_path: Some("/tmp".to_string()), + tls_config: Some(TlsConfig { + cert_path: Some("/path/to/tls.crt".to_string()), + key_path: Some("/path/to/tls.key".to_string()), + hosts: vec!["example.com".to_string(), "ldk-server.local".to_string()], + }), + chain_source: ChainSource::Esplora { + server_url: String::from("https://mempool.space/api"), + }, + rabbitmq_connection_string: "rabbitmq_connection_string".to_string(), + rabbitmq_exchange_name: "rabbitmq_exchange_name".to_string(), + lsps2_service_config: Some(LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 1000, + channel_over_provisioning_ppm: 500000, + min_channel_opening_fee_msat: 10000000, + min_channel_lifetime: 4320, + max_client_to_self_delay: 1440, + min_payment_size_msat: 10000000, + max_payment_size_msat: 25000000000, + client_trusts_lsp: true, + }), + log_level: LevelFilter::Trace, + log_file_path: Some("/var/log/ldk-server.log".to_string()), + }; + + assert_eq!(config.listening_addrs, expected.listening_addrs); + assert_eq!(config.announcement_addrs, expected.announcement_addrs); + assert_eq!(config.alias, expected.alias); + assert_eq!(config.network, expected.network); + assert_eq!(config.rest_service_addr, expected.rest_service_addr); + assert_eq!(config.storage_dir_path, expected.storage_dir_path); + assert_eq!(config.tls_config, expected.tls_config); + let ChainSource::Esplora { server_url } = config.chain_source else { + panic!("unexpected config chain source"); + }; + let ChainSource::Esplora { server_url: expected_server_url } = expected.chain_source else { + panic!("unexpected chain source"); + }; + assert_eq!(server_url, expected_server_url); + assert_eq!(config.rabbitmq_connection_string, expected.rabbitmq_connection_string); + assert_eq!(config.rabbitmq_exchange_name, expected.rabbitmq_exchange_name); + #[cfg(feature = "experimental-lsps2-support")] + assert_eq!(config.lsps2_service_config.is_some(), expected.lsps2_service_config.is_some()); + assert_eq!(config.log_level, expected.log_level); + assert_eq!(config.log_file_path, expected.log_file_path); + + // Test case where only electrum is set + + let toml_config = r#" + [node] + network = "regtest" + rest_service_address = "127.0.0.1:3002" + alias = "LDK Server" + + [storage.disk] + dir_path = "/tmp" + + [log] + level = "Trace" + file = "/var/log/ldk-server.log" + + [electrum] + server_url = "ssl://electrum.blockstream.info:50002" + + [rabbitmq] + connection_string = "rabbitmq_connection_string" + exchange_name = "rabbitmq_exchange_name" + + [liquidity.lsps2_service] + advertise_service = false + channel_opening_fee_ppm = 1000 # 0.1% fee + channel_over_provisioning_ppm = 500000 # 50% extra capacity + min_channel_opening_fee_msat = 10000000 # 10,000 satoshis + min_channel_lifetime = 4320 # ~30 days + max_client_to_self_delay = 1440 # ~10 days + min_payment_size_msat = 10000000 # 10,000 satoshis + max_payment_size_msat = 25000000000 # 0.25 BTC + client_trusts_lsp = true + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + let config = load_config(storage_path.join(config_file_name)).unwrap(); + + let ChainSource::Electrum { server_url } = config.chain_source else { + panic!("unexpected chain source"); + }; + + assert_eq!(server_url, "ssl://electrum.blockstream.info:50002"); + + // Test case where only bitcoind is set + + let toml_config = r#" + [node] + network = "regtest" + rest_service_address = "127.0.0.1:3002" + alias = "LDK Server" + + [storage.disk] + dir_path = "/tmp" + + [log] + level = "Trace" + file = "/var/log/ldk-server.log" + + [bitcoind] + rpc_address = "127.0.0.1:8332" # RPC endpoint + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + + [rabbitmq] + connection_string = "rabbitmq_connection_string" + exchange_name = "rabbitmq_exchange_name" + + [liquidity.lsps2_service] + advertise_service = false + channel_opening_fee_ppm = 1000 # 0.1% fee + channel_over_provisioning_ppm = 500000 # 50% extra capacity + min_channel_opening_fee_msat = 10000000 # 10,000 satoshis + min_channel_lifetime = 4320 # ~30 days + max_client_to_self_delay = 1440 # ~10 days + min_payment_size_msat = 10000000 # 10,000 satoshis + max_payment_size_msat = 25000000000 # 0.25 BTC + client_trusts_lsp = true + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + let config = load_config(storage_path.join(config_file_name)).unwrap(); + + let ChainSource::Rpc { rpc_address, rpc_user, rpc_password } = config.chain_source else { + panic!("unexpected chain source"); + }; + + assert_eq!(rpc_address, SocketAddr::from_str("127.0.0.1:8332").unwrap()); + assert_eq!(rpc_user, "bitcoind-testuser"); + assert_eq!(rpc_password, "bitcoind-testpassword"); + + // Test case where both bitcoind and esplora are set, resulting in an error + + let toml_config = r#" + [node] + network = "regtest" + rest_service_address = "127.0.0.1:3002" + alias = "LDK Server" + + [storage.disk] + dir_path = "/tmp" + + [log] + level = "Trace" + file = "/var/log/ldk-server.log" + + [bitcoind] + rpc_address = "127.0.0.1:8332" # RPC endpoint + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + + [esplora] + server_url = "https://mempool.space/api" + + [rabbitmq] + connection_string = "rabbitmq_connection_string" + exchange_name = "rabbitmq_exchange_name" + + [liquidity.lsps2_service] + advertise_service = false + channel_opening_fee_ppm = 1000 # 0.1% fee + channel_over_provisioning_ppm = 500000 # 50% extra capacity + min_channel_opening_fee_msat = 10000000 # 10,000 satoshis + min_channel_lifetime = 4320 # ~30 days + max_client_to_self_delay = 1440 # ~10 days + min_payment_size_msat = 10000000 # 10,000 satoshis + max_payment_size_msat = 25000000000 # 0.25 BTC + client_trusts_lsp = true + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + let error = load_config(storage_path.join(config_file_name)).unwrap_err(); + assert_eq!(error.to_string(), "Must set a single chain source, multiple were configured"); + } +} diff --git a/ldk-server/ldk-server/src/util/logger.rs b/ldk-server/ldk-server/src/util/logger.rs new file mode 100644 index 000000000..5e27a98da --- /dev/null +++ b/ldk-server/ldk-server/src/util/logger.rs @@ -0,0 +1,176 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +/// A logger implementation that writes logs to both stderr and a file. +/// +/// The logger formats log messages with RFC3339 timestamps and writes them to: +/// - stdout/stderr for console output +/// - A file specified during initialization +/// +/// All log messages follow the format: +/// `[TIMESTAMP LEVEL TARGET FILE:LINE] MESSAGE` +/// +/// Example: `[2025-12-04T10:30:45Z INFO ldk_server:42] Starting up...` +/// +/// The logger handles SIGHUP for log rotation by reopening the file handle when signaled. +pub struct ServerLogger { + /// The maximum log level to display + level: LevelFilter, + /// The file to write logs to, protected by a mutex for thread-safe access + file: Mutex, + /// Path to the log file for reopening on SIGHUP + log_file_path: PathBuf, +} + +impl ServerLogger { + /// Initializes the global logger with the specified level and file path. + /// + /// Opens or creates the log file at the given path. If the file exists, logs are appended. + /// If the file doesn't exist, it will be created along with any necessary parent directories. + /// + /// This should be called once at application startup. Subsequent calls will fail. + /// + /// Returns an Arc to the logger for signal handling purposes. + pub fn init(level: LevelFilter, log_file_path: &Path) -> Result, io::Error> { + // Create parent directories if they don't exist + if let Some(parent) = log_file_path.parent() { + fs::create_dir_all(parent)?; + } + + let file = open_log_file(log_file_path)?; + + let logger = Arc::new(ServerLogger { + level, + file: Mutex::new(file), + log_file_path: log_file_path.to_path_buf(), + }); + + log::set_boxed_logger(Box::new(LoggerWrapper(Arc::clone(&logger)))) + .map_err(io::Error::other)?; + log::set_max_level(level); + Ok(logger) + } + + /// Reopens the log file. Called on SIGHUP for log rotation. + pub fn reopen(&self) -> Result<(), io::Error> { + let new_file = open_log_file(&self.log_file_path)?; + match self.file.lock() { + Ok(mut file) => { + // Flush the old buffer before replacing with the new file + file.flush()?; + *file = new_file; + Ok(()) + }, + Err(e) => Err(io::Error::other(format!("Failed to acquire lock: {e}"))), + } + } +} + +impl Log for ServerLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let level_str = format_level(record.level()); + let line = record.line().unwrap_or(0); + + // Log to console + let _ = match record.level() { + Level::Error => { + writeln!( + io::stderr(), + "[{} {} {}:{}] {}", + format_timestamp(), + level_str, + record.target(), + line, + record.args() + ) + }, + _ => { + writeln!( + io::stdout(), + "[{} {} {}:{}] {}", + format_timestamp(), + level_str, + record.target(), + line, + record.args() + ) + }, + }; + + // Log to file + if let Ok(mut file) = self.file.lock() { + let _ = writeln!( + file, + "[{} {} {}:{}] {}", + format_timestamp(), + level_str, + record.target(), + line, + record.args() + ); + } + } + } + + fn flush(&self) { + let _ = io::stdout().flush(); + let _ = io::stderr().flush(); + if let Ok(mut file) = self.file.lock() { + let _ = file.flush(); + } + } +} + +fn format_timestamp() -> String { + let now = chrono::Utc::now(); + now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +fn format_level(level: Level) -> &'static str { + match level { + Level::Error => "ERROR", + Level::Warn => "WARN ", + Level::Info => "INFO ", + Level::Debug => "DEBUG", + Level::Trace => "TRACE", + } +} + +fn open_log_file(log_file_path: &Path) -> Result { + OpenOptions::new().create(true).append(true).open(log_file_path) +} + +/// Wrapper to allow Arc to implement Log trait +struct LoggerWrapper(Arc); + +impl Log for LoggerWrapper { + fn enabled(&self, metadata: &Metadata) -> bool { + self.0.enabled(metadata) + } + + fn log(&self, record: &Record) { + self.0.log(record) + } + + fn flush(&self) { + self.0.flush() + } +} diff --git a/ldk-server/ldk-server/src/util/mod.rs b/ldk-server/ldk-server/src/util/mod.rs new file mode 100644 index 000000000..3662b128d --- /dev/null +++ b/ldk-server/ldk-server/src/util/mod.rs @@ -0,0 +1,13 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub(crate) mod config; +pub(crate) mod logger; +pub(crate) mod proto_adapter; +pub(crate) mod tls; diff --git a/ldk-server/ldk-server/src/util/proto_adapter.rs b/ldk-server/ldk-server/src/util/proto_adapter.rs new file mode 100644 index 000000000..2eece4819 --- /dev/null +++ b/ldk-server/ldk-server/src/util/proto_adapter.rs @@ -0,0 +1,462 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use bytes::Bytes; +use hex::prelude::*; +use hyper::StatusCode; +use ldk_node::bitcoin::hashes::sha256; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; +use ldk_node::lightning::ln::types::ChannelId; +use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description, Sha256}; +use ldk_node::payment::{ + ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, +}; +use ldk_node::{ChannelDetails, LightningBalance, PendingSweepBalance, UserChannelId}; +use ldk_server_protos::error::{ErrorCode, ErrorResponse}; +use ldk_server_protos::types::confirmation_status::Status::{Confirmed, Unconfirmed}; +use ldk_server_protos::types::lightning_balance::BalanceType::{ + ClaimableAwaitingConfirmations, ClaimableOnChannelClose, ContentiousClaimable, + CounterpartyRevokedOutputClaimable, MaybePreimageClaimableHtlc, MaybeTimeoutClaimableHtlc, +}; +use ldk_server_protos::types::payment_kind::Kind::{ + Bolt11, Bolt11Jit, Bolt12Offer, Bolt12Refund, Onchain, Spontaneous, +}; +use ldk_server_protos::types::pending_sweep_balance::BalanceType::{ + AwaitingThresholdConfirmations, BroadcastAwaitingConfirmation, PendingBroadcast, +}; +use ldk_server_protos::types::{ + bolt11_invoice_description, Channel, ForwardedPayment, LspFeeLimits, OutPoint, Payment, +}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::{ + AuthError, InternalServerError, InvalidRequestError, LightningError, +}; + +pub(crate) fn channel_to_proto(channel: ChannelDetails) -> Channel { + Channel { + channel_id: channel.channel_id.0.to_lower_hex_string(), + counterparty_node_id: channel.counterparty_node_id.to_string(), + funding_txo: channel + .funding_txo + .map(|o| OutPoint { txid: o.txid.to_string(), vout: o.vout }), + user_channel_id: channel.user_channel_id.0.to_string(), + unspendable_punishment_reserve: channel.unspendable_punishment_reserve, + channel_value_sats: channel.channel_value_sats, + feerate_sat_per_1000_weight: channel.feerate_sat_per_1000_weight, + outbound_capacity_msat: channel.outbound_capacity_msat, + inbound_capacity_msat: channel.inbound_capacity_msat, + confirmations_required: channel.confirmations_required, + confirmations: channel.confirmations, + is_outbound: channel.is_outbound, + is_channel_ready: channel.is_channel_ready, + is_usable: channel.is_usable, + is_announced: channel.is_announced, + channel_config: Some(channel_config_to_proto(channel.config)), + next_outbound_htlc_limit_msat: channel.next_outbound_htlc_limit_msat, + next_outbound_htlc_minimum_msat: channel.next_outbound_htlc_minimum_msat, + force_close_spend_delay: channel.force_close_spend_delay.map(|x| x as u32), + counterparty_outbound_htlc_minimum_msat: channel.counterparty_outbound_htlc_minimum_msat, + counterparty_outbound_htlc_maximum_msat: channel.counterparty_outbound_htlc_maximum_msat, + counterparty_unspendable_punishment_reserve: channel + .counterparty_unspendable_punishment_reserve, + counterparty_forwarding_info_fee_base_msat: channel + .counterparty_forwarding_info_fee_base_msat, + counterparty_forwarding_info_fee_proportional_millionths: channel + .counterparty_forwarding_info_fee_proportional_millionths, + counterparty_forwarding_info_cltv_expiry_delta: channel + .counterparty_forwarding_info_cltv_expiry_delta + .map(|x| x as u32), + } +} + +pub(crate) fn channel_config_to_proto( + channel_config: ChannelConfig, +) -> ldk_server_protos::types::ChannelConfig { + ldk_server_protos::types::ChannelConfig { + forwarding_fee_proportional_millionths: Some( + channel_config.forwarding_fee_proportional_millionths, + ), + forwarding_fee_base_msat: Some(channel_config.forwarding_fee_base_msat), + cltv_expiry_delta: Some(channel_config.cltv_expiry_delta as u32), + force_close_avoidance_max_fee_satoshis: Some( + channel_config.force_close_avoidance_max_fee_satoshis, + ), + accept_underpaying_htlcs: Some(channel_config.accept_underpaying_htlcs), + max_dust_htlc_exposure: match channel_config.max_dust_htlc_exposure { + MaxDustHTLCExposure::FixedLimit { limit_msat } => { + Some(ldk_server_protos::types::channel_config::MaxDustHtlcExposure::FixedLimitMsat( + limit_msat, + )) + }, + MaxDustHTLCExposure::FeeRateMultiplier { multiplier } => Some( + ldk_server_protos::types::channel_config::MaxDustHtlcExposure::FeeRateMultiplier( + multiplier, + ), + ), + }, + } +} + +pub(crate) fn payment_to_proto(payment: PaymentDetails) -> Payment { + let PaymentDetails { + id, + kind, + amount_msat, + fee_paid_msat, + direction, + status, + latest_update_timestamp, + } = payment; + + Payment { + id: id.to_string(), + kind: Some(payment_kind_to_proto(kind)), + amount_msat, + fee_paid_msat, + direction: match direction { + PaymentDirection::Inbound => ldk_server_protos::types::PaymentDirection::Inbound.into(), + PaymentDirection::Outbound => { + ldk_server_protos::types::PaymentDirection::Outbound.into() + }, + }, + status: match status { + PaymentStatus::Pending => ldk_server_protos::types::PaymentStatus::Pending.into(), + PaymentStatus::Succeeded => ldk_server_protos::types::PaymentStatus::Succeeded.into(), + PaymentStatus::Failed => ldk_server_protos::types::PaymentStatus::Failed.into(), + }, + latest_update_timestamp, + } +} + +pub(crate) fn payment_kind_to_proto( + payment_kind: PaymentKind, +) -> ldk_server_protos::types::PaymentKind { + match payment_kind { + PaymentKind::Onchain { txid, status } => ldk_server_protos::types::PaymentKind { + kind: Some(Onchain(ldk_server_protos::types::Onchain { + txid: txid.to_string(), + status: Some(confirmation_status_to_proto(status)), + })), + }, + PaymentKind::Bolt11 { hash, preimage, secret } => ldk_server_protos::types::PaymentKind { + kind: Some(Bolt11(ldk_server_protos::types::Bolt11 { + hash: hash.to_string(), + preimage: preimage.map(|p| p.to_string()), + secret: secret.map(|s| Bytes::copy_from_slice(&s.0)), + })), + }, + PaymentKind::Bolt11Jit { + hash, + preimage, + secret, + lsp_fee_limits, + counterparty_skimmed_fee_msat, + } => ldk_server_protos::types::PaymentKind { + kind: Some(Bolt11Jit(ldk_server_protos::types::Bolt11Jit { + hash: hash.to_string(), + preimage: preimage.map(|p| p.to_string()), + secret: secret.map(|s| Bytes::copy_from_slice(&s.0)), + lsp_fee_limits: Some(LspFeeLimits { + max_total_opening_fee_msat: lsp_fee_limits.max_total_opening_fee_msat, + max_proportional_opening_fee_ppm_msat: lsp_fee_limits + .max_proportional_opening_fee_ppm_msat, + }), + counterparty_skimmed_fee_msat, + })), + }, + PaymentKind::Bolt12Offer { hash, preimage, secret, offer_id, payer_note, quantity } => { + ldk_server_protos::types::PaymentKind { + kind: Some(Bolt12Offer(ldk_server_protos::types::Bolt12Offer { + hash: hash.map(|h| h.to_string()), + preimage: preimage.map(|p| p.to_string()), + secret: secret.map(|s| Bytes::copy_from_slice(&s.0)), + offer_id: offer_id.0.to_lower_hex_string(), + payer_note: payer_note.map(|s| s.to_string()), + quantity, + })), + } + }, + PaymentKind::Bolt12Refund { hash, preimage, secret, payer_note, quantity } => { + ldk_server_protos::types::PaymentKind { + kind: Some(Bolt12Refund(ldk_server_protos::types::Bolt12Refund { + hash: hash.map(|h| h.to_string()), + preimage: preimage.map(|p| p.to_string()), + secret: secret.map(|s| Bytes::copy_from_slice(&s.0)), + payer_note: payer_note.map(|s| s.to_string()), + quantity, + })), + } + }, + PaymentKind::Spontaneous { hash, preimage } => ldk_server_protos::types::PaymentKind { + kind: Some(Spontaneous(ldk_server_protos::types::Spontaneous { + hash: hash.to_string(), + preimage: preimage.map(|p| p.to_string()), + })), + }, + } +} + +pub(crate) fn confirmation_status_to_proto( + confirmation_status: ConfirmationStatus, +) -> ldk_server_protos::types::ConfirmationStatus { + match confirmation_status { + ConfirmationStatus::Confirmed { block_hash, height, timestamp } => { + ldk_server_protos::types::ConfirmationStatus { + status: Some(Confirmed(ldk_server_protos::types::Confirmed { + block_hash: block_hash.to_string(), + height, + timestamp, + })), + } + }, + ConfirmationStatus::Unconfirmed => ldk_server_protos::types::ConfirmationStatus { + status: Some(Unconfirmed(ldk_server_protos::types::Unconfirmed {})), + }, + } +} + +pub(crate) fn lightning_balance_to_proto( + lightning_balance: LightningBalance, +) -> ldk_server_protos::types::LightningBalance { + match lightning_balance { + LightningBalance::ClaimableOnChannelClose { + channel_id, + counterparty_node_id, + amount_satoshis, + transaction_fee_satoshis, + outbound_payment_htlc_rounded_msat, + outbound_forwarded_htlc_rounded_msat, + inbound_claiming_htlc_rounded_msat, + inbound_htlc_rounded_msat, + } => ldk_server_protos::types::LightningBalance { + balance_type: Some(ClaimableOnChannelClose( + ldk_server_protos::types::ClaimableOnChannelClose { + channel_id: channel_id.0.to_lower_hex_string(), + counterparty_node_id: counterparty_node_id.to_string(), + amount_satoshis, + transaction_fee_satoshis, + outbound_payment_htlc_rounded_msat, + outbound_forwarded_htlc_rounded_msat, + inbound_claiming_htlc_rounded_msat, + inbound_htlc_rounded_msat, + }, + )), + }, + LightningBalance::ClaimableAwaitingConfirmations { + channel_id, + counterparty_node_id, + amount_satoshis, + confirmation_height, + .. + } => ldk_server_protos::types::LightningBalance { + balance_type: Some(ClaimableAwaitingConfirmations( + ldk_server_protos::types::ClaimableAwaitingConfirmations { + channel_id: channel_id.0.to_lower_hex_string(), + counterparty_node_id: counterparty_node_id.to_string(), + amount_satoshis, + confirmation_height, + }, + )), + }, + LightningBalance::ContentiousClaimable { + channel_id, + counterparty_node_id, + amount_satoshis, + timeout_height, + payment_hash, + payment_preimage, + } => ldk_server_protos::types::LightningBalance { + balance_type: Some(ContentiousClaimable( + ldk_server_protos::types::ContentiousClaimable { + channel_id: channel_id.0.to_lower_hex_string(), + counterparty_node_id: counterparty_node_id.to_string(), + amount_satoshis, + timeout_height, + payment_hash: payment_hash.to_string(), + payment_preimage: payment_preimage.to_string(), + }, + )), + }, + LightningBalance::MaybeTimeoutClaimableHTLC { + channel_id, + counterparty_node_id, + amount_satoshis, + claimable_height, + payment_hash, + outbound_payment, + } => ldk_server_protos::types::LightningBalance { + balance_type: Some(MaybeTimeoutClaimableHtlc( + ldk_server_protos::types::MaybeTimeoutClaimableHtlc { + channel_id: channel_id.0.to_lower_hex_string(), + counterparty_node_id: counterparty_node_id.to_string(), + amount_satoshis, + claimable_height, + payment_hash: payment_hash.to_string(), + outbound_payment, + }, + )), + }, + LightningBalance::MaybePreimageClaimableHTLC { + channel_id, + counterparty_node_id, + amount_satoshis, + expiry_height, + payment_hash, + } => ldk_server_protos::types::LightningBalance { + balance_type: Some(MaybePreimageClaimableHtlc( + ldk_server_protos::types::MaybePreimageClaimableHtlc { + channel_id: channel_id.0.to_lower_hex_string(), + counterparty_node_id: counterparty_node_id.to_string(), + amount_satoshis, + expiry_height, + payment_hash: payment_hash.to_string(), + }, + )), + }, + LightningBalance::CounterpartyRevokedOutputClaimable { + channel_id, + counterparty_node_id, + amount_satoshis, + } => ldk_server_protos::types::LightningBalance { + balance_type: Some(CounterpartyRevokedOutputClaimable( + ldk_server_protos::types::CounterpartyRevokedOutputClaimable { + channel_id: channel_id.0.to_lower_hex_string(), + counterparty_node_id: counterparty_node_id.to_string(), + amount_satoshis, + }, + )), + }, + } +} + +pub(crate) fn pending_sweep_balance_to_proto( + pending_sweep_balance: PendingSweepBalance, +) -> ldk_server_protos::types::PendingSweepBalance { + match pending_sweep_balance { + PendingSweepBalance::PendingBroadcast { channel_id, amount_satoshis } => { + ldk_server_protos::types::PendingSweepBalance { + balance_type: Some(PendingBroadcast(ldk_server_protos::types::PendingBroadcast { + channel_id: channel_id.map(|c| c.0.to_lower_hex_string()), + amount_satoshis, + })), + } + }, + PendingSweepBalance::BroadcastAwaitingConfirmation { + channel_id, + latest_broadcast_height, + latest_spending_txid, + amount_satoshis, + } => ldk_server_protos::types::PendingSweepBalance { + balance_type: Some(BroadcastAwaitingConfirmation( + ldk_server_protos::types::BroadcastAwaitingConfirmation { + channel_id: channel_id.map(|c| c.0.to_lower_hex_string()), + latest_broadcast_height, + latest_spending_txid: latest_spending_txid.to_string(), + amount_satoshis, + }, + )), + }, + PendingSweepBalance::AwaitingThresholdConfirmations { + channel_id, + latest_spending_txid, + confirmation_hash, + confirmation_height, + amount_satoshis, + } => ldk_server_protos::types::PendingSweepBalance { + balance_type: Some(AwaitingThresholdConfirmations( + ldk_server_protos::types::AwaitingThresholdConfirmations { + channel_id: channel_id.map(|c| c.0.to_lower_hex_string()), + latest_spending_txid: latest_spending_txid.to_string(), + confirmation_hash: confirmation_hash.to_string(), + confirmation_height, + amount_satoshis, + }, + )), + }, + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn forwarded_payment_to_proto( + prev_channel_id: ChannelId, next_channel_id: ChannelId, + prev_user_channel_id: Option, next_user_channel_id: Option, + prev_node_id: Option, next_node_id: Option, + total_fee_earned_msat: Option, skimmed_fee_msat: Option, claim_from_onchain_tx: bool, + outbound_amount_forwarded_msat: Option, +) -> ForwardedPayment { + ForwardedPayment { + prev_channel_id: prev_channel_id.to_string(), + next_channel_id: next_channel_id.to_string(), + prev_user_channel_id: prev_user_channel_id + .expect("prev_user_channel_id expected for ldk-server >=0.1") + .0 + .to_string(), + next_user_channel_id: next_user_channel_id.map(|u| u.0.to_string()), + prev_node_id: prev_node_id.expect("prev_node_id expected for ldk-server >=0.1").to_string(), + next_node_id: next_node_id.expect("next_node_id expected for ldk-node >=0.1").to_string(), + total_fee_earned_msat, + skimmed_fee_msat, + claim_from_onchain_tx, + outbound_amount_forwarded_msat, + } +} + +pub(crate) fn proto_to_bolt11_description( + description: Option, +) -> Result { + Ok(match description.and_then(|d| d.kind) { + Some(bolt11_invoice_description::Kind::Direct(s)) => { + Bolt11InvoiceDescription::Direct(Description::new(s).map_err(|e| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid invoice description: {}", e), + ) + })?) + }, + Some(bolt11_invoice_description::Kind::Hash(h)) => { + let hash_bytes = <[u8; 32]>::from_hex(&h).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Invalid invoice description_hash, must be 32-byte hex string".to_string(), + ) + })?; + Bolt11InvoiceDescription::Hash(Sha256(*sha256::Hash::from_bytes_ref(&hash_bytes))) + }, + None => { + Bolt11InvoiceDescription::Direct(Description::new("".to_string()).map_err(|e| { + LdkServerError::new( + InvalidRequestError, + format!("Invalid invoice description: {}", e), + ) + })?) + }, + }) +} + +pub(crate) fn to_error_response(ldk_error: LdkServerError) -> (ErrorResponse, StatusCode) { + let error_code = match ldk_error.error_code { + InvalidRequestError => ErrorCode::InvalidRequestError, + AuthError => ErrorCode::AuthError, + LightningError => ErrorCode::LightningError, + InternalServerError => ErrorCode::InternalServerError, + } as i32; + + let status = match ldk_error.error_code { + InvalidRequestError => StatusCode::BAD_REQUEST, + AuthError => StatusCode::UNAUTHORIZED, + LightningError => StatusCode::INTERNAL_SERVER_ERROR, + InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let error_response = ErrorResponse { message: ldk_error.message, error_code }; + + (error_response, status) +} diff --git a/ldk-server/ldk-server/src/util/tls.rs b/ldk-server/ldk-server/src/util/tls.rs new file mode 100644 index 000000000..c0d65fc72 --- /dev/null +++ b/ldk-server/ldk-server/src/util/tls.rs @@ -0,0 +1,228 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fs; + +use base64::Engine; +use rcgen::{generate_simple_self_signed, CertifiedKey}; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio_rustls::rustls::ServerConfig; + +use crate::util::config::TlsConfig; + +// PEM markers +const PEM_CERT_BEGIN: &str = "-----BEGIN CERTIFICATE-----"; +const PEM_CERT_END: &str = "-----END CERTIFICATE-----"; +const PEM_KEY_BEGIN: &str = "-----BEGIN PRIVATE KEY-----"; +const PEM_KEY_END: &str = "-----END PRIVATE KEY-----"; + +/// Gets or generates TLS configuration. If custom paths are provided, uses those. +/// Otherwise, generates a self-signed certificate in the storage directory. +pub fn get_or_generate_tls_config( + tls_config: Option, storage_dir: &str, +) -> Result { + if let Some(config) = tls_config { + let cert_path = config.cert_path.unwrap_or(format!("{storage_dir}/tls.crt")); + let key_path = config.key_path.unwrap_or(format!("{storage_dir}/tls.key")); + if !fs::exists(&cert_path).unwrap_or(false) || !fs::exists(&key_path).unwrap_or(false) { + generate_self_signed_cert(&cert_path, &key_path, &config.hosts)?; + } + load_tls_config(&cert_path, &key_path) + } else { + // Check if we already have generated certs, if we don't, generate new ones + let cert_path = format!("{storage_dir}/tls.crt"); + let key_path = format!("{storage_dir}/tls.key"); + if !fs::exists(&cert_path).unwrap_or(false) || !fs::exists(&key_path).unwrap_or(false) { + generate_self_signed_cert(&cert_path, &key_path, &[])?; + } + + load_tls_config(&cert_path, &key_path) + } +} + +/// Parses a PEM-encoded certificate file and returns the DER-encoded certificates. +fn parse_pem_certs(pem_data: &str) -> Result>, String> { + let mut certs = Vec::new(); + + for block in pem_data.split(PEM_CERT_END) { + if let Some(start) = block.find(PEM_CERT_BEGIN) { + let base64_content: String = block[start + PEM_CERT_BEGIN.len()..] + .lines() + .filter(|line| !line.starts_with("-----") && !line.is_empty()) + .collect(); + + let der = base64::engine::general_purpose::STANDARD + .decode(&base64_content) + .map_err(|e| format!("Failed to decode certificate base64: {e}"))?; + + certs.push(CertificateDer::from(der)); + } + } + + Ok(certs) +} + +/// Parses a PEM-encoded PKCS#8 private key file and returns the DER-encoded key. +fn parse_pem_private_key(pem_data: &str) -> Result, String> { + let start = pem_data.find(PEM_KEY_BEGIN).ok_or("Missing BEGIN PRIVATE KEY marker")?; + let end = pem_data.find(PEM_KEY_END).ok_or("Missing END PRIVATE KEY marker")?; + + let base64_content: String = pem_data[start + PEM_KEY_BEGIN.len()..end] + .lines() + .filter(|line| !line.starts_with("-----") && !line.is_empty()) + .collect(); + + let der = base64::engine::general_purpose::STANDARD + .decode(&base64_content) + .map_err(|e| format!("Failed to decode private key base64: {e}"))?; + + Ok(PrivateKeyDer::Pkcs8(der.into())) +} + +/// Generates a self-signed TLS certificate and saves it to the storage directory. +/// Returns the paths to the generated cert and key files. +fn generate_self_signed_cert( + cert_path: &str, key_path: &str, configure_hosts: &[String], +) -> Result<(), String> { + let mut hosts = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + hosts.extend_from_slice(configure_hosts); + + let CertifiedKey { cert, key_pair } = generate_simple_self_signed(hosts) + .map_err(|e| format!("Failed to generate self-signed certificate: {e}"))?; + + // Convert DER to PEM format + let cert_der = cert.der(); + let key_der = key_pair.serialize_der(); + + let cert_pem = format!( + "{PEM_CERT_BEGIN}\n{}\n{PEM_CERT_END}\n", + base64::engine::general_purpose::STANDARD + .encode(cert_der) + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n") + ); + + let key_pem = format!( + "{PEM_KEY_BEGIN}\n{}\n{PEM_KEY_END}\n", + base64::engine::general_purpose::STANDARD + .encode(&key_der) + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n") + ); + + fs::write(cert_path, &cert_pem) + .map_err(|e| format!("Failed to write TLS certificate to '{cert_path}': {e}"))?; + fs::write(key_path, &key_pem) + .map_err(|e| format!("Failed to write TLS key to '{key_path}': {e}"))?; + + Ok(()) +} + +/// Loads TLS configuration from provided paths. +fn load_tls_config(cert_path: &str, key_path: &str) -> Result { + let cert_pem = fs::read_to_string(cert_path) + .map_err(|e| format!("Failed to read TLS certificate file '{cert_path}': {e}"))?; + let key_pem = fs::read_to_string(key_path) + .map_err(|e| format!("Failed to read TLS key file '{key_path}': {e}"))?; + + let certs = parse_pem_certs(&cert_pem)?; + + if certs.is_empty() { + return Err("No certificates found in certificate file".to_string()); + } + + let key = parse_pem_private_key(&key_pem)?; + + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| format!("Failed to build TLS server config: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pem_certs() { + let pem = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu\ndXNlZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM\nBnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCEcJsggt0c0dSfEB\nmm6vv1LdCoxXnhOSCutoJgJgmCPBjU1doFFKwAtXjfOv0eSLZ3NHLu0LRKmVvOsP\nAgMBAAGjUzBRMB0GA1UdDgQWBBQK3fc0myO0psd71FJd8v7VCmDJOzAfBgNVHSME\nGDAWgBQK3fc0myO0psd71FJd8v7VCmDJOzAPBgNVHRMBAf8EBTADAQH/MA0GCSqG\nSIb3DQEBCwUAA0EAhJg0cx2pFfVfGBfbJQNFa+A4ynJBMqKYlbUnJBfWPwg13RhC\nivLjYyhKzEbnOug0TuFfVaUBGfBYbPgaJQ4BAg==\n-----END CERTIFICATE-----\n"; + + let certs = parse_pem_certs(pem).unwrap(); + assert_eq!(certs.len(), 1); + assert!(!certs[0].is_empty()); + } + + #[test] + fn test_parse_pem_certs_multiple() { + let pem = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu\ndXNlZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM\nBnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCEcJsggt0c0dSfEB\nmm6vv1LdCoxXnhOSCutoJgJgmCPBjU1doFFKwAtXjfOv0eSLZ3NHLu0LRKmVvOsP\nAgMBAAGjUzBRMB0GA1UdDgQWBBQK3fc0myO0psd71FJd8v7VCmDJOzAfBgNVHSME\nGDAWgBQK3fc0myO0psd71FJd8v7VCmDJOzAPBgNVHRMBAf8EBTADAQH/MA0GCSqG\nSIb3DQEBCwUAA0EAhJg0cx2pFfVfGBfbJQNFa+A4ynJBMqKYlbUnJBfWPwg13RhC\nivLjYyhKzEbnOug0TuFfVaUBGfBYbPgaJQ4BAg==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu\ndXNlZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM\nBnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCEcJsggt0c0dSfEB\nmm6vv1LdCoxXnhOSCutoJgJgmCPBjU1doFFKwAtXjfOv0eSLZ3NHLu0LRKmVvOsP\nAgMBAAGjUzBRMB0GA1UdDgQWBBQK3fc0myO0psd71FJd8v7VCmDJOzAfBgNVHSME\nGDAWgBQK3fc0myO0psd71FJd8v7VCmDJOzAPBgNVHRMBAf8EBTADAQH/MA0GCSqG\nSIb3DQEBCwUAA0EAhJg0cx2pFfVfGBfbJQNFa+A4ynJBMqKYlbUnJBfWPwg13RhC\nivLjYyhKzEbnOug0TuFfVaUBGfBYbPgaJQ4BAg==\n-----END CERTIFICATE-----\n"; + + let certs = parse_pem_certs(pem).unwrap(); + assert_eq!(certs.len(), 2); + } + + #[test] + fn test_parse_pem_certs_empty() { + let certs = parse_pem_certs("").unwrap(); + assert!(certs.is_empty()); + + let certs = parse_pem_certs("not a cert").unwrap(); + assert!(certs.is_empty()); + } + + #[test] + fn test_parse_pem_private_key_pkcs8() { + let pem = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2a2rwplBQLzHPDvn\nsaw8HKDP6WYBSF684gcz+D7zeVShRANCAAQq8R/E45tTNWMEpK8abYM7VzuJxpPS\nhJCi6bzjOPGHawEO8safLOWFaV7GqLJM0OdM3eu/qcz8HwgI3T8EVHQK\n-----END PRIVATE KEY-----\n"; + + let key = parse_pem_private_key(pem).unwrap(); + assert!(matches!(key, PrivateKeyDer::Pkcs8(_))); + } + + #[test] + fn test_parse_pem_private_key_invalid() { + let result = parse_pem_private_key(""); + assert!(result.is_err()); + + let result = parse_pem_private_key("not a key"); + assert!(result.is_err()); + } + + #[test] + fn test_generate_and_load_roundtrip() { + let temp_dir = std::env::temp_dir(); + let suffix: u64 = rand::random(); + let cert_path = temp_dir.join(format!("test_tls_cert_{suffix}.pem")); + let key_path = temp_dir.join(format!("test_tls_key_{suffix}.pem")); + + // Clean up any existing files to be safe + let _ = fs::remove_file(&cert_path); + let _ = fs::remove_file(&key_path); + + // Generate cert + generate_self_signed_cert(cert_path.to_str().unwrap(), key_path.to_str().unwrap(), &[]) + .unwrap(); + + // Verify files exist + assert!(cert_path.exists()); + assert!(key_path.exists()); + + // Load config + let res = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap()); + assert!(res.is_ok()); + + // Clean up + let _ = fs::remove_file(&cert_path); + let _ = fs::remove_file(&key_path); + } +} diff --git a/ldk-server/rustfmt.toml b/ldk-server/rustfmt.toml new file mode 100644 index 000000000..4900e142f --- /dev/null +++ b/ldk-server/rustfmt.toml @@ -0,0 +1,19 @@ +use_small_heuristics = "Max" +fn_params_layout = "Compressed" +hard_tabs = true +use_field_init_shorthand = true +max_width = 100 +match_block_trailing_comma = true +format_code_in_doc_comments = true +comment_width = 100 +format_macro_matchers = true +group_imports = "StdExternalCrate" +reorder_imports = true +imports_granularity = "Module" +normalize_comments = true +normalize_doc_attributes = true +style_edition = "2021" +# TBD: do we want comment and string wrapping? +#wrap_comments = true +#format_strings = true +#overflow_delimited_expr = true