Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/wallet/rpc/spend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ static UniValue FinishTransaction(const std::shared_ptr<CWallet> pwallet, const
CTransactionRef tx(MakeTransactionRef(std::move(mtx)));
result.pushKV("txid", tx->GetHash().GetHex());
if (add_to_wallet && !psbt_opt_in) {
pwallet->CommitTransaction(tx, {}, /*orderForm*/ {});
pwallet->CommitTransaction(tx, options["use_cj"].isTrue() ? mapValue_t{{"DS", "1"}} : mapValue_t{}, /*orderForm*/ {});
} else {
result.pushKV("hex", hex);
}
Expand Down Expand Up @@ -545,6 +545,7 @@ void FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& fee_out,
{"conf_target", UniValueType(UniValue::VNUM)},
{"estimate_mode", UniValueType(UniValue::VSTR)},
{"input_sizes", UniValueType(UniValue::VARR)},
{"use_cj", UniValueType(UniValue::VBOOL)},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
true, true);

Expand Down Expand Up @@ -595,6 +596,19 @@ void FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& fee_out,
if (options.exists("subtractFeeFromOutputs") || options.exists("subtract_fee_from_outputs") )
subtractFeeFromOutputs = (options.exists("subtract_fee_from_outputs") ? options["subtract_fee_from_outputs"] : options["subtractFeeFromOutputs"]).get_array();

if (options.exists("use_cj")) {
coinControl.UseCoinJoin(options["use_cj"].get_bool());
if (coinControl.IsUsingCoinJoin()) {
// Preset inputs stay in the transaction even though coin selection
// ignores non-mixed ones, so reject them here instead
for (const CTxIn& txin : tx.vin) {
if (!wallet.IsFullyMixed(txin.prevout)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Input not available. UTXO (%s:%d) is not fully mixed.", txin.prevout.hash.ToString(), txin.prevout.n));
}
}
}
}
Comment on lines +599 to +610

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject non-mixed preset inputs when enabling use_cj

Setting options.use_cj=true here is unsafe for fundrawtransaction (and send when add_inputs=true) if callers provide preselected non-mixed inputs: SelectCoins skips those preset inputs under ONLY_FULLY_MIXED (in src/wallet/spend.cpp), but the RPC funding flow later keeps the original tx.vin entries when merging the funded transaction, so those non-CoinJoin inputs are still spent while fee/change were computed as if they were absent. In that path users can unintentionally spend non-CoinJoin funds and overpay fees, so this option should reject incompatible preset inputs instead of silently proceeding.

Useful? React with 👍 / 👎.


SetFeeEstimateMode(wallet, coinControl, options["conf_target"], options["estimate_mode"], options["fee_rate"], override_min_fee);
}
} else {
Expand Down Expand Up @@ -776,6 +790,7 @@ RPCHelpMan fundrawtransaction()
},
},
},
{"use_cj", RPCArg::Type::BOOL, RPCArg::Default{false}, "Use CoinJoin funds only"},
},
FundTxDoc()),
RPCArgOptions{.oneline_description="options"}},
Expand Down Expand Up @@ -982,6 +997,7 @@ RPCHelpMan send()
{"vout_index", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "The zero-based output index, before a change output is added."},
},
},
{"use_cj", RPCArg::Type::BOOL, RPCArg::Default{false}, "Use CoinJoin funds only"},
},
FundTxDoc()),
RPCArgOptions{.oneline_description="options"}},
Expand Down Expand Up @@ -1088,6 +1104,7 @@ RPCHelpMan sendall()
{"lock_unspents", RPCArg::Type::BOOL, RPCArg::Default{false}, "Lock selected unspent outputs"},
{"psbt", RPCArg::Type::BOOL, RPCArg::DefaultHint{"automatic"}, "Always return a PSBT, implies add_to_wallet=false."},
{"send_max", RPCArg::Type::BOOL, RPCArg::Default{false}, "When true, only use UTXOs that can pay for their own fees to maximize the output amount. When 'false' (default), no UTXO is left behind. send_max is incompatible with providing specific inputs."},
{"use_cj", RPCArg::Type::BOOL, RPCArg::Default{false}, "Use CoinJoin funds only"},
},
FundTxDoc()
),
Expand Down Expand Up @@ -1162,6 +1179,10 @@ RPCHelpMan sendall()

coin_control.fAllowWatchOnly = ParseIncludeWatchonly(options["include_watching"], *pwallet);

if (options.exists("use_cj")) {
coin_control.UseCoinJoin(options["use_cj"].get_bool());
}
Comment on lines +1182 to +1184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce use_cj for manually specified sendall inputs

When sendall is called with both options.use_cj=true and options.inputs, this new flag does not actually constrain the provided inputs to fully mixed coins: the inputs branch later accepts any wallet UTXO and skips the AvailableCoins(..., &coin_control, ...) filtering path. In this scenario, callers can unintentionally spend non-CoinJoin funds despite opting into CoinJoin-only behavior, so this should either validate each specified input as fully mixed or reject the combination as incompatible.

Useful? React with 👍 / 👎.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

FeeCalculation fee_calc_out;
CFeeRate fee_rate{GetMinimumFeeRate(*pwallet, coin_control, &fee_calc_out)};
// Do not, ever, assume that it's fine to change the fee rate if the user has explicitly
Expand Down Expand Up @@ -1190,6 +1211,9 @@ RPCHelpMan sendall()
if (!tx || input.prevout.n >= tx->tx->vout.size() || !(pwallet->IsMine(tx->tx->vout[input.prevout.n]) & (coin_control.fAllowWatchOnly ? ISMINE_ALL : ISMINE_SPENDABLE))) {
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Input not found. UTXO (%s:%d) is not part of wallet.", input.prevout.hash.ToString(), input.prevout.n));
}
if (coin_control.IsUsingCoinJoin() && !pwallet->IsFullyMixed(input.prevout)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Input not available. UTXO (%s:%d) is not fully mixed.", input.prevout.hash.ToString(), input.prevout.n));
}
total_input_value += tx->tx->vout[input.prevout.n].nValue;
}
} else {
Expand Down Expand Up @@ -1418,6 +1442,7 @@ RPCHelpMan walletcreatefundedpsbt()
{"vout_index", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "The zero-based output index, before a change output is added."},
},
},
{"use_cj", RPCArg::Type::BOOL, RPCArg::Default{false}, "Use CoinJoin funds only"},
},
FundTxDoc()),
RPCArgOptions{.oneline_description="options"}},
Expand Down
33 changes: 33 additions & 0 deletions test/functional/rpc_coinjoin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import random

from test_framework.test_framework import BitcoinTestFramework
from test_framework.blocktools import COINBASE_MATURITY
from test_framework.messages import (
COIN,
MAX_MONEY,
Expand Down Expand Up @@ -55,6 +56,12 @@ def run_test(self):
self.test_coinjoinsalt(w1)
w1.unloadwallet()

node.createwallet(wallet_name='w3')
w3 = node.get_wallet_rpc('w3')
self.generatetoaddress(node, COINBASE_MATURITY + 1, w3.getnewaddress())
self.test_use_cj_option(w3)
w3.unloadwallet()

if not self.options.descriptors:
node.createwallet(wallet_name='w_keypool', blank=False, disable_private_keys=False)
w_keypool = node.get_wallet_rpc('w_keypool')
Expand Down Expand Up @@ -118,6 +125,32 @@ def test_newkeypool_stops_mixing(self, node):
node.newkeypool()
assert_equal(node.getcoinjoininfo()['running'], False)

def test_use_cj_option(self, node):
self.log.info('"use_cj" option should spend fully mixed coins only')
addr = node.getnewaddress()
utxo = node.listunspent()[0]
input_ref = {'txid': utxo['txid'], 'vout': utxo['vout']}

# Automatic coin selection should find no fully mixed coins in this wallet
assert_raises_rpc_error(-4, 'Unable to locate enough mixed funds for this transaction.',
node.send, outputs={addr: 1}, options={'use_cj': True})
assert_raises_rpc_error(-6, 'Total value of UTXO pool too low to pay for transaction.',
node.sendall, recipients=[addr], options={'use_cj': True})

# Preset non-mixed inputs should be rejected instead of silently spent
not_mixed_error = f"Input not available. UTXO ({utxo['txid']}:{utxo['vout']}) is not fully mixed."
raw_tx = node.createrawtransaction([input_ref], {addr: 1})
assert_raises_rpc_error(-8, not_mixed_error, node.fundrawtransaction, raw_tx, {'use_cj': True})
assert_raises_rpc_error(-8, not_mixed_error, node.walletcreatefundedpsbt,
[input_ref], {addr: 1}, 0, {'use_cj': True})
assert_raises_rpc_error(-8, not_mixed_error, node.send,
outputs={addr: 1}, options={'inputs': [input_ref], 'use_cj': True})
assert_raises_rpc_error(-8, not_mixed_error, node.sendall,
recipients=[addr], options={'inputs': [input_ref], 'use_cj': True})

# The same input is spendable once "use_cj" is not requested
assert_equal(node.sendall(recipients=[addr], options={'inputs': [input_ref]})['complete'], True)

def test_setcoinjoinamount(self, node):
self.log.info('"setcoinjoinamount" should update mixing target')
# Test normal and large values
Expand Down
Loading