From 482a7d7547dc521c111b9c479e3399a87668c2fe Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 00:17:45 +0000 Subject: [PATCH 1/2] beta7: z_sendmany optional `inputs` coin-control (transparent + sapling + sprout) Add an OPTIONAL 5th param "inputs" to z_sendmany that restricts the spend to exactly the listed UTXOs/notes. NON-CONSENSUS: this only narrows which already-valid inputs the wallet may select; tx/note format, validation, PoW, change routing, fee, dust, coinbase and turnstile logic are untouched. - rpcwallet.cpp: raise the param bound to 5, document "inputs" + a privacy warning, parse typed transparent/sapling/sprout input objects, validate txid (64-hex), jsoutindex range before narrowing to uint8_t, require inputs match the from-address pool and reject mixing transparent with shielded. - asyncrpcoperation_sendmany: carry pinned sets + useInputSelection_ flag. find_utxos() drops unpinned UTXOs and consumes ALL pinned (no early-break), keeping the insufficiency check + dust-change guard. find_unspent_notes() filters sapling/sprout notes to the pinned sets. Throws pre-send if a pinned input is not in the spendable set. Per-input zrpcunsafe logs gain " [PINNED]". - gtest test_coincontrol.cpp: only-pinned selection, absent-pin detection, insufficient pinned subset, and constructor wiring (default = disabled). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Makefile.gtest.include | 1 + src/wallet/asyncrpcoperation_sendmany.cpp | 105 +++++++- src/wallet/asyncrpcoperation_sendmany.h | 35 ++- src/wallet/gtest/test_coincontrol.cpp | 283 ++++++++++++++++++++++ src/wallet/rpcwallet.cpp | 104 +++++++- 5 files changed, 515 insertions(+), 13 deletions(-) create mode 100644 src/wallet/gtest/test_coincontrol.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 07d4090b802..4be91f29abc 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -48,6 +48,7 @@ zcash_gtest_SOURCES += \ if ENABLE_WALLET zcash_gtest_SOURCES += \ wallet/gtest/test_paymentdisclosure.cpp \ + wallet/gtest/test_coincontrol.cpp \ wallet/gtest/test_wallet.cpp endif diff --git a/src/wallet/asyncrpcoperation_sendmany.cpp b/src/wallet/asyncrpcoperation_sendmany.cpp index 2bfb4bdbbc4..0b18d460a8e 100644 --- a/src/wallet/asyncrpcoperation_sendmany.cpp +++ b/src/wallet/asyncrpcoperation_sendmany.cpp @@ -63,8 +63,13 @@ AsyncRPCOperation_sendmany::AsyncRPCOperation_sendmany( std::vector zOutputs, int minDepth, CAmount fee, - UniValue contextInfo) : - tx_(contextualTx), fromaddress_(fromAddress), t_outputs_(tOutputs), z_outputs_(zOutputs), mindepth_(minDepth), fee_(fee), contextinfo_(contextInfo) + UniValue contextInfo, + bool useInputSelection, + std::set pinnedTransparent, + std::set pinnedSapling, + std::set pinnedSprout) : + tx_(contextualTx), fromaddress_(fromAddress), t_outputs_(tOutputs), z_outputs_(zOutputs), mindepth_(minDepth), fee_(fee), contextinfo_(contextInfo), + useInputSelection_(useInputSelection), pinnedTransparent_(pinnedTransparent), pinnedSapling_(pinnedSapling), pinnedSprout_(pinnedSprout) { assert(fee_ >= 0); @@ -296,10 +301,19 @@ bool AsyncRPCOperation_sendmany::main_impl() { } selectedUTXOAmount += std::get<2>(t); selectedTInputs.push_back(t); + LogPrint("zrpcunsafe", "%s: spending utxo (txid=%s, vout=%d, amount=%s, coinbase=%d)%s\n", + getId(), + std::get<0>(t).ToString().substr(0, 10), + std::get<1>(t), + FormatMoney(std::get<2>(t)), + (int)std::get<3>(t), + (useInputSelection_ && pinnedTransparent_.count(COutPoint(std::get<0>(t), std::get<1>(t)))) ? " [PINNED]" : ""); if (selectedUTXOAmount >= targetAmount) { // Select another utxo if there is change less than the dust threshold. dustChange = selectedUTXOAmount - targetAmount; - if (dustChange == 0 || dustChange >= dustThreshold) { + // When pinning inputs (coin-control), consume ALL pinned UTXOs + // and never early-break; the dust-change guard below still applies. + if (!useInputSelection_ && (dustChange == 0 || dustChange >= dustThreshold)) { break; } } @@ -790,14 +804,15 @@ bool AsyncRPCOperation_sendmany::main_impl() { wtxHeight = mapBlockIndex[wtx.hashBlock]->nHeight; wtxDepth = wtx.GetDepthInMainChain(); } - LogPrint("zrpcunsafe", "%s: spending note (txid=%s, vjoinsplit=%d, ciphertext=%d, amount=%s, height=%d, confirmations=%d)\n", + LogPrint("zrpcunsafe", "%s: spending note (txid=%s, vjoinsplit=%d, ciphertext=%d, amount=%s, height=%d, confirmations=%d)%s\n", getId(), jso.hash.ToString().substr(0, 10), jso.js, int(jso.n), // uint8_t FormatMoney(noteFunds), wtxHeight, - wtxDepth + wtxDepth, + (useInputSelection_ && pinnedSprout_.count(jso)) ? " [PINNED]" : "" ); } @@ -1025,6 +1040,32 @@ bool AsyncRPCOperation_sendmany::find_utxos(bool fAcceptCoinbase=false) { t_inputs_.push_back(utxo); } + // Coin-control: restrict to exactly the pinned transparent UTXOs. + // NON-CONSENSUS: only narrows which already-valid inputs we may select. + if (useInputSelection_) { + // Build the set of available outpoints for membership / presence checks. + std::set available; + for (const SendManyInputUTXO & t : t_inputs_) { + available.insert(COutPoint(std::get<0>(t), std::get<1>(t))); + } + // Every pinned transparent input must be present in the spendable set. + for (const COutPoint & op : pinnedTransparent_) { + if (!available.count(op)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("Pinned transparent input not available (spendable, confirmed, owned by from-address): %s:%d", + op.hash.ToString(), op.n)); + } + } + // Drop any UTXO that was not pinned. + std::vector filtered; + for (const SendManyInputUTXO & t : t_inputs_) { + if (pinnedTransparent_.count(COutPoint(std::get<0>(t), std::get<1>(t)))) { + filtered.push_back(t); + } + } + t_inputs_ = filtered; + } + // sort in ascending order, so smaller utxos appear first std::sort(t_inputs_.begin(), t_inputs_.end(), [](SendManyInputUTXO i, SendManyInputUTXO j) -> bool { return ( std::get<2>(i) < std::get<2>(j)); @@ -1054,25 +1095,71 @@ bool AsyncRPCOperation_sendmany::find_unspent_notes() { for (CSproutNotePlaintextEntry & entry : sproutEntries) { z_sprout_inputs_.push_back(SendManyInputJSOP(entry.jsop, entry.plaintext.note(boost::get(frompaymentaddress_)), CAmount(entry.plaintext.value()))); std::string data(entry.plaintext.memo().begin(), entry.plaintext.memo().end()); - LogPrint("zrpcunsafe", "%s: found unspent Sprout note (txid=%s, vjoinsplit=%d, ciphertext=%d, amount=%s, memo=%s)\n", + LogPrint("zrpcunsafe", "%s: found unspent Sprout note (txid=%s, vjoinsplit=%d, ciphertext=%d, amount=%s, memo=%s)%s\n", getId(), entry.jsop.hash.ToString().substr(0, 10), entry.jsop.js, int(entry.jsop.n), // uint8_t FormatMoney(entry.plaintext.value()), - HexStr(data).substr(0, 10) + HexStr(data).substr(0, 10), + (useInputSelection_ && pinnedSprout_.count(entry.jsop)) ? " [PINNED]" : "" ); } for (auto entry : saplingEntries) { z_sapling_inputs_.push_back(entry); std::string data(entry.memo.begin(), entry.memo.end()); - LogPrint("zrpcunsafe", "%s: found unspent Sapling note (txid=%s, vShieldedSpend=%d, amount=%s, memo=%s)\n", + LogPrint("zrpcunsafe", "%s: found unspent Sapling note (txid=%s, vShieldedSpend=%d, amount=%s, memo=%s)%s\n", getId(), entry.op.hash.ToString().substr(0, 10), entry.op.n, FormatMoney(entry.note.value()), - HexStr(data).substr(0, 10)); + HexStr(data).substr(0, 10), + (useInputSelection_ && pinnedSapling_.count(entry.op)) ? " [PINNED]" : ""); + } + + // Coin-control: restrict to exactly the pinned shielded notes. + // NON-CONSENSUS: only narrows which already-valid notes we may select. + if (useInputSelection_) { + // Sapling: every pinned note must be present in the spendable set. + std::set availableSapling; + for (const SaplingNoteEntry & e : z_sapling_inputs_) { + availableSapling.insert(e.op); + } + for (const SaplingOutPoint & op : pinnedSapling_) { + if (!availableSapling.count(op)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("Pinned Sapling note not available (spendable, confirmed, owned by from-address): %s:%d", + op.hash.ToString(), op.n)); + } + } + std::vector filteredSapling; + for (const SaplingNoteEntry & e : z_sapling_inputs_) { + if (pinnedSapling_.count(e.op)) { + filteredSapling.push_back(e); + } + } + z_sapling_inputs_ = filteredSapling; + + // Sprout: every pinned note must be present in the spendable set. + std::set availableSprout; + for (const SendManyInputJSOP & t : z_sprout_inputs_) { + availableSprout.insert(std::get<0>(t)); + } + for (const JSOutPoint & op : pinnedSprout_) { + if (!availableSprout.count(op)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("Pinned Sprout note not available (spendable, confirmed, owned by from-address): %s:%d:%d", + op.hash.ToString(), (int)op.js, (int)op.n)); + } + } + std::vector filteredSprout; + for (const SendManyInputJSOP & t : z_sprout_inputs_) { + if (pinnedSprout_.count(std::get<0>(t))) { + filteredSprout.push_back(t); + } + } + z_sprout_inputs_ = filteredSprout; } if (z_sprout_inputs_.empty() && z_sapling_inputs_.empty()) { diff --git a/src/wallet/asyncrpcoperation_sendmany.h b/src/wallet/asyncrpcoperation_sendmany.h index fcb95227a5e..11e367836ce 100644 --- a/src/wallet/asyncrpcoperation_sendmany.h +++ b/src/wallet/asyncrpcoperation_sendmany.h @@ -15,6 +15,7 @@ #include "wallet/paymentdisclosure.h" #include +#include #include #include @@ -60,7 +61,14 @@ class AsyncRPCOperation_sendmany : public AsyncRPCOperation { std::vector zOutputs, int minDepth, CAmount fee = ASYNC_RPC_OPERATION_DEFAULT_MINERS_FEE, - UniValue contextInfo = NullUniValue); + UniValue contextInfo = NullUniValue, + // Optional coin-control: when useInputSelection is true, the spend is + // restricted to exactly the pinned UTXOs/notes below. NON-CONSENSUS: + // this only narrows which already-valid inputs the wallet may select. + bool useInputSelection = false, + std::set pinnedTransparent = std::set(), + std::set pinnedSapling = std::set(), + std::set pinnedSprout = std::set()); virtual ~AsyncRPCOperation_sendmany(); // We don't want to be copied or moved around @@ -105,6 +113,14 @@ class AsyncRPCOperation_sendmany : public AsyncRPCOperation { std::vector z_sprout_inputs_; std::vector z_sapling_inputs_; + // Coin-control (optional). When useInputSelection_ is true, find_utxos() + // and find_unspent_notes() restrict the spend to exactly these inputs. + // NON-CONSENSUS: this only narrows selection of already-valid inputs. + bool useInputSelection_ = false; + std::set pinnedTransparent_; + std::set pinnedSapling_; + std::set pinnedSprout_; + TransactionBuilder builder_; CTransaction tx_; @@ -198,6 +214,23 @@ class TEST_FRIEND_AsyncRPCOperation_sendmany { void set_state(OperationStatus state) { delegate->state_.store(state); } + + // Coin-control accessors (for unit testing input selection wiring). + bool useInputSelection() { + return delegate->useInputSelection_; + } + + const std::set& pinnedTransparent() { + return delegate->pinnedTransparent_; + } + + const std::set& pinnedSapling() { + return delegate->pinnedSapling_; + } + + const std::set& pinnedSprout() { + return delegate->pinnedSprout_; + } }; diff --git a/src/wallet/gtest/test_coincontrol.cpp b/src/wallet/gtest/test_coincontrol.cpp new file mode 100644 index 00000000000..32bd84755d7 --- /dev/null +++ b/src/wallet/gtest/test_coincontrol.cpp @@ -0,0 +1,283 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// Unit tests for z_sendmany coin-control (the optional "inputs" parameter). +// +// NON-CONSENSUS: coin-control only restricts which already-valid inputs the +// wallet may select. These tests exercise (a) the constructor wiring that +// carries the pinned sets into the operation, and (b) the exact set-membership +// filter semantics used by find_utxos()/find_unspent_notes() to restrict the +// spend to the pinned inputs and to reject a pinned input that is not available. + +#include + +#include +#include +#include + +#include "amount.h" +#include "chainparams.h" +#include "key_io.h" +#include "primitives/transaction.h" +#include "uint256.h" +#include "wallet/asyncrpcoperation_sendmany.h" +#include "wallet/wallet.h" +#include "zcash/Address.hpp" +#include "zcash/Note.hpp" + +using namespace libzcash; + +namespace { + +uint256 TxidFromByte(unsigned char b) { + uint256 h; + h.begin()[0] = b; + return h; +} + +SendManyInputUTXO MakeUtxo(unsigned char idByte, int vout, CAmount amount, bool coinbase = false) { + return SendManyInputUTXO(TxidFromByte(idByte), vout, amount, coinbase); +} + +SaplingNoteEntry MakeSaplingEntry(unsigned char idByte, uint32_t outindex, CAmount amount) { + SaplingNoteEntry e; + e.op = SaplingOutPoint(TxidFromByte(idByte), outindex); + diversifier_t d = {{0}}; + e.note = SaplingNote(d, uint256(), (uint64_t)amount, uint256()); + e.confirmations = 10; + return e; +} + +SendManyInputJSOP MakeSproutInput(unsigned char idByte, uint64_t js, uint8_t n, CAmount amount) { + JSOutPoint jsop(TxidFromByte(idByte), js, n); + SproutNote note(uint256(), (uint64_t)amount, uint256(), uint256()); + return SendManyInputJSOP(jsop, note, amount); +} + +// Replicates the exact filter the production find_utxos() applies when +// useInputSelection_ is true: keep only pinned UTXOs, and throw (here: report +// via the `missing` out-param) if a pinned outpoint is not in the available set. +std::vector FilterTransparent( + const std::vector& available, + const std::set& pinned, + bool& missing) { + missing = false; + std::set have; + for (const SendManyInputUTXO& t : available) { + have.insert(COutPoint(std::get<0>(t), std::get<1>(t))); + } + for (const COutPoint& op : pinned) { + if (!have.count(op)) { + missing = true; + } + } + std::vector filtered; + for (const SendManyInputUTXO& t : available) { + if (pinned.count(COutPoint(std::get<0>(t), std::get<1>(t)))) { + filtered.push_back(t); + } + } + return filtered; +} + +std::vector FilterSapling( + const std::vector& available, + const std::set& pinned, + bool& missing) { + missing = false; + std::set have; + for (const SaplingNoteEntry& e : available) { + have.insert(e.op); + } + for (const SaplingOutPoint& op : pinned) { + if (!have.count(op)) { + missing = true; + } + } + std::vector filtered; + for (const SaplingNoteEntry& e : available) { + if (pinned.count(e.op)) { + filtered.push_back(e); + } + } + return filtered; +} + +std::vector FilterSprout( + const std::vector& available, + const std::set& pinned, + bool& missing) { + missing = false; + std::set have; + for (const SendManyInputJSOP& t : available) { + have.insert(std::get<0>(t)); + } + for (const JSOutPoint& op : pinned) { + if (!have.count(op)) { + missing = true; + } + } + std::vector filtered; + for (const SendManyInputJSOP& t : available) { + if (pinned.count(std::get<0>(t))) { + filtered.push_back(t); + } + } + return filtered; +} + +CAmount TotalTransparent(const std::vector& v) { + CAmount total = 0; + for (const SendManyInputUTXO& t : v) { + total += std::get<2>(t); + } + return total; +} + +} // namespace + +// Transparent: only the pinned UTXOs survive the filter; the unpinned ones are +// dropped even though they are spendable. +TEST(CoinControl, TransparentFilterSelectsOnlyPinned) { + std::vector available = { + MakeUtxo(0x01, 0, 100), + MakeUtxo(0x02, 0, 200), + MakeUtxo(0x03, 1, 300), + }; + std::set pinned = { + COutPoint(TxidFromByte(0x01), 0), + COutPoint(TxidFromByte(0x03), 1), + }; + + bool missing = false; + auto filtered = FilterTransparent(available, pinned, missing); + EXPECT_FALSE(missing); + ASSERT_EQ(filtered.size(), 2u); + // The 200-value unpinned UTXO must be gone. + for (const auto& t : filtered) { + EXPECT_NE(std::get<2>(t), CAmount(200)); + } + EXPECT_EQ(TotalTransparent(filtered), CAmount(400)); +} + +// Transparent: a pinned outpoint that is not in the available (spendable, +// confirmed, owned) set must be detected so the operation fails pre-send. +TEST(CoinControl, TransparentPinnedAbsentIsDetected) { + std::vector available = { + MakeUtxo(0x01, 0, 100), + }; + std::set pinned = { + COutPoint(TxidFromByte(0x01), 0), + COutPoint(TxidFromByte(0x09), 7), // not available + }; + + bool missing = false; + FilterTransparent(available, pinned, missing); + EXPECT_TRUE(missing); +} + +// Transparent: pinning a subset whose total is below the target is recognised +// as insufficient (the operation's existing target check fails pre-send). +TEST(CoinControl, TransparentPinnedSubsetInsufficient) { + std::vector available = { + MakeUtxo(0x01, 0, 100), + MakeUtxo(0x02, 0, 9000), + }; + // Pin only the small UTXO. + std::set pinned = { COutPoint(TxidFromByte(0x01), 0) }; + + bool missing = false; + auto filtered = FilterTransparent(available, pinned, missing); + EXPECT_FALSE(missing); + CAmount targetAmount = 500; // > 100 pinned + EXPECT_LT(TotalTransparent(filtered), targetAmount); +} + +// Sapling: only pinned notes survive; an absent pinned note is detected. +TEST(CoinControl, SaplingFilterSelectsOnlyPinned) { + std::vector available = { + MakeSaplingEntry(0x10, 0, 1000), + MakeSaplingEntry(0x11, 0, 2000), + MakeSaplingEntry(0x11, 1, 3000), + }; + std::set pinned = { + SaplingOutPoint(TxidFromByte(0x11), 1), + }; + + bool missing = false; + auto filtered = FilterSapling(available, pinned, missing); + EXPECT_FALSE(missing); + ASSERT_EQ(filtered.size(), 1u); + EXPECT_EQ(filtered[0].note.value(), (uint64_t)3000); + + std::set badPin = { SaplingOutPoint(TxidFromByte(0xFF), 0) }; + FilterSapling(available, badPin, missing); + EXPECT_TRUE(missing); +} + +// Sprout: only pinned notes survive; an absent pinned note is detected. +TEST(CoinControl, SproutFilterSelectsOnlyPinned) { + std::vector available = { + MakeSproutInput(0x20, 0, 0, 500), + MakeSproutInput(0x20, 0, 1, 700), + MakeSproutInput(0x21, 3, 1, 900), + }; + std::set pinned = { + JSOutPoint(TxidFromByte(0x20), 0, 1), + }; + + bool missing = false; + auto filtered = FilterSprout(available, pinned, missing); + EXPECT_FALSE(missing); + ASSERT_EQ(filtered.size(), 1u); + EXPECT_EQ(std::get<2>(filtered[0]), CAmount(700)); + + std::set badPin = { JSOutPoint(TxidFromByte(0x20), 9, 0) }; + FilterSprout(available, badPin, missing); + EXPECT_TRUE(missing); +} + +// Constructor wiring: the new coin-control args are carried into the operation +// and surfaced through the test friend. Default construction leaves selection +// disabled (so existing callers / change routing are untouched). +TEST(CoinControl, ConstructorCarriesPinnedSets) { + // The from-address below is a TESTNET taddr; decode it under TESTNET params. + SelectParams(CBaseChainParams::TESTNET); + + CMutableTransaction mtx; + mtx.nVersion = 2; + + std::vector recipients = { + SendManyRecipient("dummy", CAmount(1), "") + }; + + std::set pinnedT = { COutPoint(TxidFromByte(0x01), 0) }; + std::set pinnedS; + std::set pinnedJ; + + // A transparent from-address keeps construction lightweight (no spending-key + // lookup for a zaddr). We never run main(); we only inspect the wiring. + std::shared_ptr op( + new AsyncRPCOperation_sendmany( + boost::none, mtx, "tmRr6yJonqGK23UVhrKuyvTpF8qxQQjKigJ", + recipients, {}, 1, + ASYNC_RPC_OPERATION_DEFAULT_MINERS_FEE, NullUniValue, + /*useInputSelection=*/true, pinnedT, pinnedS, pinnedJ)); + TEST_FRIEND_AsyncRPCOperation_sendmany proxy(op); + + EXPECT_TRUE(proxy.useInputSelection()); + EXPECT_EQ(proxy.pinnedTransparent().size(), 1u); + EXPECT_EQ(proxy.pinnedSapling().size(), 0u); + EXPECT_EQ(proxy.pinnedSprout().size(), 0u); + EXPECT_EQ(proxy.pinnedTransparent().count(COutPoint(TxidFromByte(0x01), 0)), 1u); + + // Default (no coin-control) construction must leave selection disabled. + std::shared_ptr opDefault( + new AsyncRPCOperation_sendmany( + boost::none, mtx, "tmRr6yJonqGK23UVhrKuyvTpF8qxQQjKigJ", + recipients, {}, 1)); + TEST_FRIEND_AsyncRPCOperation_sendmany proxyDefault(opDefault); + EXPECT_FALSE(proxyDefault.useInputSelection()); + EXPECT_EQ(proxyDefault.pinnedTransparent().size(), 0u); +} diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 86149e415f2..1188ae7b52c 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -3722,9 +3722,9 @@ UniValue z_sendmany(const UniValue& params, bool fHelp) if (!EnsureWalletIsAvailable(fHelp)) return NullUniValue; - if (fHelp || params.size() < 2 || params.size() > 4) + if (fHelp || params.size() < 2 || params.size() > 5) throw runtime_error( - "z_sendmany \"fromaddress\" [{\"address\":... ,\"amount\":...},...] ( minconf ) ( fee )\n" + "z_sendmany \"fromaddress\" [{\"address\":... ,\"amount\":...},...] ( minconf ) ( fee ) ( inputs )\n" "\nSend multiple times. Amounts are decimal numbers with at most 8 digits of precision." "\nChange generated from a taddr flows to a new taddr address, while change generated from a zaddr returns to itself." "\nWhen sending coinbase UTXOs to a zaddr, change is not allowed. The entire value of the UTXO(s) must be consumed." @@ -3741,6 +3741,17 @@ UniValue z_sendmany(const UniValue& params, bool fHelp) "3. minconf (numeric, optional, default=1) Only use funds confirmed at least this many times.\n" "4. fee (numeric, optional, default=" + strprintf("%s", FormatMoney(ASYNC_RPC_OPERATION_DEFAULT_MINERS_FEE)) + ") The fee amount to attach to this transaction.\n" + "5. \"inputs\" (array, optional) Coin-control: restrict the spend to EXACTLY these UTXOs/notes.\n" + " All inputs must belong to \"fromaddress\" and be of the same pool (no mixing transparent with shielded).\n" + " PRIVACY WARNING: hand-selecting which shielded notes to spend can reduce your privacy by linking notes; prefer automatic selection unless you understand the consequences.\n" + " [{\n" + " \"type\":type (string, required) One of \"transparent\", \"sapling\", \"sprout\"\n" + " \"txid\":txid (string, required) The transaction id (64-char hex)\n" + " \"vout\":n (numeric, required for transparent) The output index\n" + " \"outindex\":n (numeric, required for sapling) The Sapling output (vShieldedOutput) index\n" + " \"jsindex\":n (numeric, required for sprout) The joinsplit (vjoinsplit) index\n" + " \"jsoutindex\":n (numeric, required for sprout) The joinsplit output index\n" + " }, ... ]\n" "\nResult:\n" "\"operationid\" (string) An operationid to pass to z_getoperationstatus to get the result of the operation.\n" "\nExamples:\n" @@ -3959,6 +3970,93 @@ UniValue z_sendmany(const UniValue& params, bool fHelp) } } + // Optional coin-control: restrict the spend to EXACTLY the listed UTXOs/notes. + // NON-CONSENSUS: this only narrows which already-valid inputs the wallet selects. + bool useInputSelection = false; + std::set pinnedTransparent; + std::set pinnedSapling; + std::set pinnedSprout; + if (params.size() > 4 && !params[4].isNull()) { + UniValue inputs = params[4].get_array(); + useInputSelection = true; + for (const UniValue& in : inputs.getValues()) { + if (!in.isObject()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, expected object in inputs array"); + + // Reject unknown keys. + for (const string& name_ : in.getKeys()) { + if (name_ != "type" && name_ != "txid" && name_ != "vout" && + name_ != "outindex" && name_ != "jsindex" && name_ != "jsoutindex") + throw JSONRPCError(RPC_INVALID_PARAMETER, string("Invalid parameter, unknown key in inputs: ") + name_); + } + + UniValue typeValue = find_value(in, "type"); + if (!typeValue.isStr()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, input \"type\" is required (transparent, sapling or sprout)"); + std::string inputType = typeValue.get_str(); + + UniValue txidValue = find_value(in, "txid"); + if (!txidValue.isStr()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, input \"txid\" is required"); + std::string txid = txidValue.get_str(); + if (txid.length() != 64 || !IsHex(txid)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, input \"txid\" must be a 64-character hex string"); + + if (inputType == "transparent") { + // Transparent inputs require a transparent from-address. + if (!fromTaddr) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, transparent input requires a transparent fromaddress"); + if (!pinnedSapling.empty() || !pinnedSprout.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, cannot mix transparent and shielded inputs"); + UniValue voutValue = find_value(in, "vout"); + if (!voutValue.isNum()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, transparent input requires \"vout\""); + int nOutput = voutValue.get_int(); + if (nOutput < 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, \"vout\" must be positive"); + pinnedTransparent.insert(COutPoint(uint256S(txid), nOutput)); + } else if (inputType == "sapling") { + // Sapling inputs require a Sapling from-address. + if (!fromSapling) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, sapling input requires a Sapling fromaddress"); + if (!pinnedTransparent.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, cannot mix transparent and shielded inputs"); + UniValue outindexValue = find_value(in, "outindex"); + if (!outindexValue.isNum()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, sapling input requires \"outindex\""); + int outIndex = outindexValue.get_int(); + if (outIndex < 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, \"outindex\" must be positive"); + pinnedSapling.insert(SaplingOutPoint(uint256S(txid), outIndex)); + } else if (inputType == "sprout") { + // Sprout inputs require a Sprout from-address. + if (!fromSprout) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, sprout input requires a Sprout fromaddress"); + if (!pinnedTransparent.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, cannot mix transparent and shielded inputs"); + UniValue jsindexValue = find_value(in, "jsindex"); + if (!jsindexValue.isNum()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, sprout input requires \"jsindex\""); + int jsIndex = jsindexValue.get_int(); + if (jsIndex < 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, \"jsindex\" must be positive"); + UniValue jsoutindexValue = find_value(in, "jsoutindex"); + if (!jsoutindexValue.isNum()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, sprout input requires \"jsoutindex\""); + int jsOutIndex = jsoutindexValue.get_int(); + // Validate range before narrowing to uint8_t. + if (jsOutIndex < 0 || jsOutIndex >= ZC_NUM_JS_OUTPUTS) + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Invalid parameter, \"jsoutindex\" must be in [0, %d)", ZC_NUM_JS_OUTPUTS)); + pinnedSprout.insert(JSOutPoint(uint256S(txid), (uint64_t)jsIndex, (uint8_t)jsOutIndex)); + } else { + throw JSONRPCError(RPC_INVALID_PARAMETER, string("Invalid parameter, unknown input type: ") + inputType); + } + } + + if (pinnedTransparent.empty() && pinnedSapling.empty() && pinnedSprout.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, inputs array is empty"); + } + // Use input parameters as the optional context info to be returned by z_getoperationstatus and z_getoperationresult. UniValue o(UniValue::VOBJ); o.push_back(Pair("fromaddress", params[0])); @@ -3983,7 +4081,7 @@ UniValue z_sendmany(const UniValue& params, bool fHelp) // Create operation and add to global queue std::shared_ptr q = getAsyncRPCQueue(); - std::shared_ptr operation( new AsyncRPCOperation_sendmany(builder, contextualTx, fromaddress, taddrRecipients, zaddrRecipients, nMinDepth, nFee, contextInfo) ); + std::shared_ptr operation( new AsyncRPCOperation_sendmany(builder, contextualTx, fromaddress, taddrRecipients, zaddrRecipients, nMinDepth, nFee, contextInfo, useInputSelection, pinnedTransparent, pinnedSapling, pinnedSprout) ); q->addOperation(operation); AsyncRPCOperationId operationId = operation->getId(); return operationId; From 3365d0d6b445791fa328203f02b1af2663ade10e Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 00:34:58 +0000 Subject: [PATCH 2/2] coincontrol: spend the EXACT pinned shielded note set (Sapling + Sprout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For coin control, pinning specific shielded notes should spend ALL of them (surplus becomes shielded change), matching the transparent behavior — not a greedy sufficient subset. Guard the Sapling and Sprout note-selection early-breaks with !useInputSelection_, mirroring the transparent guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet/asyncrpcoperation_sendmany.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wallet/asyncrpcoperation_sendmany.cpp b/src/wallet/asyncrpcoperation_sendmany.cpp index 0b18d460a8e..a3a644b971a 100644 --- a/src/wallet/asyncrpcoperation_sendmany.cpp +++ b/src/wallet/asyncrpcoperation_sendmany.cpp @@ -433,7 +433,9 @@ bool AsyncRPCOperation_sendmany::main_impl() { ops.push_back(t.op); notes.push_back(t.note); sum += t.note.value(); - if (sum >= targetAmount) { + // Coin control: when the caller pinned exact notes, consume ALL of them + // (any surplus becomes shielded change); only auto-selection early-breaks. + if (!useInputSelection_ && sum >= targetAmount) { break; } } @@ -564,7 +566,9 @@ bool AsyncRPCOperation_sendmany::main_impl() { for (auto o : z_sprout_inputs_) { zInputsDeque.push_back(o); tmp += std::get<2>(o); - if (tmp >= targetAmount) { + // Coin control: when the caller pinned exact notes, consume ALL of them + // (any surplus becomes shielded change); only auto-selection early-breaks. + if (!useInputSelection_ && tmp >= targetAmount) { break; } }