From 5d24a9d3d5c6071413703d45a019d9081d480424 Mon Sep 17 00:00:00 2001 From: ian Date: Tue, 7 Apr 2026 16:46:48 +0800 Subject: [PATCH 1/7] fix: resolve payment on RemoveTlc(Fulfill) for force-closed channels When a channel is force-closed the channel actor stops, so any RemoveTlc(Fulfill) arriving from the peer is silently dropped. This leaves the sender's payment session stuck in Inflight forever. Handle this in handle_peer_message: when a RemoveTlc(Fulfill) arrives for a channel whose actor is gone, look up the TLC from persisted state. If the TLC has a forwarding_tlc (intermediate node), relay the fulfillment upstream. Otherwise (payment sender), emit TlcRemoveReceived so the payment actor can finalise. Also emit StoreChange::PutPreimage from insert_watch_preimage so watchtower preimage discoveries are observable by store watchers. Made-with: Cursor --- crates/fiber-lib/src/fiber/network.rs | 58 ++++++++++++++++++++ crates/fiber-lib/src/store/store_impl/mod.rs | 4 ++ 2 files changed, 62 insertions(+) diff --git a/crates/fiber-lib/src/fiber/network.rs b/crates/fiber-lib/src/fiber/network.rs index 4b7fe839f..b5f2827c0 100644 --- a/crates/fiber-lib/src/fiber/network.rs +++ b/crates/fiber-lib/src/fiber/network.rs @@ -1111,6 +1111,64 @@ where } if !found { + // When a RemoveTlc(Fulfill) arrives for a force-closed channel + // the channel actor is gone. Look up the TLC from persisted + // state and either relay the fulfillment upstream (intermediate + // node) or notify the payment actor (sender). + if let FiberChannelMessage::RemoveTlc(ref remove_tlc) = msg { + if let RemoveTlcReason::RemoveTlcFulfill(ref fulfill) = &remove_tlc.reason { + if let Some(actor_state) = + state.store.get_channel_actor_state(&channel_id) + { + if let Some(tlc) = + actor_state.get_offered_tlc(TLCId::Offered(remove_tlc.tlc_id)) + { + state.store.insert_preimage( + tlc.payment_hash, + fulfill.payment_preimage, + ); + if let Some((prev_channel_id, prev_tlc_id)) = tlc.forwarding_tlc + { + info!( + "RemoveTlc(Fulfill) on closed channel {:?}, \ + relaying upstream to channel {:?}", + channel_id, prev_channel_id, + ); + let (send, _recv) = oneshot::channel(); + let rpc_reply = RpcReplyPort::from(send); + let _ = state + .send_command_to_channel( + prev_channel_id, + ChannelCommand::RemoveTlc( + RemoveTlcCommand { + id: prev_tlc_id, + reason: remove_tlc.reason.clone(), + }, + rpc_reply, + ), + ) + .await; + } else { + info!( + "RemoveTlc(Fulfill) on closed channel {:?}, \ + notifying payment actor for {:?}", + channel_id, tlc.payment_hash, + ); + myself + .send_message(NetworkActorMessage::new_event( + NetworkActorEvent::TlcRemoveReceived( + tlc.payment_hash, + tlc.attempt_id, + remove_tlc.reason.clone(), + ), + )) + .expect(ASSUME_NETWORK_MYSELF_ALIVE); + } + return Ok(()); + } + } + } + } error!( "Received a channel message for a channel that is not created with peer: {:?}", channel_id diff --git a/crates/fiber-lib/src/store/store_impl/mod.rs b/crates/fiber-lib/src/store/store_impl/mod.rs index 097dc8252..ff9cd4815 100644 --- a/crates/fiber-lib/src/store/store_impl/mod.rs +++ b/crates/fiber-lib/src/store/store_impl/mod.rs @@ -1211,6 +1211,10 @@ impl WatchtowerStore for Store { let kv = KeyValue::WatchtowerNodePaymentHash(node_id, payment_hash); batch.put(kv.key(), kv.value()); batch.commit(); + self.notify(StoreChange::PutPreimage { + payment_hash, + payment_preimage: preimage, + }); } fn remove_watch_preimage(&self, node_id: NodeId, payment_hash: Hash256) { From b58ad043ab3defafb862e1ad47d9835925771006 Mon Sep 17 00:00:00 2001 From: ian Date: Tue, 7 Apr 2026 16:46:59 +0800 Subject: [PATCH 2/7] test: one-hop and two-hop on-chain settlement payment tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two tests that verify payment session resolution after a force close when the payee settles the TLC by revealing the preimage: - test_one_hop_payment_payee_settles_onchain: A force-closes, B settles invoice, A's payment should reach Success. - test_two_hop_payment_payee_settles_onchain: B force-closes B→C, C settles invoice, the fulfill relays through B back to A. Made-with: Cursor --- crates/fiber-lib/src/fiber/tests/payment.rs | 146 ++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/fiber-lib/src/fiber/tests/payment.rs b/crates/fiber-lib/src/fiber/tests/payment.rs index aa76d968f..dae9cbb1f 100644 --- a/crates/fiber-lib/src/fiber/tests/payment.rs +++ b/crates/fiber-lib/src/fiber/tests/payment.rs @@ -18,6 +18,7 @@ use crate::fiber::{ use crate::gen_rand_fiber_public_key; use crate::gen_rand_sha256_hash; use crate::invoice::CkbInvoice; +use crate::invoice::CkbInvoiceStatus; use crate::invoice::Currency; use crate::invoice::InvoiceBuilder; #[cfg(not(target_arch = "wasm32"))] @@ -7252,3 +7253,148 @@ async fn test_send_payment_max_fee_rate_limit() { assert_eq!(payment_data.max_fee_amount, Some(10)); } + +/// Payer force-closes a one-hop channel while a TLC is held. The payee +/// settles the invoice (revealing the preimage on-chain). The payer's +/// watchtower should discover the preimage and mark the payment as Success. +#[tokio::test] +async fn test_one_hop_payment_payee_settles_onchain() { + init_tracing(); + + let (nodes, channels) = + create_n_nodes_network(&[((0, 1), (HUGE_CKB_AMOUNT, HUGE_CKB_AMOUNT))], 2).await; + let [node_0, node_1] = nodes.try_into().expect("2 nodes"); + let channel_id = channels[0]; + + let preimage = gen_rand_sha256_hash(); + let invoice = node_1.build_basic_invoice(100_000_000, preimage); + let payment_hash = *invoice.payment_hash(); + node_1.insert_invoice(invoice.clone(), None); + + let res = node_0 + .send_payment(SendPaymentCommand { + target_pubkey: Some(node_1.pubkey), + amount: Some(100_000_000), + invoice: Some(invoice.to_string()), + max_fee_rate: Some(1000), + ..Default::default() + }) + .await; + assert!(res.is_ok(), "send_payment failed: {:?}", res); + node_0.wait_until_inflight(payment_hash).await; + + // Wait for the TLC to arrive at the payee (invoice → Received). + let started = std::time::Instant::now(); + while started.elapsed() < Duration::from_secs(10) { + if node_1.get_invoice_status(&payment_hash) == Some(CkbInvoiceStatus::Received) { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + assert_eq!( + node_1.get_invoice_status(&payment_hash), + Some(CkbInvoiceStatus::Received), + ); + + node_0.send_shutdown(channel_id, true).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + node_0 + .send_channel_shutdown_tx_confirmed_event(node_1.pubkey, channel_id, true) + .await; + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + let state = node_0.get_channel_actor_state(channel_id); + assert!( + matches!(state.state, ChannelState::Closed(flags) if flags.contains(CloseFlags::UNCOOPERATIVE_LOCAL)), + ); + + node_1 + .settle_invoice(&payment_hash, preimage) + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await; + + let status = node_0.get_payment_status(payment_hash).await; + info!("payer payment status after on-chain settlement: {status:?}"); + assert_eq!( + status, + PaymentStatus::Success, + "payer should see Success after payee settles TLC on-chain", + ); +} + +/// A→B→C two-hop payment. B force-closes the B→C channel while the TLC +/// is held. C settles the invoice (preimage revealed on-chain). A should +/// eventually see the payment as Success. +#[tokio::test] +async fn test_two_hop_payment_payee_settles_onchain() { + init_tracing(); + + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, HUGE_CKB_AMOUNT)), + ((1, 2), (HUGE_CKB_AMOUNT, HUGE_CKB_AMOUNT)), + ], + 3, + ) + .await; + let [node_a, node_b, node_c] = nodes.try_into().expect("3 nodes"); + let channel_bc = channels[1]; + + let preimage = gen_rand_sha256_hash(); + let invoice = node_c.build_basic_invoice(100_000_000, preimage); + let payment_hash = *invoice.payment_hash(); + node_c.insert_invoice(invoice.clone(), None); + + let res = node_a + .send_payment(SendPaymentCommand { + target_pubkey: Some(node_c.pubkey), + amount: Some(100_000_000), + invoice: Some(invoice.to_string()), + max_fee_rate: Some(1000), + ..Default::default() + }) + .await; + assert!(res.is_ok(), "send_payment failed: {:?}", res); + node_a.wait_until_inflight(payment_hash).await; + + let started = std::time::Instant::now(); + while started.elapsed() < Duration::from_secs(10) { + if node_c.get_invoice_status(&payment_hash) == Some(CkbInvoiceStatus::Received) { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + assert_eq!( + node_c.get_invoice_status(&payment_hash), + Some(CkbInvoiceStatus::Received), + ); + + // B force-closes the B→C channel. + node_b.send_shutdown(channel_bc, true).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + node_b + .send_channel_shutdown_tx_confirmed_event(node_c.pubkey, channel_bc, true) + .await; + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + let state = node_b.get_channel_actor_state(channel_bc); + assert!( + matches!(state.state, ChannelState::Closed(flags) if flags.contains(CloseFlags::UNCOOPERATIVE_LOCAL)), + ); + + // C settles the invoice (preimage revealed on-chain). + node_c + .settle_invoice(&payment_hash, preimage) + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await; + + let status = node_a.get_payment_status(payment_hash).await; + info!("payer (A) payment status after on-chain settlement: {status:?}"); + assert_eq!( + status, + PaymentStatus::Success, + "A should see Success after C settles TLC on-chain", + ); +} From a70f13479939f156009af7b896a17dc398c4ebac Mon Sep 17 00:00:00 2001 From: ian Date: Tue, 7 Apr 2026 16:47:06 +0800 Subject: [PATCH 3/7] test: add force-close-preimage-settled-by-recipient e2e test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bruno e2e scenario: A→B→C payment where B force-closes the B→C channel and C settles the TLC on-chain with the preimage. Verifies that A's payment status reaches Success and B's channel balance reflects the settled amount. Made-with: Cursor --- .github/workflows/e2e.yml | 1 + .../01-node1-connect-node2.bru | 36 ++++++++++ .../02-node2-connect-node3.bru | 36 ++++++++++ .../03-node1-node2-open-channel.bru | 39 +++++++++++ .../04-node2-get-auto-accepted-channel.bru | 39 +++++++++++ .../05-ckb-generate-blocks.bru | 33 +++++++++ .../06-node2-node3-open-channel.bru | 39 +++++++++++ .../07-node3-get-auto-accepted-channel.bru | 39 +++++++++++ .../08-ckb-generate-blocks.bru | 33 +++++++++ .../09-get-node1-funding-script.bru | 29 ++++++++ .../10-get-node3-funding-script.bru | 29 ++++++++ .../11-get-node1-balance.bru | 40 +++++++++++ .../12-get-node3-balance.bru | 40 +++++++++++ .../13-node3-gen-invoice.bru | 70 +++++++++++++++++++ .../14-node1-send-payment-with-invoice.bru | 42 +++++++++++ .../15-node2-force-close-channel.bru | 35 ++++++++++ .../16-node2-disconnect-node3.bru | 36 ++++++++++ ...ckb-generate-blocks-for-force-close-tx.bru | 33 +++++++++ .../18-node3-remove-tlc.bru | 38 ++++++++++ ...rate-blocks-for-settlement-tx-preimage.bru | 33 +++++++++ ...enerate-blocks-for-final-settlement-tx.bru | 33 +++++++++ ...-blocks-for-final-settlement-tx-commit.bru | 33 +++++++++ .../22-check-channel1-balance.bru | 47 +++++++++++++ .../23-check-payment-status.bru | 40 +++++++++++ .../24-disconnect.bru | 36 ++++++++++ 25 files changed, 909 insertions(+) create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/01-node1-connect-node2.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/02-node2-connect-node3.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/03-node1-node2-open-channel.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/04-node2-get-auto-accepted-channel.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/05-ckb-generate-blocks.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/06-node2-node3-open-channel.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/07-node3-get-auto-accepted-channel.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/08-ckb-generate-blocks.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/09-get-node1-funding-script.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/10-get-node3-funding-script.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/11-get-node1-balance.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/12-get-node3-balance.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/13-node3-gen-invoice.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/14-node1-send-payment-with-invoice.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/15-node2-force-close-channel.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/16-node2-disconnect-node3.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/17-ckb-generate-blocks-for-force-close-tx.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/18-node3-remove-tlc.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/19-ckb-generate-blocks-for-settlement-tx-preimage.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/20-ckb-generate-blocks-for-final-settlement-tx.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/21-ckb-generate-blocks-for-final-settlement-tx-commit.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/22-check-channel1-balance.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/23-check-payment-status.bru create mode 100644 tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/24-disconnect.bru diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 72e0695f1..7e87238f1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -35,6 +35,7 @@ jobs: - watchtower/force-close-with-pending-tlcs - watchtower/force-close-with-pending-tlcs-and-stop-watchtower - watchtower/force-close-with-pending-tlcs-and-udt + - watchtower/force-close-preimage-settled-by-recipient - watchtower/revocation release: - "0.202.0" diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/01-node1-connect-node2.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/01-node1-connect-node2.bru new file mode 100644 index 000000000..0b30b61da --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/01-node1-connect-node2.bru @@ -0,0 +1,36 @@ +meta { + name: connect peer + type: http + seq: 1 +} + +post { + url: {{NODE2_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "connect_peer", + "params": [ + {"address": "{{NODE1_ADDR}}"} + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isNull +} + +script:post-response { + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/02-node2-connect-node3.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/02-node2-connect-node3.bru new file mode 100644 index 000000000..0f48365be --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/02-node2-connect-node3.bru @@ -0,0 +1,36 @@ +meta { + name: connect peer + type: http + seq: 2 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "connect_peer", + "params": [ + {"address": "{{NODE2_ADDR}}"} + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isNull +} + +script:post-response { + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/03-node1-node2-open-channel.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/03-node1-node2-open-channel.bru new file mode 100644 index 000000000..7c13335db --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/03-node1-node2-open-channel.bru @@ -0,0 +1,39 @@ +meta { + name: Node1 open a channel to Node2 + type: http + seq: 3 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "open_channel", + "params": [ + { + "pubkey": "{{NODE2_PUBKEY}}", + "funding_amount": "0x377aab54d000" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.temporary_channel_id: isDefined +} + +script:post-response { + await new Promise(r => setTimeout(r, 2000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/04-node2-get-auto-accepted-channel.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/04-node2-get-auto-accepted-channel.bru new file mode 100644 index 000000000..1ed14f9bf --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/04-node2-get-auto-accepted-channel.bru @@ -0,0 +1,39 @@ +meta { + name: get auto accepted channel id from Node2 + type: http + seq: 4 +} + +post { + url: {{NODE2_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "list_channels", + "params": [ + { + "pubkey": "{{NODE1_PUBKEY}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.channels[0].channel_id: isDefined +} + +script:post-response { + console.log("N1-N2 channels: ", res.body.result); + bru.setVar("N1N2_CHANNEL_ID", res.body.result.channels[0].channel_id); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/05-ckb-generate-blocks.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/05-ckb-generate-blocks.bru new file mode 100644 index 000000000..def015208 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/05-ckb-generate-blocks.bru @@ -0,0 +1,33 @@ +meta { + name: generate a few epochs for channel 1 + type: http + seq: 5 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x2"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/06-node2-node3-open-channel.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/06-node2-node3-open-channel.bru new file mode 100644 index 000000000..fbe96dc56 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/06-node2-node3-open-channel.bru @@ -0,0 +1,39 @@ +meta { + name: Node2 open a channel to Node3 + type: http + seq: 6 +} + +post { + url: {{NODE2_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "open_channel", + "params": [ + { + "pubkey": "{{NODE3_PUBKEY}}", + "funding_amount": "0x377aab54d000" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.temporary_channel_id: isDefined +} + +script:post-response { + await new Promise(r => setTimeout(r, 2000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/07-node3-get-auto-accepted-channel.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/07-node3-get-auto-accepted-channel.bru new file mode 100644 index 000000000..7f7dfac71 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/07-node3-get-auto-accepted-channel.bru @@ -0,0 +1,39 @@ +meta { + name: get auto accepted channel id from Node3 + type: http + seq: 7 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "list_channels", + "params": [ + { + "pubkey": "{{NODE2_PUBKEY}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.channels[0].channel_id: isDefined +} + +script:post-response { + console.log("N2-N3 channels: ", res.body.result); + bru.setVar("N2N3_CHANNEL_ID", res.body.result.channels[0].channel_id); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/08-ckb-generate-blocks.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/08-ckb-generate-blocks.bru new file mode 100644 index 000000000..4defb9d6b --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/08-ckb-generate-blocks.bru @@ -0,0 +1,33 @@ +meta { + name: generate a few epochs for channel 2 + type: http + seq: 8 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x2"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/09-get-node1-funding-script.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/09-get-node1-funding-script.bru new file mode 100644 index 000000000..c25f2f551 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/09-get-node1-funding-script.bru @@ -0,0 +1,29 @@ +meta { + name: get node1 funding script + type: http + seq: 9 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "node_info", + "params": [] + } +} + +script:post-response { + bru.setVar("NODE1_FUNDING_SCRIPT", res.body.result.default_funding_lock_script); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/10-get-node3-funding-script.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/10-get-node3-funding-script.bru new file mode 100644 index 000000000..fb83e3df8 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/10-get-node3-funding-script.bru @@ -0,0 +1,29 @@ +meta { + name: get node3 funding script + type: http + seq: 10 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "node_info", + "params": [] + } +} + +script:post-response { + bru.setVar("NODE3_FUNDING_SCRIPT", res.body.result.default_funding_lock_script); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/11-get-node1-balance.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/11-get-node1-balance.bru new file mode 100644 index 000000000..d2b0988ec --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/11-get-node1-balance.bru @@ -0,0 +1,40 @@ +meta { + name: get node1 balance + type: http + seq: 11 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "get_cells_capacity", + "params": [ + { + "script_type": "lock" + } + ] + } +} + +script:pre-request { + let script = bru.getVar("NODE1_FUNDING_SCRIPT"); + let body = req.getBody(); + body.params[0].script = script; + req.setBody(body); +} + +script:post-response { + bru.setVar("NODE1_BALANCE", res.body.result.capacity); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/12-get-node3-balance.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/12-get-node3-balance.bru new file mode 100644 index 000000000..c02905f49 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/12-get-node3-balance.bru @@ -0,0 +1,40 @@ +meta { + name: get node3 balance + type: http + seq: 12 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "get_cells_capacity", + "params": [ + { + "script_type": "lock" + } + ] + } +} + +script:pre-request { + let script = bru.getVar("NODE3_FUNDING_SCRIPT"); + let body = req.getBody(); + body.params[0].script = script; + req.setBody(body); +} + +script:post-response { + bru.setVar("NODE3_BALANCE", res.body.result.capacity); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/13-node3-gen-invoice.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/13-node3-gen-invoice.bru new file mode 100644 index 000000000..172c9355f --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/13-node3-gen-invoice.bru @@ -0,0 +1,70 @@ +meta { + name: Node3 generate hold invoice + type: http + seq: 13 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "new_invoice", + "params": [ + { + "amount": "0x5f5e100", + "currency": "Fibd", + "description": "hold invoice for preimage settlement test", + "expiry": "0xe10", + "final_expiry_delta": "0xDFFA0", + "payment_hash": "{{payment_hash}}", + "hash_algorithm": "sha256" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isDefined +} + +script:pre-request { + let payment_preimage; + let payment_hash; + try { + const crypto = require('crypto'); + function generateRandomPreimage() { + let hex = '0x'; + for (let i = 0; i < 64; i++) { + hex += Math.floor(Math.random() * 16).toString(16); + } + return hex; + } + payment_preimage = generateRandomPreimage(); + const preimage_bytes = Buffer.from(payment_preimage.slice(2), 'hex'); + payment_hash = "0x" + crypto.createHash('sha256').update(preimage_bytes).digest('hex'); + } catch (e) { + // Fallback: hardcoded SHA256 pair if crypto module unavailable + payment_preimage = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + payment_hash = "0x8200cf0ce11447bf6353cbac964d07d1c390d61d07e6c5d0214450b3add6449b"; + } + bru.setVar("payment_preimage", payment_preimage); + bru.setVar("payment_hash", payment_hash); +} + +script:post-response { + console.log("generated result: ", res.body.result); + bru.setVar("encoded_invoice", res.body.result.invoice_address); + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/14-node1-send-payment-with-invoice.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/14-node1-send-payment-with-invoice.bru new file mode 100644 index 000000000..3476eb2f3 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/14-node1-send-payment-with-invoice.bru @@ -0,0 +1,42 @@ +meta { + name: Node1 send payment with invoice + type: http + seq: 14 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "send_payment", + "params": [ + { + "invoice": "{{encoded_invoice}}" + } + ] + } +} + +assert { + res.body.error: isUndefined +} + +script:pre-request { + await new Promise(r => setTimeout(r, 1000)); +} + +script:post-response { + bru.setVar("payment_hash", res.body.result.payment_hash); + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/15-node2-force-close-channel.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/15-node2-force-close-channel.bru new file mode 100644 index 000000000..99889dd76 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/15-node2-force-close-channel.bru @@ -0,0 +1,35 @@ +meta { + name: Node2 force close N2-N3 channel + type: http + seq: 15 +} + +post { + url: {{NODE2_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "shutdown_channel", + "params": [ + { + "channel_id": "{{N2N3_CHANNEL_ID}}", + "force": true + } + ] + } +} + +script:post-response { + console.log(res.body); + await new Promise(r => setTimeout(r, 2000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/16-node2-disconnect-node3.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/16-node2-disconnect-node3.bru new file mode 100644 index 000000000..7c359b91e --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/16-node2-disconnect-node3.bru @@ -0,0 +1,36 @@ +meta { + name: disconnect Node2 from Node3 + type: http + seq: 16 +} + +post { + url: {{NODE2_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "disconnect_peer", + "params": [ + {"pubkey": "{{NODE3_PUBKEY}}"} + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isDefined +} + +script:post-response { + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/17-ckb-generate-blocks-for-force-close-tx.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/17-ckb-generate-blocks-for-force-close-tx.bru new file mode 100644 index 000000000..c5bc0ff7d --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/17-ckb-generate-blocks-for-force-close-tx.bru @@ -0,0 +1,33 @@ +meta { + name: generate one epoch for force close transaction + type: http + seq: 17 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x1"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/18-node3-remove-tlc.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/18-node3-remove-tlc.bru new file mode 100644 index 000000000..c87b43753 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/18-node3-remove-tlc.bru @@ -0,0 +1,38 @@ +meta { + name: Node3 settles payment by revealing preimage on-chain + type: http + seq: 18 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "remove_tlc", + "params": [ + { + "channel_id": "{{N2N3_CHANNEL_ID}}", + "tlc_id": "0x0", + "reason": { + "payment_preimage": "{{payment_preimage}}" + } + } + ] + } +} + +script:post-response { + console.log(res.body); + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/19-ckb-generate-blocks-for-settlement-tx-preimage.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/19-ckb-generate-blocks-for-settlement-tx-preimage.bru new file mode 100644 index 000000000..822147239 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/19-ckb-generate-blocks-for-settlement-tx-preimage.bru @@ -0,0 +1,33 @@ +meta { + name: generate a few epochs for settlement transaction + type: http + seq: 19 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x2"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/20-ckb-generate-blocks-for-final-settlement-tx.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/20-ckb-generate-blocks-for-final-settlement-tx.bru new file mode 100644 index 000000000..a726f39b2 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/20-ckb-generate-blocks-for-final-settlement-tx.bru @@ -0,0 +1,33 @@ +meta { + name: generate a few epochs for final settlement transaction + type: http + seq: 20 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x7"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/21-ckb-generate-blocks-for-final-settlement-tx-commit.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/21-ckb-generate-blocks-for-final-settlement-tx-commit.bru new file mode 100644 index 000000000..dc8700458 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/21-ckb-generate-blocks-for-final-settlement-tx-commit.bru @@ -0,0 +1,33 @@ +meta { + name: generate one epoch for final settlement transaction commit + type: http + seq: 21 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x1"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 10000)); +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/22-check-channel1-balance.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/22-check-channel1-balance.bru new file mode 100644 index 000000000..e60600674 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/22-check-channel1-balance.bru @@ -0,0 +1,47 @@ +meta { + name: check N1-N2 channel balance, Node2 should have claimed TLC amount by finding Node3's preimage on-chain + type: http + seq: 22 +} + +post { + url: {{NODE2_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "list_channels", + "params": [ + { + "pubkey": "{{NODE1_PUBKEY}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.channels[0].offered_tlc_balance: eq "0x0" + res.body.result.channels[0].received_tlc_balance: eq "0x0" +} + +script:post-response { + console.log("N1-N2 channel state: ", res.body.result.channels[0]); + // Node2 is the payee-side intermediary: it forwarded the payment from Node1 to Node3. + // After the TLC settles, Node2's local_balance on N1-N2 should have increased + // by at least the invoice amount (0x5f5e100 = 100_000_000 shannons). + const localBalance = BigInt(res.body.result.channels[0].local_balance); + const invoiceAmount = BigInt("0x5f5e100"); + if (localBalance < invoiceAmount) { + throw new Error("Node2 local_balance " + localBalance + " is less than invoice amount " + invoiceAmount); + } +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/23-check-payment-status.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/23-check-payment-status.bru new file mode 100644 index 000000000..2511309d6 --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/23-check-payment-status.bru @@ -0,0 +1,40 @@ +meta { + name: check payment status is Success + type: http + seq: 23 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "get_payment", + "params": [ + { + "payment_hash": "{{payment_hash}}" + } + ] + } +} + +assert { + res.body.error: isUndefined +} + +script:post-response { + console.log("Payment status: ", res.body.result); + if (res.body.result.status != "Success") { + throw new Error("Assertion failed: payment status expected to be Success, got " + res.body.result.status); + } +} diff --git a/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/24-disconnect.bru b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/24-disconnect.bru new file mode 100644 index 000000000..5d5641f0c --- /dev/null +++ b/tests/bruno/e2e/watchtower/force-close-preimage-settled-by-recipient/24-disconnect.bru @@ -0,0 +1,36 @@ +meta { + name: disconnect peers + type: http + seq: 24 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "disconnect_peer", + "params": [ + {"pubkey": "{{NODE2_PUBKEY}}"} + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isDefined +} + +script:post-response { + await new Promise(r => setTimeout(r, 1000)); +} From 0cb440df5195fed774f78a6279a76844d6e9fb32 Mon Sep 17 00:00:00 2001 From: ian Date: Wed, 8 Apr 2026 16:45:18 +0800 Subject: [PATCH 4/7] fix: update invoice to Paid and TLC to RemoteRemoved on on-chain settlement After a force-closed channel's TLC is settled on-chain: - Payee: CheckChannels now scans received TLCs with LocalRemoved + Fulfill reason on both ChannelReady and Closed(UNCOOPERATIVE) channels and updates the invoice status to Paid. - Payer: the RemoveTlc(Fulfill) handler for force-closed channels now calls set_offered_tlc_removed() to transition the TLC to RemoteRemoved and persists the updated channel state. - Tests: added invoice Paid and TLC RemoteRemoved assertions to both one-hop and two-hop on-chain settlement tests. --- crates/fiber-lib/src/fiber/network.rs | 94 +++++++++++++++++++-- crates/fiber-lib/src/fiber/tests/payment.rs | 37 ++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/fiber-lib/src/fiber/network.rs b/crates/fiber-lib/src/fiber/network.rs index b5f2827c0..5e4b7bcf6 100644 --- a/crates/fiber-lib/src/fiber/network.rs +++ b/crates/fiber-lib/src/fiber/network.rs @@ -1117,18 +1117,28 @@ where // node) or notify the payment actor (sender). if let FiberChannelMessage::RemoveTlc(ref remove_tlc) = msg { if let RemoveTlcReason::RemoveTlcFulfill(ref fulfill) = &remove_tlc.reason { - if let Some(actor_state) = + if let Some(mut actor_state) = state.store.get_channel_actor_state(&channel_id) { if let Some(tlc) = actor_state.get_offered_tlc(TLCId::Offered(remove_tlc.tlc_id)) { - state.store.insert_preimage( - tlc.payment_hash, - fulfill.payment_preimage, + let payment_hash = tlc.payment_hash; + let forwarding_tlc = tlc.forwarding_tlc; + let attempt_id = tlc.attempt_id; + + state + .store + .insert_preimage(payment_hash, fulfill.payment_preimage); + // Update TLC status to RemoteRemoved in + // persisted channel state. + actor_state.tlc_state.set_offered_tlc_removed( + remove_tlc.tlc_id, + remove_tlc.reason.clone(), ); - if let Some((prev_channel_id, prev_tlc_id)) = tlc.forwarding_tlc - { + state.store.insert_channel_actor_state(actor_state); + + if let Some((prev_channel_id, prev_tlc_id)) = forwarding_tlc { info!( "RemoveTlc(Fulfill) on closed channel {:?}, \ relaying upstream to channel {:?}", @@ -1152,13 +1162,13 @@ where info!( "RemoveTlc(Fulfill) on closed channel {:?}, \ notifying payment actor for {:?}", - channel_id, tlc.payment_hash, + channel_id, payment_hash, ); myself .send_message(NetworkActorMessage::new_event( NetworkActorEvent::TlcRemoveReceived( - tlc.payment_hash, - tlc.attempt_id, + payment_hash, + attempt_id, remove_tlc.reason.clone(), ), )) @@ -1654,6 +1664,54 @@ where Ok(()) } + /// For each fulfilled received TLC on the given channel, mark the + /// corresponding invoice as `Paid` if enough TLCs have been fulfilled to + /// cover the invoice amount. Each invoice is processed at most once. + fn settle_invoices_for_fulfilled_tlcs( + &self, + actor_state: &ChannelActorState, + channel_id: &Hash256, + log_context: &str, + ) { + let mut processed_invoice_hashes = HashSet::new(); + for tlc in actor_state.tlc_state.received_tlcs.tlcs.iter() { + if !matches!( + tlc.removed_reason, + Some(RemoveTlcReason::RemoveTlcFulfill(_)) + ) { + continue; + } + if !processed_invoice_hashes.insert(tlc.payment_hash) { + // process each hash once + continue; + } + if !self + .store + .get_invoice_status(&tlc.payment_hash) + .is_some_and(|s| matches!(s, CkbInvoiceStatus::Open | CkbInvoiceStatus::Received)) + { + continue; + } + let Some(invoice) = self.store.get_invoice(&tlc.payment_hash) else { + continue; + }; + let fulfilled_tlcs = actor_state.tlc_state.received_tlcs.tlcs.iter().filter(|t| { + t.payment_hash == tlc.payment_hash + && matches!(t.removed_reason, Some(RemoveTlcReason::RemoveTlcFulfill(_))) + }); + if !is_invoice_fulfilled(&invoice, fulfilled_tlcs) { + continue; + } + self.store + .update_invoice_status(&tlc.payment_hash, CkbInvoiceStatus::Paid) + .expect("update invoice status failed"); + info!( + "Updated invoice to Paid for fulfilled TLC {:?} on {} {:?}", + tlc.payment_hash, log_context, channel_id + ); + } + } + pub async fn handle_command( &self, myself: ActorRef, @@ -1994,6 +2052,16 @@ where } } + // For received TLCs that have been locally removed + // with a Fulfill reason but whose commitment + // round-trip never completed (e.g. the remote peer + // force-closed), update the invoice to Paid. + self.settle_invoices_for_fulfilled_tlcs( + &actor_state, + &channel_id, + "check channel", + ); + let delay_epoch = EpochNumberWithFraction::from_full_value( actor_state.commitment_delay_epoch, ); @@ -2151,6 +2219,14 @@ where } } } + + // For received TLCs on force-closed channels that + // were fulfilled, update invoice to Paid. + self.settle_invoices_for_fulfilled_tlcs( + &actor_state, + &channel_id, + "closed channel", + ); } } } diff --git a/crates/fiber-lib/src/fiber/tests/payment.rs b/crates/fiber-lib/src/fiber/tests/payment.rs index dae9cbb1f..b08636237 100644 --- a/crates/fiber-lib/src/fiber/tests/payment.rs +++ b/crates/fiber-lib/src/fiber/tests/payment.rs @@ -36,6 +36,7 @@ use ckb_types::{core::tx_pool::TxStatus, packed::OutPoint}; use fiber_types::Hash256 as InternalHash256; use fiber_types::HashAlgorithm; use fiber_types::HopHint; +use fiber_types::OutboundTlcStatus; use fiber_types::RemoveTlcFulfill; use fiber_types::SessionRoute; use ractor::call; @@ -7321,6 +7322,24 @@ async fn test_one_hop_payment_payee_settles_onchain() { PaymentStatus::Success, "payer should see Success after payee settles TLC on-chain", ); + + // Payee invoice should be Paid. + assert_eq!( + node_1.get_invoice_status(&payment_hash), + Some(CkbInvoiceStatus::Paid), + "payee invoice should be Paid after on-chain settlement", + ); + + // Payer TLC should be RemoteRemoved. + let payer_state = node_0.get_channel_actor_state(channel_id); + let payer_tlc = payer_state + .get_offered_tlc(TLCId::Offered(0)) + .expect("payer TLC should exist"); + assert_eq!( + payer_tlc.outbound_status(), + OutboundTlcStatus::RemoteRemoved, + "payer TLC should be RemoteRemoved after on-chain settlement", + ); } /// A→B→C two-hop payment. B force-closes the B→C channel while the TLC @@ -7397,4 +7416,22 @@ async fn test_two_hop_payment_payee_settles_onchain() { PaymentStatus::Success, "A should see Success after C settles TLC on-chain", ); + + // Payee invoice should be Paid. + assert_eq!( + node_c.get_invoice_status(&payment_hash), + Some(CkbInvoiceStatus::Paid), + "payee (C) invoice should be Paid after on-chain settlement", + ); + + // B's offered TLC on the B→C channel should be RemoteRemoved. + let b_state = node_b.get_channel_actor_state(channel_bc); + let b_tlc = b_state + .get_offered_tlc(TLCId::Offered(0)) + .expect("B offered TLC should exist"); + assert_eq!( + b_tlc.outbound_status(), + OutboundTlcStatus::RemoteRemoved, + "B offered TLC should be RemoteRemoved after on-chain settlement", + ); } From 5ba7267a6dae4caf2a6985c14c56be67ac6c76cc Mon Sep 17 00:00:00 2001 From: ian Date: Tue, 5 May 2026 15:44:47 +0800 Subject: [PATCH 5/7] fix: harden RemoveTlc(Fulfill) fallback for force-closed channels Gate the persisted-state fallback path to channels that are actually closed and whose persisted remote pubkey matches the incoming peer, so a reconnect race or unrelated peer cannot drive fulfillment handling for a channel that does not belong to it. Also re-validate the preimage against the TLC payment hash and verify the offered TLC is in Committed status before calling `set_offered_tlc_removed`, which asserts on the status and would otherwise panic the network actor on duplicate or out-of-order removes. --- crates/fiber-lib/src/fiber/network.rs | 47 +++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/fiber-lib/src/fiber/network.rs b/crates/fiber-lib/src/fiber/network.rs index 5e4b7bcf6..7b23a57c7 100644 --- a/crates/fiber-lib/src/fiber/network.rs +++ b/crates/fiber-lib/src/fiber/network.rs @@ -100,11 +100,11 @@ use fiber_types::SessionRoute; use fiber_types::{ blake2b_hash_with_salt, AddTlcCommand, AwaitingTxSignaturesFlags, ChannelOpenRecord, ChannelOpeningStatus, ChannelState, ChannelTlcInfo, CloseFlags, EcdsaSignature, EntityHex, - FeatureVector, Hash256, NodeAnnouncement, PaymentCustomRecords, PaymentStatus, - PeeledPaymentOnionPacket, PersistentNetworkActorState, PrevTlcInfo, Privkey, Pubkey, - PublicChannelInfo, RemoveTlcFulfill, RemoveTlcReason, RetryableTlcOperation, RevocationData, - RouterHop, SettlementData, ShuttingDownFlags, TLCId, TlcErr, TlcErrPacket, TlcErrorCode, - TrampolineContext, UdtCfgInfos, + FeatureVector, Hash256, NodeAnnouncement, OutboundTlcStatus, PaymentCustomRecords, + PaymentStatus, PeeledPaymentOnionPacket, PersistentNetworkActorState, PrevTlcInfo, Privkey, + Pubkey, PublicChannelInfo, RemoveTlcFulfill, RemoveTlcReason, RetryableTlcOperation, + RevocationData, RouterHop, SettlementData, ShuttingDownFlags, TLCId, TlcErr, TlcErrPacket, + TlcErrorCode, TrampolineContext, UdtCfgInfos, }; pub const FIBER_PROTOCOL_ID: ProtocolId = ProtocolId::new(42); @@ -1120,12 +1120,47 @@ where if let Some(mut actor_state) = state.store.get_channel_actor_state(&channel_id) { - if let Some(tlc) = + if !actor_state.is_closed() { + warn!( + "Ignoring RemoveTlc(Fulfill) fallback for non-closed channel {:?}", + channel_id + ); + } else if actor_state.get_remote_pubkey() != peer_pubkey { + warn!( + "Ignoring RemoveTlc(Fulfill) fallback for channel {:?}: \ + persisted remote pubkey {:?} does not match peer {:?}", + channel_id, + actor_state.get_remote_pubkey(), + peer_pubkey + ); + } else if let Some(tlc) = actor_state.get_offered_tlc(TLCId::Offered(remove_tlc.tlc_id)) { let payment_hash = tlc.payment_hash; let forwarding_tlc = tlc.forwarding_tlc; let attempt_id = tlc.attempt_id; + let hash_algorithm = tlc.hash_algorithm; + let outbound_status = tlc.outbound_status(); + + let computed_hash: Hash256 = + hash_algorithm.hash(fulfill.payment_preimage).into(); + if computed_hash != payment_hash { + warn!( + "Ignoring RemoveTlc(Fulfill) fallback for channel {:?} \ + tlc {:?}: preimage does not match payment hash", + channel_id, remove_tlc.tlc_id, + ); + return Err(Error::ChannelNotFound(channel_id)); + } + + if outbound_status != OutboundTlcStatus::Committed { + warn!( + "Ignoring RemoveTlc(Fulfill) fallback for channel {:?} \ + tlc {:?}: outbound status is {:?}, expected Committed", + channel_id, remove_tlc.tlc_id, outbound_status, + ); + return Err(Error::ChannelNotFound(channel_id)); + } state .store From 864dfcaeeb3209ee155a0ed5c0f480d9bfe67e1e Mon Sep 17 00:00:00 2001 From: ian Date: Tue, 5 May 2026 15:45:09 +0800 Subject: [PATCH 6/7] test: poll until success for on-chain settlement payment tests Replace fixed 3s sleeps with `wait_until_success` so the assertions wait as long as needed up to the helper's timeout, reducing CI flakiness under load and producing clearer diagnostics when settlement takes longer than expected. --- crates/fiber-lib/src/fiber/tests/payment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fiber-lib/src/fiber/tests/payment.rs b/crates/fiber-lib/src/fiber/tests/payment.rs index b08636237..4721e4028 100644 --- a/crates/fiber-lib/src/fiber/tests/payment.rs +++ b/crates/fiber-lib/src/fiber/tests/payment.rs @@ -7313,7 +7313,7 @@ async fn test_one_hop_payment_payee_settles_onchain() { .settle_invoice(&payment_hash, preimage) .await .unwrap(); - tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await; + node_0.wait_until_success(payment_hash).await; let status = node_0.get_payment_status(payment_hash).await; info!("payer payment status after on-chain settlement: {status:?}"); @@ -7407,7 +7407,7 @@ async fn test_two_hop_payment_payee_settles_onchain() { .settle_invoice(&payment_hash, preimage) .await .unwrap(); - tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await; + node_a.wait_until_success(payment_hash).await; let status = node_a.get_payment_status(payment_hash).await; info!("payer (A) payment status after on-chain settlement: {status:?}"); From 483760ece3697c9079a669458744d3cd028aab80 Mon Sep 17 00:00:00 2001 From: ian Date: Wed, 6 May 2026 17:29:48 +0800 Subject: [PATCH 7/7] test: poll invoice and TLC convergence after on-chain settlement The payer reaching PaymentStatus::Success and the payee marking its invoice as Paid (and the offered TLC transitioning to RemoteRemoved) are independent events, so asserting them synchronously after wait_until_success races with their propagation and produces flaky CI failures. Wrap the post-settlement invoice and TLC checks in wait_until_async_timeout so they converge within the helper's bound, matching the polling pattern already used elsewhere in the test. --- crates/fiber-lib/src/fiber/tests/payment.rs | 61 ++++++++++----------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/crates/fiber-lib/src/fiber/tests/payment.rs b/crates/fiber-lib/src/fiber/tests/payment.rs index 4721e4028..abf4f602e 100644 --- a/crates/fiber-lib/src/fiber/tests/payment.rs +++ b/crates/fiber-lib/src/fiber/tests/payment.rs @@ -7323,23 +7323,23 @@ async fn test_one_hop_payment_payee_settles_onchain() { "payer should see Success after payee settles TLC on-chain", ); - // Payee invoice should be Paid. - assert_eq!( - node_1.get_invoice_status(&payment_hash), - Some(CkbInvoiceStatus::Paid), - "payee invoice should be Paid after on-chain settlement", - ); + // Payee invoice should be Paid. The payer reaching `Success` and the + // payee marking its invoice `Paid` are independent events, so poll + // briefly to avoid a race. + wait_until_async_timeout(|| async { + node_1.get_invoice_status(&payment_hash) == Some(CkbInvoiceStatus::Paid) + }) + .await; - // Payer TLC should be RemoteRemoved. - let payer_state = node_0.get_channel_actor_state(channel_id); - let payer_tlc = payer_state - .get_offered_tlc(TLCId::Offered(0)) - .expect("payer TLC should exist"); - assert_eq!( - payer_tlc.outbound_status(), - OutboundTlcStatus::RemoteRemoved, - "payer TLC should be RemoteRemoved after on-chain settlement", - ); + // Payer TLC should be RemoteRemoved. Likewise poll for convergence. + wait_until_async_timeout(|| async { + let payer_state = node_0.get_channel_actor_state(channel_id); + payer_state + .get_offered_tlc(TLCId::Offered(0)) + .map(|tlc| tlc.outbound_status()) + == Some(OutboundTlcStatus::RemoteRemoved) + }) + .await; } /// A→B→C two-hop payment. B force-closes the B→C channel while the TLC @@ -7417,21 +7417,20 @@ async fn test_two_hop_payment_payee_settles_onchain() { "A should see Success after C settles TLC on-chain", ); - // Payee invoice should be Paid. - assert_eq!( - node_c.get_invoice_status(&payment_hash), - Some(CkbInvoiceStatus::Paid), - "payee (C) invoice should be Paid after on-chain settlement", - ); + // Payee invoice should be Paid. Poll briefly: payer reaching `Success` + // and payee marking its invoice `Paid` are independent events. + wait_until_async_timeout(|| async { + node_c.get_invoice_status(&payment_hash) == Some(CkbInvoiceStatus::Paid) + }) + .await; // B's offered TLC on the B→C channel should be RemoteRemoved. - let b_state = node_b.get_channel_actor_state(channel_bc); - let b_tlc = b_state - .get_offered_tlc(TLCId::Offered(0)) - .expect("B offered TLC should exist"); - assert_eq!( - b_tlc.outbound_status(), - OutboundTlcStatus::RemoteRemoved, - "B offered TLC should be RemoteRemoved after on-chain settlement", - ); + wait_until_async_timeout(|| async { + let b_state = node_b.get_channel_actor_state(channel_bc); + b_state + .get_offered_tlc(TLCId::Offered(0)) + .map(|tlc| tlc.outbound_status()) + == Some(OutboundTlcStatus::RemoteRemoved) + }) + .await; }