From c026a025917be6eabc33b50f6e381571978cb3e0 Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Tue, 3 Mar 2026 13:52:13 -0500 Subject: [PATCH] feat: ability to disable automatic broadcasting of refund transactions --- ddk-manager/src/manager.rs | 59 ++++++++++++++++++-- ddk-manager/tests/manager_execution_tests.rs | 52 ++++++++++++++++- ddk/src/ddk.rs | 12 ++++ 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/ddk-manager/src/manager.rs b/ddk-manager/src/manager.rs index b72de41..5b9fcf8 100644 --- a/ddk-manager/src/manager.rs +++ b/ddk-manager/src/manager.rs @@ -62,6 +62,12 @@ static NB_CONFIRMATIONS: Lazy = Lazy::new(|| match std::env::var("NB_CONFIR Err(_) => DEFAULT_NB_CONFIRMATIONS, }); +/// Automatically broadcast the refund transaction in `check_confirmed_contracts` +static AUTOMATIC_REFUND: Lazy = Lazy::new(|| match std::env::var("AUTOMATIC_REFUND") { + Ok(val) => val.parse().unwrap_or(true), + Err(_) => true, +}); + /// The delay to set the refund value to. pub const REFUND_DELAY: u32 = 86400 * 7; /// The nSequence value used for CETs in DLC channels @@ -1415,10 +1421,15 @@ where Ok(Contract::Closed(closed_contract)) } - // TODO: Make this public to refund + /// Check if the refund locktime has passed and broadcast the refund transaction if it has. #[tracing::instrument(skip_all, level = "debug")] - async fn check_refund(&self, contract: &SignedContract) -> Result<(), Error> { - // TODO(tibo): should check for confirmation of refund before updating state + pub async fn check_and_broadcast_refund( + &self, + contract_id: &ContractId, + ) -> Result { + // Assert the contract is confirmed + let contract = get_contract_in_state!(self, contract_id, Confirmed, None::)?; + if contract .accepted_contract .dlc_transactions @@ -1448,16 +1459,52 @@ where let signer = self.signer_provider.derive_contract_signer(offer.keys_id)?; let refund = crate::contract_updater::get_signed_refund( &self.secp, - contract, + &contract, &signer, &self.logger, )?; self.blockchain.send_transaction(&refund).await?; } - self.store - .update_contract(&Contract::Refunded(contract.clone())) + let refunded = Contract::Refunded(contract.clone()); + self.store.update_contract(&refunded).await?; + Ok(refunded) + } else { + return Err(Error::InvalidParameters( + "Contract maturity has not passed to broadcast refund".to_string(), + )); + } + } + + #[tracing::instrument(skip_all, level = "debug")] + async fn check_refund(&self, contract: &SignedContract) -> Result<(), Error> { + if contract + .accepted_contract + .dlc_transactions + .refund + .lock_time + .to_consensus_u32() as u64 + <= self.time.unix_time_now() + { + let refund_txid = contract + .accepted_contract + .dlc_transactions + .refund + .compute_txid(); + let confirmations = self + .blockchain + .get_transaction_confirmations(&refund_txid) .await?; + + if confirmations > 0 { + // Counterparty (or we) already broadcast the refund tx. Update state. + self.store + .update_contract(&Contract::Refunded(contract.clone())) + .await?; + } else if *AUTOMATIC_REFUND { + self.check_and_broadcast_refund(&contract.accepted_contract.get_contract_id()) + .await?; + } } Ok(()) diff --git a/ddk-manager/tests/manager_execution_tests.rs b/ddk-manager/tests/manager_execution_tests.rs index c7a6a93..c4acebc 100644 --- a/ddk-manager/tests/manager_execution_tests.rs +++ b/ddk-manager/tests/manager_execution_tests.rs @@ -179,6 +179,7 @@ async fn numerical_common_diff_nb_digits( enum TestPath { Close, Refund, + ManualRefund, CooperativeClose, BadAcceptCetSignature, BadAcceptRefundSignature, @@ -430,6 +431,17 @@ async fn enum_single_oracle_refund_manual_test() { .await; } +#[tokio::test] +#[ignore] +async fn enum_single_oracle_manual_refund_test() { + manager_execution_test( + get_enum_test_params(1, 1, Some(get_enum_oracles(1, 0).await)).await, + TestPath::ManualRefund, + false, + ) + .await; +} + #[tokio::test] #[ignore] async fn enum_single_oracle_bad_accept_cet_sig_test() { @@ -909,7 +921,7 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ sync_receive.recv().await.expect("Error synchronizing"); assert_contract_state!(alice_manager_send, contract_id, FailedSign); } - TestPath::Close | TestPath::Refund => { + TestPath::Close | TestPath::Refund | TestPath::ManualRefund => { alice_send .send(Some(Message::Accept(accept_msg))) .await @@ -936,7 +948,7 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ alice_wallet.sync().await.unwrap(); bob_wallet.sync().await.unwrap(); match path { - TestPath::Close | TestPath::Refund => { + TestPath::Close | TestPath::Refund | TestPath::ManualRefund => { if !manual_close { test_utils::set_time((EVENT_MATURITY as u64) + 1); } @@ -1049,6 +1061,42 @@ async fn manager_execution_test(test_params: TestParams, path: TestPath, manual_ periodic_check!(second, contract_id, Refunded); } + TestPath::ManualRefund => { + alice_wallet.sync().await.unwrap(); + bob_wallet.sync().await.unwrap(); + periodic_check!(first, contract_id, Confirmed); + periodic_check!(second, contract_id, Confirmed); + + test_utils::set_time( + ((EVENT_MATURITY + ddk_manager::manager::REFUND_DELAY) as u64) + + 1, + ); + + generate_blocks(10, electrs.clone(), sink.clone()).await; + + alice_wallet.sync().await.unwrap(); + bob_wallet.sync().await.unwrap(); + + // Manually broadcast the refund for the first party. + first + .lock() + .await + .check_and_broadcast_refund(&contract_id) + .await + .expect("Error manually broadcasting refund"); + assert_contract_state!(first, contract_id, Refunded); + + // Randomly check with or without having the Refund mined. + if thread_rng().next_u32() % 2 == 0 { + generate_blocks(1, electrs.clone(), sink.clone()).await; + } + + alice_wallet.sync().await.unwrap(); + bob_wallet.sync().await.unwrap(); + + // Second party picks it up via periodic check. + periodic_check!(second, contract_id, Refunded); + } _ => unreachable!(), } } diff --git a/ddk/src/ddk.rs b/ddk/src/ddk.rs index c22054a..b0c1cb1 100644 --- a/ddk/src/ddk.rs +++ b/ddk/src/ddk.rs @@ -375,6 +375,18 @@ where Ok((contract_id, counter_party, accept_dlc)) } + /// Refunds a DLC contract. + /// + /// This method checks if the refund locktime has passed and broadcasts the refund transaction if it has. + /// + /// # Arguments + /// * `contract_id` - The ID of the contract to refund + /// + /// # Errors if the contract maturity has not passed to broadcast refund + pub async fn refund_dlc(&self, contract_id: &[u8; 32]) -> Result { + Ok(self.manager.check_and_broadcast_refund(contract_id).await?) + } + /// Retrieves the current balance state, including: /// - Confirmed balance /// - Unconfirmed changes