Add sendall RPC née sweep

_Motivation_
Currently, the wallet uses a fSubtractFeeAmount (SFFO) flag on the
recipients objects for all forms of sending calls. According to the
commit discussion, this flag was chiefly introduced to permit sweeping
without manually calculating the fees of transactions. However, the flag
leads to unintuitive behavior and makes it more complicated to test
many wallet RPCs exhaustively. We proposed to introduce a dedicated
`sendall` RPC with the intention to cover this functionality.

Since the proposal, it was discovered in further discussion that our
proposed `sendall` rpc and SFFO have subtly different scopes of
operation.
• sendall:
  Use _specific UTXOs_ to pay a destination the remainder after fees.
• SFFO:
  Use a _specific budget_ to pay an address the remainder after fees.

While `sendall` will simplify cases of spending from specific UTXOs,
emptying a wallet, or burning dust, we realized that there are some
cases in which SFFO is used to pay other parties from a limited budget,
which can often lead to the creation of change outputs. This cannot be
easily replicated using `sendall` as it would require manual computation
of the appropriate change amount.

As such, sendall cannot replace all uses of SFFO, but it still has a
different use case and will aid in simplifying some wallet calls and
numerous wallet tests.

_Sendall call details_
The proposed sendall call builds a transaction from a specific subset of
the wallet's UTXO pool (by default all of them) and assigns the funds to
one or more receivers. Receivers can either be specified with a specific
amount or receive an equal share of the remaining unassigned funds. At
least one recipient must be provided without assigned amount to collect
the remainder. The `sendall` call will never create change. The call has
a `send_max` option that changes the default behavior of spending all
UTXOs ("no UTXO left behind"), to maximizing the output amount of the
transaction by skipping uneconomic UTXOs. The `send_max` option is
incompatible with providing a specific set of inputs.
This commit is contained in:
Murch 2022-01-12 13:59:41 -05:00
parent 902793c777
commit 49090ec402
No known key found for this signature in database
GPG key ID: 7BA035CA5B901713
7 changed files with 574 additions and 24 deletions

View file

@ -0,0 +1,10 @@
New RPCs
--------
- The `sendall` RPC spends specific UTXOs to one or more recipients
without creating change. By default, the `sendall` RPC will spend
every UTXO in the wallet. `sendall` is useful to empty wallets or to
create a changeless payment from select UTXOs. When creating a payment
from a specific amount for which the recipient incurs the transaction
fee, continue to use the `subtractfeefromamount` option via the
`send`, `sendtoaddress`, or `sendmany` RPCs. (#24118)

View file

@ -142,6 +142,10 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "send", 1, "conf_target" },
{ "send", 3, "fee_rate"},
{ "send", 4, "options" },
{ "sendall", 0, "recipients" },
{ "sendall", 1, "conf_target" },
{ "sendall", 3, "fee_rate"},
{ "sendall", 4, "options" },
{ "importprivkey", 2, "rescan" },
{ "importaddress", 2, "rescan" },
{ "importaddress", 3, "p2sh" },

View file

@ -9,10 +9,12 @@
#include <rpc/rawtransaction_util.h>
#include <rpc/util.h>
#include <util/fees.h>
#include <util/rbf.h>
#include <util/translation.h>
#include <util/vector.h>
#include <wallet/coincontrol.h>
#include <wallet/feebumper.h>
#include <wallet/fees.h>
#include <wallet/rpc/util.h>
#include <wallet/spend.h>
#include <wallet/wallet.h>
@ -21,7 +23,7 @@
namespace wallet {
static void ParseRecipients(const UniValue& address_amounts, const UniValue& subtract_fee_outputs, std::vector<CRecipient> &recipients)
static void ParseRecipients(const UniValue& address_amounts, const UniValue& subtract_fee_outputs, std::vector<CRecipient>& recipients)
{
std::set<CTxDestination> destinations;
int i = 0;
@ -76,7 +78,6 @@ static void InterpretFeeEstimationInstructions(const UniValue& conf_target, cons
static UniValue FinishTransaction(const std::shared_ptr<CWallet> pwallet, const UniValue& options, const CMutableTransaction& rawTx)
{
// Make a blank psbt
PartiallySignedTransaction psbtx(rawTx);
@ -108,7 +109,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, {}, /*orderForm*/ {});
} else {
result.pushKV("hex", hex);
}
@ -449,31 +450,43 @@ RPCHelpMan settxfee()
// Only includes key documentation where the key is snake_case in all RPC methods. MixedCase keys can be added later.
static std::vector<RPCArg> FundTxDoc()
static std::vector<RPCArg> FundTxDoc(bool solving_data = true)
{
return {
std::vector<RPCArg> args = {
{"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"},
{"estimate_mode", RPCArg::Type::STR, RPCArg::Default{"unset"}, std::string() + "The fee estimate mode, must be one of (case insensitive):\n"
" \"" + FeeModes("\"\n\"") + "\""},
{"replaceable", RPCArg::Type::BOOL, RPCArg::DefaultHint{"wallet default"}, "Marks this transaction as BIP125-replaceable.\n"
"Allows this transaction to be replaced by a transaction with higher fees"},
{"solving_data", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED_NAMED_ARG, "Keys and scripts needed for producing a final transaction with a dummy signature.\n"
"Used for fee estimation during coin selection.",
{
{"pubkeys", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Public keys involved in this transaction.",
{
{"pubkey", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, "A public key"},
}},
{"scripts", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Scripts involved in this transaction.",
{
{"script", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, "A script"},
}},
{"descriptors", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Descriptors that provide solving data for this transaction.",
{
{"descriptor", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "A descriptor"},
}},
}},
{
"replaceable", RPCArg::Type::BOOL, RPCArg::DefaultHint{"wallet default"}, "Marks this transaction as BIP125-replaceable.\n"
"Allows this transaction to be replaced by a transaction with higher fees"
},
};
if (solving_data) {
args.push_back({"solving_data", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED_NAMED_ARG, "Keys and scripts needed for producing a final transaction with a dummy signature.\n"
"Used for fee estimation during coin selection.",
{
{
"pubkeys", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Public keys involved in this transaction.",
{
{"pubkey", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, "A public key"},
}
},
{
"scripts", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Scripts involved in this transaction.",
{
{"script", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, "A script"},
}
},
{
"descriptors", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Descriptors that provide solving data for this transaction.",
{
{"descriptor", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "A descriptor"},
}
},
}
});
}
return args;
}
void FundTransaction(CWallet& wallet, CMutableTransaction& tx, CAmount& fee_out, int& change_position, const UniValue& options, CCoinControl& coinControl, bool override_min_fee)
@ -1221,7 +1234,7 @@ RPCHelpMan send()
CAmount fee;
int change_position;
bool rbf = options.exists("replaceable") ? options["replaceable"].get_bool() : pwallet->m_signal_rbf;
bool rbf{options.exists("replaceable") ? options["replaceable"].get_bool() : pwallet->m_signal_rbf};
CMutableTransaction rawTx = ConstructTransaction(options["inputs"], request.params[0], options["locktime"], rbf);
CCoinControl coin_control;
// Automatically select coins, unless at least one is manually selected. Can
@ -1235,6 +1248,233 @@ RPCHelpMan send()
};
}
RPCHelpMan sendall()
{
return RPCHelpMan{"sendall",
"EXPERIMENTAL warning: this call may be changed in future releases.\n"
"\nSpend the value of all (or specific) confirmed UTXOs in the wallet to one or more recipients.\n"
"Unconfirmed inbound UTXOs and locked UTXOs will not be spent. Sendall will respect the avoid_reuse wallet flag.\n"
"If your wallet contains many small inputs, either because it received tiny payments or as a result of accumulating change, consider using `send_max` to exclude inputs that are worth less than the fees needed to spend them.\n",
{
{"recipients", RPCArg::Type::ARR, RPCArg::Optional::NO, "The sendall destinations. Each address may only appear once.\n"
"Optionally some recipients can be specified with an amount to perform payments, but at least one address must appear without a specified amount.\n",
{
{"address", RPCArg::Type::STR, RPCArg::Optional::NO, "A bitcoin address which receives an equal share of the unspecified amount."},
{"", RPCArg::Type::OBJ_USER_KEYS, RPCArg::Optional::OMITTED, "",
{
{"address", RPCArg::Type::AMOUNT, RPCArg::Optional::NO, "A key-value pair. The key (string) is the bitcoin address, the value (float or string) is the amount in " + CURRENCY_UNIT + ""},
},
},
},
},
{"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"},
{"estimate_mode", RPCArg::Type::STR, RPCArg::Default{"unset"}, std::string() + "The fee estimate mode, must be one of (case insensitive):\n"
" \"" + FeeModes("\"\n\"") + "\""},
{"fee_rate", RPCArg::Type::AMOUNT, RPCArg::DefaultHint{"not set, fall back to wallet fee estimation"}, "Specify a fee rate in " + CURRENCY_ATOM + "/vB."},
{
"options", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED_NAMED_ARG, "",
Cat<std::vector<RPCArg>>(
{
{"add_to_wallet", RPCArg::Type::BOOL, RPCArg::Default{true}, "When false, returns the serialized transaction without broadcasting or adding it to the wallet"},
{"fee_rate", RPCArg::Type::AMOUNT, RPCArg::DefaultHint{"not set, fall back to wallet fee estimation"}, "Specify a fee rate in " + CURRENCY_ATOM + "/vB."},
{"include_watching", RPCArg::Type::BOOL, RPCArg::DefaultHint{"true for watch-only wallets, otherwise false"}, "Also select inputs which are watch-only.\n"
"Only solvable inputs can be used. Watch-only destinations are solvable if the public key and/or output script was imported,\n"
"e.g. with 'importpubkey' or 'importmulti' with the 'pubkeys' or 'desc' field."},
{"inputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "Use exactly the specified inputs to build the transaction. Specifying inputs is incompatible with send_max. A JSON array of JSON objects",
{
{"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id"},
{"vout", RPCArg::Type::NUM, RPCArg::Optional::NO, "The output number"},
{"sequence", RPCArg::Type::NUM, RPCArg::Optional::NO, "The sequence number"},
},
},
{"locktime", RPCArg::Type::NUM, RPCArg::Default{0}, "Raw locktime. Non-0 value also locktime-activates inputs"},
{"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."},
},
FundTxDoc()
),
"options"
},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::BOOL, "complete", "If the transaction has a complete set of signatures"},
{RPCResult::Type::STR_HEX, "txid", /*optional=*/true, "The transaction id for the send. Only 1 transaction is created regardless of the number of addresses."},
{RPCResult::Type::STR_HEX, "hex", /*optional=*/true, "If add_to_wallet is false, the hex-encoded raw transaction with signature(s)"},
{RPCResult::Type::STR, "psbt", /*optional=*/true, "If more signatures are needed, or if add_to_wallet is false, the base64-encoded (partially) signed transaction"}
}
},
RPCExamples{""
"\nSpend all UTXOs from the wallet with a fee rate of 1 " + CURRENCY_ATOM + "/vB using named arguments\n"
+ HelpExampleCli("-named sendall", "recipients='[\"" + EXAMPLE_ADDRESS[0] + "\"]' fee_rate=1\n") +
"Spend all UTXOs with a fee rate of 1.1 " + CURRENCY_ATOM + "/vB using positional arguments\n"
+ HelpExampleCli("sendall", "'[\"" + EXAMPLE_ADDRESS[0] + "\"]' null \"unset\" 1.1\n") +
"Spend all UTXOs split into equal amounts to two addresses with a fee rate of 1.5 " + CURRENCY_ATOM + "/vB using the options argument\n"
+ HelpExampleCli("sendall", "'[\"" + EXAMPLE_ADDRESS[0] + "\", \"" + EXAMPLE_ADDRESS[1] + "\"]' null \"unset\" null '{\"fee_rate\": 1.5}'\n") +
"Leave dust UTXOs in wallet, spend only UTXOs with positive effective value with a fee rate of 10 " + CURRENCY_ATOM + "/vB using the options argument\n"
+ HelpExampleCli("sendall", "'[\"" + EXAMPLE_ADDRESS[0] + "\"]' null \"unset\" null '{\"fee_rate\": 10, \"send_max\": true}'\n") +
"Spend all UTXOs with a fee rate of 1.3 " + CURRENCY_ATOM + "/vB using named arguments and sending a 0.25 " + CURRENCY_UNIT + " to another recipient\n"
+ HelpExampleCli("-named sendall", "recipients='[{\"" + EXAMPLE_ADDRESS[1] + "\": 0.25}, \""+ EXAMPLE_ADDRESS[0] + "\"]' fee_rate=1.3\n")
},
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
{
RPCTypeCheck(request.params, {
UniValue::VARR, // recipients
UniValue::VNUM, // conf_target
UniValue::VSTR, // estimate_mode
UniValueType(), // fee_rate, will be checked by AmountFromValue() in SetFeeEstimateMode()
UniValue::VOBJ, // options
}, true
);
std::shared_ptr<CWallet> const pwallet{GetWalletForJSONRPCRequest(request)};
if (!pwallet) return NullUniValue;
// Make sure the results are valid at least up to the most recent block
// the user could have gotten from another RPC command prior to now
pwallet->BlockUntilSyncedToCurrentChain();
UniValue options{request.params[4].isNull() ? UniValue::VOBJ : request.params[4]};
InterpretFeeEstimationInstructions(/*conf_target=*/request.params[1], /*estimate_mode=*/request.params[2], /*fee_rate=*/request.params[3], options);
PreventOutdatedOptions(options);
std::set<std::string> addresses_without_amount;
UniValue recipient_key_value_pairs(UniValue::VARR);
const UniValue& recipients{request.params[0]};
for (unsigned int i = 0; i < recipients.size(); ++i) {
const UniValue& recipient{recipients[i]};
if (recipient.isStr()) {
UniValue rkvp(UniValue::VOBJ);
rkvp.pushKV(recipient.get_str(), 0);
recipient_key_value_pairs.push_back(rkvp);
addresses_without_amount.insert(recipient.get_str());
} else {
recipient_key_value_pairs.push_back(recipient);
}
}
if (addresses_without_amount.size() == 0) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Must provide at least one address without a specified amount");
}
CCoinControl coin_control;
SetFeeEstimateMode(*pwallet, coin_control, options["conf_target"], options["estimate_mode"], options["fee_rate"], /*override_min_fee=*/false);
coin_control.fAllowWatchOnly = ParseIncludeWatchonly(options["include_watching"], *pwallet);
const bool rbf{options.exists("replaceable") ? options["replaceable"].get_bool() : pwallet->m_signal_rbf};
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
// provided one
if (coin_control.m_feerate && fee_rate > *coin_control.m_feerate) {
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Fee rate (%s) is lower than the minimum fee rate setting (%s)", coin_control.m_feerate->ToString(FeeEstimateMode::SAT_VB), fee_rate.ToString(FeeEstimateMode::SAT_VB)));
}
if (fee_calc_out.reason == FeeReason::FALLBACK && !pwallet->m_allow_fallback_fee) {
// eventually allow a fallback fee
throw JSONRPCError(RPC_WALLET_ERROR, "Fee estimation failed. Fallbackfee is disabled. Wait a few blocks or enable -fallbackfee.");
}
CMutableTransaction rawTx{ConstructTransaction(options["inputs"], recipient_key_value_pairs, options["locktime"], rbf)};
LOCK(pwallet->cs_wallet);
std::vector<COutput> all_the_utxos;
CAmount total_input_value(0);
bool send_max{options.exists("send_max") ? options["send_max"].get_bool() : false};
if (options.exists("inputs") && options.exists("send_max")) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Cannot combine send_max with specific inputs.");
} else if (options.exists("inputs")) {
for (const CTxIn& input : rawTx.vin) {
if (pwallet->IsSpent(input.prevout.hash, input.prevout.n)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Input not available. UTXO (%s:%d) was already spent.", input.prevout.hash.ToString(), input.prevout.n));
}
const CWalletTx* tx{pwallet->GetWalletTx(input.prevout.hash)};
if (!tx || 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));
}
total_input_value += tx->tx->vout[input.prevout.n].nValue;
}
} else {
AvailableCoins(*pwallet, all_the_utxos, &coin_control, /*nMinimumAmount=*/0);
for (const COutput& output : all_the_utxos) {
CHECK_NONFATAL(output.input_bytes > 0);
if (send_max && fee_rate.GetFee(output.input_bytes) > output.txout.nValue) {
continue;
}
CTxIn input(output.outpoint.hash, output.outpoint.n, CScript(), rbf ? MAX_BIP125_RBF_SEQUENCE : CTxIn::SEQUENCE_FINAL);
rawTx.vin.push_back(input);
total_input_value += output.txout.nValue;
}
}
// estimate final size of tx
const TxSize tx_size{CalculateMaximumSignedTxSize(CTransaction(rawTx), pwallet.get())};
const CAmount fee_from_size{fee_rate.GetFee(tx_size.vsize)};
const CAmount effective_value{total_input_value - fee_from_size};
if (effective_value <= 0) {
if (send_max) {
throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Total value of UTXO pool too low to pay for transaction, try using lower feerate.");
} else {
throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Total value of UTXO pool too low to pay for transaction. Try using lower feerate or excluding uneconomic UTXOs with 'send_max' option.");
}
}
CAmount output_amounts_claimed{0};
for (CTxOut out : rawTx.vout) {
output_amounts_claimed += out.nValue;
}
if (output_amounts_claimed > total_input_value) {
throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Assigned more value to outputs than available funds.");
}
const CAmount remainder{effective_value - output_amounts_claimed};
if (remainder < 0) {
throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Insufficient funds for fees after creating specified outputs.");
}
const CAmount per_output_without_amount{remainder / (long)addresses_without_amount.size()};
bool gave_remaining_to_first{false};
for (CTxOut& out : rawTx.vout) {
CTxDestination dest;
ExtractDestination(out.scriptPubKey, dest);
std::string addr{EncodeDestination(dest)};
if (addresses_without_amount.count(addr) > 0) {
out.nValue = per_output_without_amount;
if (!gave_remaining_to_first) {
out.nValue += remainder % addresses_without_amount.size();
gave_remaining_to_first = true;
}
if (IsDust(out, pwallet->chain().relayDustFee())) {
// Dynamically generated output amount is dust
throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Dynamically assigned remainder results in dust output.");
}
} else {
if (IsDust(out, pwallet->chain().relayDustFee())) {
// Specified output amount is dust
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Specified output amount to %s is below dust threshold.", addr));
}
}
}
const bool lock_unspents{options.exists("lock_unspents") ? options["lock_unspents"].get_bool() : false};
if (lock_unspents) {
for (const CTxIn& txin : rawTx.vin) {
pwallet->LockCoin(txin.prevout);
}
}
return FinishTransaction(pwallet, options, rawTx);
}
};
}
RPCHelpMan walletprocesspsbt()
{
return RPCHelpMan{"walletprocesspsbt",

View file

@ -644,6 +644,7 @@ RPCHelpMan fundrawtransaction();
RPCHelpMan bumpfee();
RPCHelpMan psbtbumpfee();
RPCHelpMan send();
RPCHelpMan sendall();
RPCHelpMan walletprocesspsbt();
RPCHelpMan walletcreatefundedpsbt();
RPCHelpMan signrawtransactionwithwallet();
@ -723,6 +724,7 @@ static const CRPCCommand commands[] =
{ "wallet", &setwalletflag, },
{ "wallet", &signmessage, },
{ "wallet", &signrawtransactionwithwallet, },
{ "wallet", &sendall, },
{ "wallet", &unloadwallet, },
{ "wallet", &upgradewallet, },
{ "wallet", &walletcreatefundedpsbt, },

View file

@ -278,6 +278,8 @@ BASE_SCRIPTS = [
'wallet_create_tx.py --legacy-wallet',
'wallet_send.py --legacy-wallet',
'wallet_send.py --descriptors',
'wallet_sendall.py --legacy-wallet',
'wallet_sendall.py --descriptors',
'wallet_create_tx.py --descriptors',
'wallet_taproot.py',
'wallet_inactive_hdchains.py',

286
test/functional/wallet_sendall.py Executable file
View file

@ -0,0 +1,286 @@
#!/usr/bin/env python3
# Copyright (c) 2022 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test the sendall RPC command."""
from decimal import Decimal, getcontext
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_greater_than,
assert_raises_rpc_error,
)
# Decorator to reset activewallet to zero utxos
def cleanup(func):
def wrapper(self):
try:
func(self)
finally:
if 0 < self.wallet.getbalances()["mine"]["trusted"]:
self.wallet.sendall([self.remainder_target])
assert_equal(0, self.wallet.getbalances()["mine"]["trusted"]) # wallet is empty
return wrapper
class SendallTest(BitcoinTestFramework):
# Setup and helpers
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def set_test_params(self):
getcontext().prec=10
self.num_nodes = 1
self.setup_clean_chain = True
def assert_balance_swept_completely(self, tx, balance):
output_sum = sum([o["value"] for o in tx["decoded"]["vout"]])
assert_equal(output_sum, balance + tx["fee"])
assert_equal(0, self.wallet.getbalances()["mine"]["trusted"]) # wallet is empty
def assert_tx_has_output(self, tx, addr, value=None):
for output in tx["decoded"]["vout"]:
if addr == output["scriptPubKey"]["address"] and value is None or value == output["value"]:
return
raise AssertionError("Output to {} not present or wrong amount".format(addr))
def assert_tx_has_outputs(self, tx, expected_outputs):
assert_equal(len(expected_outputs), len(tx["decoded"]["vout"]))
for eo in expected_outputs:
self.assert_tx_has_output(tx, eo["address"], eo["value"])
def add_utxos(self, amounts):
for a in amounts:
self.def_wallet.sendtoaddress(self.wallet.getnewaddress(), a)
self.generate(self.nodes[0], 1)
assert_greater_than(self.wallet.getbalances()["mine"]["trusted"], 0)
return self.wallet.getbalances()["mine"]["trusted"]
# Helper schema for success cases
def test_sendall_success(self, sendall_args, remaining_balance = 0):
sendall_tx_receipt = self.wallet.sendall(sendall_args)
self.generate(self.nodes[0], 1)
# wallet has remaining balance (usually empty)
assert_equal(remaining_balance, self.wallet.getbalances()["mine"]["trusted"])
assert_equal(sendall_tx_receipt["complete"], True)
return self.wallet.gettransaction(txid = sendall_tx_receipt["txid"], verbose = True)
@cleanup
def gen_and_clean(self):
self.add_utxos([15, 2, 4])
def test_cleanup(self):
self.log.info("Test that cleanup wrapper empties wallet")
self.gen_and_clean()
assert_equal(0, self.wallet.getbalances()["mine"]["trusted"]) # wallet is empty
# Actual tests
@cleanup
def sendall_two_utxos(self):
self.log.info("Testing basic sendall case without specific amounts")
pre_sendall_balance = self.add_utxos([10,11])
tx_from_wallet = self.test_sendall_success(sendall_args = [self.remainder_target])
self.assert_tx_has_outputs(tx = tx_from_wallet,
expected_outputs = [
{ "address": self.remainder_target, "value": pre_sendall_balance + tx_from_wallet["fee"] } # fee is neg
]
)
self.assert_balance_swept_completely(tx_from_wallet, pre_sendall_balance)
@cleanup
def sendall_split(self):
self.log.info("Testing sendall where two recipients have unspecified amount")
pre_sendall_balance = self.add_utxos([1, 2, 3, 15])
tx_from_wallet = self.test_sendall_success([self.remainder_target, self.split_target])
half = (pre_sendall_balance + tx_from_wallet["fee"]) / 2
self.assert_tx_has_outputs(tx_from_wallet,
expected_outputs = [
{ "address": self.split_target, "value": half },
{ "address": self.remainder_target, "value": half }
]
)
self.assert_balance_swept_completely(tx_from_wallet, pre_sendall_balance)
@cleanup
def sendall_and_spend(self):
self.log.info("Testing sendall in combination with paying specified amount to recipient")
pre_sendall_balance = self.add_utxos([8, 13])
tx_from_wallet = self.test_sendall_success([{self.recipient: 5}, self.remainder_target])
self.assert_tx_has_outputs(tx_from_wallet,
expected_outputs = [
{ "address": self.recipient, "value": 5 },
{ "address": self.remainder_target, "value": pre_sendall_balance - 5 + tx_from_wallet["fee"] }
]
)
self.assert_balance_swept_completely(tx_from_wallet, pre_sendall_balance)
@cleanup
def sendall_invalid_recipient_addresses(self):
self.log.info("Test having only recipient with specified amount, missing recipient with unspecified amount")
self.add_utxos([12, 9])
assert_raises_rpc_error(
-8,
"Must provide at least one address without a specified amount" ,
self.wallet.sendall,
[{self.recipient: 5}]
)
@cleanup
def sendall_duplicate_recipient(self):
self.log.info("Test duplicate destination")
self.add_utxos([1, 8, 3, 9])
assert_raises_rpc_error(
-8,
"Invalid parameter, duplicated address: {}".format(self.remainder_target),
self.wallet.sendall,
[self.remainder_target, self.remainder_target]
)
@cleanup
def sendall_invalid_amounts(self):
self.log.info("Test sending more than balance")
pre_sendall_balance = self.add_utxos([7, 14])
expected_tx = self.wallet.sendall(recipients=[{self.recipient: 5}, self.remainder_target], options={"add_to_wallet": False})
tx = self.wallet.decoderawtransaction(expected_tx['hex'])
fee = 21 - sum([o["value"] for o in tx["vout"]])
assert_raises_rpc_error(-6, "Assigned more value to outputs than available funds.", self.wallet.sendall,
[{self.recipient: pre_sendall_balance + 1}, self.remainder_target])
assert_raises_rpc_error(-6, "Insufficient funds for fees after creating specified outputs.", self.wallet.sendall,
[{self.recipient: pre_sendall_balance}, self.remainder_target])
assert_raises_rpc_error(-8, "Specified output amount to {} is below dust threshold".format(self.recipient),
self.wallet.sendall, [{self.recipient: 0.00000001}, self.remainder_target])
assert_raises_rpc_error(-6, "Dynamically assigned remainder results in dust output.", self.wallet.sendall,
[{self.recipient: pre_sendall_balance - fee}, self.remainder_target])
assert_raises_rpc_error(-6, "Dynamically assigned remainder results in dust output.", self.wallet.sendall,
[{self.recipient: pre_sendall_balance - fee - Decimal(0.00000010)}, self.remainder_target])
# @cleanup not needed because different wallet used
def sendall_negative_effective_value(self):
self.log.info("Test that sendall fails if all UTXOs have negative effective value")
# Use dedicated wallet for dust amounts and unload wallet at end
self.nodes[0].createwallet("dustwallet")
dust_wallet = self.nodes[0].get_wallet_rpc("dustwallet")
self.def_wallet.sendtoaddress(dust_wallet.getnewaddress(), 0.00000400)
self.def_wallet.sendtoaddress(dust_wallet.getnewaddress(), 0.00000300)
self.generate(self.nodes[0], 1)
assert_greater_than(dust_wallet.getbalances()["mine"]["trusted"], 0)
assert_raises_rpc_error(-6, "Total value of UTXO pool too low to pay for transaction."
+ " Try using lower feerate or excluding uneconomic UTXOs with 'send_max' option.",
dust_wallet.sendall, recipients=[self.remainder_target], fee_rate=300)
dust_wallet.unloadwallet()
@cleanup
def sendall_with_send_max(self):
self.log.info("Check that `send_max` option causes negative value UTXOs to be left behind")
self.add_utxos([0.00000400, 0.00000300, 1])
# sendall with send_max
sendall_tx_receipt = self.wallet.sendall(recipients=[self.remainder_target], fee_rate=300, options={"send_max": True})
tx_from_wallet = self.wallet.gettransaction(txid = sendall_tx_receipt["txid"], verbose = True)
assert_equal(len(tx_from_wallet["decoded"]["vin"]), 1)
self.assert_tx_has_outputs(tx_from_wallet, [{"address": self.remainder_target, "value": 1 + tx_from_wallet["fee"]}])
assert_equal(self.wallet.getbalances()["mine"]["trusted"], Decimal("0.00000700"))
self.def_wallet.sendtoaddress(self.wallet.getnewaddress(), 1)
self.generate(self.nodes[0], 1)
@cleanup
def sendall_specific_inputs(self):
self.log.info("Test sendall with a subset of UTXO pool")
self.add_utxos([17, 4])
utxo = self.wallet.listunspent()[0]
sendall_tx_receipt = self.wallet.sendall(recipients=[self.remainder_target], options={"inputs": [utxo]})
tx_from_wallet = self.wallet.gettransaction(txid = sendall_tx_receipt["txid"], verbose = True)
assert_equal(len(tx_from_wallet["decoded"]["vin"]), 1)
assert_equal(len(tx_from_wallet["decoded"]["vout"]), 1)
assert_equal(tx_from_wallet["decoded"]["vin"][0]["txid"], utxo["txid"])
assert_equal(tx_from_wallet["decoded"]["vin"][0]["vout"], utxo["vout"])
self.assert_tx_has_output(tx_from_wallet, self.remainder_target)
self.generate(self.nodes[0], 1)
assert_greater_than(self.wallet.getbalances()["mine"]["trusted"], 0)
@cleanup
def sendall_fails_on_missing_input(self):
# fails because UTXO was previously spent, and wallet is empty
self.log.info("Test sendall fails because specified UTXO is not available")
self.add_utxos([16, 5])
spent_utxo = self.wallet.listunspent()[0]
# fails on unconfirmed spent UTXO
self.wallet.sendall(recipients=[self.remainder_target])
assert_raises_rpc_error(-8,
"Input not available. UTXO ({}:{}) was already spent.".format(spent_utxo["txid"], spent_utxo["vout"]),
self.wallet.sendall, recipients=[self.remainder_target], options={"inputs": [spent_utxo]})
# fails on specific previously spent UTXO, while other UTXOs exist
self.generate(self.nodes[0], 1)
self.add_utxos([19, 2])
assert_raises_rpc_error(-8,
"Input not available. UTXO ({}:{}) was already spent.".format(spent_utxo["txid"], spent_utxo["vout"]),
self.wallet.sendall, recipients=[self.remainder_target], options={"inputs": [spent_utxo]})
# fails because UTXO is unknown, while other UTXOs exist
foreign_utxo = self.def_wallet.listunspent()[0]
assert_raises_rpc_error(-8, "Input not found. UTXO ({}:{}) is not part of wallet.".format(foreign_utxo["txid"],
foreign_utxo["vout"]), self.wallet.sendall, recipients=[self.remainder_target],
options={"inputs": [foreign_utxo]})
def run_test(self):
self.nodes[0].createwallet("activewallet")
self.wallet = self.nodes[0].get_wallet_rpc("activewallet")
self.def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
self.generate(self.nodes[0], 101)
self.recipient = self.def_wallet.getnewaddress() # payee for a specific amount
self.remainder_target = self.def_wallet.getnewaddress() # address that receives everything left after payments and fees
self.split_target = self.def_wallet.getnewaddress() # 2nd target when splitting rest
# Test cleanup
self.test_cleanup()
# Basic sweep: everything to one address
self.sendall_two_utxos()
# Split remainder to two addresses with equal amounts
self.sendall_split()
# Pay recipient and sweep remainder
self.sendall_and_spend()
# sendall fails if no recipient has unspecified amount
self.sendall_invalid_recipient_addresses()
# Sendall fails if same destination is provided twice
self.sendall_duplicate_recipient()
# Sendall fails when trying to spend more than the balance
self.sendall_invalid_amounts()
# Sendall fails when wallet has no economically spendable UTXOs
self.sendall_negative_effective_value()
# Leave dust behind if using send_max
self.sendall_with_send_max()
# Sendall succeeds with specific inputs
self.sendall_specific_inputs()
# Fails for the right reasons on missing or previously spent UTXOs
self.sendall_fails_on_missing_input()
if __name__ == '__main__':
SendallTest().main()

View file

@ -194,6 +194,12 @@ class WalletSignerTest(BitcoinTestFramework):
assert(res["complete"])
assert_equal(res["hex"], mock_tx)
self.log.info('Test sendall using hww1')
res = hww.sendall(recipients=[{dest:0.5}, hww.getrawchangeaddress()],options={"add_to_wallet": False})
assert(res["complete"])
assert_equal(res["hex"], mock_tx)
# # Handle error thrown by script
# self.set_mock_result(self.nodes[4], "2")
# assert_raises_rpc_error(-1, 'Unable to parse JSON',