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/crates/fiber-lib/src/fiber/network.rs b/crates/fiber-lib/src/fiber/network.rs index 4b7fe839f..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); @@ -1111,6 +1111,109 @@ 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(mut actor_state) = + state.store.get_channel_actor_state(&channel_id) + { + 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 + .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(), + ); + 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 {:?}", + 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, payment_hash, + ); + myself + .send_message(NetworkActorMessage::new_event( + NetworkActorEvent::TlcRemoveReceived( + payment_hash, + 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 @@ -1596,6 +1699,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, @@ -1936,6 +2087,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, ); @@ -2093,6 +2254,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 aa76d968f..abf4f602e 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"))] @@ -35,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; @@ -7252,3 +7254,183 @@ 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(); + 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:?}"); + assert_eq!( + status, + PaymentStatus::Success, + "payer should see Success after payee settles TLC on-chain", + ); + + // 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. 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 +/// 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(); + 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:?}"); + assert_eq!( + status, + PaymentStatus::Success, + "A should see Success after C settles TLC on-chain", + ); + + // 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. + 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; +} 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) { 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)); +}