diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 93a29ac5..0dcc16e6 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -18,7 +18,8 @@ use hex_conservative::DisplayHex; use ldk_server_client::client::LdkServerClient; use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse}; use ldk_server_grpc::api::{ - GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest, + open_channel_request, GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, + OpenChannelRequest, }; use serde_json::Value; @@ -737,7 +738,9 @@ pub async fn setup_funded_channel( .open_channel(OpenChannelRequest { node_pubkey: server_b.node_id().to_string(), address: format!("127.0.0.1:{}", server_b.p2p_port), - channel_amount_sats, + amount: Some(open_channel_request::Amount::ChannelAmountSats( + channel_amount_sats, + )), push_to_counterparty_msat: None, channel_config: None, announce_channel: true, diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index f0ed65ec..04781d62 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -23,8 +23,8 @@ use ldk_node::lightning::offers::offer::Offer; use ldk_node::lightning_invoice::Bolt11Invoice; use ldk_server_client::client::EventStream; use ldk_server_client::ldk_server_grpc::api::{ - Bolt11ReceiveRequest, Bolt12ReceiveRequest, GetBalancesRequest, OnchainReceiveRequest, - OpenChannelRequest, + open_channel_request, Bolt11ReceiveRequest, Bolt12ReceiveRequest, GetBalancesRequest, + OnchainReceiveRequest, OpenChannelRequest, }; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; use ldk_server_client::ldk_server_grpc::events::{ @@ -376,7 +376,7 @@ async fn test_cli_onchain_send_all() { let balances_before = server.client().get_balances(GetBalancesRequest {}).await.unwrap(); let address = bitcoind.bitcoind.client.new_address().unwrap().to_string(); - let output = run_cli(&server, &["onchain-send", &address, "--send-all", "true"]); + let output = run_cli(&server, &["onchain-send", &address, "all"]); assert!(!output["txid"].as_str().unwrap().is_empty()); mine_and_sync(&bitcoind, &[&server], 6).await; @@ -435,8 +435,7 @@ async fn test_cli_list_peers() { // === CLI tests: Group 4 — Two-node with channel === -#[tokio::test] -async fn test_cli_open_channel() { +async fn open_channel_via_cli(channel_amount: &str) { let bitcoind = TestBitcoind::new(); let server_a = LdkServerHandle::start(&bitcoind).await; let server_b = LdkServerHandle::start(&bitcoind).await; @@ -454,11 +453,27 @@ async fn test_cli_open_channel() { let addr = format!("127.0.0.1:{}", server_b.p2p_port); let output = run_cli( &server_a, - &["open-channel", server_b.node_id(), &addr, "100000sat", "--announce-channel"], + &[ + "open-channel", + server_b.node_id(), + &addr, + channel_amount, + "--announce-channel", + ], ); assert!(!output["user_channel_id"].as_str().unwrap().is_empty()); } +#[tokio::test] +async fn test_cli_open_channel() { + open_channel_via_cli("100000sat").await; +} + +#[tokio::test] +async fn test_cli_open_channel_with_all() { + open_channel_via_cli("all").await; +} + #[tokio::test] async fn test_subscribe_events_channel_state_lifecycle_pending_ready_closed() { let bitcoind = TestBitcoind::new(); @@ -481,7 +496,9 @@ async fn test_subscribe_events_channel_state_lifecycle_pending_ready_closed() { .open_channel(OpenChannelRequest { node_pubkey: server_b.node_id().to_string(), address: format!("127.0.0.1:{}", server_b.p2p_port), - channel_amount_sats: 100_000, + amount: Some(open_channel_request::Amount::ChannelAmountSats( + 100_000, + )), push_to_counterparty_msat: None, channel_config: None, announce_channel: true, @@ -645,7 +662,9 @@ async fn test_subscribe_events_channel_state_lifecycle_pending_ready_force_close .open_channel(OpenChannelRequest { node_pubkey: server_b.node_id().to_string(), address: format!("127.0.0.1:{}", server_b.p2p_port), - channel_amount_sats: 100_000, + amount: Some(open_channel_request::Amount::ChannelAmountSats( + 100_000, + )), push_to_counterparty_msat: None, channel_config: None, announce_channel: true, @@ -1119,18 +1138,29 @@ async fn test_cli_force_close_channel() { assert!(channels_output["channels"].as_array().unwrap().is_empty()); } -#[tokio::test] -async fn test_cli_splice_in() { +async fn splice_in_via_cli(splice_amount: &str) { let bitcoind = TestBitcoind::new(); let server_a = LdkServerHandle::start(&bitcoind).await; let server_b = LdkServerHandle::start(&bitcoind).await; let user_channel_id = setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; - let output = - run_cli(&server_a, &["splice-in", &user_channel_id, server_b.node_id(), "50000sat"]); + let output = run_cli( + &server_a, + &["splice-in", &user_channel_id, server_b.node_id(), splice_amount], + ); assert!(output.is_object()); } +#[tokio::test] +async fn test_cli_splice_in() { + splice_in_via_cli("50000sat").await; +} + +#[tokio::test] +async fn test_cli_splice_in_with_all() { + splice_in_via_cli("all").await; +} + #[tokio::test] async fn test_cli_splice_out() { let bitcoind = TestBitcoind::new(); diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 29e3779e..224e1d42 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -23,6 +23,7 @@ use ldk_server_client::error::LdkServerErrorCode::{ AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, }; use ldk_server_client::ldk_server_grpc::api::{ + onchain_send_request, open_channel_request, splice_in_request, AllFunds, Bolt11ClaimForHashRequest, Bolt11ClaimForHashResponse, Bolt11FailForHashRequest, Bolt11FailForHashResponse, Bolt11ReceiveForHashRequest, Bolt11ReceiveForHashResponse, Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11ReceiveVariableAmountViaJitChannelRequest, @@ -56,8 +57,8 @@ use ldk_server_client::{ use serde::Serialize; use serde_json::{json, Value}; use types::{ - Amount, CliListForwardedPaymentsResponse, CliListPaymentsResponse, CliPaginatedResponse, - Preimage, + Amount, AmountOrAll, CliListForwardedPaymentsResponse, CliListPaymentsResponse, + CliPaginatedResponse, Preimage, }; mod types; @@ -115,14 +116,9 @@ enum Commands { #[arg(help = "The address to send coins to")] address: String, #[arg( - help = "The amount to send, e.g. 50sat or 50000msat, must be a whole sat amount, cannot send msats on-chain. Will respect any on-chain reserve needed for anchor channels" + help = "The amount to send, e.g. 50sat or 50000msat, or 'all' to use all available on-chain funds. Exact amounts must be a whole sat amount. Will respect any on-chain reserve needed for anchor channels" )] - amount: Option, - #[arg( - long, - help = "Send all available balance to the address while retaining on-chain reserves for anchor channels" - )] - send_all: Option, + amount: AmountOrAll, #[arg( long, help = "Fee rate in satoshis per virtual byte. If not set, a reasonable estimate will be used" @@ -419,9 +415,9 @@ enum Commands { )] address: String, #[arg( - help = "The amount to commit to the channel, e.g. 100sat or 100000msat, must be a whole sat amount, cannot send msats on-chain." + help = "The amount to commit to the channel, e.g. 100sat or 100000msat, or 'all' to use all available on-chain funds. Exact amounts must be a whole sat amount." )] - channel_amount: Amount, + channel_amount: AmountOrAll, #[arg(long, help = "Amount to push to the remote side, e.g. 50sat or 50000msat")] push_to_counterparty: Option, #[arg(long, help = "Whether the channel should be public")] @@ -457,9 +453,9 @@ enum Commands { #[arg(help = "The hex-encoded public key of the channel's counterparty node")] counterparty_node_id: String, #[arg( - help = "The amount to splice into the channel, e.g. 50sat or 50000msat, must be a whole sat amount, cannot send msats on-chain." + help = "The amount to splice into the channel, e.g. 50sat or 50000msat, or 'all' to use all available on-chain funds. Exact amounts must be a whole sat amount." )] - splice_amount: Amount, + splice_amount: AmountOrAll, }, #[command(about = "Decrease the channel balance by the given amount")] SpliceOut { @@ -660,15 +656,17 @@ async fn main() { client.onchain_receive(OnchainReceiveRequest {}).await, ); }, - Commands::OnchainSend { address, amount, send_all, fee_rate_sat_per_vb } => { - let amount_sats = amount.map(|a| a.to_sat().unwrap_or_else(|e| handle_error_msg(e))); + Commands::OnchainSend { address, amount, fee_rate_sat_per_vb } => { + let amount = match amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)) { + Some(amount_sats) => onchain_send_request::Amount::AmountSats(amount_sats), + None => onchain_send_request::Amount::AllFunds(AllFunds {}), + }; handle_response_result::<_, OnchainSendResponse>( client .onchain_send(OnchainSendRequest { address, - amount_sats, - send_all, fee_rate_sat_per_vb, + amount: Some(amount), }) .await, ); @@ -983,8 +981,10 @@ async fn main() { forwarding_fee_base_msat, cltv_expiry_delta, } => { - let channel_amount_sats = - channel_amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)); + let amount = match channel_amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)) { + Some(amount_sats) => open_channel_request::Amount::ChannelAmountSats(amount_sats), + None => open_channel_request::Amount::AllFunds(AllFunds {}), + }; let push_to_counterparty_msat = push_to_counterparty.map(|a| a.to_msat()); let channel_config = build_open_channel_config( forwarding_fee_proportional_millionths, @@ -1003,7 +1003,7 @@ async fn main() { .open_channel(OpenChannelRequest { node_pubkey, address, - channel_amount_sats, + amount: Some(amount), push_to_counterparty_msat, channel_config, announce_channel, @@ -1013,13 +1013,16 @@ async fn main() { ); }, Commands::SpliceIn { user_channel_id, counterparty_node_id, splice_amount } => { - let splice_amount_sats = splice_amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)); + let amount = match splice_amount.to_sat().unwrap_or_else(|e| handle_error_msg(e)) { + Some(amount_sats) => splice_in_request::Amount::SpliceAmountSats(amount_sats), + None => splice_in_request::Amount::AllFunds(AllFunds {}), + }; handle_response_result::<_, SpliceInResponse>( client .splice_in(SpliceInRequest { user_channel_id, counterparty_node_id, - splice_amount_sats, + amount: Some(amount), }) .await, ); diff --git a/ldk-server-cli/src/types.rs b/ldk-server-cli/src/types.rs index 7e262278..895ea171 100644 --- a/ldk-server-cli/src/types.rs +++ b/ldk-server-cli/src/types.rs @@ -120,6 +120,35 @@ impl FromStr for Amount { } } +/// An exact on-chain amount or all available on-chain funds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AmountOrAll { + Exact(Amount), + All, +} + +impl AmountOrAll { + /// Returns the exact amount in satoshis, or `None` when all funds should be used. + pub fn to_sat(self) -> Result, String> { + match self { + Self::Exact(amount) => amount.to_sat().map(Some), + Self::All => Ok(None), + } + } +} + +impl FromStr for AmountOrAll { + type Err = String; + + fn from_str(s: &str) -> Result { + if s.trim() == "all" { + Ok(Self::All) + } else { + Amount::from_str(s).map(Self::Exact) + } + } +} + /// A validated 32-byte payment preimage, parsed from a 64-character hex string. #[derive(Debug, Clone)] pub struct Preimage(pub [u8; 32]); @@ -207,6 +236,14 @@ mod tests { assert!(Amount::from_str(&big).is_err()); } + #[test] + fn amount_or_all_parses_exact_amount_or_all() { + assert_eq!(AmountOrAll::from_str("all").unwrap(), AmountOrAll::All); + assert_eq!(AmountOrAll::from_str(" all ").unwrap(), AmountOrAll::All); + assert_eq!(AmountOrAll::from_str("100sat").unwrap().to_sat().unwrap(), Some(100)); + assert_eq!(AmountOrAll::All.to_sat().unwrap(), None); + } + #[test] fn preimage_parsing_and_roundtrip() { // valid 64-char hex string diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index ade887f4..5187fa40 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -109,6 +109,7 @@ pub struct OnchainReceiveResponse { pub address: ::prost::alloc::string::String, } /// Send an on-chain payment to the given address. +/// See more: #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[cfg_attr(feature = "serde", serde(default))] @@ -118,23 +119,29 @@ 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 the node will send all available balance to the specified address. - /// - /// Any on-chain reserves needed for Anchor channels will be retained. - /// 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, + /// Required. The amount to send. + #[prost(oneof = "onchain_send_request::Amount", tags = "2, 3")] + pub amount: ::core::option::Option, +} +/// Nested message and enum types in `OnchainSendRequest`. +pub mod onchain_send_request { + /// Required. The amount to send. + #[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 Amount { + /// Send the given amount of satoshis while retaining any required Anchor channel reserves. + #[prost(uint64, tag = "2")] + AmountSats(u64), + /// Send all available on-chain funds, minus fees and any required Anchor channel reserves. + #[prost(message, tag = "3")] + AllFunds(super::AllFunds), + } } /// The response for the `OnchainSend` RPC. On failure, a gRPC error status is returned. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -521,6 +528,13 @@ pub struct SpontaneousSendResponse { #[prost(string, tag = "1")] pub payment_id: ::prost::alloc::string::String, } +/// Selects all available on-chain funds. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AllFunds {} /// Creates a new outbound channel to the given remote node. /// See more: #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -536,9 +550,6 @@ pub struct OpenChannelRequest { /// 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, @@ -551,6 +562,25 @@ pub struct OpenChannelRequest { /// Allow the counterparty to spend all its channel balance. This cannot be set together with `announce_channel`. #[prost(bool, tag = "7")] pub disable_counterparty_reserve: bool, + /// Required. The funds to commit to the channel. + #[prost(oneof = "open_channel_request::Amount", tags = "3, 8")] + pub amount: ::core::option::Option, +} +/// Nested message and enum types in `OpenChannelRequest`. +pub mod open_channel_request { + /// Required. The funds to commit to the 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::Oneof)] + pub enum Amount { + /// Commit the given amount of satoshis while retaining any required Anchor channel reserves. + #[prost(uint64, tag = "3")] + ChannelAmountSats(u64), + /// Commit all available on-chain funds, minus fees and any required Anchor channel reserves. + #[prost(message, tag = "8")] + AllFunds(super::AllFunds), + } } /// The response for the `OpenChannel` RPC. On failure, a gRPC error status is returned. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -577,9 +607,25 @@ pub struct SpliceInRequest { /// 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, + /// Required. The funds to splice into the channel. + #[prost(oneof = "splice_in_request::Amount", tags = "3, 4")] + pub amount: ::core::option::Option, +} +/// Nested message and enum types in `SpliceInRequest`. +pub mod splice_in_request { + /// Required. The funds to splice into the 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::Oneof)] + pub enum Amount { + /// Splice in the given amount of satoshis while retaining any required Anchor channel reserves. + #[prost(uint64, tag = "3")] + SpliceAmountSats(u64), + /// Splice in all available confirmed on-chain funds, minus fees and any required Anchor channel reserves. + #[prost(message, tag = "4")] + AllFunds(super::AllFunds), + } } /// The response for the `SpliceIn` RPC. On failure, a gRPC error status is returned. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index df5f22cc..4c982a4f 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -91,23 +91,20 @@ message OnchainReceiveResponse { } // Send an on-chain payment to the given address. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.OnchainPayment.html#method.send_to_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; + // Required. The amount to send. + oneof amount { + // Send the given amount of satoshis while retaining any required Anchor channel reserves. + uint64 amount_sats = 2; - // If set, the amount_sats field should be unset. - // It indicates that the node will send all available balance to the specified address. - // - // Any on-chain reserves needed for Anchor channels will be retained. - // See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.OnchainPayment.html#method.send_all_to_address - optional bool send_all = 3; + // Send all available on-chain funds, minus fees and any required Anchor channel reserves. + AllFunds all_funds = 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. @@ -408,6 +405,9 @@ message SpontaneousSendResponse { string payment_id = 1; } +// Selects all available on-chain funds. +message AllFunds {} + // 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 { @@ -419,8 +419,14 @@ message OpenChannelRequest { // 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; + // Required. The funds to commit to the channel. + oneof amount { + // Commit the given amount of satoshis while retaining any required Anchor channel reserves. + uint64 channel_amount_sats = 3; + + // Commit all available on-chain funds, minus fees and any required Anchor channel reserves. + AllFunds all_funds = 8; + } // The amount of satoshis to push to the remote side as part of the initial commitment state. optional uint64 push_to_counterparty_msat = 4; @@ -452,8 +458,14 @@ message SpliceInRequest { // 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; + // Required. The funds to splice into the channel. + oneof amount { + // Splice in the given amount of satoshis while retaining any required Anchor channel reserves. + uint64 splice_amount_sats = 3; + + // Splice in all available confirmed on-chain funds, minus fees and any required Anchor channel reserves. + AllFunds all_funds = 4; + } } // The response for the `SpliceIn` RPC. On failure, a gRPC error status is returned. diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 6557fbdf..7d3a42c2 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -37,6 +37,33 @@ fn parse_request(args: Value) -> Result { serde_json::from_value(args).map_err(|e| McpError::invalid_params(e.to_string())) } +fn parse_request_with_amount( + mut args: Value, amount_field: &str, +) -> Result { + { + let args = + args.as_object_mut().ok_or_else(|| McpError::invalid_params("Expected an object"))?; + let amount = args + .remove(amount_field) + .ok_or_else(|| McpError::invalid_params(format!("Missing `{amount_field}`")))?; + let amount = match amount { + Value::String(value) if value == "all" => json!({"all_funds": {}}), + Value::Number(_) => { + let mut amount_choice = serde_json::Map::new(); + amount_choice.insert(amount_field.to_string(), amount); + Value::Object(amount_choice) + }, + _ => { + return Err(McpError::invalid_params(format!( + "`{amount_field}` must be an integer or 'all'" + ))); + }, + }; + args.insert("amount".to_string(), amount); + } + parse_request(args) +} + fn serialize_response(response: T) -> Result { serde_json::to_value(response) .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) @@ -115,7 +142,7 @@ pub async fn handle_onchain_receive( } pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { - let request: OnchainSendRequest = parse_request(args)?; + let request: OnchainSendRequest = parse_request_with_amount(args, "amount_sats")?; let response = client.onchain_send(request).await.map_err(McpError::from)?; serialize_response(response) } @@ -241,13 +268,13 @@ pub async fn handle_unified_send(client: &LdkServerClient, args: Value) -> Resul } pub async fn handle_open_channel(client: &LdkServerClient, args: Value) -> Result { - let request: OpenChannelRequest = parse_request(args)?; + let request: OpenChannelRequest = parse_request_with_amount(args, "channel_amount_sats")?; let response = client.open_channel(request).await.map_err(McpError::from)?; serialize_response(response) } pub async fn handle_splice_in(client: &LdkServerClient, args: Value) -> Result { - let request: SpliceInRequest = parse_request(args)?; + let request: SpliceInRequest = parse_request_with_amount(args, "splice_amount_sats")?; let response = client.splice_in(request).await.map_err(McpError::from)?; serialize_response(response) } @@ -422,8 +449,63 @@ pub async fn handle_graph_get_node( #[cfg(test)] mod tests { + use ldk_server_client::ldk_server_grpc::api::{ + onchain_send_request, open_channel_request, splice_in_request, + }; + use super::*; + const NODE_PUBKEY: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn parse_request_with_amount_accepts_all() { + let request: OpenChannelRequest = parse_request_with_amount( + json!({ + "node_pubkey": NODE_PUBKEY, + "address": "127.0.0.1:9735", + "channel_amount_sats": "all" + }), + "channel_amount_sats", + ) + .unwrap(); + + assert!(matches!(request.amount, Some(open_channel_request::Amount::AllFunds(_)))); + } + + #[test] + fn parse_request_with_amount_preserves_exact_amount() { + let splice_amount_sats = 50_000; + let request: SpliceInRequest = parse_request_with_amount( + json!({ + "user_channel_id": "42", + "counterparty_node_id": NODE_PUBKEY, + "splice_amount_sats": splice_amount_sats + }), + "splice_amount_sats", + ) + .unwrap(); + + assert!(matches!( + request.amount, + Some(splice_in_request::Amount::SpliceAmountSats(amount_sats)) + if amount_sats == splice_amount_sats + )); + } + + #[test] + fn parse_request_with_amount_populates_onchain_oneof() { + let request: OnchainSendRequest = parse_request_with_amount( + json!({ + "address": "bc1qexample", + "amount_sats": "all" + }), + "amount_sats", + ) + .unwrap(); + + assert!(matches!(request.amount, Some(onchain_send_request::Amount::AllFunds(_)))); + } + #[test] fn parse_request_with_route_parameters_fills_missing_defaults() { let request: Bolt11SendRequest = parse_request_with_route_parameters( diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs index 46c372f9..e8aed08d 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -163,19 +163,18 @@ pub fn onchain_send_schema() -> Value { "description": "The Bitcoin address to send coins to" }, "amount_sats": { - "type": "integer", - "description": "The amount in satoshis to send. Respects on-chain reserve for anchor channels" - }, - "send_all": { - "type": "boolean", - "description": "If true, send all available balance while retaining on-chain reserves for anchor channels (amount_sats must be unset)" + "oneOf": [ + {"type": "integer"}, + {"type": "string", "const": "all"} + ], + "description": "The amount in satoshis to send, or 'all' to use all available on-chain funds. Respects on-chain reserve for anchor channels" }, "fee_rate_sat_per_vb": { "type": "integer", "description": "Fee rate in satoshis per virtual byte. If not set, a reasonable estimate will be used" } }, - "required": ["address"] + "required": ["address", "amount_sats"] }) } @@ -433,8 +432,11 @@ pub fn open_channel_schema() -> Value { "description": "Address of the remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" }, "channel_amount_sats": { - "type": "integer", - "description": "The amount in satoshis to commit to the channel" + "oneOf": [ + {"type": "integer"}, + {"type": "string", "const": "all"} + ], + "description": "The amount in satoshis to commit to the channel, or 'all' to use all available on-chain funds" }, "push_to_counterparty_msat": { "type": "integer", @@ -467,8 +469,11 @@ pub fn splice_in_schema() -> Value { "description": "The hex-encoded public key of the channel's counterparty node" }, "splice_amount_sats": { - "type": "integer", - "description": "The amount in satoshis to splice into the channel" + "oneOf": [ + {"type": "integer"}, + {"type": "string", "const": "all"} + ], + "description": "The amount in satoshis to splice into the channel, or 'all' to use all available on-chain funds" } }, "required": ["user_channel_id", "counterparty_node_id", "splice_amount_sats"] diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index 2947257d..15ff0f13 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -52,6 +52,15 @@ pub(crate) mod unified_send; pub(crate) mod update_channel_config; pub(crate) mod verify_signature; +pub(crate) fn require_amount(amount: Option) -> Result { + amount.ok_or_else(|| { + LdkServerError::new( + InvalidRequestError, + "Must specify either an exact amount or all available funds", + ) + }) +} + pub(crate) fn build_channel_config_from_proto( default_config: ChannelConfig, proto_channel_config: ldk_server_grpc::types::ChannelConfig, ) -> Result { @@ -140,6 +149,12 @@ pub(crate) fn node_to_proto_custom_tlv(node: &NodeCustomTlvRecord) -> ProtoCusto mod tests { use super::*; + #[test] + fn amount_is_required() { + assert_eq!(require_amount(Some(42)).unwrap(), 42); + assert!(require_amount::(None).is_err()); + } + #[test] fn proto_to_node_custom_tlv_preserves_fields() { let proto = diff --git a/ldk-server/src/api/onchain_send.rs b/ldk-server/src/api/onchain_send.rs index 4485824c..829da987 100644 --- a/ldk-server/src/api/onchain_send.rs +++ b/ldk-server/src/api/onchain_send.rs @@ -11,10 +11,12 @@ use std::str::FromStr; use std::sync::Arc; use ldk_node::bitcoin::{Address, FeeRate}; +use ldk_server_grpc::api::onchain_send_request::Amount; use ldk_server_grpc::api::{OnchainSendRequest, OnchainSendResponse}; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::api::require_amount; use crate::service::Context; pub(crate) async fn handle_onchain_send_request( @@ -31,19 +33,13 @@ pub(crate) async fn handle_onchain_send_request( })?; 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) => { + let txid = match require_amount(request.amount)? { + Amount::AmountSats(amount_sats) => { context.node.onchain_payment().send_to_address(&address, amount_sats, fee_rate)? }, - (None, Some(true)) => { + Amount::AllFunds(_) => { context.node.onchain_payment().send_all_to_address(&address, true, 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/src/api/open_channel.rs b/ldk-server/src/api/open_channel.rs index a654f66a..951a5680 100644 --- a/ldk-server/src/api/open_channel.rs +++ b/ldk-server/src/api/open_channel.rs @@ -13,10 +13,11 @@ use std::sync::Arc; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::config::ChannelConfig; use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_server_grpc::api::open_channel_request::Amount; use ldk_server_grpc::api::{OpenChannelRequest, OpenChannelResponse}; -use crate::api::build_channel_config_from_proto; use crate::api::error::{LdkServerError, LdkServerErrorCode}; +use crate::api::{build_channel_config_from_proto, require_amount}; use crate::service::Context; pub(crate) async fn handle_open_channel( @@ -26,43 +27,66 @@ pub(crate) async fn handle_open_channel( .map_err(|_| ldk_node::NodeError::InvalidPublicKey)?; let address = SocketAddress::from_str(&request.address) .map_err(|_| ldk_node::NodeError::InvalidSocketAddress)?; - if request.announce_channel && request.disable_counterparty_reserve { - return Err(LdkServerError::new( - LdkServerErrorCode::InvalidRequestError, - "Cannot set both `announce_channel` and `disable_counterparty_reserve`", - )); - } + + let amount = require_amount(request.amount)?; 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 if request.disable_counterparty_reserve { - context.node.open_0reserve_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 user_channel_id = + match (request.announce_channel, request.disable_counterparty_reserve, amount) { + (true, false, Amount::AllFunds(_)) => context.node.open_announced_channel_with_all( + node_id, + address, + request.push_to_counterparty_msat, + channel_config, + )?, + (true, false, Amount::ChannelAmountSats(amount_sats)) => { + context.node.open_announced_channel( + node_id, + address, + amount_sats, + request.push_to_counterparty_msat, + channel_config, + )? + }, + (false, true, Amount::AllFunds(_)) => context.node.open_0reserve_channel_with_all( + node_id, + address, + request.push_to_counterparty_msat, + channel_config, + )?, + (false, true, Amount::ChannelAmountSats(amount_sats)) => { + context.node.open_0reserve_channel( + node_id, + address, + amount_sats, + request.push_to_counterparty_msat, + channel_config, + )? + }, + (false, false, Amount::AllFunds(_)) => context.node.open_channel_with_all( + node_id, + address, + request.push_to_counterparty_msat, + channel_config, + )?, + (false, false, Amount::ChannelAmountSats(amount_sats)) => context.node.open_channel( + node_id, + address, + amount_sats, + request.push_to_counterparty_msat, + channel_config, + )?, + (true, true, _) => { + return Err(LdkServerError::new( + LdkServerErrorCode::InvalidRequestError, + "Cannot set both `announce_channel` and `disable_counterparty_reserve`", + )); + }, + }; let response = OpenChannelResponse { user_channel_id: user_channel_id.0.to_string() }; Ok(response) diff --git a/ldk-server/src/api/splice_channel.rs b/ldk-server/src/api/splice_channel.rs index 52a6989d..beba1cb3 100644 --- a/ldk-server/src/api/splice_channel.rs +++ b/ldk-server/src/api/splice_channel.rs @@ -13,12 +13,14 @@ use std::sync::Arc; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Address; use ldk_node::UserChannelId; +use ldk_server_grpc::api::splice_in_request::Amount; use ldk_server_grpc::api::{ SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, }; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::api::require_amount; use crate::service::Context; pub(crate) async fn handle_splice_in_request( @@ -27,7 +29,14 @@ pub(crate) async fn handle_splice_in_request( 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)?; + match require_amount(request.amount)? { + Amount::SpliceAmountSats(amount_sats) => { + context.node.splice_in(&user_channel_id, counterparty_node_id, amount_sats)? + }, + Amount::AllFunds(_) => { + context.node.splice_in_with_all(&user_channel_id, counterparty_node_id)? + }, + } Ok(SpliceInResponse {}) }