feat: handle on-chain TLC settlement for force-closed channels#1254
feat: handle on-chain TLC settlement for force-closed channels#1254doitian wants to merge 3 commits into
Conversation
|
The current test has passed, but whether the results of list_channel and get_invoice need to be synchronizedly modified ? lnd0->lnd1->fiber1->fiber2
|
4fc682d to
85ec855
Compare
| ) | ||
| }) | ||
| { | ||
| self.store |
There was a problem hiding this comment.
TODO: check the invoice is fully paid.
There was a problem hiding this comment.
Pull request overview
Fixes a long-standing edge case in Fiber’s payment lifecycle where a peer’s RemoveTlc(Fulfill) can arrive after a channel has been force-closed and the channel actor is already gone, leaving the sender (and downstream CCH tracking) stuck in Inflight. The PR adds network-level handling to recover fulfillment from persisted channel state, plus tests to validate one-hop and two-hop on-chain settlement flows and a new watchtower Bruno e2e scenario.
Changes:
- Intercept
RemoveTlc(Fulfill)for force-closed channels inNetworkActor::handle_peer_message, recover TLC info from persisted channel state, and propagate fulfillment upstream / to the payment actor. - Ensure invoices are marked
Paidwhen a fulfilled received-TLC is locally removed but the commitment round-trip never completes (common around force-closes). - Add unit tests and register a new Bruno e2e watchtower scenario in CI.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
crates/fiber-lib/src/fiber/network.rs |
Adds fallback handling for fulfill removes on closed channels and additional invoice-status reconciliation in periodic channel checks. |
crates/fiber-lib/src/store/store_impl/mod.rs |
Emits StoreChange::PutPreimage when inserting watchtower preimages so downstream watchers (e.g. CCH) can react. |
crates/fiber-lib/src/fiber/tests/payment.rs |
Adds one-hop and two-hop unit tests covering on-chain settlement after force-close. |
.github/workflows/e2e.yml |
Registers the new Bruno e2e scenario in CI. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/01-node1-connect-node2.bru |
New e2e step: connect Node1↔Node2. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/02-node2-connect-node3.bru |
New e2e step: connect Node2↔Node3. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/03-node1-node2-open-channel.bru |
New e2e step: open Node1→Node2 channel. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/04-node2-get-auto-accepted-channel.bru |
New e2e step: discover N1–N2 channel id. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/05-ckb-generate-blocks.bru |
New e2e step: mine epochs for N1–N2 channel confirmation. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/06-node2-node3-open-channel.bru |
New e2e step: open Node2→Node3 channel. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/07-node3-get-auto-accepted-channel.bru |
New e2e step: discover N2–N3 channel id. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/08-ckb-generate-blocks.bru |
New e2e step: mine epochs for N2–N3 channel confirmation. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/09-get-node1-funding-script.bru |
New e2e step: read Node1 funding lock script. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/10-get-node3-funding-script.bru |
New e2e step: read Node3 funding lock script. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/11-get-node1-balance.bru |
New e2e step: snapshot Node1 chain balance. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/12-get-node3-balance.bru |
New e2e step: snapshot Node3 chain balance. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/13-node3-gen-invoice.bru |
New e2e step: create hold invoice / preimage pair for the scenario. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/14-node1-send-payment-with-invoice.bru |
New e2e step: Node1 initiates payment via invoice. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/15-node2-force-close-channel.bru |
New e2e step: Node2 force-closes N2–N3 to force on-chain resolution path. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/16-node2-disconnect-node3.bru |
New e2e step: disconnect Node2 from Node3. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/17-ckb-generate-blocks-for-force-close-tx.bru |
New e2e step: mine epochs for force-close tx confirmation. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/18-node3-remove-tlc.bru |
New e2e step: Node3 removes TLC with preimage (fulfill). |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/19-ckb-generate-blocks-for-settlement-tx-preimage.bru |
New e2e step: mine epochs for settlement tx/preimage discovery. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/20-ckb-generate-blocks-for-final-settlement-tx.bru |
New e2e step: mine epochs to reach final settlement. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/21-ckb-generate-blocks-for-final-settlement-tx-commit.bru |
New e2e step: mine epochs to commit final settlement. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/22-check-channel1-balance.bru |
New e2e assertion: N1–N2 channel balance reflects claimed amount. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/23-check-payment-status.bru |
New e2e assertion: payment status becomes Success. |
tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/24-disconnect.bru |
New e2e teardown: disconnect peers. |
85ec855 to
0cb440d
Compare
429002c to
ff39903
Compare
|
|
||
| for (_pubkey, channel_id, channel_state) in self.store.get_channel_states(None) { | ||
| if matches!( | ||
| if matches!(channel_state, ChannelState::ChannelReady) { |
There was a problem hiding this comment.
I'm not sure is it too heavy to run these in CheckChannels. Also the code contains many logic similar to existing code blocks with nuance.
| state.store.insert_channel_actor_state(actor_state); | ||
| if let Some(actor) = state.channels.get(&channel_id) { | ||
| if let Err(err) = actor.send_message(ChannelActorMessage::Command( | ||
| ChannelCommand::ReloadState(ReloadParams { |
There was a problem hiding this comment.
This is a test/bench command before and is promoted to release environment here to synchronize the state in case the actor is still alive.
|
Turn to draft since there are complex edge cases not fixed yet. See test cases post by @gpBlockchain above. |
5c49ebc to
444f96a
Compare
64e2922 to
56ae7aa
Compare
43ed975 to
7df4f96
Compare
403699e to
2535d26
Compare
|
|
- Update TLCs status when they are settled on-chain with preimage - Use the discovered preimage to settle upstream forwarding TLC - Update the status of the payment and invoice the TLCs belong to
2535d26 to
ace11a3
Compare
The unconditional settle_onchain_fulfilled_tlcs call on every MaintainChannelTlcs tick scans all TLCs against the watchtower store --- even on healthy ready channels that can never have on-chain preimages. Under concurrent load (benchmark: 30 workers, 90 s) this blocks the channel actor with synchronous RocksDB lookups and degrades throughput from 24.26 TPS to 9.19 TPS (-62%). Restrict the scan to closed or shutting-down channels. Healthy ready channels skip it entirely, recovering the lost throughput.
When `settle_onchain_fulfilled_tlcs` discovers no on-chain preimages (the common case even for closed channels), the subsequent call to `sync_already_fulfilled_onchain_tlcs` was still iterating every offered and received TLC searching for stale RemoveTlcFulfill markers. Gate this on the current tick actually having found fulfillments.
bb5ea39 to
d06695c
Compare


Closes #1222
Summary
When a force-closed channel settles an HTLC on-chain, the watchtower already records whether the claim included a preimage or not. This PR wires those signals into channel and payment state so off-chain flows can complete after an on-chain settlement.
MaintainChannelTlcstick,settle_onchain_fulfilled_tlcslooks up watchtower-discovered preimages, marks the local TLC fulfilled, relays fulfillment upstream for forwarded TLCs, notifies the payer payment session, and marks the payee invoice paid when fulfilled received TLCs satisfy the invoice amount.is_tlc_settled_on_chainis set and no on-chain preimage was discovered — the two signals are mutually exclusive.CheckChannelsusescollect_onchain_fulfillable_upstream_tlcsto fulfill upstream forwarded TLCs for force-closed channels whose actor is no longer live (e.g. after settlement is finalized).sync_already_fulfilled_onchain_tlcsreconciles TLC status and invoice state for TLCs already marked fulfilled (e.g. after a partial off-chain fulfill on a closing channel).is_tlc_settled_on_chainandget_on_chain_discovered_preimage; addWatchtowerStore::is_tlc_settledand watchtower logging plus unit tests for preimage vs without-preimage on-chain claims.Test plan
cargo nextest run -p fnn -p fiber-bintest_closed_channel_upstream_fulfillment_from_onchain_preimage— forwarding node fulfills upstream TLC from watchtower preimagetest_payer_payment_success_from_onchain_preimage— payer payment session reachesSuccesstest_payee_invoice_paid_from_onchain_preimage— payee invoice status updated toPaidtest_onchain_settlement_restart_restores_upstream_waiting_commitment_actor— node restart after downstream force-close still drives upstream fulfillment