diff --git a/src/cch/actor.rs b/src/cch/actor.rs index 2fb3d699..45c5beee 100644 --- a/src/cch/actor.rs +++ b/src/cch/actor.rs @@ -15,9 +15,9 @@ use tokio::{select, time::sleep}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use crate::ckb::channel::{ - ChannelCommand, ChannelCommandWithId, RemoveTlcCommand, TlcNotification, + AddTlcCommand, ChannelCommand, ChannelCommandWithId, RemoveTlcCommand, TlcNotification, }; -use crate::ckb::types::{Hash256, RemoveTlcFulfill, RemoveTlcReason}; +use crate::ckb::types::{Hash256, LockTime, RemoveTlcFulfill, RemoveTlcReason}; use crate::ckb::{NetworkActorCommand, NetworkActorMessage}; use crate::ckb_chain::contracts::{get_script_by_contract, Contract}; use crate::invoice::Currency; @@ -84,10 +84,13 @@ pub enum CchMessage { SendBTC(SendBTC, RpcReplyPort>), ReceiveBTC(ReceiveBTC, RpcReplyPort>), + GetReceiveBTCOrder(String, RpcReplyPort>), + SettleSendBTCOrder(SettleSendBTCOrderEvent), SettleReceiveBTCOrder(SettleReceiveBTCOrderEvent), - TlcNotification(TlcNotification), + PendingReceivedTlcNotification(TlcNotification), + SettledTlcNotification(TlcNotification), } #[derive(Clone)] @@ -199,6 +202,17 @@ impl Actor for CchActor { } Ok(()) } + CchMessage::GetReceiveBTCOrder(payment_hash, port) => { + let result = state + .orders_db + .get_receive_btc_order(&payment_hash) + .await + .map_err(Into::into); + if !port.is_closed() { + port.send(result).expect("send reply"); + } + Ok(()) + } CchMessage::SettleSendBTCOrder(event) => { log::debug!("settle_send_btc_order {:?}", event); if let Err(err) = self.settle_send_btc_order(state, event).await { @@ -213,9 +227,21 @@ impl Actor for CchActor { } Ok(()) } - CchMessage::TlcNotification(tlc_notification) => { - if let Err(err) = self.handle_tlc_notification(state, tlc_notification).await { - log::error!("handle_tlc_notification failed: {}", err); + CchMessage::PendingReceivedTlcNotification(tlc_notification) => { + if let Err(err) = self + .handle_pending_received_tlc_notification(state, tlc_notification) + .await + { + log::error!("handle_pending_received_tlc_notification failed: {}", err); + } + Ok(()) + } + CchMessage::SettledTlcNotification(tlc_notification) => { + if let Err(err) = self + .handle_settled_tlc_notification(state, tlc_notification) + .await + { + log::error!("handle_settled_tlc_notification failed: {}", err); } Ok(()) } @@ -299,7 +325,7 @@ impl CchActor { } // On receiving new TLC, check whether it matches the SendBTC order - async fn handle_tlc_notification( + async fn handle_pending_received_tlc_notification( &self, state: &mut CchState, tlc_notification: TlcNotification, @@ -356,6 +382,43 @@ impl CchActor { Ok(()) } + async fn handle_settled_tlc_notification( + &self, + state: &mut CchState, + tlc_notification: TlcNotification, + ) -> Result<()> { + let payment_hash = format!("{:#x}", tlc_notification.tlc.payment_hash); + log::debug!("[settled tlc] payment hash: {}", payment_hash); + + match state.orders_db.get_receive_btc_order(&payment_hash).await { + Err(CchDbError::NotFound(_)) => return Ok(()), + Err(err) => return Err(err.into()), + _ => { + // ignore + } + }; + + let preimage = tlc_notification + .tlc + .payment_preimage + .ok_or(CchError::ReceiveBTCMissingPreimage)?; + + log::debug!("[settled tlc] preimage: {:#x}", preimage); + + // settle the lnd invoice + let req = invoicesrpc::SettleInvoiceMsg { + preimage: preimage.as_ref().to_vec(), + }; + log::debug!("[settled tlc] SettleInvoiceMsg: {:?}", req); + + let mut client = state.lnd_connection.create_invoices_client().await?; + // TODO: set a fee + let resp = client.settle_invoice(req).await?.into_inner(); + log::debug!("[settled tlc] SettleInvoiceResp: {:?}", resp); + + Ok(()) + } + async fn settle_send_btc_order( &self, state: &mut CchState, @@ -474,6 +537,7 @@ impl CchActor { wrapped_btc_type_script, // TODO: check the channel exists and has enough local balance. channel_id: receive_btc.channel_id, + tlc_id: None, }; state @@ -498,28 +562,50 @@ impl CchActor { state: &mut CchState, event: SettleReceiveBTCOrderEvent, ) -> Result<()> { - if event.preimage.is_some() { - log::info!( - "SettleReceiveBTCOrder: payment_hash={}, status={:?}", - event.payment_hash, - event.status - ); - // TODO: 1. Create a CKB payment to the payee to get preimage when event.status is Accepted - // TODO: 2. Subscribe to the CKB payment events, once it's settled, use the preimage to settle the BTC payment via invoicesrpc `settle_invoice`. - match state - .orders_db - .update_receive_btc_order(&event.payment_hash, event.preimage, event.status) - .await - { - Err(CchDbError::NotFound(_)) => { - // ignore payments not found in the db - Ok(()) - } - result => result.map_err(Into::into), - } - } else { - Ok(()) + let mut order = match state + .orders_db + .get_receive_btc_order(&event.payment_hash) + .await + { + Err(CchDbError::NotFound(_)) => return Ok(()), + Err(err) => return Err(err.into()), + Ok(order) => order, + }; + + if event.status == CchOrderStatus::Accepted && self.network_actor.is_some() { + // AddTlc to initiate the CKB payment + let message = |rpc_reply| -> NetworkActorMessage { + NetworkActorMessage::Command(NetworkActorCommand::ControlPcnChannel( + ChannelCommandWithId { + channel_id: order.channel_id, + command: ChannelCommand::AddTlc( + AddTlcCommand { + amount: order.amount_sats - order.fee_sats, + preimage: None, + payment_hash: Some( + Hash256::from_str(&order.payment_hash).expect("parse Hash256"), + ), + expiry: LockTime::new(self.config.ckb_final_tlc_expiry), + }, + rpc_reply, + ), + }, + )) + }; + let tlc_response = call!(self.network_actor.as_ref().unwrap(), message) + .expect("call actor") + .map_err(|msg| anyhow!(msg))?; + order.tlc_id = Some(tlc_response.tlc_id); } + + order.status = event.status; + order.payment_preimage = event.preimage.clone(); + + state + .orders_db + .update_receive_btc_order(order.clone()) + .await?; + Ok(()) } } diff --git a/src/cch/config.rs b/src/cch/config.rs index dbaf18dc..5eb72d90 100644 --- a/src/cch/config.rs +++ b/src/cch/config.rs @@ -5,11 +5,9 @@ use clap_serde_derive::ClapSerde; /// Default cross-chain order expiry time in seconds. pub const DEFAULT_ORDER_EXPIRY_TIME: u64 = 3600; /// Default BTC final-hop HTLC expiry time in seconds. -/// CCH will only use one-hop payment in CKB network. pub const DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME: u64 = 36; -/// Default CKB final-hop HTLC expiry time in seconds. -/// Leave enough time for routing the BTC payment -pub const DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME: u64 = 108; +/// Default CKB final-hop HTLC expiry time in blocks. +pub const DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS: u64 = 10; // Use prefix `cch-`/`CCH_` #[derive(ClapSerde, Debug, Clone)] @@ -95,13 +93,13 @@ pub struct CchConfig { )] pub btc_final_tlc_expiry: u64, - /// Final tlc expiry time for CKB network. - #[default(DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME)] + /// Final tlc expiry time for CKB network in blocks. + #[default(DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS)] #[arg( - name = "CCH_CKB_FINAL_TLC_EXPIRY", - long = "cch-ckb-final-tlc-expiry", + name = "CCH_CKB_FINAL_TLC_EXPIRY_BLOCKS", + long = "cch-ckb-final-tlc-expiry-blocks", env, - help = format!("final tlc expiry time in seconds for CKB network, default is {}", DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME), + help = format!("final tlc expiry time in blocks for CKB network, default is {}", DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS), )] pub ckb_final_tlc_expiry: u64, } diff --git a/src/cch/error.rs b/src/cch/error.rs index a008b04a..201dbfaa 100644 --- a/src/cch/error.rs +++ b/src/cch/error.rs @@ -32,6 +32,12 @@ pub enum CchError { ReceiveBTCOrderAmountTooSmall, #[error("ReceiveBTC order payment amount is too large")] ReceiveBTCOrderAmountTooLarge, + #[error("ReceiveBTC order already paid")] + ReceiveBTCOrderAlreadyPaid, + #[error("ReceiveBTC received payment amount is too small")] + ReceiveBTCReceivedAmountTooSmall, + #[error("ReceiveBTC expected preimage but missing")] + ReceiveBTCMissingPreimage, #[error("System time error: {0}")] SystemTimeError(#[from] SystemTimeError), #[error("JSON serialization error: {0}")] diff --git a/src/cch/mod.rs b/src/cch/mod.rs index dc4f5d7e..2466611e 100644 --- a/src/cch/mod.rs +++ b/src/cch/mod.rs @@ -6,7 +6,7 @@ pub use error::{CchError, CchResult}; mod config; pub use config::{ - CchConfig, DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME, DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME, + CchConfig, DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME, DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS, DEFAULT_ORDER_EXPIRY_TIME, }; diff --git a/src/cch/order.rs b/src/cch/order.rs index 24ce75eb..ef7a7488 100644 --- a/src/cch/order.rs +++ b/src/cch/order.rs @@ -47,6 +47,7 @@ impl From for CchOrderStatus { match state { InvoiceState::Accepted => CchOrderStatus::Accepted, InvoiceState::Canceled => CchOrderStatus::Failed, + InvoiceState::Settled => CchOrderStatus::Succeeded, _ => CchOrderStatus::Pending, } } @@ -122,6 +123,8 @@ pub struct ReceiveBTCOrder { pub payment_hash: String, pub payment_preimage: Option, pub channel_id: Hash256, + #[serde_as(as = "Option")] + pub tlc_id: Option, /// Amount required to pay in Satoshis via BTC, including the fee for the cross-chain hub #[serde_as(as = "U128Hex")] diff --git a/src/cch/orders_db.rs b/src/cch/orders_db.rs index 4ff064b3..c6573f04 100644 --- a/src/cch/orders_db.rs +++ b/src/cch/orders_db.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use super::{error::CchDbError, CchOrderStatus, ReceiveBTCOrder, SendBTCOrder}; +use super::{error::CchDbError, ReceiveBTCOrder, SendBTCOrder}; // TODO: persist orders #[derive(Default)] @@ -60,18 +60,12 @@ impl CchOrdersDb { pub async fn update_receive_btc_order( &mut self, - payment_hash: &str, - payment_preimage: Option, - status: CchOrderStatus, + order: ReceiveBTCOrder, ) -> Result<(), CchDbError> { - let payment_mut = self - .receive_btc_orders - .get_mut(payment_hash) - .ok_or_else(|| CchDbError::NotFound(payment_hash.to_string()))?; - if payment_preimage.is_some() { - payment_mut.payment_preimage = payment_preimage; + let key = order.payment_hash.clone(); + match self.receive_btc_orders.insert(key.clone(), order) { + Some(_) => Ok(()), + None => Err(CchDbError::NotFound(key)), } - payment_mut.status = status; - Ok(()) } } diff --git a/src/ckb/channel.rs b/src/ckb/channel.rs index bb347262..4eace64c 100644 --- a/src/ckb/channel.rs +++ b/src/ckb/channel.rs @@ -177,12 +177,14 @@ pub enum ChannelInitializationParameter { #[derive(Clone)] pub struct ChannelSubscribers { pub pending_received_tlcs_subscribers: Arc>, + pub settled_tlcs_subscribers: Arc>, } impl Default for ChannelSubscribers { fn default() -> Self { Self { pending_received_tlcs_subscribers: Arc::new(OutputPort::default()), + settled_tlcs_subscribers: Arc::new(OutputPort::default()), } } } @@ -407,31 +409,51 @@ impl ChannelActor { } PCNMessage::RemoveTlc(remove_tlc) => { state.check_state_for_tlc_update()?; + let channel_id = state.get_id(); match state.pending_offered_tlcs.entry(remove_tlc.tlc_id) { hash_map::Entry::Occupied(entry) => { let current = entry.get(); + let mut preimage = None; + match remove_tlc.reason { RemoveTlcReason::RemoveTlcFail(_fail) => { state.to_local_amount += current.amount; } RemoveTlcReason::RemoveTlcFulfill(fulfill) => { - let preimage = fulfill.payment_preimage; // TODO: let channel parties negotiate the used hash method. // // Now CKB uses blake2b and bitcoin uses sha256, it makes cross-chain using the same payment hash impossible. // Here is a workaround for the demo to accept the preimage using sha256 hash. - if current.payment_hash != blake2b_256(preimage).into() - && current.payment_hash != sha256(preimage).into() + if current.payment_hash + != blake2b_256(fulfill.payment_preimage).into() + && current.payment_hash + != sha256(fulfill.payment_preimage).into() { return Err(ProcessingChannelError::InvalidParameter( "Payment preimage does not match the hash".to_string(), )); } + preimage = Some(fulfill.payment_preimage); state.to_remote_amount += current.amount; } } + + if let (Some(ref udt_type_script), Some(preimage)) = + (state.funding_udt_type_script.clone(), preimage) + { + let mut tlc = current.clone(); + tlc.payment_preimage = Some(preimage); + self.subscribers + .settled_tlcs_subscribers + .send(TlcNotification { + tlc, + channel_id, + script: udt_type_script.clone(), + }); + } entry.remove(); + if state.pending_offered_tlcs.is_empty() { state.maybe_transition_to_shutdown(&self.network)?; } diff --git a/src/main.rs b/src/main.rs index 9ae18fa0..73c66fa1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,11 +134,18 @@ pub async fn main() { return; } Ok(actor) => { - subscribers - .pending_received_tlcs_subscribers - .subscribe(actor.clone(), |tlc_notification| { - Some(CchMessage::TlcNotification(tlc_notification)) - }); + subscribers.pending_received_tlcs_subscribers.subscribe( + actor.clone(), + |tlc_notification| { + Some(CchMessage::PendingReceivedTlcNotification(tlc_notification)) + }, + ); + subscribers.settled_tlcs_subscribers.subscribe( + actor.clone(), + |tlc_notification| { + Some(CchMessage::SettledTlcNotification(tlc_notification)) + }, + ); Some(actor) } diff --git a/src/rpc/cch.rs b/src/rpc/cch.rs index c442264c..b012438b 100644 --- a/src/rpc/cch.rs +++ b/src/rpc/cch.rs @@ -1,5 +1,5 @@ use crate::{ - cch::{CchMessage, CchOrderStatus}, + cch::{CchMessage, CchOrderStatus, ReceiveBTCOrder}, ckb::{ serde_utils::{U128Hex, U64Hex}, types::Hash256, @@ -64,6 +64,12 @@ pub struct ReceiveBtcParams { pub final_tlc_expiry: u64, } +#[derive(Serialize, Deserialize)] +pub struct GetReceiveBtcOrderParams { + /// Payment hash for the HTLC for both CKB and BTC. + pub payment_hash: String, +} + #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReceiveBTCResponse { @@ -82,6 +88,8 @@ pub struct ReceiveBTCResponse { pub btc_pay_req: String, pub payment_hash: String, pub channel_id: Hash256, + #[serde_as(as = "Option")] + pub tlc_id: Option, // Amount will be received by the payee #[serde_as(as = "U128Hex")] @@ -102,6 +110,12 @@ pub trait CchRpc { &self, params: ReceiveBtcParams, ) -> Result; + + #[method(name = "get_receive_btc_order")] + async fn get_receive_btc_order( + &self, + params: GetReceiveBtcOrderParams, + ) -> Result; } pub struct CchRpcServerImpl { @@ -176,19 +190,45 @@ impl CchRpcServer for CchRpcServerImpl { ) })?; - result - .map(|order| ReceiveBTCResponse { - timestamp: order.timestamp, - expiry: order.expiry, - ckb_final_tlc_expiry: order.ckb_final_tlc_expiry, - wrapped_btc_type_script: order.wrapped_btc_type_script, - btc_pay_req: order.btc_pay_req, - payment_hash: order.payment_hash, - channel_id: order.channel_id, - amount_sats: order.amount_sats, - fee_sats: order.fee_sats, - status: order.status, - }) - .map_err(Into::into) + result.map(Into::into).map_err(Into::into) + } + + async fn get_receive_btc_order( + &self, + params: GetReceiveBtcOrderParams, + ) -> Result { + let result = call_t!( + self.cch_actor, + CchMessage::GetReceiveBTCOrder, + TIMEOUT, + params.payment_hash + ) + .map_err(|ractor_error| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + ractor_error.to_string(), + Option::<()>::None, + ) + })?; + + result.map(Into::into).map_err(Into::into) + } +} + +impl From for ReceiveBTCResponse { + fn from(value: ReceiveBTCOrder) -> Self { + Self { + timestamp: value.timestamp, + expiry: value.expiry, + ckb_final_tlc_expiry: value.ckb_final_tlc_expiry, + wrapped_btc_type_script: value.wrapped_btc_type_script, + btc_pay_req: value.btc_pay_req, + payment_hash: value.payment_hash, + channel_id: value.channel_id, + tlc_id: value.tlc_id, + amount_sats: value.amount_sats, + fee_sats: value.fee_sats, + status: value.status, + } } } diff --git a/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru b/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru index 4efb8a20..a1c0e77e 100644 --- a/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru +++ b/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru @@ -32,12 +32,13 @@ script:pre-request { console.log(url); console.log(body); - await axios({ + const resp = await axios({ method: 'POST', url: url, data: body, responseType: 'stream' }); + resp.data.destroy(); } docs { diff --git a/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru b/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru new file mode 100644 index 00000000..0e72c010 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru @@ -0,0 +1,61 @@ +meta { + name: 11-get-receive-btc-order-tlc-id + type: http + seq: 11 +} + +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": "get_receive_btc_order", + "params": [ + { + "payment_hash": "{{PAYMENT_HASH}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.status: eq 200 +} + +script:pre-request { + if(bru.getVar("iteration") === undefined){ + bru.setVar("iteration", 0); + } +} + +script:post-response { + const i = bru.getVar("iteration"); + const n = bru.getVar("max_iterations"); + if (i < n) { + console.log(`Try ${i+1}/${n}`); + } + + if (res.body.result.tlc_id !== null) { + bru.setVar("N3N1_TLC_ID1", res.body.result.tlc_id); + console.log(`Node 3 has sent a pending tlc: ${res.body.result.tlc_id}`); + bru.setVar("iteration", 0); + } else if (i+1 < n) { + await new Promise(r => setTimeout(r, 10)); + bru.setVar("iteration", i + 1); + bru.setNextRequest("11-get-receive-btc-order-tlc-id"); + } else { + bru.setVar("iteration", 0); + throw new Error("Node 3 has not sent a pending tlc"); + } +} diff --git a/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru new file mode 100644 index 00000000..c0ede646 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru @@ -0,0 +1,43 @@ +meta { + name: 12-remove-tlc-for-receive-btc-order + type: http + seq: 12 +} + +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": "remove_tlc", + "params": [ + { + "channel_id": "{{N1N3_CHANNEL_ID}}", + "tlc_id": "{{N3N1_TLC_ID1}}", + "reason": { + "payment_preimage": "{{PAYMENT_PREIMAGE}}" + } + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isNull +} + +script:post-response { + // Sleep for sometime to make sure current operation finishes before next request starts. + await new Promise(r => setTimeout(r, 100)); +}