From 2c662fcf9f0212e063669b3cccd14da1bce52377 Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Mon, 7 Jul 2025 16:41:21 -0700 Subject: [PATCH 1/2] feat: implement single-funded DLC transactions - add support for single-funded DLCs where one party has zero inputs - filter out dust change outputs from funding transactions - fix fee computation for single-funded DLCs with proper distribution - add comprehensive tests for single and batch single-funded DLCs - support no-change single-funded DLCs for privacy benefits --- src/cfddlc_transactions.cpp | 104 ++++++++++++++--- test/test_cfddlc_transactions.cpp | 187 +++++++++++++++++++++++++++++- 2 files changed, 269 insertions(+), 22 deletions(-) diff --git a/src/cfddlc_transactions.cpp b/src/cfddlc_transactions.cpp index 317dac4..a8b55ee 100644 --- a/src/cfddlc_transactions.cpp +++ b/src/cfddlc_transactions.cpp @@ -171,8 +171,14 @@ TransactionController DlcManager::CreateFundTransaction( remote_serial_id}; outputs_info.push_back(fund_output_info); - outputs_info.push_back(local_output_info); - outputs_info.push_back(remote_output_info); + + // Only add change outputs if they're not dust (for single-funded DLC support) + if (!IsDustOutputInfo(local_output_info)) { + outputs_info.push_back(local_output_info); + } + if (!IsDustOutputInfo(remote_output_info)) { + outputs_info.push_back(remote_output_info); + } std::sort(outputs_info.begin(), outputs_info.end(), CompareOutputSerialId); @@ -258,8 +264,13 @@ TransactionController DlcManager::CreateBatchFundTransaction( remote_change_output.GetLockingScript(), remote_change_output.GetValue(), remote_serial_id}; - outputs_info.push_back(local_output_info); - outputs_info.push_back(remote_output_info); + // Only add change outputs if they're not dust (for single-funded DLC support) + if (!IsDustOutputInfo(local_output_info)) { + outputs_info.push_back(local_output_info); + } + if (!IsDustOutputInfo(remote_output_info)) { + outputs_info.push_back(remote_output_info); + } std::sort(outputs_info.begin(), outputs_info.end(), CompareOutputSerialId); @@ -645,6 +656,30 @@ DlcTransactions DlcManager::CreateDlcTransactions( std::tie(remote_change_output, remote_fund_fee, remote_cet_fee) = GetChangeOutputAndFees(remote_params, fee_rate); + // Handle single-funded DLC: funding party pays for both CET fees + if (local_params.input_amount == 0) { + // Local party has no inputs, so remote party should pay for both CET fees + remote_cet_fee += local_cet_fee; + local_cet_fee = 0; + // Recalculate remote change: fund both collaterals and all fees + auto remote_total_cost = local_params.collateral + + remote_params.collateral + remote_fund_fee + + remote_cet_fee; + remote_change_output = TxOut( + remote_params.input_amount - remote_total_cost - option_premium, + remote_params.change_script_pubkey); + } else if (remote_params.input_amount == 0) { + // Remote party has no inputs, so local party should pay for both CET fees + local_cet_fee += remote_cet_fee; + remote_cet_fee = 0; + // Recalculate local change: fund both collaterals and all fees + auto local_total_cost = local_params.collateral + remote_params.collateral + + local_fund_fee + local_cet_fee; + local_change_output = TxOut( + local_params.input_amount - local_total_cost - option_premium, + local_params.change_script_pubkey); + } + auto fund_output_value = local_params.input_amount + remote_params.input_amount - local_change_output.GetValue().GetSatoshiValue() - @@ -679,16 +714,22 @@ DlcTransactions DlcManager::CreateDlcTransactions( // the given lock time. auto fund_tx_id = fund_tx.GetTransaction().GetTxid(); - std::vector change_serial_ids = { - fund_output_serial_id, local_params.change_serial_id, - remote_params.change_serial_id}; + // Build list of actual serial IDs that were included (excluding dust outputs) + std::vector actual_serial_ids = {fund_output_serial_id}; - std::sort(change_serial_ids.begin(), change_serial_ids.end()); + if (local_change_output.GetValue() >= DUST_LIMIT) { + actual_serial_ids.push_back(local_params.change_serial_id); + } + if (remote_change_output.GetValue() >= DUST_LIMIT) { + actual_serial_ids.push_back(remote_params.change_serial_id); + } + + std::sort(actual_serial_ids.begin(), actual_serial_ids.end()); uint32_t fund_vout = 0; - for (size_t i = 0; i < change_serial_ids.size(); i++) { - if (change_serial_ids[i] == fund_output_serial_id) { + for (size_t i = 0; i < actual_serial_ids.size(); i++) { + if (actual_serial_ids[i] == fund_output_serial_id) { fund_vout = static_cast(i); break; } @@ -824,21 +865,26 @@ BatchDlcTransactions DlcManager::CreateBatchDlcTransactions( fund_vouts.push_back(i); } } else { - // set change_serial_ids to fund_output_serial_ids and change_serial_ids and - // sort - std::vector change_serial_ids = fund_output_serial_ids; - change_serial_ids.push_back(local_params.change_serial_id); - change_serial_ids.push_back(remote_params.change_serial_id); - std::sort(change_serial_ids.begin(), change_serial_ids.end()); + // Build list of actual serial IDs that were included (excluding dust + // outputs) + std::vector actual_serial_ids = fund_output_serial_ids; + + if (local_change_output.GetValue() >= DUST_LIMIT) { + actual_serial_ids.push_back(local_params.change_serial_id); + } + if (remote_change_output.GetValue() >= DUST_LIMIT) { + actual_serial_ids.push_back(remote_params.change_serial_id); + } + std::sort(actual_serial_ids.begin(), actual_serial_ids.end()); // set fund_vouts to empty array fund_vouts.resize(fund_output_serial_ids.size()); // set fund_vouts to the index of fund_output_serial_ids in - // change_serial_ids + // actual_serial_ids for (size_t i = 0; i < fund_output_serial_ids.size(); i++) { - for (size_t j = 0; j < change_serial_ids.size(); j++) { - if (fund_output_serial_ids[i] == change_serial_ids[j]) { + for (size_t j = 0; j < actual_serial_ids.size(); j++) { + if (fund_output_serial_ids[i] == actual_serial_ids[j]) { fund_vouts[i] = j; break; } @@ -926,6 +972,16 @@ std::tuple DlcManager::GetChangeOutputAndFees( params.final_script_pubkey.GetData().GetDataSize() * 4); auto cet_fee = ceil(cet_weight / 4) * fee_rate; auto fund_out = params.collateral + fund_fee + cet_fee; + + // Handle single-funded DLC case where one party has zero inputs + if (params.input_amount == 0) { + // Party with no inputs: return zero change output and zero fees + // The funding party pays for all fees including this party's CET fees + TxOut change_output( + Amount::CreateBySatoshiAmount(0), params.change_script_pubkey); + return std::make_tuple(change_output, 0, 0); + } + if (params.input_amount < (fund_out + option_premium)) { throw CfdException( CfdError::kCfdIllegalArgumentError, @@ -961,6 +1017,16 @@ std::tuple DlcManager::GetBatchChangeOutputAndFees( params.collaterals.begin(), params.collaterals.end(), Amount(0)); auto fund_out = collateral + fund_fee + cet_fee; + + // Handle single-funded DLC case where one party has zero inputs + if (params.input_amount == 0) { + // Party with no inputs: return zero change output and zero fees + // The funding party pays for all fees including this party's CET fees + TxOut change_output( + Amount::CreateBySatoshiAmount(0), params.change_script_pubkey); + return std::make_tuple(change_output, 0, 0); + } + if (params.input_amount < fund_out) { throw CfdException( CfdError::kCfdIllegalArgumentError, diff --git a/test/test_cfddlc_transactions.cpp b/test/test_cfddlc_transactions.cpp index 1be6590..9d41d58 100644 --- a/test/test_cfddlc_transactions.cpp +++ b/test/test_cfddlc_transactions.cpp @@ -800,19 +800,131 @@ TEST(DlcManager, CreateCetTransactionNotEnoughInputTest) { auto local_params_short = LOCAL_PARAMS; local_params_short.input_amount = Amount::CreateBySatoshiAmount(1000); - auto remote_params_short = LOCAL_PARAMS; + auto remote_params_short = REMOTE_PARAMS; remote_params_short.input_amount = Amount::CreateBySatoshiAmount(1000); - // Act/Assert + + // Act/Assert - Still should throw for insufficient funds (not zero) ASSERT_THROW( auto dlc_transactions = DlcManager::CreateDlcTransactions( outcomes, local_params_short, REMOTE_PARAMS, REFUND_LOCKTIME, 1), CfdException); ASSERT_THROW( auto dlc_transactions = DlcManager::CreateDlcTransactions( - outcomes, remote_params_short, REMOTE_PARAMS, REFUND_LOCKTIME, 1), + outcomes, local_params_short, remote_params_short, REFUND_LOCKTIME, 1), CfdException); } +TEST(DlcManager, CreateSingleFundedDlcTransactions) { + // Arrange + std::vector outcomes = { + {WIN_AMOUNT, LOSE_AMOUNT}, {LOSE_AMOUNT, WIN_AMOUNT}}; + + // Create params for single-funded DLC where acceptor has zero inputs + auto local_params_funded = LOCAL_PARAMS; + local_params_funded.input_amount = Amount::CreateByCoinAmount( + 100); // Double the input for both parties' collateral + + auto remote_params_unfunded = REMOTE_PARAMS; + remote_params_unfunded.input_amount = + Amount::CreateBySatoshiAmount(0); // Zero inputs for acceptor + remote_params_unfunded.inputs_info.clear(); // No inputs + + // Act + auto dlc_transactions = DlcManager::CreateDlcTransactions( + outcomes, local_params_funded, remote_params_unfunded, REFUND_LOCKTIME, 1); + + auto fund_tx = dlc_transactions.fund_transaction; + auto refund_tx = dlc_transactions.refund_transaction; + auto cets = dlc_transactions.cets; + + // Assert + EXPECT_EQ(dlc_transactions.cets.size(), outcomes.size()); + + // Fund transaction should have only 2 outputs (fund + local change), remote + // change should be filtered out as dust + EXPECT_EQ(2, fund_tx.GetTransaction().GetTxOutCount()); + + // Verify fund output value accounts for both parties' collateral + auto fund_output_amount = fund_tx.GetTransaction().GetTxOut(0).GetValue(); + EXPECT_GT( + fund_output_amount.GetSatoshiValue(), + LOCAL_COLLATERAL_AMOUNT.GetSatoshiValue() + + REMOTE_COLLATERAL_AMOUNT.GetSatoshiValue()); + + // Verify refund transaction correctly distributes collateral + EXPECT_EQ( + LOCAL_COLLATERAL_AMOUNT.GetSatoshiValue(), + refund_tx.GetTransaction().GetTxOut(0).GetValue().GetSatoshiValue()); + EXPECT_EQ( + REMOTE_COLLATERAL_AMOUNT.GetSatoshiValue(), + refund_tx.GetTransaction().GetTxOut(1).GetValue().GetSatoshiValue()); + + // Verify CETs have correct payouts + EXPECT_EQ( + WIN_AMOUNT.GetSatoshiValue(), + cets[0].GetTransaction().GetTxOut(0).GetValue().GetSatoshiValue()); + EXPECT_EQ( + LOSE_AMOUNT.GetSatoshiValue(), + cets[0].GetTransaction().GetTxOut(1).GetValue().GetSatoshiValue()); +} + +TEST(DlcManager, CreateBatchSingleFundedDlcTransactions) { + // Arrange + std::vector outcomes = { + {WIN_AMOUNT, LOSE_AMOUNT}, {LOSE_AMOUNT, WIN_AMOUNT}}; + std::vector> outcomes_batch = {outcomes, outcomes}; + std::vector refund_locktimes = {REFUND_LOCKTIME, REFUND_LOCKTIME}; + + // Create params for single-funded batch DLC where acceptor has zero inputs + auto local_batch_params_funded = LOCAL_BATCH_PARAMS; + local_batch_params_funded.input_amount = + Amount::CreateByCoinAmount(200); // Enough for both DLCs + + auto remote_batch_params_unfunded = REMOTE_BATCH_PARAMS; + remote_batch_params_unfunded.input_amount = + Amount::CreateBySatoshiAmount(0); // Zero inputs for acceptor + remote_batch_params_unfunded.inputs_info.clear(); // No inputs + + // Act + auto dlc_transactions = DlcManager::CreateBatchDlcTransactions( + outcomes_batch, local_batch_params_funded, remote_batch_params_unfunded, + refund_locktimes, 1); + + auto fund_tx = dlc_transactions.fund_transaction; + auto refund_txs = dlc_transactions.refund_transactions; + auto cets_list = dlc_transactions.cets_list; + + // Assert + EXPECT_EQ(cets_list.size(), outcomes_batch.size()); + EXPECT_EQ(refund_txs.size(), outcomes_batch.size()); + + // Fund transaction should have only 3 outputs (2 fund outputs + local + // change), remote change should be filtered out as dust + EXPECT_EQ(3, fund_tx.GetTransaction().GetTxOutCount()); + + // Verify each refund transaction correctly distributes collateral + for (size_t i = 0; i < refund_txs.size(); i++) { + EXPECT_EQ( + LOCAL_COLLATERAL_AMOUNT.GetSatoshiValue(), + refund_txs[i].GetTransaction().GetTxOut(0).GetValue().GetSatoshiValue()); + EXPECT_EQ( + REMOTE_COLLATERAL_AMOUNT.GetSatoshiValue(), + refund_txs[i].GetTransaction().GetTxOut(1).GetValue().GetSatoshiValue()); + } + + // Verify each CET list has correct payouts + for (size_t i = 0; i < cets_list.size(); i++) { + auto cets = cets_list[i]; + EXPECT_EQ(cets.size(), outcomes.size()); + EXPECT_EQ( + WIN_AMOUNT.GetSatoshiValue(), + cets[0].GetTransaction().GetTxOut(0).GetValue().GetSatoshiValue()); + EXPECT_EQ( + LOSE_AMOUNT.GetSatoshiValue(), + cets[0].GetTransaction().GetTxOut(1).GetValue().GetSatoshiValue()); + } +} + TEST(DlcManager, FundTransactionWithPremiumTest) { // Arrange auto local_change = Amount::CreateBySatoshiAmount(4899899758); @@ -1150,3 +1262,72 @@ TEST(DlcManager, CetTestSerialId) { REMOTE_FUND_PRIVKEY, fund_script, FUND_TX_SERIAL_ID, 0, FUND_OUTPUT); EXPECT_EQ(cet.GetHex(), CET_SERIAL_ID_HEX_SIGNED.GetHex()); } + +TEST(DlcManager, CreateSingleFundedDlcTransactionsNoChange) { + // Arrange + std::vector outcomes = { + {WIN_AMOUNT, LOSE_AMOUNT}, {LOSE_AMOUNT, WIN_AMOUNT}}; + + // First, run with large amount to see what the change is + auto local_params_large = LOCAL_PARAMS; + local_params_large.input_amount = + Amount::CreateByCoinAmount(100); // Large amount + + auto remote_params_unfunded = REMOTE_PARAMS; + remote_params_unfunded.input_amount = Amount::CreateBySatoshiAmount(0); + remote_params_unfunded.inputs_info.clear(); + + // Get the change amount from a large input + auto dlc_transactions_large = DlcManager::CreateDlcTransactions( + outcomes, local_params_large, remote_params_unfunded, REFUND_LOCKTIME, 1); + auto fund_tx_large = dlc_transactions_large.fund_transaction; + + // Calculate exact amount needed: large_input - change_amount + auto large_input = local_params_large.input_amount; + auto change_amount = fund_tx_large.GetTransaction() + .GetTxOut(1) + .GetValue(); // Change is second output + auto exact_amount_needed = large_input - change_amount; + + // Now create params with exact amount + auto local_params_exact = LOCAL_PARAMS; + local_params_exact.input_amount = exact_amount_needed; + + // Act + auto dlc_transactions = DlcManager::CreateDlcTransactions( + outcomes, local_params_exact, remote_params_unfunded, REFUND_LOCKTIME, 1); + + auto fund_tx = dlc_transactions.fund_transaction; + auto refund_tx = dlc_transactions.refund_transaction; + auto cets = dlc_transactions.cets; + + // Assert + EXPECT_EQ(dlc_transactions.cets.size(), outcomes.size()); + + // Fund transaction should have exactly 1 output (just the funding output, no + // change) + EXPECT_EQ(1, fund_tx.GetTransaction().GetTxOutCount()); + + // The single output should be the funding output + auto fund_output_amount = fund_tx.GetTransaction().GetTxOut(0).GetValue(); + EXPECT_GT( + fund_output_amount.GetSatoshiValue(), + LOCAL_COLLATERAL_AMOUNT.GetSatoshiValue() + + REMOTE_COLLATERAL_AMOUNT.GetSatoshiValue()); + + // Verify refund transaction correctly distributes collateral + EXPECT_EQ( + LOCAL_COLLATERAL_AMOUNT.GetSatoshiValue(), + refund_tx.GetTransaction().GetTxOut(0).GetValue().GetSatoshiValue()); + EXPECT_EQ( + REMOTE_COLLATERAL_AMOUNT.GetSatoshiValue(), + refund_tx.GetTransaction().GetTxOut(1).GetValue().GetSatoshiValue()); + + // Verify CETs have correct payouts + EXPECT_EQ( + WIN_AMOUNT.GetSatoshiValue(), + cets[0].GetTransaction().GetTxOut(0).GetValue().GetSatoshiValue()); + EXPECT_EQ( + LOSE_AMOUNT.GetSatoshiValue(), + cets[0].GetTransaction().GetTxOut(1).GetValue().GetSatoshiValue()); +} From abae6879cebaa6b9e6d7ecf91f9340de08bf2748 Mon Sep 17 00:00:00 2001 From: Matthew Black Date: Mon, 7 Jul 2025 16:54:27 -0700 Subject: [PATCH 2/2] docs: add comments explaining single-funded DLC logic - clarify fee distribution between funding/non-funding parties - document zero input handling in fee calculation fns --- src/cfddlc_transactions.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cfddlc_transactions.cpp b/src/cfddlc_transactions.cpp index a8b55ee..963ea4a 100644 --- a/src/cfddlc_transactions.cpp +++ b/src/cfddlc_transactions.cpp @@ -172,7 +172,7 @@ TransactionController DlcManager::CreateFundTransaction( outputs_info.push_back(fund_output_info); - // Only add change outputs if they're not dust (for single-funded DLC support) + // Single-funded DLC: exclude zero-value change outputs (dust filtering) if (!IsDustOutputInfo(local_output_info)) { outputs_info.push_back(local_output_info); } @@ -264,7 +264,7 @@ TransactionController DlcManager::CreateBatchFundTransaction( remote_change_output.GetLockingScript(), remote_change_output.GetValue(), remote_serial_id}; - // Only add change outputs if they're not dust (for single-funded DLC support) + // Single-funded DLC: exclude zero-value change outputs (dust filtering) if (!IsDustOutputInfo(local_output_info)) { outputs_info.push_back(local_output_info); } @@ -656,7 +656,8 @@ DlcTransactions DlcManager::CreateDlcTransactions( std::tie(remote_change_output, remote_fund_fee, remote_cet_fee) = GetChangeOutputAndFees(remote_params, fee_rate); - // Handle single-funded DLC: funding party pays for both CET fees + // Single-funded DLC: when one party has no inputs, the funding party pays all + // fees if (local_params.input_amount == 0) { // Local party has no inputs, so remote party should pay for both CET fees remote_cet_fee += local_cet_fee; @@ -973,7 +974,7 @@ std::tuple DlcManager::GetChangeOutputAndFees( auto cet_fee = ceil(cet_weight / 4) * fee_rate; auto fund_out = params.collateral + fund_fee + cet_fee; - // Handle single-funded DLC case where one party has zero inputs + // Single-funded DLC: party with no inputs contributes zero fees if (params.input_amount == 0) { // Party with no inputs: return zero change output and zero fees // The funding party pays for all fees including this party's CET fees @@ -1018,7 +1019,7 @@ std::tuple DlcManager::GetBatchChangeOutputAndFees( auto fund_out = collateral + fund_fee + cet_fee; - // Handle single-funded DLC case where one party has zero inputs + // Single-funded DLC: party with no inputs contributes zero fees if (params.input_amount == 0) { // Party with no inputs: return zero change output and zero fees // The funding party pays for all fees including this party's CET fees