Skip to content

Commit 21e39b9

Browse files
committed
wallet: Deniability API
This PR is the wallet API and implementation portion of the GUI PR ( #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).
1 parent d752349 commit 21e39b9

10 files changed

Lines changed: 867 additions & 0 deletions

File tree

src/interfaces/wallet.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ class Wallet
153153
WalletValueMap value_map,
154154
WalletOrderForm order_form) = 0;
155155

156+
virtual std::pair<unsigned int, bool> calculateDeniabilizationCycles(const COutPoint& outpoint) = 0;
157+
158+
virtual util::Result<CTransactionRef> createDeniabilizationTransaction(const std::set<COutPoint>& inputs,
159+
const std::optional<OutputType>& opt_output_type,
160+
unsigned int confirm_target,
161+
unsigned int deniabilization_cycles,
162+
bool sign,
163+
bool& insufficient_amount,
164+
CAmount& fee) = 0;
165+
156166
//! Return whether transaction can be abandoned.
157167
virtual bool transactionCanBeAbandoned(const uint256& txid) = 0;
158168

@@ -179,6 +189,13 @@ class Wallet
179189
std::vector<bilingual_str>& errors,
180190
uint256& bumped_txid) = 0;
181191

192+
//! Create a fee bump transaction for a deniabilization transaction
193+
virtual util::Result<CTransactionRef> createBumpDeniabilizationTransaction(const uint256& txid,
194+
unsigned int confirm_target,
195+
bool sign,
196+
CAmount& old_fee,
197+
CAmount& new_fee) = 0;
198+
182199
//! Get a transaction.
183200
virtual CTransactionRef getTx(const uint256& txid) = 0;
184201

@@ -250,6 +267,9 @@ class Wallet
250267
int* returned_target,
251268
FeeReason* reason) = 0;
252269

270+
//! Get the fee rate for deniabilization
271+
virtual CFeeRate getDeniabilizationFeeRate(unsigned int confirm_target) = 0;
272+
253273
//! Get tx confirm target.
254274
virtual unsigned int getConfirmTarget() = 0;
255275

src/rpc/client.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ static const CRPCConvertParam vRPCConvertParams[] =
162162
{ "walletcreatefundedpsbt", 3, "replaceable"},
163163
{ "walletcreatefundedpsbt", 3, "solving_data"},
164164
{ "walletcreatefundedpsbt", 4, "bip32derivs" },
165+
{ "walletdeniabilizecoin", 0, "inputs" },
166+
{ "walletdeniabilizecoin", 2, "conf_target" },
167+
{ "walletdeniabilizecoin", 3, "add_to_wallet" },
165168
{ "walletprocesspsbt", 1, "sign" },
166169
{ "walletprocesspsbt", 3, "bip32derivs" },
167170
{ "walletprocesspsbt", 4, "finalize" },

src/wallet/feebumper.cpp

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,5 +388,104 @@ Result CommitTransaction(CWallet& wallet, const uint256& txid, CMutableTransacti
388388
return Result::OK;
389389
}
390390

391+
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)
392+
{
393+
CCoinControl coin_control = SetupDeniabilizationCoinControl(confirm_target);
394+
coin_control.m_feerate = CalculateDeniabilizationFeeRate(wallet, confirm_target);
395+
396+
LOCK(wallet.cs_wallet);
397+
398+
auto it = wallet.mapWallet.find(txid);
399+
if (it == wallet.mapWallet.end()) {
400+
error = Untranslated("Invalid or non-wallet transaction id");
401+
return Result::INVALID_ADDRESS_OR_KEY;
402+
}
403+
const CWalletTx& wtx = it->second;
404+
405+
// Retrieve all of the UTXOs and add them to coin control
406+
// While we're here, calculate the input amount
407+
std::map<COutPoint, Coin> coins;
408+
CAmount input_value = 0;
409+
for (const CTxIn& txin : wtx.tx->vin) {
410+
coins[txin.prevout]; // Create empty map entry keyed by prevout.
411+
}
412+
wallet.chain().findCoins(coins);
413+
for (const CTxIn& txin : wtx.tx->vin) {
414+
const Coin& coin = coins.at(txin.prevout);
415+
if (coin.out.IsNull()) {
416+
error = Untranslated(strprintf("%s:%u is already spent", txin.prevout.hash.GetHex(), txin.prevout.n));
417+
return Result::MISC_ERROR;
418+
}
419+
if (!wallet.IsMine(txin.prevout)) {
420+
error = Untranslated("All inputs must be from our wallet.");
421+
return Result::MISC_ERROR;
422+
}
423+
coin_control.Select(txin.prevout);
424+
input_value += coin.out.nValue;
425+
}
426+
427+
std::vector<bilingual_str> dymmy_errors;
428+
Result result = PreconditionChecks(wallet, wtx, /*require_mine=*/true, dymmy_errors);
429+
if (result != Result::OK) {
430+
error = dymmy_errors.front();
431+
return result;
432+
}
433+
434+
// Calculate the old output amount.
435+
CAmount output_value = 0;
436+
for (const auto& old_output : wtx.tx->vout) {
437+
output_value += old_output.nValue;
438+
}
439+
440+
old_fee = input_value - output_value;
441+
442+
std::vector<CRecipient> recipients;
443+
for (const auto& output : wtx.tx->vout) {
444+
CTxDestination destination = CNoDestination();
445+
ExtractDestination(output.scriptPubKey, destination);
446+
CRecipient recipient = {destination, output.nValue, false};
447+
recipients.push_back(recipient);
448+
}
449+
// the last recipient gets the old fee
450+
recipients.back().nAmount += old_fee;
451+
// and pays the new fee
452+
recipients.back().fSubtractFeeFromAmount = true;
453+
// we don't expect to get change, but we provide the address to prevent CreateTransactionInternal from generating a change address
454+
coin_control.destChange = recipients.back().dest;
455+
456+
for (const auto& inputs : wtx.tx->vin) {
457+
coin_control.Select(COutPoint(inputs.prevout));
458+
}
459+
460+
constexpr int RANDOM_CHANGE_POSITION = -1;
461+
auto res = CreateTransaction(wallet, recipients, RANDOM_CHANGE_POSITION, coin_control, /*sign=*/false);
462+
if (!res) {
463+
error = util::ErrorString(res);
464+
return Result::WALLET_ERROR;
465+
}
466+
467+
// make sure we didn't get a change position assigned (we don't expect to use the channge address)
468+
Assert(res->change_pos == RANDOM_CHANGE_POSITION);
469+
470+
// spoof the transaction fingerprint to increase the transaction privacy
471+
{
472+
FastRandomContext rng_fast;
473+
CMutableTransaction spoofedTx(*res->tx);
474+
SpoofTransactionFingerprint(spoofedTx, rng_fast, coin_control.m_signal_bip125_rbf);
475+
if (sign && !wallet.SignTransaction(spoofedTx)) {
476+
error = Untranslated("Signing the deniabilization fee bump transaction failed.");
477+
return Result::MISC_ERROR;
478+
}
479+
// store the spoofed transaction in the result
480+
res->tx = MakeTransactionRef(std::move(spoofedTx));
481+
}
482+
483+
// write back the new fee
484+
new_fee = res->fee;
485+
// write back the transaction
486+
new_tx = res->tx;
487+
return Result::OK;
488+
}
489+
391490
} // namespace feebumper
392491
} // namespace wallet

src/wallet/feebumper.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ Result CommitTransaction(CWallet& wallet,
7272
std::vector<bilingual_str>& errors,
7373
uint256& bumped_txid);
7474

75+
Result CreateRateBumpDeniabilizationTransaction(CWallet& wallet,
76+
const uint256& txid,
77+
unsigned int confirm_target,
78+
bool sign,
79+
bilingual_str& error,
80+
CAmount& old_fee,
81+
CAmount& new_fee,
82+
CTransactionRef& new_tx);
83+
7584
struct SignatureWeights
7685
{
7786
private:

src/wallet/interfaces.cpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,28 @@ class WalletImpl : public Wallet
297297
LOCK(m_wallet->cs_wallet);
298298
m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form));
299299
}
300+
std::pair<unsigned int, bool> calculateDeniabilizationCycles(const COutPoint& outpoint) override
301+
{
302+
LOCK(m_wallet->cs_wallet); // TODO - Do we need a lock here?
303+
return CalculateDeniabilizationCycles(*m_wallet, outpoint);
304+
}
305+
util::Result<CTransactionRef> createDeniabilizationTransaction(const std::set<COutPoint>& inputs,
306+
const std::optional<OutputType>& opt_output_type,
307+
unsigned int confirm_target,
308+
unsigned int deniabilization_cycles,
309+
bool sign,
310+
bool& insufficient_amount,
311+
CAmount& fee) override
312+
{
313+
LOCK(m_wallet->cs_wallet); // TODO - Do we need a lock here?
314+
auto res = CreateDeniabilizationTransaction(*m_wallet, inputs, opt_output_type, confirm_target, deniabilization_cycles, sign, insufficient_amount);
315+
if (!res) {
316+
return util::Error{util::ErrorString(res)};
317+
}
318+
const auto& txr = *res;
319+
fee = txr.fee;
320+
return txr.tx;
321+
}
300322
bool transactionCanBeAbandoned(const uint256& txid) override { return m_wallet->TransactionCanBeAbandoned(txid); }
301323
bool abandonTransaction(const uint256& txid) override
302324
{
@@ -326,6 +348,20 @@ class WalletImpl : public Wallet
326348
return feebumper::CommitTransaction(*m_wallet.get(), txid, std::move(mtx), errors, bumped_txid) ==
327349
feebumper::Result::OK;
328350
}
351+
util::Result<CTransactionRef> createBumpDeniabilizationTransaction(const uint256& txid,
352+
unsigned int confirm_target,
353+
bool sign,
354+
CAmount& old_fee,
355+
CAmount& new_fee) override
356+
{
357+
bilingual_str error;
358+
CTransactionRef new_tx;
359+
auto res = feebumper::CreateRateBumpDeniabilizationTransaction(*m_wallet.get(), txid, confirm_target, sign, error, old_fee, new_fee, new_tx);
360+
if (res != feebumper::Result::OK) {
361+
return util::Error{error};
362+
}
363+
return new_tx;
364+
}
329365
CTransactionRef getTx(const uint256& txid) override
330366
{
331367
LOCK(m_wallet->cs_wallet);
@@ -508,6 +544,10 @@ class WalletImpl : public Wallet
508544
if (reason) *reason = fee_calc.reason;
509545
return result;
510546
}
547+
CFeeRate getDeniabilizationFeeRate(unsigned int confirm_target) override
548+
{
549+
return CalculateDeniabilizationFeeRate(*m_wallet, confirm_target);
550+
}
511551
unsigned int getConfirmTarget() override { return m_wallet->m_confirm_target; }
512552
bool hdEnabled() override { return m_wallet->IsHDEnabled(); }
513553
bool canGetAddresses() override { return m_wallet->CanGetAddresses(); }

src/wallet/rpc/spend.cpp

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,4 +1748,129 @@ RPCHelpMan walletcreatefundedpsbt()
17481748
},
17491749
};
17501750
}
1751+
1752+
// clang-format off
1753+
RPCHelpMan walletdeniabilizecoin()
1754+
{
1755+
return RPCHelpMan{"walletdeniabilizecoin",
1756+
"\nDeniabilize one or more UTXOs that share the same address.\n",
1757+
{
1758+
{"inputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Specify inputs (must share the same address). A JSON array of JSON objects",
1759+
{
1760+
{"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id"},
1761+
{"vout", RPCArg::Type::NUM, RPCArg::Optional::NO, "The output number"},
1762+
},
1763+
},
1764+
{"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."},
1765+
{"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"},
1766+
{"add_to_wallet", RPCArg::Type::BOOL, RPCArg::Default{true}, "When false, returns the serialized transaction without broadcasting or adding it to the wallet"},
1767+
},
1768+
RPCResult{
1769+
RPCResult::Type::OBJ, "", "",
1770+
{
1771+
{RPCResult::Type::STR_HEX, "txid", "The deniabilization transaction id."},
1772+
{RPCResult::Type::STR_AMOUNT, "fee", "The fee used in the deniabilization transaction."},
1773+
{RPCResult::Type::STR_HEX, "hex", /*optional=*/true, "If add_to_wallet is false, the hex-encoded raw transaction with signature(s)"},
1774+
}
1775+
},
1776+
RPCExamples{
1777+
"\nDeniabilize a single UTXO\n"
1778+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]'") +
1779+
"\nDeniabilize a single UTXO using a specific output type\n"
1780+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' bech32") +
1781+
"\nDeniabilize a single UTXO with an explicit confirmation target\n"
1782+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' null 144") +
1783+
"\nDeniabilize a single UTXO without broadcasting the transaction\n"
1784+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' null 6 false")
1785+
},
1786+
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
1787+
{
1788+
std::shared_ptr<CWallet> const pwallet = GetWalletForJSONRPCRequest(request);
1789+
if (!pwallet) return UniValue::VNULL;
1790+
1791+
std::optional<CScript> shared_script;
1792+
std::set<COutPoint> inputs;
1793+
unsigned int deniabilization_cycles = UINT_MAX;
1794+
for (const UniValue& input : request.params[0].get_array().getValues()) {
1795+
uint256 txid = ParseHashO(input, "txid");
1796+
1797+
const UniValue& vout_v = input.find_value("vout");
1798+
if (!vout_v.isNum()) {
1799+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, missing vout key");
1800+
}
1801+
int nOutput = vout_v.getInt<int>();
1802+
if (nOutput < 0) {
1803+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, vout cannot be negative");
1804+
}
1805+
1806+
COutPoint outpoint(txid, nOutput);
1807+
LOCK(pwallet->cs_wallet);
1808+
auto walletTx = pwallet->GetWalletTx(outpoint.hash);
1809+
if (!walletTx) {
1810+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, txid not found in wallet.");
1811+
}
1812+
if (outpoint.n >= walletTx->tx->vout.size()) {
1813+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, vout is out of range");
1814+
}
1815+
const auto& output = walletTx->tx->vout[outpoint.n];
1816+
1817+
isminetype mine = pwallet->IsMine(output);
1818+
if (mine == ISMINE_NO) {
1819+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, transaction's output doesn't belong to this wallet.");
1820+
}
1821+
1822+
bool spendable = (mine & ISMINE_SPENDABLE) != ISMINE_NO;
1823+
if (spendable) {
1824+
auto script = FindNonChangeParentOutput(*pwallet, outpoint).scriptPubKey;
1825+
if (!shared_script) {
1826+
shared_script = script;
1827+
}
1828+
else if (!(*shared_script == script)) {
1829+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must share the same address");
1830+
}
1831+
} else {
1832+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must be spendable and have a valid address");
1833+
}
1834+
1835+
inputs.emplace(outpoint);
1836+
auto cycles_res = CalculateDeniabilizationCycles(*pwallet, outpoint);
1837+
deniabilization_cycles = std::min(deniabilization_cycles, cycles_res.first);
1838+
}
1839+
1840+
if (inputs.empty()) {
1841+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must not be empty");
1842+
}
1843+
1844+
std::optional<OutputType> opt_output_type = !request.params[1].isNull() ? ParseOutputType(request.params[1].get_str()) : std::nullopt;
1845+
unsigned int confirm_target = !request.params[2].isNull() ? request.params[2].getInt<unsigned int>() : pwallet->m_confirm_target;
1846+
const bool add_to_wallet = !request.params[3].isNull() ? request.params[3].get_bool() : true;
1847+
1848+
CTransactionRef tx;
1849+
CAmount tx_fee = 0;
1850+
{
1851+
bool sign = !pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS);
1852+
bool insufficient_amount = false;
1853+
auto res = CreateDeniabilizationTransaction(*pwallet, inputs, opt_output_type, confirm_target, deniabilization_cycles, sign, insufficient_amount);
1854+
if (!res) {
1855+
throw JSONRPCError(RPC_TRANSACTION_ERROR, ErrorString(res).original);
1856+
}
1857+
tx = res->tx;
1858+
tx_fee = res->fee;
1859+
}
1860+
1861+
UniValue result(UniValue::VOBJ);
1862+
result.pushKV("txid", tx->GetHash().GetHex());
1863+
if (add_to_wallet) {
1864+
pwallet->CommitTransaction(tx, {}, /*orderForm=*/{});
1865+
} else {
1866+
std::string hex{EncodeHexTx(*tx)};
1867+
result.pushKV("hex", hex);
1868+
}
1869+
result.pushKV("fee", ValueFromAmount(tx_fee));
1870+
return result;
1871+
}
1872+
};
1873+
}
1874+
// clang-format on
1875+
17511876
} // namespace wallet

src/wallet/rpc/wallet.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,7 @@ RPCHelpMan send();
866866
RPCHelpMan sendall();
867867
RPCHelpMan walletprocesspsbt();
868868
RPCHelpMan walletcreatefundedpsbt();
869+
RPCHelpMan walletdeniabilizecoin();
869870
RPCHelpMan signrawtransactionwithwallet();
870871

871872
// signmessage
@@ -953,6 +954,7 @@ Span<const CRPCCommand> GetWalletRPCCommands()
953954
{"wallet", &walletpassphrase},
954955
{"wallet", &walletpassphrasechange},
955956
{"wallet", &walletprocesspsbt},
957+
{"wallet", &walletdeniabilizecoin},
956958
};
957959
return commands;
958960
}

0 commit comments

Comments
 (0)