diff --git a/docs/api-guide.md b/docs/api-guide.md index 5621f15e..a08e5f54 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -97,13 +97,13 @@ RPCs with no permission mapping return `UNIMPLEMENTED`, even for admin tokens. | ---------- | ------ | | `node:read` | Node information, balances, and pathfinding scores | | `onchain:receive` | Create on-chain receive addresses | -| `onchain:send` | Send on-chain funds | +| `onchain:send` | Send on-chain funds or bump an on-chain payment fee | | `invoices:create` | Create BOLT11/BOLT12 invoices and incoming refund requests | | `payments:read` | Read payments, forwarded payments, and forwarding statistics | | `payments:claim` | Claim or fail held BOLT11 payments | | `payments:send` | Send BOLT11, BOLT12, spontaneous, unified, and refund payments; splice out | | `channels:read` | List channels | -| `channels:manage` | Open, configure, cooperatively close, or splice funds into channels | +| `channels:manage` | Open, configure, cooperatively close, splice funds into channels, or bump pending splice fees | | `channels:force_close` | Force-close channels | | `peers:read` | List peers | | `peers:manage` | Connect or disconnect peers | @@ -174,6 +174,12 @@ All RPCs are unary (single request, single response) unless noted otherwise. |------------------|----------------------------------------------------------------------| | `OnchainReceive` | Generate a new on-chain funding address | | `OnchainSend` | Send to a Bitcoin address (with optional fee rate and send-all mode) | +| `OnchainBumpFee` | Raise the fee of an unconfirmed outbound on-chain payment using RBF | + +`OnchainBumpFee` replaces a payment's transaction while preserving its payment ID and recipient +amount. Use the `payment_id` from `ListPayments`. The optional `fee_rate_sat_per_vb` sets the new +total fee rate in sat/vB; omit it to use an automatic rate. The response contains the replacement +`txid`. Confirmed, inbound, Lightning, and channel funding payments are not eligible. ### BOLT11 Payments @@ -229,15 +235,28 @@ a channel just-in-time when the invoice is paid. ### Channel Management -| RPC | Description | -|-----------------------|------------------------------------------------------------------------| -| `OpenChannel` | Open a new outbound channel (with optional push amount and fee config) | -| `CloseChannel` | Cooperatively close a channel | -| `ForceCloseChannel` | Force-close a channel unilaterally | -| `SpliceIn` | Add on-chain funds to an existing channel | -| `SpliceOut` | Remove funds from a channel back on-chain | -| `UpdateChannelConfig` | Update forwarding fees and CLTV expiry delta | -| `ListChannels` | List all channels with balances and configuration | +| RPC | Description | +| ----------------------- | ---------------------------------------------------------------------- | +| `OpenChannel` | Open a new outbound channel (with optional push amount and fee config) | +| `CloseChannel` | Cooperatively close a channel | +| `ForceCloseChannel` | Force-close a channel unilaterally | +| `SpliceIn` | Add on-chain funds to an existing channel | +| `SpliceOut` | Remove funds from a channel back on-chain | +| `BumpChannelFundingFee` | Raise the fee of a pending splice transaction | +| `UpdateChannelConfig` | Update forwarding fees and CLTV expiry delta | +| `ListChannels` | List all channels with balances and configuration | + +> [!NOTE] +> `BumpChannelFundingFee` supports pending splices only. It preserves the splice amount and +> destination, and LDK Node selects the fee rate. General channel-opening fee bumps and +> caller-selected splice fee rates are not supported. + +Call it on the node that contributed to the splice, using the channel's `user_channel_id` and +`counterparty_node_id`. A channel with no pending splice returns an error. An empty response means +the fee bump has started; use [channel events](#event-streaming) to follow its progress. + +The automatic increase can be below Bitcoin Core 29's minimum relay fee increase. Check that the +replacement transaction reaches the mempool; a successful RPC does not guarantee relay or confirmation. ### Payment History diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 47e86409..d31723f7 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -11,9 +11,10 @@ use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use corepc_node::Node; +use ldk_node::bitcoin::Amount; use ldk_server_client::client::{EventStream, LdkServerClient}; use ldk_server_client::error::LdkServerErrorCode; use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse}; @@ -30,7 +31,7 @@ use ldk_server_grpc::types::{ ClaimableAwaitingConfirmations, ForwardedPayment, LightningBalance, Payment, PaymentDirection, PaymentStatus, }; -use serde_json::Value; +use serde_json::{json, Value}; const EVENT_TIMEOUT: Duration = Duration::from_secs(15); @@ -58,6 +59,8 @@ impl TestBitcoind { fn with_extra_args(extra_args: &[&str]) -> Self { let mut conf = corepc_node::Conf::default(); + // Match the pinned LDK Node splice fixtures' 0.1 sat/vB relay fee increase. + conf.args.push("-incrementalrelayfee=0.00000100"); conf.args.extend_from_slice(extra_args); let bitcoind = match std::env::var("BITCOIND_EXE") { @@ -585,6 +588,17 @@ pub async fn wait_for_event( .expect("Timed out waiting for event") } +/// Wait for a negotiated splice and return its funding transaction ID. +pub async fn splice_txid(events: &mut EventStream) -> String { + let event = wait_for_event(events, |e| matches!(e, Event::SpliceNegotiated(_))).await; + match event.event.unwrap() { + Event::SpliceNegotiated(splice) => { + splice.new_funding_txo.split(':').next().unwrap().to_string() + }, + _ => unreachable!(), + } +} + /// Poll get_node_info until the server responds successfully. async fn wait_for_server_ready(handle: &LdkServerHandle, timeout: Duration) -> GetNodeInfoResponse { let start = std::time::Instant::now(); @@ -772,6 +786,81 @@ pub async fn mine_and_sync( } } +/// Wait for a transaction to enter the mempool and return its decoded details. +pub async fn wait_for_transaction(bitcoind: &TestBitcoind, txid: &str) -> Value { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let mempool: Vec = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap(); + if mempool.iter().any(|id| id == txid) { + return bitcoind + .bitcoind + .client + .call("getrawtransaction", &[json!(txid), json!(true)]) + .unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("transaction did not enter the mempool") +} + +/// Wait for the on-chain wallet to complete another sync. +/// +/// The pinned wallet records a replacement before its background sync sees the transaction. +pub async fn wait_for_wallet_sync(server: &LdkServerHandle) { + let after = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let info = server.client().get_node_info(GetNodeInfoRequest {}).await.unwrap(); + if info.latest_onchain_wallet_sync_timestamp.is_some_and(|timestamp| timestamp > after) + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("wallet did not sync after the replacement"); +} + +/// Wait for a transaction to appear in the node's payment history. +pub async fn payment_for_tx(server: &LdkServerHandle, txid: &str) -> Payment { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + for payment in list_payments(server).await { + if let Some(payment_kind::Kind::Onchain(onchain)) = + payment.kind.as_ref().and_then(|kind| kind.kind.as_ref()) + { + if onchain.txid == txid { + return payment; + } + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("payment was not recorded") +} + +/// Check that a replacement preserves the recipient amount. +pub async fn assert_replacement( + bitcoind: &TestBitcoind, old: &str, new: &str, address: &str, amount: Amount, +) { + assert_ne!(old, new); + let tx = wait_for_transaction(bitcoind, new).await; + let recipient = tx["vout"] + .as_array() + .unwrap() + .iter() + .find(|output| output["scriptPubKey"]["address"] == address) + .unwrap(); + assert_eq!(Amount::from_btc(recipient["value"].as_f64().unwrap()).unwrap(), amount); + let mempool: Vec = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap(); + assert!(!mempool.iter().any(|id| id == old)); +} + /// Wait until the given client has at least one usable channel, /// periodically mining blocks to trigger chain sync. pub async fn wait_for_usable_channel( diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 132b08c6..be22d12a 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -12,32 +12,39 @@ use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use e2e_tests::{ - close_channel, find_available_port, list_payments, mine_and_sync, run_cli, run_cli_raw, - run_cli_with_config, send_bolt11_payment, setup_funded_channel, wait_for_channels, - wait_for_event, wait_for_gossip, wait_for_onchain_balance, wait_for_settled_balance, - wait_for_usable_channel, LdkServerConfig, LdkServerHandle, TestBitcoind, TestConfigBuilder, + assert_replacement, close_channel, find_available_port, list_payments, mine_and_sync, + payment_for_tx, run_cli, run_cli_raw, run_cli_with_config, send_bolt11_payment, + setup_funded_channel, splice_txid, wait_for_channels, wait_for_event, wait_for_gossip, + wait_for_onchain_balance, wait_for_settled_balance, wait_for_transaction, + wait_for_usable_channel, wait_for_wallet_sync, LdkServerConfig, LdkServerHandle, TestBitcoind, + TestConfigBuilder, }; use hex_conservative::{DisplayHex, FromHex}; use ldk_node::bitcoin::hashes::{sha256, Hash}; +use ldk_node::bitcoin::Amount; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::offers::offer::Offer; use ldk_node::lightning::offers::refund::Refund; use ldk_node::lightning_invoice::Bolt11Invoice; -use ldk_server_client::error::LdkServerErrorCode::InvalidRequestError; +use ldk_server_client::error::LdkServerErrorCode::{InvalidRequestError, LightningError}; use ldk_server_client::ldk_server_grpc::api::{ - open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest, Bolt11ReceiveRequest, - Bolt12ReceiveRequest, GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, - ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest, - ListForwardedPaymentsRequest, OnchainReceiveRequest, OpenChannelRequest, + onchain_send_request, open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest, + Bolt11ReceiveRequest, Bolt11SendRequest, Bolt12ReceiveRequest, BumpChannelFundingFeeRequest, + GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, GetPaymentDetailsRequest, + ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest, ListChannelsRequest, + ListForwardedPaymentsRequest, OnchainBumpFeeRequest, OnchainReceiveRequest, OnchainSendRequest, + OpenChannelRequest, }; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; use ldk_server_client::ldk_server_grpc::events::{ ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind, PaymentFailureReason, }; use ldk_server_client::ldk_server_grpc::types::{ - bolt11_invoice_description, Bolt11InvoiceDescription, ChannelShutdownState, ReserveType, + bolt11_invoice_description, Bolt11InvoiceDescription, ChannelShutdownState, PaymentDirection, + ReserveType, }; use ldk_server_grpc::types::payment_kind; +use serde_json::json; #[tokio::test] async fn test_cli_get_node_info() { @@ -411,6 +418,20 @@ async fn test_cli_onchain_send() { let recv_output = run_cli(&server, &["onchain-receive"]); let dest_addr = recv_output["address"].as_str().unwrap(); + for rate in [0, u64::MAX / 250 + 1, u64::MAX] { + let error = server + .client() + .onchain_send(OnchainSendRequest { + address: dest_addr.into(), + amount: Some(onchain_send_request::Amount::AmountSats(50_000)), + fee_rate_sat_per_vb: Some(rate), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + assert_eq!(error.message, ldk_node::NodeError::InvalidFeeRate.to_string()); + } + let output = run_cli(&server, &["onchain-send", dest_addr, "50000sat"]); assert!(!output["txid"].as_str().unwrap().is_empty()); } @@ -435,6 +456,176 @@ async fn test_cli_onchain_send_all() { wait_for_settled_balance(&server, 0, Duration::from_secs(30)).await; } +#[tokio::test] +async fn test_onchain_fee_bump_client_cli() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let address = server.client().onchain_receive(OnchainReceiveRequest {}).await.unwrap().address; + bitcoind.fund_address(&address, 1.0); + mine_and_sync(&bitcoind, &[&server], 6).await; + wait_for_onchain_balance(server.client(), Duration::from_secs(30)).await; + let destination = bitcoind.bitcoind.client.new_address().unwrap().to_string(); + let amount = Amount::from_sat(50_000); + let original = server + .client() + .onchain_send(OnchainSendRequest { + address: destination.clone(), + amount: Some(onchain_send_request::Amount::AmountSats(amount.to_sat())), + fee_rate_sat_per_vb: Some(2), + }) + .await + .unwrap() + .txid; + wait_for_transaction(&bitcoind, &original).await; + let payment = payment_for_tx(&server, &original).await; + assert_ne!(payment.payment_id, original, "payment IDs use a different byte order"); + + // A rate below the original must fail instead of selecting an automatic rate. + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: payment.payment_id.clone(), + fee_rate_sat_per_vb: Some(1), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + + let replacement = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: payment.payment_id.clone(), + fee_rate_sat_per_vb: Some(5), + }) + .await + .unwrap() + .txid; + assert_replacement(&bitcoind, &original, &replacement, &destination, amount).await; + wait_for_wallet_sync(&server).await; + let cli = + run_cli(&server, &["onchain-bump-fee", &payment.payment_id, "--fee-rate-sat-per-vb", "10"]); + let cli_txid = cli["txid"].as_str().unwrap(); + assert_replacement(&bitcoind, &replacement, cli_txid, &destination, amount).await; + let updated = payment_for_tx(&server, cli_txid).await; + assert_eq!(updated.payment_id, payment.payment_id); + assert_eq!(updated.amount_msat, Some(amount.to_sat() * 1000)); + assert!(updated.fee_paid_msat > payment.fee_paid_msat); + + mine_and_sync(&bitcoind, &[&server], 6).await; + // Wait for wallet confirmation, which can follow the Lightning tip update. + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let details = server + .client() + .get_payment_details(GetPaymentDetailsRequest { + payment_id: payment.payment_id.clone(), + }) + .await + .unwrap() + .payment + .unwrap(); + if let Some(payment_kind::Kind::Onchain(onchain)) = + details.kind.and_then(|kind| kind.kind) + { + if matches!( + onchain.status.and_then(|status| status.status), + Some(ldk_server_grpc::types::confirmation_status::Status::Confirmed(_)) + ) { + break; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .unwrap(); + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: payment.payment_id, + fee_rate_sat_per_vb: Some(20), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); +} + +#[tokio::test] +async fn test_onchain_fee_bump_invalid_requests_and_ineligible_payments() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + for id in [String::new(), "ab".repeat(31), "ab".repeat(33), "zz".repeat(32), "00".repeat(32)] { + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { payment_id: id, fee_rate_sat_per_vb: None }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + } + for rate in [0, u64::MAX / 250 + 1, u64::MAX] { + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: "00".repeat(32), + fee_rate_sat_per_vb: Some(rate), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + assert_eq!(error.message, ldk_node::NodeError::InvalidFeeRate.to_string()); + } + + let peer = LdkServerHandle::start(&bitcoind).await; + let invoice = peer + .client() + .bolt11_receive(Bolt11ReceiveRequest { + amount_msat: Some(100_000), + expiry_secs: 3600, + ..Default::default() + }) + .await + .unwrap() + .invoice; + // A failed send with no route still records an outbound Lightning payment. + let _ = server.client().bolt11_send(Bolt11SendRequest { invoice, ..Default::default() }).await; + + let payments = list_payments(&server).await; + let lightning = payments + .iter() + .find(|p| { + matches!( + p.kind.as_ref().and_then(|k| k.kind.as_ref()), + Some(payment_kind::Kind::Bolt11(_)) + ) + }) + .unwrap(); + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: lightning.payment_id.clone(), + fee_rate_sat_per_vb: None, + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + + let address = server.client().onchain_receive(OnchainReceiveRequest {}).await.unwrap().address; + // Leave the incoming payment unconfirmed to exercise the direction guard. + let txid: String = + bitcoind.bitcoind.client.call("sendtoaddress", &[json!(address), json!(0.1)]).unwrap(); + let incoming = payment_for_tx(&server, &txid).await; + assert_eq!(incoming.direction, PaymentDirection::Inbound as i32); + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: incoming.payment_id, + fee_rate_sat_per_vb: None, + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); +} + #[tokio::test] async fn test_cli_connect_peer() { let bitcoind = TestBitcoind::new(); @@ -1331,6 +1522,132 @@ async fn test_cli_splice_out() { assert!(address.starts_with("bcrt1"), "Expected regtest address, got: {}", address); } +#[tokio::test] +async fn test_pending_splice_fee_bump_client_cli() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let peer = LdkServerHandle::start(&bitcoind).await; + let channel = setup_funded_channel(&bitcoind, &server, &peer, 100_000).await; + let request = BumpChannelFundingFeeRequest { + user_channel_id: channel.clone(), + counterparty_node_id: peer.node_id().into(), + }; + let error = server.client().bump_channel_funding_fee(request.clone()).await.unwrap_err(); + assert_eq!(error.error_code, LightningError); + let mut wrong_peer = request.clone(); + wrong_peer.counterparty_node_id = server.node_id().into(); + assert_eq!( + server.client().bump_channel_funding_fee(wrong_peer).await.unwrap_err().error_code, + LightningError + ); + + let mut events = server.client().subscribe_events().await.unwrap(); + // Use the same funded channel and splice-in operation as the existing splice fixtures. + run_cli(&server, &["splice-in", &channel, peer.node_id(), "50000sat"]); + let original = splice_txid(&mut events).await; + let original_tx = wait_for_transaction(&bitcoind, &original).await; + let funding_output = original_tx["vout"] + .as_array() + .unwrap() + .iter() + .find(|output| output["scriptPubKey"]["type"] == "witness_v0_scripthash") + .unwrap(); + let expected_channel_value = + ldk_node::bitcoin::Amount::from_btc(funding_output["value"].as_f64().unwrap()) + .unwrap() + .to_sat(); + assert!(expected_channel_value >= 150_000); + + let funding = payment_for_tx(&server, &original).await; + let error = server + .client() + .onchain_bump_fee(OnchainBumpFeeRequest { + payment_id: funding.payment_id, + fee_rate_sat_per_vb: Some(10), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + + server.client().bump_channel_funding_fee(request.clone()).await.unwrap(); + let replacement = splice_txid(&mut events).await; + assert_ne!(original, replacement); + wait_for_transaction(&bitcoind, &replacement).await; + let cli = run_cli(&server, &["bump-channel-funding-fee", &channel, peer.node_id()]); + assert_eq!(cli, json!({})); + let cli_txid = splice_txid(&mut events).await; + assert_ne!(replacement, cli_txid); + let replacement_tx = wait_for_transaction(&bitcoind, &cli_txid).await; + let replacement_output = replacement_tx["vout"] + .as_array() + .unwrap() + .iter() + .find(|output| output["scriptPubKey"] == funding_output["scriptPubKey"]) + .unwrap(); + assert_eq!(replacement_output["value"], funding_output["value"]); + let mempool: Vec = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap(); + for old in [&original, &replacement] { + assert!(!mempool.contains(old)); + } + mine_and_sync(&bitcoind, &[&server, &peer], 6).await; + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let channels = server.client().list_channels(ListChannelsRequest {}).await.unwrap(); + if channels.channels.iter().any(|c| { + c.user_channel_id == channel + && c.channel_value_sats == expected_channel_value + && c.is_usable + }) { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("replacement splice did not confirm with the original amount"); + assert_eq!( + server.client().bump_channel_funding_fee(request).await.unwrap_err().error_code, + LightningError + ); +} + +#[tokio::test] +async fn test_pending_splice_fee_bump_invalid_requests() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + for id in ["", "-1", "xyz", "340282366920938463463374607431768211456"] { + let error = server + .client() + .bump_channel_funding_fee(BumpChannelFundingFeeRequest { + user_channel_id: id.into(), + counterparty_node_id: server.node_id().into(), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + } + for peer in ["", "invalid", &"00".repeat(33)] { + let error = server + .client() + .bump_channel_funding_fee(BumpChannelFundingFeeRequest { + user_channel_id: "1".into(), + counterparty_node_id: peer.into(), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, InvalidRequestError); + } + let error = server + .client() + .bump_channel_funding_fee(BumpChannelFundingFeeRequest { + user_channel_id: u128::MAX.to_string(), + counterparty_node_id: server.node_id().into(), + }) + .await + .unwrap_err(); + assert_eq!(error.error_code, LightningError); +} + #[tokio::test] async fn test_cli_graph_list_channels_empty() { let bitcoind = TestBitcoind::new(); diff --git a/e2e-tests/tests/macaroons.rs b/e2e-tests/tests/macaroons.rs index afdd3bc1..2cd0d9f4 100644 --- a/e2e-tests/tests/macaroons.rs +++ b/e2e-tests/tests/macaroons.rs @@ -101,6 +101,27 @@ async fn test_scoped_macaroon_lifecycle() { ); } +#[tokio::test] +async fn test_macaroon_onchain_fee_bump_permissions() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + // An invalid payment ID distinguishes reaching the handler from an auth rejection. + for (name, permission, expected_error) in [ + ("onchain-sender", "onchain:send", InvalidRequestError), + ("lightning-sender", "payments:send", AuthorizationError), + ("reader", "payments:read", AuthorizationError), + ("full-admin", "admin", InvalidRequestError), + ] { + let created = run_cli(&server, &["create-macaroon", name, "--permissions", permission]); + let scoped_client = + client_with_macaroon(&server, created["token"].as_str().unwrap().to_string()); + assert_eq!( + scoped_client.onchain_bump_fee(Default::default()).await.unwrap_err().error_code, + expected_error + ); + } +} + #[tokio::test] async fn test_macaroon_splice_permissions() { let bitcoind = TestBitcoind::new(); @@ -123,6 +144,14 @@ async fn test_macaroon_splice_permissions() { scoped_client.splice_out(Default::default()).await.unwrap_err().error_code, splice_out_error ); + assert_eq!( + scoped_client + .bump_channel_funding_fee(Default::default()) + .await + .unwrap_err() + .error_code, + splice_in_error + ); } } diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 53eb6a5e..119d9c23 100644 --- a/e2e-tests/tests/mcp.rs +++ b/e2e-tests/tests/mcp.rs @@ -8,10 +8,19 @@ // licenses. use std::str::FromStr; +use std::time::Duration; -use e2e_tests::{setup_funded_channel, wait_for_event, LdkServerHandle, McpHandle, TestBitcoind}; +use e2e_tests::{ + assert_replacement, mine_and_sync, payment_for_tx, setup_funded_channel, splice_txid, + wait_for_event, wait_for_onchain_balance, wait_for_transaction, wait_for_wallet_sync, + LdkServerHandle, McpHandle, TestBitcoind, +}; +use ldk_node::bitcoin::Amount; use ldk_node::lightning::offers::refund::Refund; -use ldk_server_client::ldk_server_grpc::api::Bolt11ReceiveRequest; +use ldk_server_client::ldk_server_grpc::api::{ + onchain_send_request, splice_in_request, Bolt11ReceiveRequest, OnchainReceiveRequest, + OnchainSendRequest, SpliceInRequest, +}; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, payment_kind, Bolt11InvoiceDescription, @@ -232,3 +241,103 @@ async fn test_mcp_bolt12_refund() { assert_eq!(refund.hash.as_deref(), Some(payment_hash)); wait_for_event(&mut events_b, |event| matches!(event, Event::PaymentSuccessful(_))).await; } + +#[tokio::test] +async fn test_mcp_onchain_fee_bump() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let address = server.client().onchain_receive(OnchainReceiveRequest {}).await.unwrap().address; + bitcoind.fund_address(&address, 1.0); + mine_and_sync(&bitcoind, &[&server], 6).await; + wait_for_onchain_balance(server.client(), Duration::from_secs(30)).await; + let destination = bitcoind.bitcoind.client.new_address().unwrap().to_string(); + let amount = Amount::from_sat(50_000); + let original = server + .client() + .onchain_send(OnchainSendRequest { + address: destination.clone(), + amount: Some(onchain_send_request::Amount::AmountSats(amount.to_sat())), + fee_rate_sat_per_vb: Some(2), + }) + .await + .unwrap() + .txid; + wait_for_transaction(&bitcoind, &original).await; + let payment = payment_for_tx(&server, &original).await; + let mut mcp = McpHandle::start(&server); + let mut previous = original; + for rate in [Some(5), None] { + let mut arguments = json!({"payment_id": payment.payment_id}); + if let Some(rate) = rate { + arguments["fee_rate_sat_per_vb"] = json!(rate); + } + let response = mcp.call( + 1, + "tools/call", + json!({ + "name": "onchain_bump_fee", "arguments": arguments + }), + ); + assert_ne!(response["result"]["isError"], true, "{response}"); + let output = tool_result_json(&response); + let replacement = output["txid"].as_str().unwrap(); + assert_replacement(&bitcoind, &previous, replacement, &destination, amount).await; + let updated = payment_for_tx(&server, replacement).await; + assert_eq!(updated.payment_id, payment.payment_id); + assert_eq!(updated.amount_msat, Some(amount.to_sat() * 1000)); + assert!(updated.fee_paid_msat > payment.fee_paid_msat); + previous = replacement.to_string(); + if rate.is_some() { + wait_for_wallet_sync(&server).await; + } + } +} + +#[tokio::test] +async fn test_mcp_pending_splice_fee_bump() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let peer = LdkServerHandle::start(&bitcoind).await; + let channel = setup_funded_channel(&bitcoind, &server, &peer, 100_000).await; + let mut events = server.client().subscribe_events().await.unwrap(); + server + .client() + .splice_in(SpliceInRequest { + user_channel_id: channel.clone(), + counterparty_node_id: peer.node_id().into(), + amount: Some(splice_in_request::Amount::SpliceAmountSats(50_000)), + }) + .await + .unwrap(); + let original = splice_txid(&mut events).await; + let original_tx = wait_for_transaction(&bitcoind, &original).await; + let funding_output = original_tx["vout"] + .as_array() + .unwrap() + .iter() + .find(|output| output["scriptPubKey"]["type"] == "witness_v0_scripthash") + .unwrap(); + let mut mcp = McpHandle::start(&server); + let response = mcp.call( + 1, + "tools/call", + json!({ + "name": "bump_channel_funding_fee", + "arguments": {"user_channel_id": channel, "counterparty_node_id": peer.node_id()} + }), + ); + assert_ne!(response["result"]["isError"], true, "{response}"); + assert_eq!(tool_result_json(&response), json!({})); + let replacement = splice_txid(&mut events).await; + assert_ne!(original, replacement); + let replacement_tx = wait_for_transaction(&bitcoind, &replacement).await; + let replacement_output = replacement_tx["vout"] + .as_array() + .unwrap() + .iter() + .find(|output| output["scriptPubKey"] == funding_output["scriptPubKey"]) + .unwrap(); + assert_eq!(replacement_output["value"], funding_output["value"]); + let mempool: Vec = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap(); + assert!(!mempool.contains(&original)); +} diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 990f3e46..6b90816d 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -35,11 +35,12 @@ use ldk_server_client::ldk_server_grpc::api::{ Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12CreatePayerProofRequest, Bolt12CreatePayerProofResponse, Bolt12ReceiveRefundRequest, Bolt12ReceiveRefundResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRefundRequest, Bolt12SendRefundResponse, - Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, - ConnectPeerRequest, ConnectPeerResponse, CreateMacaroonRequest, CreateMacaroonResponse, - DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, - DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, - ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, + Bolt12SendRequest, Bolt12SendResponse, BumpChannelFundingFeeRequest, + BumpChannelFundingFeeResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, + ConnectPeerResponse, CreateMacaroonRequest, CreateMacaroonResponse, DecodeInvoiceRequest, + DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest, + DisconnectPeerResponse, ExportPathfindingScoresRequest, ForceCloseChannelRequest, + ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetChannelForwardingStatsRequest, GetChannelForwardingStatsResponse, GetForwardedPaymentDetailsRequest, GetForwardedPaymentDetailsResponse, GetForwardedPaymentTrackingModeRequest, GetForwardedPaymentTrackingModeResponse, @@ -49,12 +50,13 @@ use ldk_server_client::ldk_server_grpc::api::{ GraphListNodesRequest, GraphListNodesResponse, ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest, ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, ListMacaroonsRequest, ListMacaroonsResponse, ListPaymentsRequest, - ListPeersRequest, ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, - OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, - RevokeMacaroonRequest, RevokeMacaroonResponse, SignMessageRequest, SignMessageResponse, - SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, - SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, - UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, + ListPeersRequest, ListPeersResponse, OnchainBumpFeeRequest, OnchainBumpFeeResponse, + OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, + OpenChannelRequest, OpenChannelResponse, RevokeMacaroonRequest, RevokeMacaroonResponse, + SignMessageRequest, SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, + SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, + UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, + VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_client::ldk_server_grpc::permissions::MacaroonPreset; use ldk_server_client::ldk_server_grpc::types::{ @@ -137,6 +139,18 @@ enum Commands { )] fee_rate_sat_per_vb: Option, }, + #[command(about = "Replace an unconfirmed outbound on-chain payment using RBF")] + OnchainBumpFee { + #[arg( + help = "Payment ID from list-payments: 32 bytes encoded as hex, not the transaction ID" + )] + payment_id: String, + #[arg( + long, + help = "Absolute fee rate in sat/vB, not an increment. Must be positive and high enough for RBF. If omitted, LDK Node selects the rate" + )] + fee_rate_sat_per_vb: Option, + }, #[command(about = "Create a BOLT11 invoice to receive a payment")] Bolt11Receive { #[arg( @@ -558,6 +572,15 @@ enum Commands { )] address: Option, }, + #[command( + about = "Bump a pending splice fee. Does not support general channel-opening fee bumping. LDK Node selects the fee rate; callers cannot set it" + )] + BumpChannelFundingFee { + #[arg(help = "The local user channel ID as a decimal u128 string")] + user_channel_id: String, + #[arg(help = "The hex-encoded public key of the channel's peer")] + counterparty_node_id: String, + }, #[command(about = "Return a list of known channels")] ListChannels, #[command(about = "Retrieve list of all payments")] @@ -831,6 +854,13 @@ async fn main() { client.onchain_receive(OnchainReceiveRequest {}).await, ); }, + Commands::OnchainBumpFee { payment_id, fee_rate_sat_per_vb } => { + handle_response_result::<_, OnchainBumpFeeResponse>( + client + .onchain_bump_fee(OnchainBumpFeeRequest { payment_id, fee_rate_sat_per_vb }) + .await, + ); + }, 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), @@ -1285,6 +1315,16 @@ async fn main() { .await, ); }, + Commands::BumpChannelFundingFee { user_channel_id, counterparty_node_id } => { + handle_response_result::<_, BumpChannelFundingFeeResponse>( + client + .bump_channel_funding_fee(BumpChannelFundingFeeRequest { + user_channel_id, + counterparty_node_id, + }) + .await, + ); + }, Commands::ListChannels => { handle_response_result::<_, ListChannelsResponse>( client.list_channels(ListChannelsRequest {}).await, @@ -1724,6 +1764,56 @@ mod tests { assert!(help.contains("[possible values: readonly, invoice, admin]")); } + #[test] + fn onchain_bump_fee_arguments() { + for rate in [None, Some("12")] { + let mut args = vec!["ldk-server-cli", "onchain-bump-fee", "payment"]; + if let Some(rate) = rate { + args.extend(["--fee-rate-sat-per-vb", rate]); + } + let cli = Cli::try_parse_from(args).unwrap(); + match cli.command { + Commands::OnchainBumpFee { payment_id, fee_rate_sat_per_vb } => { + assert_eq!(payment_id, "payment"); + assert_eq!(fee_rate_sat_per_vb, rate.map(|r| r.parse().unwrap())); + }, + _ => panic!("wrong command"), + } + } + for rate in ["-1", "1.5", "18446744073709551616"] { + assert!(Cli::try_parse_from([ + "ldk-server-cli", + "onchain-bump-fee", + "payment", + "--fee-rate-sat-per-vb", + rate + ]) + .is_err()); + } + } + + #[test] + fn bump_channel_funding_fee_arguments() { + let cli = Cli::try_parse_from(["ldk-server-cli", "bump-channel-funding-fee", "42", "peer"]) + .unwrap(); + match cli.command { + Commands::BumpChannelFundingFee { user_channel_id, counterparty_node_id } => { + assert_eq!(user_channel_id, "42"); + assert_eq!(counterparty_node_id, "peer"); + }, + _ => panic!("wrong command"), + } + assert!(Cli::try_parse_from([ + "ldk-server-cli", + "bump-channel-funding-fee", + "42", + "peer", + "--fee-rate-sat-per-vb", + "10" + ]) + .is_err()); + } + #[tokio::test] async fn fetch_paginated_collects_multiple_pages() { let response = fetch_paginated( diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 87680c50..0bae0bdb 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -21,48 +21,49 @@ use ldk_server_grpc::api::{ Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12CreatePayerProofRequest, Bolt12CreatePayerProofResponse, Bolt12ReceiveRefundRequest, Bolt12ReceiveRefundResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRefundRequest, Bolt12SendRefundResponse, - Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, - ConnectPeerRequest, ConnectPeerResponse, CreateMacaroonRequest, CreateMacaroonResponse, - DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, - DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, - ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, - GetBalancesRequest, GetBalancesResponse, GetChannelForwardingStatsRequest, - GetChannelForwardingStatsResponse, GetForwardedPaymentDetailsRequest, - GetForwardedPaymentDetailsResponse, GetForwardedPaymentTrackingModeRequest, - GetForwardedPaymentTrackingModeResponse, GetNodeInfoRequest, GetNodeInfoResponse, - GetPaymentDetailsRequest, GetPaymentDetailsResponse, GetPermissionsRequest, - GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, - GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, + Bolt12SendRequest, Bolt12SendResponse, BumpChannelFundingFeeRequest, + BumpChannelFundingFeeResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, + ConnectPeerResponse, CreateMacaroonRequest, CreateMacaroonResponse, DecodeInvoiceRequest, + DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest, + DisconnectPeerResponse, ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, + ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, + GetChannelForwardingStatsRequest, GetChannelForwardingStatsResponse, + GetForwardedPaymentDetailsRequest, GetForwardedPaymentDetailsResponse, + GetForwardedPaymentTrackingModeRequest, GetForwardedPaymentTrackingModeResponse, + GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + GetPermissionsRequest, GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, + GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelForwardingStatsRequest, ListChannelForwardingStatsResponse, ListChannelPairForwardingStatsRequest, ListChannelPairForwardingStatsResponse, ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListMacaroonsRequest, ListMacaroonsResponse, ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, - ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, - OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, RevokeMacaroonRequest, - RevokeMacaroonResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, - SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, - SpontaneousSendResponse, SubscribeEventsRequest, UnifiedSendRequest, UnifiedSendResponse, - UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, - VerifySignatureResponse, + ListPeersResponse, OnchainBumpFeeRequest, OnchainBumpFeeResponse, OnchainReceiveRequest, + OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, + OpenChannelResponse, RevokeMacaroonRequest, RevokeMacaroonResponse, SignMessageRequest, + SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, + SpontaneousSendRequest, SpontaneousSendResponse, SubscribeEventsRequest, UnifiedSendRequest, + UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, + VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_grpc::endpoints::{ BOLT11_CLAIM_FOR_ID_PATH, BOLT11_FAIL_FOR_ID_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, BOLT12_CREATE_PAYER_PROOF_PATH, BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, - BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, - CREATE_MACAROON_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, - EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, - GET_CHANNEL_FORWARDING_STATS_PATH, GET_FORWARDED_PAYMENT_DETAILS_PATH, + BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, BUMP_CHANNEL_FUNDING_FEE_PATH, CLOSE_CHANNEL_PATH, + CONNECT_PEER_PATH, CREATE_MACAROON_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, + DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, + GET_BALANCES_PATH, GET_CHANNEL_FORWARDING_STATS_PATH, GET_FORWARDED_PAYMENT_DETAILS_PATH, GET_FORWARDED_PAYMENT_TRACKING_MODE_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GET_PERMISSIONS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, GRPC_SERVICE_PREFIX, LIST_CHANNELS_PATH, LIST_CHANNEL_FORWARDING_STATS_PATH, LIST_CHANNEL_PAIR_FORWARDING_STATS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, - ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_MACAROON_PATH, - SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, - SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + ONCHAIN_BUMP_FEE_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, + REVOKE_MACAROON_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, + SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, + VERIFY_SIGNATURE_PATH, }; use ldk_server_grpc::events::EventEnvelope; use ldk_server_grpc::grpc::{ @@ -191,6 +192,15 @@ impl LdkServerClient { self.grpc_unary(&request, ONCHAIN_SEND_PATH).await } + /// Replace an unconfirmed outbound on-chain payment using RBF. + /// The payment ID is 32 bytes encoded as hex. The optional absolute rate is in sat/vB. + /// Funding payments are not eligible. Returns the replacement transaction ID. + pub async fn onchain_bump_fee( + &self, request: OnchainBumpFeeRequest, + ) -> Result { + self.grpc_unary(&request, ONCHAIN_BUMP_FEE_PATH).await + } + /// Retrieve a new BOLT11 payable invoice. pub async fn bolt11_receive( &self, request: Bolt11ReceiveRequest, @@ -307,6 +317,15 @@ impl LdkServerClient { self.grpc_unary(&request, SPLICE_OUT_PATH).await } + /// Initiate a fee bump for a pending splice, preserving its amount and destination. + /// This does not support general channel-opening fee bumping or a caller-selected fee rate. + /// Returns an error if the channel has no pending splice. + pub async fn bump_channel_funding_fee( + &self, request: BumpChannelFundingFeeRequest, + ) -> Result { + self.grpc_unary(&request, BUMP_CHANNEL_FUNDING_FEE_PATH).await + } + /// Closes the channel specified by given request. pub async fn close_channel( &self, request: CloseChannelRequest, diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 070167e1..003339a0 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -165,6 +165,35 @@ pub struct OnchainSendResponse { #[prost(string, tag = "1")] pub txid: ::prost::alloc::string::String, } +/// Replace an unconfirmed outbound on-chain payment with a higher-fee transaction. +/// Channel funding payments (including splices) are not eligible. +#[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 OnchainBumpFeeRequest { + /// The payment ID from ListPayments or GetPaymentDetails: 32 bytes encoded as hex. + /// This is not the displayed transaction ID. + #[prost(string, tag = "1")] + pub payment_id: ::prost::alloc::string::String, + /// Absolute fee rate in satoshis per virtual byte, not a fee increment. + /// Must be positive and high enough to replace the current transaction. + /// If omitted, LDK Node selects a rate from its estimate and the RBF minimum. + #[prost(uint64, optional, tag = "2")] + pub fee_rate_sat_per_vb: ::core::option::Option, +} +/// The response for OnchainBumpFee. On failure, a gRPC error status is returned. +#[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 OnchainBumpFeeResponse { + /// The replacement transaction ID. The recipient amount is preserved. + #[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. @@ -777,6 +806,30 @@ pub struct SpliceOutResponse { #[prost(string, tag = "1")] pub address: ::prost::alloc::string::String, } +/// Replace a pending splice transaction with a higher-fee transaction. +/// This does not support general channel-opening fee bumping. +/// LDK Node selects the fee rate; callers cannot set it. +#[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 BumpChannelFundingFeeRequest { + /// The local user channel ID as a decimal u128 string. + #[prost(string, tag = "1")] + pub user_channel_id: ::prost::alloc::string::String, + /// The hex-encoded public key of the channel's peer. + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, +} +/// The pending splice fee bump was initiated. Confirmation is asynchronous. +/// On failure, a gRPC error status is returned, including when no splice is pending. +#[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 BumpChannelFundingFeeResponse {} /// Update the config for a previously opened channel. /// See more: #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server-grpc/src/endpoints.rs b/ldk-server-grpc/src/endpoints.rs index 2bdb3ce0..65a421ef 100644 --- a/ldk-server-grpc/src/endpoints.rs +++ b/ldk-server-grpc/src/endpoints.rs @@ -13,6 +13,7 @@ pub const GRPC_SERVICE_PREFIX: &str = "/api.LightningNode/"; 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_BUMP_FEE_PATH: &str = "OnchainBumpFee"; pub const ONCHAIN_SEND_PATH: &str = "OnchainSend"; pub const BOLT11_RECEIVE_PATH: &str = "Bolt11Receive"; pub const BOLT11_RECEIVE_FOR_HASH_PATH: &str = "Bolt11ReceiveForHash"; @@ -31,6 +32,7 @@ pub const BOLT12_CREATE_PAYER_PROOF_PATH: &str = "Bolt12CreatePayerProof"; pub const OPEN_CHANNEL_PATH: &str = "OpenChannel"; pub const SPLICE_IN_PATH: &str = "SpliceIn"; pub const SPLICE_OUT_PATH: &str = "SpliceOut"; +pub const BUMP_CHANNEL_FUNDING_FEE_PATH: &str = "BumpChannelFundingFee"; pub const CLOSE_CHANNEL_PATH: &str = "CloseChannel"; pub const FORCE_CLOSE_CHANNEL_PATH: &str = "ForceCloseChannel"; pub const LIST_CHANNELS_PATH: &str = "ListChannels"; diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index a1b9ef7b..8119f015 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -129,6 +129,25 @@ message OnchainSendResponse { string txid = 1; } +// Replace an unconfirmed outbound on-chain payment with a higher-fee transaction. +// Channel funding payments (including splices) are not eligible. +message OnchainBumpFeeRequest { + // The payment ID from ListPayments or GetPaymentDetails: 32 bytes encoded as hex. + // This is not the displayed transaction ID. + string payment_id = 1; + + // Absolute fee rate in satoshis per virtual byte, not a fee increment. + // Must be positive and high enough to replace the current transaction. + // If omitted, LDK Node selects a rate from its estimate and the RBF minimum. + optional uint64 fee_rate_sat_per_vb = 2; +} + +// The response for OnchainBumpFee. On failure, a gRPC error status is returned. +message OnchainBumpFeeResponse { + // The replacement transaction ID. The recipient amount is preserved. + 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. @@ -580,6 +599,21 @@ message SpliceOutResponse { string address = 1; } +// Replace a pending splice transaction with a higher-fee transaction. +// This does not support general channel-opening fee bumping. +// LDK Node selects the fee rate; callers cannot set it. +message BumpChannelFundingFeeRequest { + // The local user channel ID as a decimal u128 string. + string user_channel_id = 1; + + // The hex-encoded public key of the channel's peer. + string counterparty_node_id = 2; +} + +// The pending splice fee bump was initiated. Confirmation is asynchronous. +// On failure, a gRPC error status is returned, including when no splice is pending. +message BumpChannelFundingFeeResponse {} + // 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 { @@ -1156,6 +1190,8 @@ service LightningNode { rpc OnchainReceive(OnchainReceiveRequest) returns (OnchainReceiveResponse); // Send an on-chain payment to the given address. rpc OnchainSend(OnchainSendRequest) returns (OnchainSendResponse); + // Replace an unconfirmed outbound on-chain payment using RBF. + rpc OnchainBumpFee(OnchainBumpFeeRequest) returns (OnchainBumpFeeResponse); // Return a BOLT11 payable invoice. rpc Bolt11Receive(Bolt11ReceiveRequest) returns (Bolt11ReceiveResponse); // Return a BOLT11 payable invoice for a given payment hash. @@ -1191,6 +1227,9 @@ service LightningNode { rpc SpliceIn(SpliceInRequest) returns (SpliceInResponse); // Splice funds out of a channel. rpc SpliceOut(SpliceOutRequest) returns (SpliceOutResponse); + // Bump the fee of a pending splice. + // Channel-open funding transactions and caller-selected fee rates are not supported. + rpc BumpChannelFundingFee(BumpChannelFundingFeeRequest) returns (BumpChannelFundingFeeResponse); // Update the config for a channel. rpc UpdateChannelConfig(UpdateChannelConfigRequest) returns (UpdateChannelConfigResponse); // Close a channel cooperatively. diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md index 69388cef..ae765c66 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -100,6 +100,8 @@ Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `me The `create_macaroon` tool returns a private token that may be saved in chat history or tool logs. To keep it out of that history, create it with the CLI and supply it through `LDK_MACAROON`. +See the [API Guide](../docs/api-guide.md#endpoint-reference) for the available operations. + ## MCP Protocol - **Protocol version**: `2025-11-25` diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 7ba00a80..614bd2a0 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -14,18 +14,18 @@ use ldk_server_client::ldk_server_grpc::api::{ Bolt11ReceiveRequest, Bolt11ReceiveVariableAmountViaJitChannelRequest, Bolt11ReceiveViaJitChannelRequest, Bolt11SendRequest, Bolt11SendUnderpayingRequest, Bolt12CreatePayerProofRequest, Bolt12ReceiveRefundRequest, Bolt12ReceiveRequest, - Bolt12SendRefundRequest, Bolt12SendRequest, CloseChannelRequest, ConnectPeerRequest, - CreateMacaroonRequest, DecodeInvoiceRequest, DecodeOfferRequest, DisconnectPeerRequest, - ExportPathfindingScoresRequest, ForceCloseChannelRequest, GetBalancesRequest, - GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, + Bolt12SendRefundRequest, Bolt12SendRequest, BumpChannelFundingFeeRequest, CloseChannelRequest, + ConnectPeerRequest, CreateMacaroonRequest, DecodeInvoiceRequest, DecodeOfferRequest, + DisconnectPeerRequest, ExportPathfindingScoresRequest, ForceCloseChannelRequest, + GetBalancesRequest, GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, GetForwardedPaymentTrackingModeRequest, GetNodeInfoRequest, GetPaymentDetailsRequest, GetPermissionsRequest, GraphGetChannelRequest, GraphGetNodeRequest, GraphListChannelsRequest, GraphListNodesRequest, ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest, ListChannelsRequest, ListForwardedPaymentsRequest, - ListMacaroonsRequest, ListPaymentsRequest, ListPeersRequest, OnchainReceiveRequest, - OnchainSendRequest, OpenChannelRequest, RevokeMacaroonRequest, SignMessageRequest, - SpliceInRequest, SpliceOutRequest, SpontaneousSendRequest, UnifiedSendRequest, - UpdateChannelConfigRequest, VerifySignatureRequest, + ListMacaroonsRequest, ListPaymentsRequest, ListPeersRequest, OnchainBumpFeeRequest, + OnchainReceiveRequest, OnchainSendRequest, OpenChannelRequest, RevokeMacaroonRequest, + SignMessageRequest, SpliceInRequest, SpliceOutRequest, SpontaneousSendRequest, + UnifiedSendRequest, UpdateChannelConfigRequest, VerifySignatureRequest, }; use ldk_server_client::ldk_server_grpc::types::RouteParametersConfig; use ldk_server_client::{ @@ -178,6 +178,14 @@ pub async fn handle_onchain_receive( serialize_response(response) } +pub async fn handle_onchain_bump_fee( + client: &LdkServerClient, args: Value, +) -> Result { + let request: OnchainBumpFeeRequest = parse_request(args)?; + let response = client.onchain_bump_fee(request).await.map_err(McpError::from)?; + serialize_response(response) +} + pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { let request: OnchainSendRequest = parse_request_with_amount(args, "amount_sats")?; let response = client.onchain_send(request).await.map_err(McpError::from)?; @@ -352,6 +360,14 @@ pub async fn handle_splice_out(client: &LdkServerClient, args: Value) -> Result< serialize_response(response) } +pub async fn handle_bump_channel_funding_fee( + client: &LdkServerClient, args: Value, +) -> Result { + let request: BumpChannelFundingFeeRequest = parse_request(args)?; + let response = client.bump_channel_funding_fee(request).await.map_err(McpError::from)?; + serialize_response(response) +} + pub async fn handle_close_channel( client: &LdkServerClient, args: Value, ) -> Result { @@ -580,6 +596,37 @@ mod tests { assert!(parse_request::(json!({"id": 123})).is_err()); } + #[test] + fn onchain_bump_fee_argument_mapping() { + let id = "ab".repeat(32); + for rate in [None, Some(12)] { + let mut args = json!({"payment_id": id}); + if let Some(rate) = rate { + args["fee_rate_sat_per_vb"] = json!(rate); + } + let request: OnchainBumpFeeRequest = parse_request(args).unwrap(); + assert_eq!(request.payment_id, id); + assert_eq!(request.fee_rate_sat_per_vb, rate); + } + for rate in [json!(-1), json!(1.5), json!("10")] { + assert!(parse_request::(json!({ + "payment_id": id, "fee_rate_sat_per_vb": rate + })) + .is_err()); + } + } + + #[test] + fn bump_channel_funding_fee_argument_mapping() { + let args = json!({ + "user_channel_id": "340282366920938463463374607431768211455", + "counterparty_node_id": "peer" + }); + let request: BumpChannelFundingFeeRequest = parse_request(args).unwrap(); + assert_eq!(request.user_channel_id, u128::MAX.to_string()); + assert_eq!(request.counterparty_node_id, "peer"); + } + #[test] fn parse_request_with_amount_accepts_all() { let request: OpenChannelRequest = parse_request_with_amount( diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index 981521fe..e089e478 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -111,6 +111,12 @@ pub fn build_tool_registry() -> ToolRegistry { schema::onchain_receive_schema, |client, args| Box::pin(handlers::handle_onchain_receive(client, args)), ), + tool_spec( + "onchain_bump_fee", + "Replace an unconfirmed outbound on-chain payment using RBF. Funding payments are not eligible. Returns the replacement transaction ID", + schema::onchain_bump_fee_schema, + |client, args| Box::pin(handlers::handle_onchain_bump_fee(client, args)), + ), tool_spec( "onchain_send", "Send an on-chain Bitcoin payment to an address", @@ -229,6 +235,12 @@ pub fn build_tool_registry() -> ToolRegistry { schema::splice_out_schema, |client, args| Box::pin(handlers::handle_splice_out(client, args)), ), + tool_spec( + "bump_channel_funding_fee", + "Bump a pending splice fee, preserving its amount and destination. No general channel-opening fee bumping or caller-selected fee rate. Returns empty success when initiated", + schema::bump_channel_funding_fee_schema, + |client, args| Box::pin(handlers::handle_bump_channel_funding_fee(client, args)), + ), tool_spec( "close_channel", "Cooperatively close a Lightning channel", diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs index 957c5e1d..184bb3cf 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -202,6 +202,26 @@ pub fn onchain_send_schema() -> Value { }) } +/// Replace an eligible on-chain payment using RBF. +pub fn onchain_bump_fee_schema() -> Value { + json!({ + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$", + "description": "Payment ID from list_payments: 32 bytes encoded as hex, not the transaction ID" + }, + "fee_rate_sat_per_vb": { + "type": "integer", + "minimum": 1, + "description": "Absolute fee rate in sat/vB, not an increment. Must meet the RBF minimum. If omitted, LDK Node selects the rate" + } + }, + "required": ["payment_id"] + }) +} + pub fn bolt11_receive_schema() -> Value { json!({ "type": "object", @@ -620,6 +640,24 @@ pub fn splice_out_schema() -> Value { }) } +/// Only pending splices can be fee-bumped at the pinned LDK Node revision. +pub fn bump_channel_funding_fee_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_channel_id": { + "type": "string", + "description": "The local user channel ID as a decimal u128 string" + }, + "counterparty_node_id": { + "type": "string", + "description": "The hex-encoded public key of the channel's peer" + } + }, + "required": ["user_channel_id", "counterparty_node_id"] + }) +} + pub fn close_channel_schema() -> Value { json!({ "type": "object", diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs index d15ffd94..0b274ed3 100644 --- a/ldk-server-mcp/tests/integration.rs +++ b/ldk-server-mcp/tests/integration.rs @@ -11,7 +11,7 @@ use std::io::{BufRead, BufReader, Write}; use serde_json::{json, Value}; -const NUM_TOOLS: usize = 50; +const NUM_TOOLS: usize = 52; const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "bolt11_claim_for_id", "bolt11_fail_for_id", @@ -26,6 +26,7 @@ const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "bolt12_receive_refund", "bolt12_send", "bolt12_send_refund", + "bump_channel_funding_fee", "close_channel", "connect_peer", "create_macaroon", @@ -52,6 +53,7 @@ const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "list_forwarded_payments", "list_payments", "list_peers", + "onchain_bump_fee", "onchain_receive", "onchain_send", "open_channel", @@ -211,6 +213,16 @@ fn test_tools_list() { unary_rpc_tools.sort(); assert_eq!(tool_names, unary_rpc_tools, "Every unary RPC must have an MCP tool"); + let onchain = tools.iter().find(|tool| tool["name"] == "onchain_bump_fee").unwrap(); + assert_eq!(onchain["inputSchema"]["required"], json!(["payment_id"])); + assert_eq!(onchain["inputSchema"]["properties"]["fee_rate_sat_per_vb"]["minimum"], 1); + let splice = tools.iter().find(|tool| tool["name"] == "bump_channel_funding_fee").unwrap(); + assert_eq!( + splice["inputSchema"]["required"], + json!(["user_channel_id", "counterparty_node_id"]) + ); + assert_eq!(splice["inputSchema"]["properties"].as_object().unwrap().len(), 2); + for tool in tools { assert!(tool["name"].is_string(), "Tool missing name"); assert!(tool["description"].is_string(), "Tool missing description"); diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index d7cf74c3..09c981e6 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -8,6 +8,7 @@ // licenses. use hex::FromHex; +use ldk_node::bitcoin::FeeRate; use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning::routing::router::RouteParametersConfig; @@ -52,6 +53,7 @@ pub(crate) mod list_forwarded_payments; pub(crate) mod list_payments; pub(crate) mod list_peers; pub(crate) mod macaroons; +pub(crate) mod onchain_bump_fee; pub(crate) mod onchain_receive; pub(crate) mod onchain_send; pub(crate) mod open_channel; @@ -71,6 +73,16 @@ pub(crate) fn require_amount(amount: Option) -> Result }) } +pub(crate) fn parse_fee_rate(rate: Option) -> Result, LdkServerError> { + rate.map(|rate| { + if rate == 0 { + return Err(ldk_node::NodeError::InvalidFeeRate.into()); + } + FeeRate::from_sat_per_vb(rate).ok_or_else(|| ldk_node::NodeError::InvalidFeeRate.into()) + }) + .transpose() +} + pub(crate) fn parse_payment_id(payment_id: &str) -> Result { let bytes = <[u8; PaymentId::LENGTH]>::from_hex(payment_id).map_err(|_| { LdkServerError::new( @@ -175,6 +187,16 @@ mod tests { assert!(require_amount::(None).is_err()); } + #[test] + fn fee_rate_is_optional_and_checked() { + assert_eq!(parse_fee_rate(None).unwrap(), None); + assert_eq!(parse_fee_rate(Some(2)).unwrap().unwrap().to_sat_per_kwu(), 500); + assert!(parse_fee_rate(Some(u64::MAX / 250)).is_ok()); + for rate in [0, u64::MAX / 250 + 1, u64::MAX] { + assert_eq!(parse_fee_rate(Some(rate)).unwrap_err().error_code, InvalidRequestError); + } + } + #[test] fn payment_id_is_parsed() { let payment_id = "01".repeat(PaymentId::LENGTH); diff --git a/ldk-server/src/api/onchain_bump_fee.rs b/ldk-server/src/api/onchain_bump_fee.rs new file mode 100644 index 00000000..65e93018 --- /dev/null +++ b/ldk-server/src/api/onchain_bump_fee.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. + +use std::sync::Arc; + +use ldk_server_grpc::api::{OnchainBumpFeeRequest, OnchainBumpFeeResponse}; + +use crate::api::error::LdkServerError; +use crate::api::{parse_fee_rate, parse_payment_id}; +use crate::service::Context; + +pub(crate) async fn handle_onchain_bump_fee_request( + context: Arc, request: OnchainBumpFeeRequest, +) -> Result { + let payment_id = parse_payment_id(&request.payment_id)?; + let fee_rate = parse_fee_rate(request.fee_rate_sat_per_vb)?; + let txid = context.node.onchain_payment().bump_fee_rbf(payment_id, fee_rate)?; + Ok(OnchainBumpFeeResponse { txid: txid.to_string() }) +} diff --git a/ldk-server/src/api/onchain_send.rs b/ldk-server/src/api/onchain_send.rs index 829da987..042fd52f 100644 --- a/ldk-server/src/api/onchain_send.rs +++ b/ldk-server/src/api/onchain_send.rs @@ -10,13 +10,13 @@ use std::str::FromStr; use std::sync::Arc; -use ldk_node::bitcoin::{Address, FeeRate}; +use ldk_node::bitcoin::Address; 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::api::{parse_fee_rate, require_amount}; use crate::service::Context; pub(crate) async fn handle_onchain_send_request( @@ -32,7 +32,7 @@ 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 fee_rate = parse_fee_rate(request.fee_rate_sat_per_vb)?; let txid = match require_amount(request.amount)? { Amount::AmountSats(amount_sats) => { context.node.onchain_payment().send_to_address(&address, amount_sats, fee_rate)? diff --git a/ldk-server/src/api/splice_channel.rs b/ldk-server/src/api/splice_channel.rs index beba1cb3..eda6ae97 100644 --- a/ldk-server/src/api/splice_channel.rs +++ b/ldk-server/src/api/splice_channel.rs @@ -15,7 +15,8 @@ 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, + BumpChannelFundingFeeRequest, BumpChannelFundingFeeResponse, SpliceInRequest, SpliceInResponse, + SpliceOutRequest, SpliceOutResponse, }; use crate::api::error::LdkServerError; @@ -72,6 +73,15 @@ pub(crate) async fn handle_splice_out_request( Ok(SpliceOutResponse { address: address.to_string() }) } +pub(crate) async fn handle_bump_channel_funding_fee_request( + context: Arc, request: BumpChannelFundingFeeRequest, +) -> 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.bump_channel_funding_fee(&user_channel_id, counterparty_node_id)?; + Ok(BumpChannelFundingFeeResponse {}) +} + fn parse_user_channel_id(id: &str) -> Result { let parsed = id.parse::().map_err(|_| { LdkServerError::new(InvalidRequestError, "Invalid UserChannelId.".to_string()) diff --git a/ldk-server/src/macaroons/authorization.rs b/ldk-server/src/macaroons/authorization.rs index b74a07ec..c7fd642a 100644 --- a/ldk-server/src/macaroons/authorization.rs +++ b/ldk-server/src/macaroons/authorization.rs @@ -14,18 +14,18 @@ use ldk_server_grpc::endpoints::{ BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, BOLT12_CREATE_PAYER_PROOF_PATH, BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, - BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, - CREATE_MACAROON_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, - EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, - GET_CHANNEL_FORWARDING_STATS_PATH, GET_FORWARDED_PAYMENT_DETAILS_PATH, + BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, BUMP_CHANNEL_FUNDING_FEE_PATH, CLOSE_CHANNEL_PATH, + CONNECT_PEER_PATH, CREATE_MACAROON_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, + DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, + GET_BALANCES_PATH, GET_CHANNEL_FORWARDING_STATS_PATH, GET_FORWARDED_PAYMENT_DETAILS_PATH, GET_FORWARDED_PAYMENT_TRACKING_MODE_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GET_PERMISSIONS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH, LIST_CHANNEL_FORWARDING_STATS_PATH, LIST_CHANNEL_PAIR_FORWARDING_STATS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, - LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, - OPEN_CHANNEL_PATH, REVOKE_MACAROON_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, - SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, - VERIFY_SIGNATURE_PATH, + LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_BUMP_FEE_PATH, ONCHAIN_RECEIVE_PATH, + ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_MACAROON_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, + SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, + UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use ldk_server_grpc::permissions::{ CHANNELS_FORCE_CLOSE_PERMISSION, CHANNELS_MANAGE_PERMISSION, CHANNELS_READ_PERMISSION, @@ -48,7 +48,9 @@ pub(crate) fn method_authorization(method: &str) -> MethodAuthorization { MethodAuthorization::Permission(NODE_READ_PERMISSION) }, ONCHAIN_RECEIVE_PATH => MethodAuthorization::Permission(ONCHAIN_RECEIVE_PERMISSION), - ONCHAIN_SEND_PATH => MethodAuthorization::Permission(ONCHAIN_SEND_PERMISSION), + ONCHAIN_SEND_PATH | ONCHAIN_BUMP_FEE_PATH => { + MethodAuthorization::Permission(ONCHAIN_SEND_PERMISSION) + }, BOLT11_RECEIVE_PATH | BOLT11_RECEIVE_FOR_HASH_PATH | BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH @@ -76,9 +78,11 @@ pub(crate) fn method_authorization(method: &str) -> MethodAuthorization { MethodAuthorization::Permission(PAYMENTS_READ_PERMISSION) }, LIST_CHANNELS_PATH => MethodAuthorization::Permission(CHANNELS_READ_PERMISSION), - OPEN_CHANNEL_PATH | UPDATE_CHANNEL_CONFIG_PATH | CLOSE_CHANNEL_PATH | SPLICE_IN_PATH => { - MethodAuthorization::Permission(CHANNELS_MANAGE_PERMISSION) - }, + OPEN_CHANNEL_PATH + | UPDATE_CHANNEL_CONFIG_PATH + | CLOSE_CHANNEL_PATH + | SPLICE_IN_PATH + | BUMP_CHANNEL_FUNDING_FEE_PATH => MethodAuthorization::Permission(CHANNELS_MANAGE_PERMISSION), FORCE_CLOSE_CHANNEL_PATH => { MethodAuthorization::Permission(CHANNELS_FORCE_CLOSE_PERMISSION) }, @@ -123,6 +127,7 @@ mod tests { ("GetBalances", Some("node:read")), ("OnchainReceive", Some("onchain:receive")), ("OnchainSend", Some("onchain:send")), + ("OnchainBumpFee", Some("onchain:send")), ("Bolt11Receive", Some("invoices:create")), ("Bolt11ReceiveForHash", Some("invoices:create")), ("Bolt11ClaimForId", Some("payments:claim")), @@ -140,6 +145,7 @@ mod tests { ("OpenChannel", Some("channels:manage")), ("SpliceIn", Some("channels:manage")), ("SpliceOut", Some("payments:send")), + ("BumpChannelFundingFee", Some("channels:manage")), ("UpdateChannelConfig", Some("channels:manage")), ("CloseChannel", Some("channels:manage")), ("ForceCloseChannel", Some("channels:force_close")), diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index ec2af133..89e36fd9 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -21,18 +21,19 @@ use ldk_server_grpc::endpoints::{ BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, BOLT12_CREATE_PAYER_PROOF_PATH, BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, - BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, - CREATE_MACAROON_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, - EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, - GET_CHANNEL_FORWARDING_STATS_PATH, GET_FORWARDED_PAYMENT_DETAILS_PATH, + BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, BUMP_CHANNEL_FUNDING_FEE_PATH, CLOSE_CHANNEL_PATH, + CONNECT_PEER_PATH, CREATE_MACAROON_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, + DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, + GET_BALANCES_PATH, GET_CHANNEL_FORWARDING_STATS_PATH, GET_FORWARDED_PAYMENT_DETAILS_PATH, GET_FORWARDED_PAYMENT_TRACKING_MODE_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GET_PERMISSIONS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH, LIST_CHANNEL_FORWARDING_STATS_PATH, LIST_CHANNEL_PAIR_FORWARDING_STATS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, - ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_MACAROON_PATH, - SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, - SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + ONCHAIN_BUMP_FEE_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, + REVOKE_MACAROON_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, + SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, + VERIFY_SIGNATURE_PATH, }; use ldk_server_grpc::events::EventEnvelope; use ldk_server_grpc::grpc::{ @@ -87,11 +88,14 @@ use crate::api::macaroons::{ handle_create_macaroon_request, handle_get_permissions_request, handle_list_macaroons_request, handle_revoke_macaroon_request, }; +use crate::api::onchain_bump_fee::handle_onchain_bump_fee_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::sign_message::handle_sign_message_request; -use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_request}; +use crate::api::splice_channel::{ + handle_bump_channel_funding_fee_request, handle_splice_in_request, handle_splice_out_request, +}; use crate::api::spontaneous_send::handle_spontaneous_send_request; use crate::api::unified_send::handle_unified_send_request; use crate::api::update_channel_config::handle_update_channel_config_request; @@ -242,6 +246,9 @@ impl Service> for NodeService { ONCHAIN_RECEIVE_PATH => { handle_grpc_unary(context, body_bytes, handle_onchain_receive_request).await }, + ONCHAIN_BUMP_FEE_PATH => { + handle_grpc_unary(context, body_bytes, handle_onchain_bump_fee_request).await + }, ONCHAIN_SEND_PATH => { handle_grpc_unary(context, body_bytes, handle_onchain_send_request).await }, @@ -307,6 +314,10 @@ impl Service> for NodeService { SPLICE_OUT_PATH => { handle_grpc_unary(context, body_bytes, handle_splice_out_request).await }, + BUMP_CHANNEL_FUNDING_FEE_PATH => { + handle_grpc_unary(context, body_bytes, handle_bump_channel_funding_fee_request) + .await + }, CLOSE_CHANNEL_PATH => { handle_grpc_unary(context, body_bytes, handle_close_channel_request).await },