From a035b42fd6c3c5e9601905d4846e3ad68c7d12da Mon Sep 17 00:00:00 2001 From: Denny Avila Date: Fri, 26 May 2023 01:10:01 -0700 Subject: [PATCH] wallet: Deniability API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is the wallet API and implementation portion of the GUI PR ( https://github.com/bitcoin-core/gui/pull/733 ) which is an implementation of the ideas in Paul Sztorc's blog post "Deniability - Unilateral Transaction Meta-Privacy"(https://www.truthcoin.info/blog/deniability/). The GUI PR has all the details and screenshots of the GUI additions. Here I'll just copy the relevant context for the wallet API changes: " In short, Paul's idea is to periodically split coins and send them to yourself, making it look like common "spend" transactions, such that blockchain ownership analysis becomes more difficult, and thus improving the user's privacy. I've implemented this as an additional "Deniability" wallet view. The majority of the code is in a new deniabilitydialog.cpp/h source files containing a new DeniabilityDialog class, hooked up to the WalletView class.  " While the Deniability dialog can be implemented entirely with the existing API, adding the core "deniabilization" functions to the CWallet and interfaces::Wallet API allows us to implement the GUI portion with much less code, and more importantly allows us to add RPC support and more thorough unit tests. --- src/interfaces/wallet.h | 17 +++ src/wallet/feebumper.cpp | 83 ++++++++++++++ src/wallet/feebumper.h | 9 ++ src/wallet/interfaces.cpp | 34 ++++++ src/wallet/spend.cpp | 221 ++++++++++++++++++++++++++++++++++++++ src/wallet/spend.h | 32 ++++++ 6 files changed, 396 insertions(+) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 8c31112fc94850..76578533d1fd19 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -151,6 +151,13 @@ class Wallet WalletValueMap value_map, WalletOrderForm order_form) = 0; + virtual util::Result createDeniabilizationTransaction(const std::set& inputs, + uint confirm_target, + uint deniabilization_cycles, + bool sign, + bool& insufficient_amount, + CAmount& fee) = 0; + //! Return whether transaction can be abandoned. virtual bool transactionCanBeAbandoned(const uint256& txid) = 0; @@ -177,6 +184,13 @@ class Wallet std::vector& errors, uint256& bumped_txid) = 0; + //! Create a fee bump transaction for a deniabilization transaction + virtual util::Result createBumpDeniabilizationTransaction(const uint256& txid, + uint confirm_target, + bool sign, + CAmount& old_fee, + CAmount& new_fee) = 0; + //! Get a transaction. virtual CTransactionRef getTx(const uint256& txid) = 0; @@ -248,6 +262,9 @@ class Wallet int* returned_target, FeeReason* reason) = 0; + //! Get the fee rate for deniabilization + virtual CFeeRate getDeniabilizationFeeRate(uint confirm_target) = 0; + //! Get tx confirm target. virtual unsigned int getConfirmTarget() = 0; diff --git a/src/wallet/feebumper.cpp b/src/wallet/feebumper.cpp index b6b1fa1d3e256c..0e2440e6b4e43c 100644 --- a/src/wallet/feebumper.cpp +++ b/src/wallet/feebumper.cpp @@ -365,5 +365,88 @@ Result CommitTransaction(CWallet& wallet, const uint256& txid, CMutableTransacti return Result::OK; } +Result CreateRateBumpDeniabilizationTransaction(CWallet& wallet, const uint256& txid, uint confirm_target, bool sign, bilingual_str& error, CAmount& old_fee, CAmount& new_fee, CTransactionRef& new_tx) +{ + CCoinControl coin_control = SetupDeniabilizationCoinControl(confirm_target); + coin_control.m_feerate = CalculateDeniabilizationFeeRate(wallet, confirm_target); + + LOCK(wallet.cs_wallet); + + auto it = wallet.mapWallet.find(txid); + if (it == wallet.mapWallet.end()) { + error = Untranslated("Invalid or non-wallet transaction id"); + return Result::INVALID_ADDRESS_OR_KEY; + } + const CWalletTx& wtx = it->second; + + // Retrieve all of the UTXOs and add them to coin control + // While we're here, calculate the input amount + std::map coins; + CAmount input_value = 0; + for (const CTxIn& txin : wtx.tx->vin) { + coins[txin.prevout]; // Create empty map entry keyed by prevout. + } + wallet.chain().findCoins(coins); + for (const CTxIn& txin : wtx.tx->vin) { + const Coin& coin = coins.at(txin.prevout); + if (coin.out.IsNull()) { + error = Untranslated(strprintf("%s:%u is already spent", txin.prevout.hash.GetHex(), txin.prevout.n)); + return Result::MISC_ERROR; + } + if (!wallet.IsMine(txin.prevout)) { + error = Untranslated("All inputs must be from our wallet."); + return Result::MISC_ERROR; + } + coin_control.Select(txin.prevout); + input_value += coin.out.nValue; + } + + std::vector dymmy_errors; + Result result = PreconditionChecks(wallet, wtx, /*require_mine=*/true, dymmy_errors); + if (result != Result::OK) { + error = dymmy_errors.front(); + return result; + } + + // Calculate the old output amount. + CAmount output_value = 0; + for (const auto& old_output : wtx.tx->vout) { + output_value += old_output.nValue; + } + + old_fee = input_value - output_value; + + std::vector recipients; + for (const auto& output : wtx.tx->vout) { + CRecipient recipient = {output.scriptPubKey, output.nValue, false}; + recipients.push_back(recipient); + } + // the last recipient gets the old fee + recipients.back().nAmount += old_fee; + // and pays the new fee + recipients.back().fSubtractFeeFromAmount = true; + // we don't expect to get change, but we provide the address to prevent CreateTransactionInternal from generating a change address + ExtractDestination(recipients.back().scriptPubKey, coin_control.destChange); + + for (const auto& inputs : wtx.tx->vin) { + coin_control.Select(COutPoint(inputs.prevout)); + } + + constexpr int RANDOM_CHANGE_POSITION = -1; + auto res = CreateTransaction(wallet, recipients, RANDOM_CHANGE_POSITION, coin_control, sign); + if (!res) { + error = util::ErrorString(res); + return Result::WALLET_ERROR; + } + + // make sure we didn't get a change position assigned (we don't expect to use the channge address) + Assert(res->change_pos == RANDOM_CHANGE_POSITION); + // write back the new fee + new_fee = res->fee; + // write back the transaction + new_tx = res->tx; + return Result::OK; +} + } // namespace feebumper } // namespace wallet diff --git a/src/wallet/feebumper.h b/src/wallet/feebumper.h index 53cf16e0f1925e..c2ac4c9905a81b 100644 --- a/src/wallet/feebumper.h +++ b/src/wallet/feebumper.h @@ -69,6 +69,15 @@ Result CommitTransaction(CWallet& wallet, std::vector& errors, uint256& bumped_txid); +Result CreateRateBumpDeniabilizationTransaction(CWallet& wallet, + const uint256& txid, + uint confirm_target, + bool sign, + bilingual_str& error, + CAmount& old_fee, + CAmount& new_fee, + CTransactionRef& new_tx); + struct SignatureWeights { private: diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index cd438cfe2f399b..0de6fd6560e551 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -295,6 +295,22 @@ class WalletImpl : public Wallet LOCK(m_wallet->cs_wallet); m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form)); } + util::Result createDeniabilizationTransaction(const std::set& inputs, + uint confirm_target, + uint deniabilization_cycles, + bool sign, + bool& insufficient_amount, + CAmount& fee) override + { + LOCK(m_wallet->cs_wallet); // TODO - Do we need a lock here? + auto res = CreateDeniabilizationTransaction(*m_wallet, inputs, confirm_target, deniabilization_cycles, sign, insufficient_amount); + if (!res) { + return util::Error{util::ErrorString(res)}; + } + const auto& txr = *res; + fee = txr.fee; + return txr.tx; + } bool transactionCanBeAbandoned(const uint256& txid) override { return m_wallet->TransactionCanBeAbandoned(txid); } bool abandonTransaction(const uint256& txid) override { @@ -324,6 +340,20 @@ class WalletImpl : public Wallet return feebumper::CommitTransaction(*m_wallet.get(), txid, std::move(mtx), errors, bumped_txid) == feebumper::Result::OK; } + util::Result createBumpDeniabilizationTransaction(const uint256& txid, + uint confirm_target, + bool sign, + CAmount& old_fee, + CAmount& new_fee) override + { + bilingual_str error; + CTransactionRef new_tx; + auto res = feebumper::CreateRateBumpDeniabilizationTransaction(*m_wallet.get(), txid, confirm_target, sign, error, old_fee, new_fee, new_tx); + if (res != feebumper::Result::OK) { + return util::Error{error}; + } + return new_tx; + } CTransactionRef getTx(const uint256& txid) override { LOCK(m_wallet->cs_wallet); @@ -506,6 +536,10 @@ class WalletImpl : public Wallet if (reason) *reason = fee_calc.reason; return result; } + CFeeRate getDeniabilizationFeeRate(uint confirm_target) override + { + return CalculateDeniabilizationFeeRate(*m_wallet, confirm_target); + } unsigned int getConfirmTarget() override { return m_wallet->m_confirm_target; } bool hdEnabled() override { return m_wallet->IsHDEnabled(); } bool canGetAddresses() override { return m_wallet->CanGetAddresses(); } diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index b14a30921b6930..f8b234f8ac9ecf 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -1248,4 +1248,225 @@ bool FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& nFeeRet, return true; } + +constexpr int NUM_DENIABILIZATION_OUTPUTS = 2; + +static uint CalculateDeniabilizationTxSize(const CTxDestination& address, CAmount value, uint numTxIn) +{ + // Calculation based on the comments and code in GetDustThreshold and CreateTransactionInternal + CScript script = GetScriptForDestination(address); + uint txOutSize = (uint)GetSerializeSize(CTxOut(value, script), PROTOCOL_VERSION); + + const size_t txOutCount = NUM_DENIABILIZATION_OUTPUTS; + uint txSize = 10 + GetSizeOfCompactSize(txOutCount); // bytes for output count + txSize += txOutSize * txOutCount; + + int witnessversion = 0; + std::vector witnessprogram; + if (script.IsWitnessProgram(witnessversion, witnessprogram)) { + txSize += (uint)(numTxIn * 67.75f + 0.5f); + } else { + txSize += numTxIn * 148; + } + return txSize; +} + +float CalculateDeniabilizationProbability(uint deniabilization_cycles) +{ + // 100%, 50%, 25%, 13%, 6%, 3%, 2%, 1% + return powf(0.5f, deniabilization_cycles); +} + +bool IsDeniabilizationWorthwhile(CAmount total_value, CAmount fee_estimate) +{ + constexpr CAmount value_to_fee_ratio = 10; + return total_value > fee_estimate * value_to_fee_ratio; +} + +CCoinControl SetupDeniabilizationCoinControl(uint confirm_target) +{ + CCoinControl coin_control; + coin_control.m_avoid_address_reuse = true; + coin_control.m_avoid_partial_spends = true; + coin_control.m_allow_other_inputs = false; + coin_control.m_signal_bip125_rbf = true; + coin_control.m_confirm_target = confirm_target; + // we'll automatically bump the fee if economical ends up not confirming by the next deniabilization cycle + coin_control.m_fee_mode = FeeEstimateMode::ECONOMICAL; + return coin_control; +} + +CFeeRate CalculateDeniabilizationFeeRate(const CWallet& wallet, uint confirm_target) +{ + CCoinControl coin_control = SetupDeniabilizationCoinControl(confirm_target); + + CFeeRate requiredFeeRate = GetRequiredFeeRate(wallet); + FeeCalculation fee_calc; + CFeeRate minFeeRate = GetMinimumFeeRate(wallet, coin_control, &fee_calc); + if (fee_calc.reason == FeeReason::FALLBACK || requiredFeeRate > minFeeRate) + return requiredFeeRate; + return minFeeRate; +} + +static CAmount CalculateDeniabilizationTxFee(const CTxDestination& shared_destination, CAmount total_value, uint num_utxos, const CFeeRate& fee_rate) +{ + Assert(num_utxos > 0); + uint deniabilization_tx_size = CalculateDeniabilizationTxSize(shared_destination, total_value, num_utxos); + return fee_rate.GetFee(deniabilization_tx_size); +} + +CAmount CalculateDeniabilizationFeeEstimate(const CTxDestination& shared_destination, CAmount total_value, uint num_utxos, uint deniabilization_cycles, const CFeeRate& fee_rate) +{ + float deniabilizationProbability = CalculateDeniabilizationProbability(deniabilization_cycles); + // convert to integer percent to truncate and check for zero probability + uint deniabilizationProbabilityPercent = deniabilizationProbability * 100; + if (deniabilizationProbabilityPercent == 0) { + return 0; + } + + // this cycle will use all the UTXOs, while following cycles will have just one UTXO + CAmount deniabilizationFee = CalculateDeniabilizationTxFee(shared_destination, total_value, num_utxos, fee_rate); + + // calculate the fees from future deniabilization cycles + CAmount futureDeniabilizationFee = CalculateDeniabilizationFeeEstimate(shared_destination, total_value / NUM_DENIABILIZATION_OUTPUTS, 1, deniabilization_cycles + 1, fee_rate) * 2; + + // if it's worthwhile to do future deniabilizations then add them to this cycle estimate + if (IsDeniabilizationWorthwhile(total_value, deniabilizationFee + futureDeniabilizationFee)) { + deniabilizationFee += futureDeniabilizationFee; + } + return deniabilizationFee; +} + +util::Result CreateDeniabilizationTransaction( + CWallet& wallet, + const std::set& inputs, + uint confirm_target, + uint deniabilization_cycles, + bool sign, + bool& insufficient_amount) +{ + if (inputs.empty()) { + return util::Error{_("Inputs must not be empty")}; + } + + CCoinControl coin_control = SetupDeniabilizationCoinControl(confirm_target); + for (const auto& input : inputs) { + coin_control.Select(input); + } + Assert(coin_control.HasSelected()); + CFeeRate deniabilization_fee_rate = CalculateDeniabilizationFeeRate(wallet, confirm_target); + coin_control.m_feerate = deniabilization_fee_rate; + + LOCK(wallet.cs_wallet); + + FastRandomContext rng_fast; + CoinSelectionParams coin_selection_params{rng_fast}; + auto res_fetch_inputs = FetchSelectedInputs(wallet, coin_control, coin_selection_params); + if (!res_fetch_inputs) { + return util::Error{util::ErrorString(res_fetch_inputs)}; + } + PreSelectedInputs preset_inputs = *res_fetch_inputs; + CAmount total_amount = preset_inputs.total_amount; + + // validate that all UTXOs share the same address + std::optional op_shared_destination; + for (const auto& coin : preset_inputs.coins) { + CTxDestination destination = CNoDestination(); + if (ExtractDestination(coin->txout.scriptPubKey, destination) && !op_shared_destination) { + op_shared_destination = destination; + } + if (!op_shared_destination || !(*op_shared_destination == destination)) { + return util::Error{_("Input addresses must all match.")}; + } + } + Assert(op_shared_destination); + CTxDestination shared_destination = *op_shared_destination; + + CFeeRate discard_feerate = GetDiscardRate(wallet); + CAmount dust_threshold = GetDustThreshold(CTxOut(total_amount, GetScriptForDestination(shared_destination)), discard_feerate); + + // deniabilize the UTXOs by splitting the value randomly + // find a split that leaves enough amount post split to finish the deniabilization process in each new UTXO + CAmount min_post_split_amount = CalculateDeniabilizationFeeEstimate(shared_destination, total_amount / NUM_DENIABILIZATION_OUTPUTS, 1, deniabilization_cycles + 1, deniabilization_fee_rate) + dust_threshold; + CAmount estimated_tx_fee = CalculateDeniabilizationTxFee(shared_destination, total_amount, preset_inputs.coins.size(), deniabilization_fee_rate); + + CAmount total_random_range = total_amount - min_post_split_amount * NUM_DENIABILIZATION_OUTPUTS - estimated_tx_fee; + if (total_random_range < 0) { + insufficient_amount = true; + return util::Error{strprintf(_("Insufficient amount (%d) for a deniabilization transaction, min amount (%d), tx fee (%d)."), total_amount, min_post_split_amount, estimated_tx_fee)}; + } + + std::optional op_output_type = OutputTypeFromDestination(shared_destination); + if (!op_output_type) { + op_output_type = wallet.m_default_change_type; + } + if (!op_output_type) { + return util::Error{_("Unable to determine output type.")}; + } + OutputType output_type = *op_output_type; + + const int num_recipients = NUM_DENIABILIZATION_OUTPUTS; + std::vector recipients(num_recipients); + std::list reservedests; + constexpr bool reservdest_internal = false; // TODO: Should this be "true" or "false". What does "internal" mean? + for (int recipient_index = 0; recipient_index < num_recipients; recipient_index++) { + bool lastRecipient = recipient_index == (num_recipients - 1); + if (!lastRecipient) { + // all recipients except for the last one, + // calculate a random range based on the remaining total random range and the number of remaining recipients + // then generate a random amount within that range + CAmount random_range = total_random_range / (num_recipients - recipient_index - 1); + CAmount random_amount = 0; + if (random_range > 0) { + random_amount = GetRand(random_range); + Assert(total_random_range >= random_amount); + total_random_range -= random_amount; + } + recipients[recipient_index].nAmount = min_post_split_amount + random_amount; + } else { + // the last recipient takes any leftover random amount and the estimated fee + recipients[recipient_index].nAmount = min_post_split_amount + total_random_range + estimated_tx_fee; + } + + // the last recipient pays the tx fees + recipients[recipient_index].fSubtractFeeFromAmount = lastRecipient; + + auto& reservedest = reservedests.emplace_back(&wallet, output_type); + CTxDestination dest; + auto op_dest = reservedest.GetReservedDestination(reservdest_internal); + if (!op_dest) { + return util::Error{_("Failed to reserve a new address.") + Untranslated(" ") + util::ErrorString(op_dest)}; + } + dest = *op_dest; + recipients[recipient_index].scriptPubKey = GetScriptForDestination(dest); + if (lastRecipient) { + // we don't expect to get change, but we provide the address to prevent CreateTransactionInternal from generating a change address + coin_control.destChange = dest; + } + } + + CAmount recipient_amount = std::accumulate(recipients.cbegin(), recipients.cend(), CAmount{0}, [](CAmount sum, const CRecipient& recipient) { return sum + recipient.nAmount; }); + Assert(total_amount == recipient_amount); + + constexpr int RANDOM_CHANGE_POSITION = -1; + auto res = CreateTransactionInternal(wallet, recipients, RANDOM_CHANGE_POSITION, coin_control, sign); + if (!res) { + TRACE4(coin_selection, normal_create_tx_internal, wallet.GetName().c_str(), false, 0, 0); + return res; + } + + // the transaction was created successfully + TRACE4(coin_selection, normal_create_tx_internal, wallet.GetName().c_str(), true, res->fee, res->change_pos); + // make sure we didn't get a change position assigned (we don't expect to use the channge address) + Assert(res->change_pos == RANDOM_CHANGE_POSITION); + // add to the address book and commit the reserved destinations + for (auto& reservedest : reservedests) { + auto op_dest = reservedest.GetReservedDestination(reservdest_internal); + Assert(op_dest); + wallet.SetAddressBook(*op_dest, "deniability", AddressPurpose::RECEIVE); + reservedest.KeepDestination(); + } + return res; +} + } // namespace wallet diff --git a/src/wallet/spend.h b/src/wallet/spend.h index cc9ccf30118f66..4f482d56afd162 100644 --- a/src/wallet/spend.h +++ b/src/wallet/spend.h @@ -223,6 +223,38 @@ util::Result CreateTransaction(CWallet& wallet, const * calling CreateTransaction(); */ bool FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& nFeeRet, int& nChangePosInOut, bilingual_str& error, bool lockUnspents, const std::set& setSubtractFeeFromOutputs, CCoinControl); + +/** + * Calculate the probability for a deniabilization transaction given the number of deniabilization cycles already performed + */ +float CalculateDeniabilizationProbability(uint deniabilization_cycles); + +/** + * Determine if it's worth performing deniabilization given a coin amount and fee estimate (see CalculateDeniabilizationFeeEstimate) + */ +bool IsDeniabilizationWorthwhile(CAmount total_value, CAmount fee_estimate); + +/** + * Setup a coin control to be used in deniabilization transactions + */ +CCoinControl SetupDeniabilizationCoinControl(uint confirm_target); + +/** + * Estimate the total deniabilization transaction fees for a given set of UTXOs that share an input destination + */ +CAmount CalculateDeniabilizationFeeEstimate(const CTxDestination& shared_destination, CAmount total_value, uint num_utxos, uint deniabilization_cycles, const CFeeRate& fee_rate); + +/** + * Calculate the fee rate for a deniabilization transaction + */ +CFeeRate CalculateDeniabilizationFeeRate(const CWallet& wallet, uint confirm_target); + +/** + * Create a deniabilization transaction with the provided set of inputs (must share the same destination) + * confirm_target is the confirmation target for the deniabilization transaction + * deniabilization_cycles is the number of deniabilization cycles these inputs have already had + */ +util::Result CreateDeniabilizationTransaction(CWallet& wallet, const std::set& inputs, uint confirm_target, uint deniabilization_cycles, bool sign, bool& insufficient_amount); } // namespace wallet #endif // BITCOIN_WALLET_SPEND_H