Skip to content

Commit d8a5839

Browse files
committed
Track anchor reserves for unresolved monitors
Anchor reserve accounting only looked at channels returned by the ChannelManager. That misses an important force-close transition: ChannelManager::list_channels can stop returning a channel before its commitment transaction confirms. During that window the ChannelMonitor can still broadcast, rebroadcast, or fee-bump the holder commitment and its anchor spend, so the wallet's emergency reserve must remain available. We now require a anchor reserve to be kept for non-trusted channels where the type is not yet known; we've recently made anchor channels always supported in ldk-node, so at this point it is safe to assume most channels of ldk-node will be anchor channels. We now no longer count existing 0FC channels to validate support for submitpackage on the chain source if those channels are in the trusted peers list. Co-Authored-By: HAL 9000
1 parent 7830a16 commit d8a5839

9 files changed

Lines changed: 298 additions & 73 deletions

File tree

bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,40 @@ fun mineAndWait(esploraEndpoint: String, blocks: UInt) {
5555
waitForBlock(esploraEndpoint, lastBlockHash)
5656
}
5757

58+
fun mineChannelClosureUntilSpendable(esploraEndpoint: String, nodes: List<Node>) {
59+
var blocksToMine = 0u
60+
nodes.forEach { node ->
61+
val currentHeight = node.status().currentBestBlock.height
62+
node.listBalances().lightningBalances.forEach { balance ->
63+
val confirmationHeight = when (balance) {
64+
is LightningBalance.ClaimableAwaitingConfirmations -> balance.confirmationHeight
65+
else -> error("Unexpected balance after cooperative close: $balance")
66+
}
67+
val blocksForNode =
68+
if (confirmationHeight > currentHeight) {
69+
confirmationHeight - currentHeight
70+
} else {
71+
0u
72+
}
73+
if (blocksForNode > blocksToMine) {
74+
blocksToMine = blocksForNode
75+
}
76+
}
77+
}
78+
79+
if (blocksToMine > 0u) {
80+
mineAndWait(esploraEndpoint, blocksToMine)
81+
nodes.forEach { it.syncWallets() }
82+
}
83+
84+
nodes.forEach { node ->
85+
val balances = node.listBalances()
86+
val failureMessage = "Unexpected balances after cooperative close: $balances"
87+
assertTrue(balances.lightningBalances.isEmpty(), failureMessage)
88+
assertTrue(balances.pendingBalancesFromChannelClosures.isEmpty(), failureMessage)
89+
}
90+
}
91+
5892
fun sendToAddress(address: String, amountSats: UInt): String {
5993
val amountBtc = amountSats.toDouble() / 100000000.0
6094
val output = bitcoinCli("sendtoaddress", address, amountBtc.toString())
@@ -77,6 +111,23 @@ fun waitForTx(esploraEndpoint: String, txid: String) {
77111
}
78112
}
79113

114+
fun waitForOutpointSpend(esploraEndpoint: String, outpoint: OutPoint) {
115+
var esploraPickedUpOutpointSpend = false
116+
val re = Regex("\"spent\"\\s*:\\s*true")
117+
while (!esploraPickedUpOutpointSpend) {
118+
val client = HttpClient.newBuilder().build()
119+
val url = esploraEndpoint + "/tx/" + outpoint.txid + "/outspend/" + outpoint.vout
120+
val request = HttpRequest.newBuilder()
121+
.uri(URI.create(url))
122+
.build()
123+
124+
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
125+
126+
esploraPickedUpOutpointSpend = re.containsMatchIn(response.body())
127+
Thread.sleep(500)
128+
}
129+
}
130+
80131
fun waitForBlock(esploraEndpoint: String, blockHash: String) {
81132
var esploraPickedUpBlock = false
82133
val re = Regex("\"in_best_chain\":true")
@@ -251,11 +302,13 @@ class LibraryTest {
251302
assert(channelPendingEvent2 is Event.ChannelPending)
252303
node2.eventHandled()
253304

254-
val fundingTxid = when (channelPendingEvent1) {
255-
is Event.ChannelPending -> channelPendingEvent1.fundingTxo.txid
305+
val fundingTxo = when (channelPendingEvent1) {
306+
is Event.ChannelPending -> channelPendingEvent1.fundingTxo
256307
else -> return
257308
}
258309

310+
val fundingTxid = fundingTxo.txid
311+
259312
waitForTx(esploraEndpoint, fundingTxid)
260313

261314
mineAndWait(esploraEndpoint, 6u)
@@ -316,11 +369,15 @@ class LibraryTest {
316369
assert(channelClosedEvent2 is Event.ChannelClosed)
317370
node2.eventHandled()
318371

372+
waitForOutpointSpend(esploraEndpoint, fundingTxo)
373+
319374
mineAndWait(esploraEndpoint, 1u)
320375

321376
node1.syncWallets()
322377
node2.syncWallets()
323378

379+
mineChannelClosureUntilSpendable(esploraEndpoint, listOf(node1, node2))
380+
324381
val spendableBalance1AfterClose = node1.listBalances().spendableOnchainBalanceSats
325382
val spendableBalance2AfterClose = node2.listBalances().spendableOnchainBalanceSats
326383
println("Spendable balance 1 after close: $spendableBalance1AfterClose")

bindings/python/src/ldk_node/test_ldk_node.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ def wait_for_tx(esplora_endpoint, txid):
8989

9090
raise Exception(f"Failed to confirm transaction {txid} after {max_attempts} attempts")
9191

92+
def wait_for_outpoint_spend(esplora_endpoint, outpoint):
93+
url = esplora_endpoint + "/tx/" + outpoint.txid + "/outspend/" + str(outpoint.vout)
94+
attempts = 0
95+
max_attempts = 30
96+
97+
while attempts < max_attempts:
98+
try:
99+
res = requests.get(url, timeout=10)
100+
json = res.json()
101+
if json.get('spent'):
102+
return
103+
104+
except Exception as e:
105+
print(f"Error: {e}")
106+
107+
attempts += 1
108+
time.sleep(0.5)
109+
110+
raise Exception(f"Failed to confirm outpoint spend {outpoint} after {max_attempts} attempts")
111+
92112
def send_to_address(address, amount_sats):
93113
amount_btc = amount_sats/100000000.0
94114
cmd = "sendtoaddress " + str(address) + " " + str(amount_btc)
@@ -175,6 +195,29 @@ def open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_address_2,
175195
channel_ready_event_2 = expect_event(node_2, Event.CHANNEL_READY)
176196
return channel_ready_event_1, channel_ready_event_2, funding_txid
177197

198+
def mine_channel_closure_until_spendable(nodes, esplora_endpoint):
199+
blocks_to_mine = 0
200+
for node in nodes:
201+
current_height = node.status().current_best_block.height
202+
balances = node.list_balances()
203+
for lightning_balance in balances.lightning_balances:
204+
confirmation_height = getattr(lightning_balance, "confirmation_height", None)
205+
assert confirmation_height is not None, (
206+
f"Unexpected balance after cooperative close: {lightning_balance}"
207+
)
208+
blocks_to_mine = max(blocks_to_mine, confirmation_height - current_height)
209+
210+
if blocks_to_mine > 0:
211+
mine_and_wait(esplora_endpoint, blocks_to_mine)
212+
for node in nodes:
213+
node.sync_wallets()
214+
215+
for node in nodes:
216+
balances = node.list_balances()
217+
failure_message = f"Unexpected balances after cooperative close: {balances}"
218+
assert len(balances.lightning_balances) == 0, failure_message
219+
assert len(balances.pending_balances_from_channel_closures) == 0, failure_message
220+
178221
def stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2):
179222
node_1.stop()
180223
node_2.stop()
@@ -307,11 +350,15 @@ def test_channel_full_cycle(self):
307350

308351
expect_event(node_2, Event.CHANNEL_CLOSED)
309352

353+
wait_for_outpoint_spend(esplora_endpoint, channel_ready_event_1.funding_txo)
354+
310355
mine_and_wait(esplora_endpoint, 1)
311356

312357
node_1.sync_wallets()
313358
node_2.sync_wallets()
314359

360+
mine_channel_closure_until_spendable([node_1, node_2], esplora_endpoint)
361+
315362
spendable_balance_after_close_1 = node_1.list_balances().spendable_onchain_balance_sats
316363
assert spendable_balance_after_close_1 > 95000
317364
assert spendable_balance_after_close_1 < 100000

src/builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2103,6 +2103,7 @@ fn build_with_store_internal(
21032103
Arc::clone(&wallet),
21042104
Arc::clone(&channel_manager),
21052105
Arc::clone(&keys_manager),
2106+
Arc::clone(&chain_monitor),
21062107
Arc::clone(&tx_broadcaster),
21072108
Arc::clone(&kv_store),
21082109
Arc::clone(&config),

src/event.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ use crate::payment::PaymentMetadata;
5555
use crate::probing::Prober;
5656
use crate::runtime::Runtime;
5757
use crate::types::{
58-
CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet,
58+
ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper,
59+
Wallet,
5960
};
6061
use crate::{
6162
hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore,
@@ -530,6 +531,7 @@ where
530531
wallet: Arc<Wallet>,
531532
bump_tx_event_handler: Arc<BumpTransactionEventHandler>,
532533
channel_manager: Arc<ChannelManager>,
534+
chain_monitor: Arc<ChainMonitor>,
533535
connection_manager: Arc<ConnectionManager<L>>,
534536
output_sweeper: Arc<Sweeper>,
535537
network_graph: Arc<Graph>,
@@ -553,19 +555,20 @@ where
553555
pub fn new(
554556
event_queue: Arc<EventQueue<L>>, wallet: Arc<Wallet>,
555557
bump_tx_event_handler: Arc<BumpTransactionEventHandler>,
556-
channel_manager: Arc<ChannelManager>, connection_manager: Arc<ConnectionManager<L>>,
557-
output_sweeper: Arc<Sweeper>, network_graph: Arc<Graph>,
558-
liquidity_source: Arc<LiquiditySource<Arc<Logger>>>, payment_store: Arc<PaymentStore>,
559-
peer_store: Arc<PeerStore<L>>, keys_manager: Arc<KeysManager>,
560-
static_invoice_store: Option<StaticInvoiceStore>, onion_messenger: Arc<OnionMessenger>,
561-
om_mailbox: Option<Arc<OnionMessageMailbox>>, prober: Option<Arc<Prober>>,
562-
runtime: Arc<Runtime>, logger: L, config: Arc<Config>,
558+
channel_manager: Arc<ChannelManager>, chain_monitor: Arc<ChainMonitor>,
559+
connection_manager: Arc<ConnectionManager<L>>, output_sweeper: Arc<Sweeper>,
560+
network_graph: Arc<Graph>, liquidity_source: Arc<LiquiditySource<Arc<Logger>>>,
561+
payment_store: Arc<PaymentStore>, peer_store: Arc<PeerStore<L>>,
562+
keys_manager: Arc<KeysManager>, static_invoice_store: Option<StaticInvoiceStore>,
563+
onion_messenger: Arc<OnionMessenger>, om_mailbox: Option<Arc<OnionMessageMailbox>>,
564+
prober: Option<Arc<Prober>>, runtime: Arc<Runtime>, logger: L, config: Arc<Config>,
563565
) -> Self {
564566
Self {
565567
event_queue,
566568
wallet,
567569
bump_tx_event_handler,
568570
channel_manager,
571+
chain_monitor,
569572
connection_manager,
570573
output_sweeper,
571574
network_graph,
@@ -1276,6 +1279,7 @@ where
12761279
if required_reserve_sats > 0 {
12771280
let cur_anchor_reserve_sats = crate::total_anchor_channels_reserve_sats(
12781281
&self.channel_manager,
1282+
&self.chain_monitor,
12791283
&self.config,
12801284
);
12811285
let spendable_amount_sats =

0 commit comments

Comments
 (0)