Skip to content

Commit cbcc032

Browse files
pytest: reproduce issue #8899 fulfilled HTLC SENT_REMOVE_HTLC deadline force-close
Add test_fulfilled_htlc_deadline_no_force_close to reproduce the bug where CLN force-closes a channel with "Fulfilled HTLC SENT_REMOVE_HTLC cltv hit deadline" even though it has the preimage and just needs to reconnect to send update_fulfill_htlc upstream. The test sets up l1->l2->l3, sends a payment, and disconnects l2 from l1 right before update_fulfill_htlc is sent (-WIRE_UPDATE_FULFILL_HTLC). This leaves the incoming HTLC on the l1-l2 channel stuck in SENT_REMOVE_HTLC (or SENT_REMOVE_COMMIT under Valgrind). Mining blocks to the deadline triggers the buggy force-close. Reproduces: #8899 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1354512 commit cbcc032

1 file changed

Lines changed: 85 additions & 0 deletions

File tree

tests/test_closing.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3957,6 +3957,91 @@ def censoring_sendrawtx(r):
39573957
# FIXME: l2 should complain!
39583958

39593959

3960+
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors unsupported')
3961+
def test_fulfilled_htlc_deadline_no_force_close(node_factory, bitcoind):
3962+
"""Test that l2 does not force-close when fulfilled HTLC is in
3963+
SENT_REMOVE_HTLC state (preimage known, fulfill queued to channeld
3964+
but not yet sent to upstream peer).
3965+
3966+
Reproduces https://github.com/ElementsProject/lightning/issues/8899:
3967+
CLN force-closed with 'Fulfilled HTLC SENT_REMOVE_HTLC cltv hit deadline'
3968+
without attempting to send update_fulfill_htlc upstream first.
3969+
"""
3970+
# l1 -> l2 -> l3 topology.
3971+
# l2 disconnects from l1 right before sending update_fulfill_htlc,
3972+
# so the incoming HTLC on l1-l2 stays in SENT_REMOVE_HTLC.
3973+
# l2 cannot reconnect (dev-no-reconnect), simulating the scenario where
3974+
# the upstream peer appears connected but isn't processing messages.
3975+
#
3976+
# Use identical feerates to avoid gratuitous commits to update them.
3977+
opts = [{'dev-no-reconnect': None,
3978+
'feerates': (7500, 7500, 7500, 7500)},
3979+
{'disconnect': ['-WIRE_UPDATE_FULFILL_HTLC'],
3980+
'dev-no-reconnect': None,
3981+
'feerates': (7500, 7500, 7500, 7500)},
3982+
{'feerates': (7500, 7500, 7500, 7500)}]
3983+
3984+
l1, l2, l3 = node_factory.line_graph(3, opts=opts, wait_for_announce=True)
3985+
3986+
amt = 12300000
3987+
inv = l3.rpc.invoice(amt, 'test_fulfilled_deadline', 'desc')
3988+
3989+
# Use explicit route with known delays to have predictable cltv_expiry.
3990+
# delay=16 for first hop (cltv_delta=6 + cltv_final=10),
3991+
# delay=10 for second hop (cltv_final=10).
3992+
route = [{'amount_msat': amt + 1 + amt * 10 // 1000000,
3993+
'id': l2.info['id'],
3994+
'delay': 16,
3995+
'channel': first_scid(l1, l2)},
3996+
{'amount_msat': amt,
3997+
'id': l3.info['id'],
3998+
'delay': 10,
3999+
'channel': first_scid(l2, l3)}]
4000+
l1.rpc.sendpay(route, inv['payment_hash'],
4001+
payment_secret=inv['payment_secret'])
4002+
4003+
# l3 fulfills the HTLC, preimage flows back to l2.
4004+
# l2 transitions the incoming HTLC (from l1) to SENT_REMOVE_HTLC,
4005+
# then tries to send update_fulfill_htlc to l1 but disconnects.
4006+
# Wait for channeld on the l1-l2 channel to die after the disconnect.
4007+
# Note: "Peer transient failure" appears just before the dev_disconnect
4008+
# log in connectd, so we must wait for it first to avoid advancing the
4009+
# log search position past it.
4010+
l2.daemon.wait_for_log(r'chan#1: Peer transient failure in CHANNELD_NORMAL')
4011+
4012+
# Get the incoming HTLC's cltv_expiry from listpeerchannels.
4013+
htlc = only_one(only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels'])['htlcs'])
4014+
# Under Valgrind, the state may advance to SENT_REMOVE_COMMIT before
4015+
# the disconnect fully takes effect. Both states reproduce the bug.
4016+
assert htlc['state'] in ('SENT_REMOVE_HTLC', 'SENT_REMOVE_COMMIT'), \
4017+
f"Expected SENT_REMOVE_HTLC or SENT_REMOVE_COMMIT, got {htlc['state']}"
4018+
cltv_expiry = htlc['expiry']
4019+
4020+
# Compute the deadline dynamically from the actual HTLC cltv_expiry.
4021+
# htlc_in_deadline = cltv_expiry - (cltv_expiry_delta + 1)/2
4022+
# With regtest cltv_expiry_delta=6: deadline = cltv_expiry - 3
4023+
deadline = cltv_expiry - (6 + 1) // 2
4024+
current_height = bitcoind.rpc.getblockcount()
4025+
4026+
# Mine up to one block before the deadline — should NOT trigger force-close.
4027+
blocks_to_deadline = deadline - current_height
4028+
assert blocks_to_deadline > 1, f"Not enough room: deadline={deadline}, height={current_height}"
4029+
bitcoind.generate_block(blocks_to_deadline - 1)
4030+
sync_blockheight(bitcoind, [l2])
4031+
assert not l2.daemon.is_in_log('hit deadline')
4032+
4033+
# Mine one more block to hit the deadline.
4034+
#
4035+
# BUG: l2 force-closes with "Fulfilled HTLC 0 SENT_REMOVE_HTLC cltv ... hit deadline"
4036+
# even though it has the preimage and just needs to reconnect to send it upstream.
4037+
# After a fix, this line should be changed to assert the force-close
4038+
# does NOT happen:
4039+
# assert not l2.daemon.is_in_log('Fulfilled HTLC 0 SENT_REMOVE_HTLC')
4040+
bitcoind.generate_block(1)
4041+
sync_blockheight(bitcoind, [l2])
4042+
l2.daemon.wait_for_log(r'Fulfilled HTLC 0 SENT_REMOVE_(HTLC|COMMIT) cltv .* hit deadline')
4043+
4044+
39604045
def test_closing_tx_valid(node_factory, bitcoind):
39614046
l1, l2 = node_factory.line_graph(2, opts={'may_reconnect': True,
39624047
'dev-no-reconnect': None})

0 commit comments

Comments
 (0)