From 99a818c698c90bb9fb333c1f155b9985a8037674 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Tue, 31 Mar 2026 00:08:09 +0700 Subject: [PATCH] feat: add use_cj feature to RPCs send, sendall and fundrawtransaction --- src/wallet/rpc/spend.cpp | 27 ++++++++++++++++++++++++++- test/functional/rpc_coinjoin.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp index 90f25dc2f154..f79a1406ff72 100644 --- a/src/wallet/rpc/spend.cpp +++ b/src/wallet/rpc/spend.cpp @@ -129,7 +129,7 @@ static UniValue FinishTransaction(const std::shared_ptr 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); } @@ -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)}, }, true, true); @@ -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)); + } + } + } + } + SetFeeEstimateMode(wallet, coinControl, options["conf_target"], options["estimate_mode"], options["fee_rate"], override_min_fee); } } else { @@ -776,6 +790,7 @@ RPCHelpMan fundrawtransaction() }, }, }, + {"use_cj", RPCArg::Type::BOOL, RPCArg::Default{false}, "Use CoinJoin funds only"}, }, FundTxDoc()), RPCArgOptions{.oneline_description="options"}}, @@ -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"}}, @@ -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() ), @@ -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()); + } + 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 @@ -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 { @@ -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"}}, diff --git a/test/functional/rpc_coinjoin.py b/test/functional/rpc_coinjoin.py index b5e11850f2fe..52cf35b8b30f 100755 --- a/test/functional/rpc_coinjoin.py +++ b/test/functional/rpc_coinjoin.py @@ -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, @@ -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') @@ -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