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