Skip to content

Commit 290bbde

Browse files
committed
Add on-chain payment fee bumping
On-chain payments can remain unconfirmed when fees rise after they are sent. Allow operators to raise a payment's fee while preserving its recipient amount and payment ID, so they can get the payment confirmed without creating another payment. AI assistance: OpenAI Codex.
1 parent aa2d60f commit 290bbde

20 files changed

Lines changed: 623 additions & 45 deletions

File tree

‎docs/api-guide.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ RPCs with no permission mapping return `UNIMPLEMENTED`, even for admin tokens.
9797
| ---------- | ------ |
9898
| `node:read` | Node information, balances, and pathfinding scores |
9999
| `onchain:receive` | Create on-chain receive addresses |
100-
| `onchain:send` | Send on-chain funds |
100+
| `onchain:send` | Send on-chain funds or bump an on-chain payment fee |
101101
| `invoices:create` | Create BOLT11/BOLT12 invoices and incoming refund requests |
102102
| `payments:read` | Read payments, forwarded payments, and forwarding statistics |
103103
| `payments:claim` | Claim or fail held BOLT11 payments |
@@ -174,6 +174,12 @@ All RPCs are unary (single request, single response) unless noted otherwise.
174174
|------------------|----------------------------------------------------------------------|
175175
| `OnchainReceive` | Generate a new on-chain funding address |
176176
| `OnchainSend` | Send to a Bitcoin address (with optional fee rate and send-all mode) |
177+
| `OnchainBumpFee` | Raise the fee of an unconfirmed outbound on-chain payment using RBF |
178+
179+
`OnchainBumpFee` replaces a payment's transaction while preserving its payment ID and recipient
180+
amount. Use the `payment_id` from `ListPayments`. The optional `fee_rate_sat_per_vb` sets the new
181+
total fee rate in sat/vB; omit it to use an automatic rate. The response contains the replacement
182+
`txid`. Confirmed, inbound, Lightning, and channel funding payments are not eligible.
177183

178184
### BOLT11 Payments
179185

‎e2e-tests/src/lib.rs‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ use std::io::{BufRead, BufReader, Write};
1111
use std::net::TcpListener;
1212
use std::path::{Path, PathBuf};
1313
use std::process::{Child, Command, Stdio};
14-
use std::time::{Duration, Instant};
14+
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1515

1616
use corepc_node::Node;
17+
use ldk_node::bitcoin::Amount;
1718
use ldk_server_client::client::{EventStream, LdkServerClient};
1819
use ldk_server_client::error::LdkServerErrorCode;
1920
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
@@ -30,7 +31,7 @@ use ldk_server_grpc::types::{
3031
ClaimableAwaitingConfirmations, ForwardedPayment, LightningBalance, Payment, PaymentDirection,
3132
PaymentStatus,
3233
};
33-
use serde_json::Value;
34+
use serde_json::{json, Value};
3435

3536
const EVENT_TIMEOUT: Duration = Duration::from_secs(15);
3637

@@ -772,6 +773,81 @@ pub async fn mine_and_sync(
772773
}
773774
}
774775

776+
/// Wait for a transaction to enter the mempool and return its decoded details.
777+
pub async fn wait_for_transaction(bitcoind: &TestBitcoind, txid: &str) -> Value {
778+
tokio::time::timeout(Duration::from_secs(30), async {
779+
loop {
780+
let mempool: Vec<String> = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap();
781+
if mempool.iter().any(|id| id == txid) {
782+
return bitcoind
783+
.bitcoind
784+
.client
785+
.call("getrawtransaction", &[json!(txid), json!(true)])
786+
.unwrap();
787+
}
788+
tokio::time::sleep(Duration::from_millis(100)).await;
789+
}
790+
})
791+
.await
792+
.expect("transaction did not enter the mempool")
793+
}
794+
795+
/// Wait for the on-chain wallet to complete another sync.
796+
///
797+
/// The pinned wallet records a replacement before its background sync sees the transaction.
798+
pub async fn wait_for_wallet_sync(server: &LdkServerHandle) {
799+
let after = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
800+
tokio::time::timeout(Duration::from_secs(30), async {
801+
loop {
802+
let info = server.client().get_node_info(GetNodeInfoRequest {}).await.unwrap();
803+
if info.latest_onchain_wallet_sync_timestamp.is_some_and(|timestamp| timestamp > after)
804+
{
805+
break;
806+
}
807+
tokio::time::sleep(Duration::from_millis(100)).await;
808+
}
809+
})
810+
.await
811+
.expect("wallet did not sync after the replacement");
812+
}
813+
814+
/// Wait for a transaction to appear in the node's payment history.
815+
pub async fn payment_for_tx(server: &LdkServerHandle, txid: &str) -> Payment {
816+
tokio::time::timeout(Duration::from_secs(30), async {
817+
loop {
818+
for payment in list_payments(server).await {
819+
if let Some(payment_kind::Kind::Onchain(onchain)) =
820+
payment.kind.as_ref().and_then(|kind| kind.kind.as_ref())
821+
{
822+
if onchain.txid == txid {
823+
return payment;
824+
}
825+
}
826+
}
827+
tokio::time::sleep(Duration::from_millis(100)).await;
828+
}
829+
})
830+
.await
831+
.expect("payment was not recorded")
832+
}
833+
834+
/// Check that a replacement preserves the recipient amount.
835+
pub async fn assert_replacement(
836+
bitcoind: &TestBitcoind, old: &str, new: &str, address: &str, amount: Amount,
837+
) {
838+
assert_ne!(old, new);
839+
let tx = wait_for_transaction(bitcoind, new).await;
840+
let recipient = tx["vout"]
841+
.as_array()
842+
.unwrap()
843+
.iter()
844+
.find(|output| output["scriptPubKey"]["address"] == address)
845+
.unwrap();
846+
assert_eq!(Amount::from_btc(recipient["value"].as_f64().unwrap()).unwrap(), amount);
847+
let mempool: Vec<String> = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap();
848+
assert!(!mempool.iter().any(|id| id == old));
849+
}
850+
775851
/// Wait until the given client has at least one usable channel,
776852
/// periodically mining blocks to trigger chain sync.
777853
pub async fn wait_for_usable_channel(

‎e2e-tests/tests/e2e.rs‎

Lines changed: 199 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,39 @@ use std::str::FromStr;
1212
use std::time::{Duration, SystemTime, UNIX_EPOCH};
1313

1414
use e2e_tests::{
15-
close_channel, find_available_port, list_payments, mine_and_sync, run_cli, run_cli_raw,
16-
run_cli_with_config, send_bolt11_payment, setup_funded_channel, wait_for_channels,
17-
wait_for_event, wait_for_gossip, wait_for_onchain_balance, wait_for_settled_balance,
18-
wait_for_usable_channel, LdkServerConfig, LdkServerHandle, TestBitcoind, TestConfigBuilder,
15+
assert_replacement, close_channel, find_available_port, list_payments, mine_and_sync,
16+
payment_for_tx, run_cli, run_cli_raw, run_cli_with_config, send_bolt11_payment,
17+
setup_funded_channel, wait_for_channels, wait_for_event, wait_for_gossip,
18+
wait_for_onchain_balance, wait_for_settled_balance, wait_for_transaction,
19+
wait_for_usable_channel, wait_for_wallet_sync, LdkServerConfig, LdkServerHandle, TestBitcoind,
20+
TestConfigBuilder,
1921
};
2022
use hex_conservative::{DisplayHex, FromHex};
2123
use ldk_node::bitcoin::hashes::{sha256, Hash};
24+
use ldk_node::bitcoin::Amount;
2225
use ldk_node::lightning::ln::msgs::SocketAddress;
2326
use ldk_node::lightning::offers::offer::Offer;
2427
use ldk_node::lightning::offers::refund::Refund;
2528
use ldk_node::lightning_invoice::Bolt11Invoice;
2629
use ldk_server_client::error::LdkServerErrorCode::InvalidRequestError;
2730
use ldk_server_client::ldk_server_grpc::api::{
28-
open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest, Bolt11ReceiveRequest,
29-
Bolt12ReceiveRequest, GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest,
31+
onchain_send_request, open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest,
32+
Bolt11ReceiveRequest, Bolt11SendRequest, Bolt12ReceiveRequest,
33+
GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, GetPaymentDetailsRequest,
3034
ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest,
31-
ListForwardedPaymentsRequest, OnchainReceiveRequest, OpenChannelRequest,
35+
ListForwardedPaymentsRequest, OnchainBumpFeeRequest, OnchainReceiveRequest, OnchainSendRequest,
36+
OpenChannelRequest,
3237
};
3338
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
3439
use ldk_server_client::ldk_server_grpc::events::{
3540
ChannelClosureInitiator, ChannelState, ChannelStateChangeReasonKind, PaymentFailureReason,
3641
};
3742
use ldk_server_client::ldk_server_grpc::types::{
38-
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelShutdownState, ReserveType,
43+
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelShutdownState, PaymentDirection,
44+
ReserveType,
3945
};
4046
use ldk_server_grpc::types::payment_kind;
47+
use serde_json::json;
4148

4249
#[tokio::test]
4350
async fn test_cli_get_node_info() {
@@ -411,6 +418,20 @@ async fn test_cli_onchain_send() {
411418
let recv_output = run_cli(&server, &["onchain-receive"]);
412419
let dest_addr = recv_output["address"].as_str().unwrap();
413420

421+
for rate in [0, u64::MAX / 250 + 1, u64::MAX] {
422+
let error = server
423+
.client()
424+
.onchain_send(OnchainSendRequest {
425+
address: dest_addr.into(),
426+
amount: Some(onchain_send_request::Amount::AmountSats(50_000)),
427+
fee_rate_sat_per_vb: Some(rate),
428+
})
429+
.await
430+
.unwrap_err();
431+
assert_eq!(error.error_code, InvalidRequestError);
432+
assert_eq!(error.message, ldk_node::NodeError::InvalidFeeRate.to_string());
433+
}
434+
414435
let output = run_cli(&server, &["onchain-send", dest_addr, "50000sat"]);
415436
assert!(!output["txid"].as_str().unwrap().is_empty());
416437
}
@@ -435,6 +456,176 @@ async fn test_cli_onchain_send_all() {
435456
wait_for_settled_balance(&server, 0, Duration::from_secs(30)).await;
436457
}
437458

459+
#[tokio::test]
460+
async fn test_onchain_fee_bump_client_cli() {
461+
let bitcoind = TestBitcoind::new();
462+
let server = LdkServerHandle::start(&bitcoind).await;
463+
let address = server.client().onchain_receive(OnchainReceiveRequest {}).await.unwrap().address;
464+
bitcoind.fund_address(&address, 1.0);
465+
mine_and_sync(&bitcoind, &[&server], 6).await;
466+
wait_for_onchain_balance(server.client(), Duration::from_secs(30)).await;
467+
let destination = bitcoind.bitcoind.client.new_address().unwrap().to_string();
468+
let amount = Amount::from_sat(50_000);
469+
let original = server
470+
.client()
471+
.onchain_send(OnchainSendRequest {
472+
address: destination.clone(),
473+
amount: Some(onchain_send_request::Amount::AmountSats(amount.to_sat())),
474+
fee_rate_sat_per_vb: Some(2),
475+
})
476+
.await
477+
.unwrap()
478+
.txid;
479+
wait_for_transaction(&bitcoind, &original).await;
480+
let payment = payment_for_tx(&server, &original).await;
481+
assert_ne!(payment.payment_id, original, "payment IDs use a different byte order");
482+
483+
// A rate below the original must fail instead of selecting an automatic rate.
484+
let error = server
485+
.client()
486+
.onchain_bump_fee(OnchainBumpFeeRequest {
487+
payment_id: payment.payment_id.clone(),
488+
fee_rate_sat_per_vb: Some(1),
489+
})
490+
.await
491+
.unwrap_err();
492+
assert_eq!(error.error_code, InvalidRequestError);
493+
494+
let replacement = server
495+
.client()
496+
.onchain_bump_fee(OnchainBumpFeeRequest {
497+
payment_id: payment.payment_id.clone(),
498+
fee_rate_sat_per_vb: Some(5),
499+
})
500+
.await
501+
.unwrap()
502+
.txid;
503+
assert_replacement(&bitcoind, &original, &replacement, &destination, amount).await;
504+
wait_for_wallet_sync(&server).await;
505+
let cli =
506+
run_cli(&server, &["onchain-bump-fee", &payment.payment_id, "--fee-rate-sat-per-vb", "10"]);
507+
let cli_txid = cli["txid"].as_str().unwrap();
508+
assert_replacement(&bitcoind, &replacement, cli_txid, &destination, amount).await;
509+
let updated = payment_for_tx(&server, cli_txid).await;
510+
assert_eq!(updated.payment_id, payment.payment_id);
511+
assert_eq!(updated.amount_msat, Some(amount.to_sat() * 1000));
512+
assert!(updated.fee_paid_msat > payment.fee_paid_msat);
513+
514+
mine_and_sync(&bitcoind, &[&server], 6).await;
515+
// Wait for wallet confirmation, which can follow the Lightning tip update.
516+
tokio::time::timeout(Duration::from_secs(30), async {
517+
loop {
518+
let details = server
519+
.client()
520+
.get_payment_details(GetPaymentDetailsRequest {
521+
payment_id: payment.payment_id.clone(),
522+
})
523+
.await
524+
.unwrap()
525+
.payment
526+
.unwrap();
527+
if let Some(payment_kind::Kind::Onchain(onchain)) =
528+
details.kind.and_then(|kind| kind.kind)
529+
{
530+
if matches!(
531+
onchain.status.and_then(|status| status.status),
532+
Some(ldk_server_grpc::types::confirmation_status::Status::Confirmed(_))
533+
) {
534+
break;
535+
}
536+
}
537+
tokio::time::sleep(Duration::from_millis(100)).await;
538+
}
539+
})
540+
.await
541+
.unwrap();
542+
let error = server
543+
.client()
544+
.onchain_bump_fee(OnchainBumpFeeRequest {
545+
payment_id: payment.payment_id,
546+
fee_rate_sat_per_vb: Some(20),
547+
})
548+
.await
549+
.unwrap_err();
550+
assert_eq!(error.error_code, InvalidRequestError);
551+
}
552+
553+
#[tokio::test]
554+
async fn test_onchain_fee_bump_invalid_requests_and_ineligible_payments() {
555+
let bitcoind = TestBitcoind::new();
556+
let server = LdkServerHandle::start(&bitcoind).await;
557+
for id in [String::new(), "ab".repeat(31), "ab".repeat(33), "zz".repeat(32), "00".repeat(32)] {
558+
let error = server
559+
.client()
560+
.onchain_bump_fee(OnchainBumpFeeRequest { payment_id: id, fee_rate_sat_per_vb: None })
561+
.await
562+
.unwrap_err();
563+
assert_eq!(error.error_code, InvalidRequestError);
564+
}
565+
for rate in [0, u64::MAX / 250 + 1, u64::MAX] {
566+
let error = server
567+
.client()
568+
.onchain_bump_fee(OnchainBumpFeeRequest {
569+
payment_id: "00".repeat(32),
570+
fee_rate_sat_per_vb: Some(rate),
571+
})
572+
.await
573+
.unwrap_err();
574+
assert_eq!(error.error_code, InvalidRequestError);
575+
assert_eq!(error.message, ldk_node::NodeError::InvalidFeeRate.to_string());
576+
}
577+
578+
let peer = LdkServerHandle::start(&bitcoind).await;
579+
let invoice = peer
580+
.client()
581+
.bolt11_receive(Bolt11ReceiveRequest {
582+
amount_msat: Some(100_000),
583+
expiry_secs: 3600,
584+
..Default::default()
585+
})
586+
.await
587+
.unwrap()
588+
.invoice;
589+
// A failed send with no route still records an outbound Lightning payment.
590+
let _ = server.client().bolt11_send(Bolt11SendRequest { invoice, ..Default::default() }).await;
591+
592+
let payments = list_payments(&server).await;
593+
let lightning = payments
594+
.iter()
595+
.find(|p| {
596+
matches!(
597+
p.kind.as_ref().and_then(|k| k.kind.as_ref()),
598+
Some(payment_kind::Kind::Bolt11(_))
599+
)
600+
})
601+
.unwrap();
602+
let error = server
603+
.client()
604+
.onchain_bump_fee(OnchainBumpFeeRequest {
605+
payment_id: lightning.payment_id.clone(),
606+
fee_rate_sat_per_vb: None,
607+
})
608+
.await
609+
.unwrap_err();
610+
assert_eq!(error.error_code, InvalidRequestError);
611+
612+
let address = server.client().onchain_receive(OnchainReceiveRequest {}).await.unwrap().address;
613+
// Leave the incoming payment unconfirmed to exercise the direction guard.
614+
let txid: String =
615+
bitcoind.bitcoind.client.call("sendtoaddress", &[json!(address), json!(0.1)]).unwrap();
616+
let incoming = payment_for_tx(&server, &txid).await;
617+
assert_eq!(incoming.direction, PaymentDirection::Inbound as i32);
618+
let error = server
619+
.client()
620+
.onchain_bump_fee(OnchainBumpFeeRequest {
621+
payment_id: incoming.payment_id,
622+
fee_rate_sat_per_vb: None,
623+
})
624+
.await
625+
.unwrap_err();
626+
assert_eq!(error.error_code, InvalidRequestError);
627+
}
628+
438629
#[tokio::test]
439630
async fn test_cli_connect_peer() {
440631
let bitcoind = TestBitcoind::new();

‎e2e-tests/tests/macaroons.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,27 @@ async fn test_scoped_macaroon_lifecycle() {
101101
);
102102
}
103103

104+
#[tokio::test]
105+
async fn test_macaroon_onchain_fee_bump_permissions() {
106+
let bitcoind = TestBitcoind::new();
107+
let server = LdkServerHandle::start(&bitcoind).await;
108+
// An invalid payment ID distinguishes reaching the handler from an auth rejection.
109+
for (name, permission, expected_error) in [
110+
("onchain-sender", "onchain:send", InvalidRequestError),
111+
("lightning-sender", "payments:send", AuthorizationError),
112+
("reader", "payments:read", AuthorizationError),
113+
("full-admin", "admin", InvalidRequestError),
114+
] {
115+
let created = run_cli(&server, &["create-macaroon", name, "--permissions", permission]);
116+
let scoped_client =
117+
client_with_macaroon(&server, created["token"].as_str().unwrap().to_string());
118+
assert_eq!(
119+
scoped_client.onchain_bump_fee(Default::default()).await.unwrap_err().error_code,
120+
expected_error
121+
);
122+
}
123+
}
124+
104125
#[tokio::test]
105126
async fn test_macaroon_splice_permissions() {
106127
let bitcoind = TestBitcoind::new();

0 commit comments

Comments
 (0)