From 4fd852bb3d63517af43b18492ac245c9350ae2f3 Mon Sep 17 00:00:00 2001 From: denavila Date: Fri, 20 Oct 2023 16:20:36 -0700 Subject: [PATCH] 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. ----- Implemented basic deniability unit tests to wallet_tests ----- Implemented a new 'walletdeniabilizecoin' RPC. ----- Implemented fingerprint spoofing for deniabilization (and fee bump) transactions. Currently spoofing with data for 6 different wallet implementations, with 4 specific fingerprint-able behaviors (version, anti-fee-sniping, bip69 ordering, no-rbf). --- src/interfaces/wallet.h | 20 ++ src/rpc/client.cpp | 3 + src/wallet/feebumper.cpp | 98 ++++++++ src/wallet/feebumper.h | 9 + src/wallet/interfaces.cpp | 40 ++++ src/wallet/rpc/spend.cpp | 125 ++++++++++ src/wallet/rpc/wallet.cpp | 2 + src/wallet/spend.cpp | 398 +++++++++++++++++++++++++++++++ src/wallet/spend.h | 44 ++++ src/wallet/test/wallet_tests.cpp | 126 ++++++++++ 10 files changed, 865 insertions(+) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index c41f35829d7..a1bbae00604 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -153,6 +153,16 @@ class Wallet WalletValueMap value_map, WalletOrderForm order_form) = 0; + virtual std::pair calculateDeniabilizationCycles(const COutPoint& outpoint) = 0; + + virtual util::Result createDeniabilizationTransaction(const std::set& inputs, + const std::optional& opt_output_type, + unsigned int confirm_target, + unsigned int deniabilization_cycles, + bool sign, + bool& insufficient_amount, + CAmount& fee) = 0; + //! Return whether transaction can be abandoned. virtual bool transactionCanBeAbandoned(const uint256& txid) = 0; @@ -179,6 +189,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, + unsigned int confirm_target, + bool sign, + CAmount& old_fee, + CAmount& new_fee) = 0; + //! Get a transaction. virtual CTransactionRef getTx(const uint256& txid) = 0; @@ -250,6 +267,9 @@ class Wallet int* returned_target, FeeReason* reason) = 0; + //! Get the fee rate for deniabilization + virtual CFeeRate getDeniabilizationFeeRate(unsigned int confirm_target) = 0; + //! Get tx confirm target. virtual unsigned int getConfirmTarget() = 0; diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index b8dc148eaea..52240136cf8 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -165,6 +165,9 @@ static const CRPCConvertParam vRPCConvertParams[] = { "walletcreatefundedpsbt", 3, "replaceable"}, { "walletcreatefundedpsbt", 3, "solving_data"}, { "walletcreatefundedpsbt", 4, "bip32derivs" }, + { "walletdeniabilizecoin", 0, "inputs" }, + { "walletdeniabilizecoin", 2, "conf_target" }, + { "walletdeniabilizecoin", 3, "add_to_wallet" }, { "walletprocesspsbt", 1, "sign" }, { "walletprocesspsbt", 3, "bip32derivs" }, { "walletprocesspsbt", 4, "finalize" }, diff --git a/src/wallet/feebumper.cpp b/src/wallet/feebumper.cpp index 6a8453965be..e9406790231 100644 --- a/src/wallet/feebumper.cpp +++ b/src/wallet/feebumper.cpp @@ -386,5 +386,103 @@ Result CommitTransaction(CWallet& wallet, const uint256& txid, CMutableTransacti return Result::OK; } +Result CreateRateBumpDeniabilizationTransaction(CWallet& wallet, const uint256& txid, unsigned int 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) { + CTxDestination destination = CNoDestination(); + ExtractDestination(output.scriptPubKey, destination); + CRecipient recipient = {destination, 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 + coin_control.destChange = recipients.back().dest; + + for (const auto& inputs : wtx.tx->vin) { + coin_control.Select(COutPoint(inputs.prevout)); + } + + auto res = CreateTransaction(wallet, recipients, std::nullopt, coin_control, /*sign=*/false); + 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.has_value()); + + // spoof the transaction fingerprint to increase the transaction privacy + { + FastRandomContext rng_fast; + CMutableTransaction spoofedTx(*res->tx); + SpoofTransactionFingerprint(spoofedTx, rng_fast, coin_control.m_signal_bip125_rbf); + if (sign && !wallet.SignTransaction(spoofedTx)) { + error = Untranslated("Signing the deniabilization fee bump transaction failed."); + return Result::MISC_ERROR; + } + // store the spoofed transaction in the result + res->tx = MakeTransactionRef(std::move(spoofedTx)); + } + + // 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 d3d43861efc..29cddbd3c91 100644 --- a/src/wallet/feebumper.h +++ b/src/wallet/feebumper.h @@ -72,6 +72,15 @@ Result CommitTransaction(CWallet& wallet, std::vector& errors, uint256& bumped_txid); +Result CreateRateBumpDeniabilizationTransaction(CWallet& wallet, + const uint256& txid, + unsigned int 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 0c1cae7253c..2b261f792ec 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -297,6 +297,28 @@ class WalletImpl : public Wallet LOCK(m_wallet->cs_wallet); m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form)); } + std::pair calculateDeniabilizationCycles(const COutPoint& outpoint) override + { + LOCK(m_wallet->cs_wallet); // TODO - Do we need a lock here? + return CalculateDeniabilizationCycles(*m_wallet, outpoint); + } + util::Result createDeniabilizationTransaction(const std::set& inputs, + const std::optional& opt_output_type, + unsigned int confirm_target, + unsigned int 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, opt_output_type, 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 { @@ -326,6 +348,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, + unsigned int 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); @@ -508,6 +544,10 @@ class WalletImpl : public Wallet if (reason) *reason = fee_calc.reason; return result; } + CFeeRate getDeniabilizationFeeRate(unsigned int 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/rpc/spend.cpp b/src/wallet/rpc/spend.cpp index 1a364a75edc..04103158339 100644 --- a/src/wallet/rpc/spend.cpp +++ b/src/wallet/rpc/spend.cpp @@ -1753,4 +1753,129 @@ RPCHelpMan walletcreatefundedpsbt() }, }; } + +// clang-format off +RPCHelpMan walletdeniabilizecoin() +{ + return RPCHelpMan{"walletdeniabilizecoin", + "\nDeniabilize one or more UTXOs that share the same address.\n", + { + {"inputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Specify inputs (must share the same address). A JSON array of JSON objects", + { + {"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id"}, + {"vout", RPCArg::Type::NUM, RPCArg::Optional::NO, "The output number"}, + }, + }, + {"output_type", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "Optional output type to use. Options are \"legacy\", \"p2sh-segwit\", \"bech32\" and \"bech32m\". If not specified the output type is inferred from the inputs."}, + {"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"}, + {"add_to_wallet", RPCArg::Type::BOOL, RPCArg::Default{true}, "When false, returns the serialized transaction without broadcasting or adding it to the wallet"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR_HEX, "txid", "The deniabilization transaction id."}, + {RPCResult::Type::STR_AMOUNT, "fee", "The fee used in the deniabilization transaction."}, + {RPCResult::Type::STR_HEX, "hex", /*optional=*/true, "If add_to_wallet is false, the hex-encoded raw transaction with signature(s)"}, + } + }, + RPCExamples{ + "\nDeniabilize a single UTXO\n" + + HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]'") + + "\nDeniabilize a single UTXO using a specific output type\n" + + HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' bech32") + + "\nDeniabilize a single UTXO with an explicit confirmation target\n" + + HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' null 144") + + "\nDeniabilize a single UTXO without broadcasting the transaction\n" + + HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' null 6 false") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue + { + std::shared_ptr const pwallet = GetWalletForJSONRPCRequest(request); + if (!pwallet) return UniValue::VNULL; + + std::optional shared_script; + std::set inputs; + unsigned int deniabilization_cycles = UINT_MAX; + for (const UniValue& input : request.params[0].get_array().getValues()) { + Txid txid = Txid::FromUint256(ParseHashO(input, "txid")); + + const UniValue& vout_v = input.find_value("vout"); + if (!vout_v.isNum()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, missing vout key"); + } + int nOutput = vout_v.getInt(); + if (nOutput < 0) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, vout cannot be negative"); + } + + COutPoint outpoint(txid, nOutput); + LOCK(pwallet->cs_wallet); + auto walletTx = pwallet->GetWalletTx(outpoint.hash); + if (!walletTx) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, txid not found in wallet."); + } + if (outpoint.n >= walletTx->tx->vout.size()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, vout is out of range"); + } + const auto& output = walletTx->tx->vout[outpoint.n]; + + isminetype mine = pwallet->IsMine(output); + if (mine == ISMINE_NO) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, transaction's output doesn't belong to this wallet."); + } + + bool spendable = (mine & ISMINE_SPENDABLE) != ISMINE_NO; + if (spendable) { + auto script = FindNonChangeParentOutput(*pwallet, outpoint).scriptPubKey; + if (!shared_script) { + shared_script = script; + } + else if (!(*shared_script == script)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must share the same address"); + } + } else { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must be spendable and have a valid address"); + } + + inputs.emplace(outpoint); + auto cycles_res = CalculateDeniabilizationCycles(*pwallet, outpoint); + deniabilization_cycles = std::min(deniabilization_cycles, cycles_res.first); + } + + if (inputs.empty()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must not be empty"); + } + + std::optional opt_output_type = !request.params[1].isNull() ? ParseOutputType(request.params[1].get_str()) : std::nullopt; + unsigned int confirm_target = !request.params[2].isNull() ? request.params[2].getInt() : pwallet->m_confirm_target; + const bool add_to_wallet = !request.params[3].isNull() ? request.params[3].get_bool() : true; + + CTransactionRef tx; + CAmount tx_fee = 0; + { + bool sign = !pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS); + bool insufficient_amount = false; + auto res = CreateDeniabilizationTransaction(*pwallet, inputs, opt_output_type, confirm_target, deniabilization_cycles, sign, insufficient_amount); + if (!res) { + throw JSONRPCError(RPC_TRANSACTION_ERROR, ErrorString(res).original); + } + tx = res->tx; + tx_fee = res->fee; + } + + UniValue result(UniValue::VOBJ); + result.pushKV("txid", tx->GetHash().GetHex()); + if (add_to_wallet) { + pwallet->CommitTransaction(tx, {}, /*orderForm=*/{}); + } else { + std::string hex{EncodeHexTx(*tx)}; + result.pushKV("hex", hex); + } + result.pushKV("fee", ValueFromAmount(tx_fee)); + return result; + } + }; +} +// clang-format on + } // namespace wallet diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp index f1cb595271e..fa0b7f695ff 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -1083,6 +1083,7 @@ RPCHelpMan send(); RPCHelpMan sendall(); RPCHelpMan walletprocesspsbt(); RPCHelpMan walletcreatefundedpsbt(); +RPCHelpMan walletdeniabilizecoin(); RPCHelpMan signrawtransactionwithwallet(); // signmessage @@ -1172,6 +1173,7 @@ Span GetWalletRPCCommands() {"wallet", &walletpassphrase}, {"wallet", &walletpassphrasechange}, {"wallet", &walletprocesspsbt}, + {"wallet", &walletdeniabilizecoin}, }; return commands; } diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index 9a7e166e689..be275333007 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -1437,4 +1437,402 @@ util::Result FundTransaction(CWallet& wallet, const CM return res; } + +// We use 2 outputs for deniablization transactions, as that's a common output count for spend transactions (which we're trying to mimic) +// Furthermore, more outputs would rapidly increase the cost per cycle, thus limiting the number of cycles for a given budget +// At any rate, we use the below constant in case we want to play with other output counts in the future. +constexpr int NUM_DENIABILIZATION_OUTPUTS = 2; + +static unsigned int CalculateDeniabilizationTxSize(const CScript& script, CAmount value, unsigned int numTxIn) +{ + // Calculation based on the comments and code in GetDustThreshold and CreateTransactionInternal + unsigned int txOutSize = (unsigned int)GetSerializeSize(CTxOut(value, script)); + + const size_t txOutCount = NUM_DENIABILIZATION_OUTPUTS; + unsigned int txSize = 10 + GetSizeOfCompactSize(txOutCount); // bytes for output count + txSize += txOutSize * txOutCount; + + int witnessversion = 0; + std::vector witnessprogram; + if (script.IsWitnessProgram(witnessversion, witnessprogram)) { + txSize += (unsigned int)roundf(numTxIn * (41 + 107 / float(WITNESS_SCALE_FACTOR))); + } else { + txSize += numTxIn * (41 + 107); + } + return txSize; +} + +float CalculateDeniabilizationProbability(unsigned int 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(unsigned int 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, unsigned int 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 CScript& shared_script, CAmount total_value, unsigned int num_utxos, const CFeeRate& fee_rate) +{ + Assert(num_utxos > 0); + unsigned int deniabilization_tx_size = CalculateDeniabilizationTxSize(shared_script, total_value, num_utxos); + return fee_rate.GetFee(deniabilization_tx_size); +} + +CAmount CalculateDeniabilizationFeeEstimate(const CScript& shared_script, CAmount total_value, unsigned int num_utxos, unsigned int deniabilization_cycles, const CFeeRate& fee_rate) +{ + float deniabilizationProbability = CalculateDeniabilizationProbability(deniabilization_cycles); + // convert to integer percent to truncate and check for zero probability + unsigned int 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_script, total_value, num_utxos, fee_rate); + + // calculate the fees from future deniabilization cycles + CAmount futureDeniabilizationFee = CalculateDeniabilizationFeeEstimate(shared_script, total_value / NUM_DENIABILIZATION_OUTPUTS, 1, deniabilization_cycles + 1, fee_rate) * NUM_DENIABILIZATION_OUTPUTS; + + // 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; +} + +std::pair CalculateDeniabilizationCycles(CWallet& wallet, const COutPoint& outpoint) +{ + LOCK(wallet.cs_wallet); + auto walletTx = wallet.GetWalletTx(outpoint.hash); + if (!walletTx) { + return std::make_pair(0, false); + } + auto tx = walletTx->tx; + + if (tx->IsCoinBase()) { + // this is a block reward tx, so we tag it as such + return std::make_pair(0, true); + } + + // an deniabilized coin is one we sent to ourselves + // all txIn should belong to our wallet + if (tx->vin.empty()) { + return std::make_pair(0, false); + } + for (const auto& txIn : tx->vin) { + if (InputIsMine(wallet, txIn) == ISMINE_NO) { + return std::make_pair(0, false); + } + } + + // all txOut should belong to our wallet + Assert(outpoint.n < tx->vout.size()); + unsigned int n = 0; + for (const auto& txOut : tx->vout) { + if (wallet.IsMine(txOut) == ISMINE_NO) { + Assert(n != outpoint.n); + return std::make_pair(0, false); + } + n++; + } + + unsigned int uniqueTxOutCount = 0; + for (const auto& txOut : tx->vout) { + // check if it's a valid destination + CTxDestination txOutDestination; + ExtractDestination(txOut.scriptPubKey, txOutDestination); + if (std::get_if(&txOutDestination)) { + continue; + } + + // don't count outputs that match any input addresses (eg it's change output) + bool matchesInput = false; + for (const auto& txIn : tx->vin) { + auto prevWalletTx = wallet.GetWalletTx(txIn.prevout.hash); + if (prevWalletTx && prevWalletTx->tx->vout[txIn.prevout.n].scriptPubKey == txOut.scriptPubKey) { + matchesInput = true; + break; + } + } + if (matchesInput) { + continue; + } + + uniqueTxOutCount++; + } + + // we consider two or more unique outputs an "deniabilization" of the coin + unsigned int deniabilizationCycles = uniqueTxOutCount >= 2 ? 1 : 0; + + // all txIn and txOut are from our wallet + // however if we have multiple txIn this was either an initial deniabilization of multiple UTXOs or the user manually merged deniabilized UTXOs + // in either case we don't need to recurse into parent transactions and we can return the calculated cycles + if (tx->vin.size() > 1) { + return std::make_pair(deniabilizationCycles, false); + } + + const auto& txIn = tx->vin[0]; + // now recursively calculate the deniabilization cycles of the input + auto inputStats = CalculateDeniabilizationCycles(wallet, txIn.prevout); + return std::make_pair(inputStats.first + deniabilizationCycles, inputStats.second); +}; + +void SpoofTransactionFingerprint(CMutableTransaction& tx, FastRandomContext& rng_fast, const std::optional& signal_bip125_rbf) +{ + // Transaction "fingerprint" spoofing + struct Fingerprint { + bool standardVersion = false; + bool antiFeeSniping = false; + bool bip69Ordering = false; + bool noRBF = false; + }; + + // wallet fingerprints based on info from variuous sources, see: + // https://github.com/achow101/wallet-fingerprinting/blob/main/fingerprints.md + // https://gitlab.com/1440000bytes/goldfish + // https://ishaana.com/blog/wallet_fingerprinting/ + // clang-format off + static const Fingerprint s_walletFingerprints[] = { + // std-ver, anti-sniping, bip69, no-rbf + { true, true, false, false }, // Core + { true, true, true, false }, // Electrum + { true, false, false, false }, // Blue + { false, false, true, false }, // Trezor + { false, false, false, false }, // Trust, Ledger + { true, false, false, true }, // Coinbase/Exodus + }; + // clang-format on + constexpr size_t NUM_WALLET_FINGERPRINTS = sizeof(s_walletFingerprints) / sizeof(s_walletFingerprints[0]); + + auto fingerprintIndex = rng_fast.randrange(NUM_WALLET_FINGERPRINTS); + const Fingerprint& fingerprint = s_walletFingerprints[fingerprintIndex]; + + if (fingerprint.standardVersion) { + Assert(tx.nVersion == TX_MAX_STANDARD_VERSION); + } else { + tx.nVersion = 1; + } + + if (fingerprint.antiFeeSniping) { + // By default "Core" implements anti-fee-sniping (nLockTime == block_height - rng_fast.randrange(100)) + } else { + // no anti-fee-sniping + tx.nLockTime = 0; + } + + if (fingerprint.bip69Ordering) { + // Sort the inputs and outputs in accordance with BIP69 + auto sortInputsBip69 = [](const CTxIn& a, const CTxIn& b) { + // COutPoint operator< does sort in accordance with Bip69, so just use that. + return a.prevout < b.prevout; + }; + std::sort(tx.vin.begin(), tx.vin.end(), sortInputsBip69); + + auto sortOutputsBip69 = [](const CTxOut& a, const CTxOut& b) { + if (a.nValue == b.nValue) { + // Note: prevector operator< does NOT properly order scriptPubKeys lexicographically. So instead we + // fall-back to using std::memcmp. + const auto& spkA = a.scriptPubKey; + const auto& spkB = b.scriptPubKey; + const int cmp = std::memcmp(spkA.data(), spkB.data(), std::min(spkA.size(), spkB.size())); + return cmp < 0 || (cmp == 0 && spkA.size() < spkB.size()); + } + return a.nValue < b.nValue; + }; + std::sort(tx.vout.begin(), tx.vout.end(), sortOutputsBip69); + } else { + // By default "Core" doesn't perform BIP69 ordering + } + + if (!signal_bip125_rbf.value_or(false) && fingerprint.noRBF) { + for (auto& in : tx.vin) { + in.nSequence = CTxIn::MAX_SEQUENCE_NONFINAL; + } + } else { + // By default "Core" respects the opt-in RBF flag + for (const auto& in : tx.vin) { + Assert(in.nSequence == CTxIn::MAX_SEQUENCE_NONFINAL || in.nSequence == MAX_BIP125_RBF_SEQUENCE); + } + } +} + +util::Result CreateDeniabilizationTransaction(CWallet& wallet, const std::set& inputs, const std::optional& opt_output_type, unsigned int confirm_target, unsigned int deniabilization_cycles, bool sign, bool& insufficient_amount) +{ + if (inputs.empty()) { + return util::Error{_("Inputs must not be empty")}; + } + + CCoinControl coin_control = SetupDeniabilizationCoinControl(confirm_target); + // TODO: Do we need to limit number of inputs to OUTPUT_GROUP_MAX_ENTRIES + 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}; + coin_selection_params.m_avoid_partial_spends = coin_control.m_avoid_partial_spends; + coin_selection_params.m_include_unsafe_inputs = coin_control.m_include_unsafe_inputs; + coin_selection_params.m_effective_feerate = deniabilization_fee_rate; + coin_selection_params.m_long_term_feerate = wallet.m_consolidate_feerate; + coin_selection_params.m_subtract_fee_outputs = true; + + 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_script; + for (const auto& coin : preset_inputs.coins) { + if (!op_shared_script) { + op_shared_script = coin->txout.scriptPubKey; + } + if (!op_shared_script || !(*op_shared_script == coin->txout.scriptPubKey)) { + return util::Error{_("Input addresses must all match.")}; + } + } + Assert(op_shared_script); + CScript shared_script = *op_shared_script; + + CFeeRate discard_feerate = GetDiscardRate(wallet); + CAmount dust_threshold = GetDustThreshold(CTxOut(total_amount, shared_script), 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_script, total_amount / NUM_DENIABILIZATION_OUTPUTS, 1, deniabilization_cycles + 1, deniabilization_fee_rate) + dust_threshold; + CAmount estimated_tx_fee = CalculateDeniabilizationTxFee(shared_script, 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)}; + } + + OutputType output_type = wallet.m_default_address_type; + if (opt_output_type) { + output_type = *opt_output_type; + } else { + // if no output type was specified, try to infer it from the source inputs + CTxDestination shared_destination = CNoDestination(); + if (ExtractDestination(shared_script, shared_destination)) { + std::optional opt_shared_output_type = OutputTypeFromDestination(shared_destination); + if (opt_shared_output_type) { + output_type = *opt_shared_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].dest = 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); + + auto res = CreateTransactionInternal(wallet, recipients, std::nullopt, coin_control, /*sign=*/false); + if (!res) { + TRACE4(coin_selection, normal_create_tx_internal, wallet.GetName().c_str(), false, 0, 0); + return res; + } + + // make sure we didn't get a change position assigned (we don't expect to use the channge address) + Assert(!res->change_pos.has_value()); + // the transaction was created successfully + TRACE4(coin_selection, normal_create_tx_internal, wallet.GetName().c_str(), true, res->fee, 0); + + // spoof the transaction fingerprint to increase the transaction privacy + { + CMutableTransaction spoofedTx(*res->tx); + SpoofTransactionFingerprint(spoofedTx, rng_fast, coin_control.m_signal_bip125_rbf); + if (sign && !wallet.SignTransaction(spoofedTx)) { + return util::Error{_("Signing the deniabilization transaction failed")}; + } + // store the spoofed transaction in the result + res->tx = MakeTransactionRef(std::move(spoofedTx)); + } + + // 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 62a7b4e4c89..e3ac3e54d7e 100644 --- a/src/wallet/spend.h +++ b/src/wallet/spend.h @@ -225,6 +225,50 @@ util::Result CreateTransaction(CWallet& wallet, const * calling CreateTransaction(); */ util::Result FundTransaction(CWallet& wallet, const CMutableTransaction& tx, const std::vector& recipients, std::optional change_pos, bool lockUnspents, CCoinControl); + +/** + * Calculate the probability for a deniabilization transaction given the number of deniabilization cycles already performed + */ +float CalculateDeniabilizationProbability(unsigned int 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(unsigned int confirm_target); + +/** + * Estimate the total deniabilization transaction fees for a given set of UTXOs that share an input destination + */ +CAmount CalculateDeniabilizationFeeEstimate(const CScript& shared_script, CAmount total_value, unsigned int num_utxos, unsigned int deniabilization_cycles, const CFeeRate& fee_rate); + +/** + * Calculate the fee rate for a deniabilization transaction + */ +CFeeRate CalculateDeniabilizationFeeRate(const CWallet& wallet, unsigned int confirm_target); + +/** + * Calculate how many deniabilization cycles have been performed for the given UTXO + * Result.first is the deniabilization cycle count + * Result.second indicates if the transaction chain is from a coinbase transaction (block reward) + */ +std::pair CalculateDeniabilizationCycles(CWallet& wallet, const COutPoint& outpoint); + +/** + * Spoof the transaction fingerprint to increase transaction privacy + */ +void SpoofTransactionFingerprint(CMutableTransaction& tx, FastRandomContext& rng_fast, const std::optional& signal_bip125_rbf); + +/** + * 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, const std::optional& opt_output_type, unsigned int confirm_target, unsigned int deniabilization_cycles, bool sign, bool& insufficient_amount); } // namespace wallet #endif // BITCOIN_WALLET_SPEND_H diff --git a/src/wallet/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp index 3a67b9a4333..668aa9f11cb 100644 --- a/src/wallet/test/wallet_tests.cpp +++ b/src/wallet/test/wallet_tests.cpp @@ -17,6 +17,7 @@ #include #include