Skip to content

Commit 38119b9

Browse files
committed
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). ----- Implemented CalculateDeniabilizationFeeEstimate and CalculateDeniabilizationCycles as non-recursive functions.
1 parent 93e4824 commit 38119b9

File tree

10 files changed

+885
-0
lines changed

10 files changed

+885
-0
lines changed

src/interfaces/wallet.h

+20
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,16 @@ class Wallet
158158
WalletValueMap value_map,
159159
WalletOrderForm order_form) = 0;
160160

161+
virtual std::pair<unsigned int, bool> calculateDeniabilizationCycles(const COutPoint& outpoint) = 0;
162+
163+
virtual util::Result<CTransactionRef> createDeniabilizationTransaction(const std::set<COutPoint>& inputs,
164+
const std::optional<OutputType>& opt_output_type,
165+
unsigned int confirm_target,
166+
unsigned int deniabilization_cycles,
167+
bool sign,
168+
bool& insufficient_amount,
169+
CAmount& fee) = 0;
170+
161171
//! Return whether transaction can be abandoned.
162172
virtual bool transactionCanBeAbandoned(const uint256& txid) = 0;
163173

@@ -184,6 +194,13 @@ class Wallet
184194
std::vector<bilingual_str>& errors,
185195
uint256& bumped_txid) = 0;
186196

197+
//! Create a fee bump transaction for a deniabilization transaction
198+
virtual util::Result<CTransactionRef> createBumpDeniabilizationTransaction(const uint256& txid,
199+
unsigned int confirm_target,
200+
bool sign,
201+
CAmount& old_fee,
202+
CAmount& new_fee) = 0;
203+
187204
//! Get a transaction.
188205
virtual CTransactionRef getTx(const uint256& txid) = 0;
189206

@@ -255,6 +272,9 @@ class Wallet
255272
int* returned_target,
256273
FeeReason* reason) = 0;
257274

275+
//! Get the fee rate for deniabilization
276+
virtual CFeeRate getDeniabilizationFeeRate(unsigned int confirm_target) = 0;
277+
258278
//! Get tx confirm target.
259279
virtual unsigned int getConfirmTarget() = 0;
260280

src/rpc/client.cpp

+3
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ static const CRPCConvertParam vRPCConvertParams[] =
167167
{ "walletcreatefundedpsbt", 3, "solving_data"},
168168
{ "walletcreatefundedpsbt", 3, "max_tx_weight"},
169169
{ "walletcreatefundedpsbt", 4, "bip32derivs" },
170+
{ "walletdeniabilizecoin", 0, "inputs" },
171+
{ "walletdeniabilizecoin", 2, "conf_target" },
172+
{ "walletdeniabilizecoin", 3, "add_to_wallet" },
170173
{ "walletprocesspsbt", 1, "sign" },
171174
{ "walletprocesspsbt", 3, "bip32derivs" },
172175
{ "walletprocesspsbt", 4, "finalize" },

src/wallet/feebumper.cpp

+98
Original file line numberDiff line numberDiff line change
@@ -387,5 +387,103 @@ Result CommitTransaction(CWallet& wallet, const uint256& txid, CMutableTransacti
387387
return Result::OK;
388388
}
389389

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

src/wallet/feebumper.h

+9
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

+40
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,28 @@ class WalletImpl : public Wallet
299299
LOCK(m_wallet->cs_wallet);
300300
m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form));
301301
}
302+
std::pair<unsigned int, bool> calculateDeniabilizationCycles(const COutPoint& outpoint) override
303+
{
304+
LOCK(m_wallet->cs_wallet); // TODO - Do we need a lock here?
305+
return CalculateDeniabilizationCycles(*m_wallet, outpoint);
306+
}
307+
util::Result<CTransactionRef> createDeniabilizationTransaction(const std::set<COutPoint>& inputs,
308+
const std::optional<OutputType>& opt_output_type,
309+
unsigned int confirm_target,
310+
unsigned int deniabilization_cycles,
311+
bool sign,
312+
bool& insufficient_amount,
313+
CAmount& fee) override
314+
{
315+
LOCK(m_wallet->cs_wallet); // TODO - Do we need a lock here?
316+
auto res = CreateDeniabilizationTransaction(*m_wallet, inputs, opt_output_type, confirm_target, deniabilization_cycles, sign, insufficient_amount);
317+
if (!res) {
318+
return util::Error{util::ErrorString(res)};
319+
}
320+
const auto& txr = *res;
321+
fee = txr.fee;
322+
return txr.tx;
323+
}
302324
bool transactionCanBeAbandoned(const uint256& txid) override { return m_wallet->TransactionCanBeAbandoned(txid); }
303325
bool abandonTransaction(const uint256& txid) override
304326
{
@@ -328,6 +350,20 @@ class WalletImpl : public Wallet
328350
return feebumper::CommitTransaction(*m_wallet.get(), txid, std::move(mtx), errors, bumped_txid) ==
329351
feebumper::Result::OK;
330352
}
353+
util::Result<CTransactionRef> createBumpDeniabilizationTransaction(const uint256& txid,
354+
unsigned int confirm_target,
355+
bool sign,
356+
CAmount& old_fee,
357+
CAmount& new_fee) override
358+
{
359+
bilingual_str error;
360+
CTransactionRef new_tx;
361+
auto res = feebumper::CreateRateBumpDeniabilizationTransaction(*m_wallet.get(), txid, confirm_target, sign, error, old_fee, new_fee, new_tx);
362+
if (res != feebumper::Result::OK) {
363+
return util::Error{error};
364+
}
365+
return new_tx;
366+
}
331367
CTransactionRef getTx(const uint256& txid) override
332368
{
333369
LOCK(m_wallet->cs_wallet);
@@ -510,6 +546,10 @@ class WalletImpl : public Wallet
510546
if (reason) *reason = fee_calc.reason;
511547
return result;
512548
}
549+
CFeeRate getDeniabilizationFeeRate(unsigned int confirm_target) override
550+
{
551+
return CalculateDeniabilizationFeeRate(*m_wallet, confirm_target);
552+
}
513553
unsigned int getConfirmTarget() override { return m_wallet->m_confirm_target; }
514554
bool hdEnabled() override { return m_wallet->IsHDEnabled(); }
515555
bool canGetAddresses() override { return m_wallet->CanGetAddresses(); }

src/wallet/rpc/spend.cpp

+125
Original file line numberDiff line numberDiff line change
@@ -1784,4 +1784,129 @@ RPCHelpMan walletcreatefundedpsbt()
17841784
},
17851785
};
17861786
}
1787+
1788+
// clang-format off
1789+
RPCHelpMan walletdeniabilizecoin()
1790+
{
1791+
return RPCHelpMan{"walletdeniabilizecoin",
1792+
"\nDeniabilize one or more UTXOs that share the same address.\n",
1793+
{
1794+
{"inputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Specify inputs (must share the same address). A JSON array of JSON objects",
1795+
{
1796+
{"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id"},
1797+
{"vout", RPCArg::Type::NUM, RPCArg::Optional::NO, "The output number"},
1798+
},
1799+
},
1800+
{"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."},
1801+
{"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"},
1802+
{"add_to_wallet", RPCArg::Type::BOOL, RPCArg::Default{true}, "When false, returns the serialized transaction without broadcasting or adding it to the wallet"},
1803+
},
1804+
RPCResult{
1805+
RPCResult::Type::OBJ, "", "",
1806+
{
1807+
{RPCResult::Type::STR_HEX, "txid", "The deniabilization transaction id."},
1808+
{RPCResult::Type::STR_AMOUNT, "fee", "The fee used in the deniabilization transaction."},
1809+
{RPCResult::Type::STR_HEX, "hex", /*optional=*/true, "If add_to_wallet is false, the hex-encoded raw transaction with signature(s)"},
1810+
}
1811+
},
1812+
RPCExamples{
1813+
"\nDeniabilize a single UTXO\n"
1814+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]'") +
1815+
"\nDeniabilize a single UTXO using a specific output type\n"
1816+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' bech32") +
1817+
"\nDeniabilize a single UTXO with an explicit confirmation target\n"
1818+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' null 144") +
1819+
"\nDeniabilize a single UTXO without broadcasting the transaction\n"
1820+
+ HelpExampleCli("walletdeniabilizecoin", "'[{\"txid\":\"4c14d20709daef476854fe7ef75bdfcfd5a7636a431b4622ec9481f297e12e8c\", \"vout\": 0}]' null 6 false")
1821+
},
1822+
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
1823+
{
1824+
std::shared_ptr<CWallet> const pwallet = GetWalletForJSONRPCRequest(request);
1825+
if (!pwallet) return UniValue::VNULL;
1826+
1827+
std::optional<CScript> shared_script;
1828+
std::set<COutPoint> inputs;
1829+
unsigned int deniabilization_cycles = UINT_MAX;
1830+
for (const UniValue& input : request.params[0].get_array().getValues()) {
1831+
Txid txid = Txid::FromUint256(ParseHashO(input, "txid"));
1832+
1833+
const UniValue& vout_v = input.find_value("vout");
1834+
if (!vout_v.isNum()) {
1835+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, missing vout key");
1836+
}
1837+
int nOutput = vout_v.getInt<int>();
1838+
if (nOutput < 0) {
1839+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, vout cannot be negative");
1840+
}
1841+
1842+
COutPoint outpoint(txid, nOutput);
1843+
LOCK(pwallet->cs_wallet);
1844+
auto walletTx = pwallet->GetWalletTx(outpoint.hash);
1845+
if (!walletTx) {
1846+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, txid not found in wallet.");
1847+
}
1848+
if (outpoint.n >= walletTx->tx->vout.size()) {
1849+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, vout is out of range");
1850+
}
1851+
const auto& output = walletTx->tx->vout[outpoint.n];
1852+
1853+
isminetype mine = pwallet->IsMine(output);
1854+
if (mine == ISMINE_NO) {
1855+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, transaction's output doesn't belong to this wallet.");
1856+
}
1857+
1858+
bool spendable = (mine & ISMINE_SPENDABLE) != ISMINE_NO;
1859+
if (spendable) {
1860+
auto script = FindNonChangeParentOutput(*pwallet, outpoint).scriptPubKey;
1861+
if (!shared_script) {
1862+
shared_script = script;
1863+
}
1864+
else if (!(*shared_script == script)) {
1865+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must share the same address");
1866+
}
1867+
} else {
1868+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must be spendable and have a valid address");
1869+
}
1870+
1871+
inputs.emplace(outpoint);
1872+
auto cycles_res = CalculateDeniabilizationCycles(*pwallet, outpoint);
1873+
deniabilization_cycles = std::min(deniabilization_cycles, cycles_res.first);
1874+
}
1875+
1876+
if (inputs.empty()) {
1877+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs must not be empty");
1878+
}
1879+
1880+
std::optional<OutputType> opt_output_type = !request.params[1].isNull() ? ParseOutputType(request.params[1].get_str()) : std::nullopt;
1881+
unsigned int confirm_target = !request.params[2].isNull() ? request.params[2].getInt<unsigned int>() : pwallet->m_confirm_target;
1882+
const bool add_to_wallet = !request.params[3].isNull() ? request.params[3].get_bool() : true;
1883+
1884+
CTransactionRef tx;
1885+
CAmount tx_fee = 0;
1886+
{
1887+
bool sign = !pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS);
1888+
bool insufficient_amount = false;
1889+
auto res = CreateDeniabilizationTransaction(*pwallet, inputs, opt_output_type, confirm_target, deniabilization_cycles, sign, insufficient_amount);
1890+
if (!res) {
1891+
throw JSONRPCError(RPC_TRANSACTION_ERROR, ErrorString(res).original);
1892+
}
1893+
tx = res->tx;
1894+
tx_fee = res->fee;
1895+
}
1896+
1897+
UniValue result(UniValue::VOBJ);
1898+
result.pushKV("txid", tx->GetHash().GetHex());
1899+
if (add_to_wallet) {
1900+
pwallet->CommitTransaction(tx, {}, /*orderForm=*/{});
1901+
} else {
1902+
std::string hex{EncodeHexTx(*tx)};
1903+
result.pushKV("hex", hex);
1904+
}
1905+
result.pushKV("fee", ValueFromAmount(tx_fee));
1906+
return result;
1907+
}
1908+
};
1909+
}
1910+
// clang-format on
1911+
17871912
} // namespace wallet

src/wallet/rpc/wallet.cpp

+2
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,7 @@ RPCHelpMan send();
10831083
RPCHelpMan sendall();
10841084
RPCHelpMan walletprocesspsbt();
10851085
RPCHelpMan walletcreatefundedpsbt();
1086+
RPCHelpMan walletdeniabilizecoin();
10861087
RPCHelpMan signrawtransactionwithwallet();
10871088

10881089
// signmessage
@@ -1172,6 +1173,7 @@ Span<const CRPCCommand> GetWalletRPCCommands()
11721173
{"wallet", &walletpassphrase},
11731174
{"wallet", &walletpassphrasechange},
11741175
{"wallet", &walletprocesspsbt},
1176+
{"wallet", &walletdeniabilizecoin},
11751177
};
11761178
return commands;
11771179
}

0 commit comments

Comments
 (0)