diff --git a/doc/release-notes/release-notes.md b/doc/release-notes/release-notes.md --- a/doc/release-notes/release-notes.md +++ b/doc/release-notes/release-notes.md @@ -12,3 +12,6 @@ will still be loaded. Users without an unnamed `""` wallet and without any other wallets to be loaded on startup will be prompted to either choose a wallet to load, or to create a new wallet. +- A new `send` RPC with similar syntax to `walletcreatefundedpsbt`, including + support for coin selection and a custom fee rate. Using the new `send` method + is encouraged: `sendmany` and `sendtoaddress` may be deprecated in a future release. diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -113,6 +113,8 @@ {"gettxoutproof", 0, "txids"}, {"lockunspent", 0, "unlock"}, {"lockunspent", 1, "transactions"}, + {"send", 0, "outputs"}, + {"send", 1, "options"}, {"importprivkey", 2, "rescan"}, {"importaddress", 2, "rescan"}, {"importaddress", 3, "p2sh"}, diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -3694,17 +3695,21 @@ options, { {"add_inputs", UniValueType(UniValue::VBOOL)}, + {"add_to_wallet", UniValueType(UniValue::VBOOL)}, {"changeAddress", UniValueType(UniValue::VSTR)}, {"change_address", UniValueType(UniValue::VSTR)}, {"changePosition", UniValueType(UniValue::VNUM)}, {"change_position", UniValueType(UniValue::VNUM)}, {"includeWatching", UniValueType(UniValue::VBOOL)}, {"include_watching", UniValueType(UniValue::VBOOL)}, + {"inputs", UniValueType(UniValue::VARR)}, {"lockUnspents", UniValueType(UniValue::VBOOL)}, {"lock_unspents", UniValueType(UniValue::VBOOL)}, + {"locktime", UniValueType(UniValue::VNUM)}, // will be checked below {"feeRate", UniValueType()}, {"fee_rate", UniValueType()}, + {"psbt", UniValueType(UniValue::VBOOL)}, {"subtractFeeFromOutputs", UniValueType(UniValue::VARR)}, {"subtract_fee_from_outputs", UniValueType(UniValue::VARR)}, }, @@ -4573,6 +4578,251 @@ return ret; } +static RPCHelpMan send() { + return RPCHelpMan{ + "send", + "\nSend a transaction.\n", + { + { + "outputs", + RPCArg::Type::ARR, + RPCArg::Optional::NO, + "a json array with outputs (key-value pairs), where none of " + "the keys are duplicated.\n" + "That is, each address can only appear once and there can only " + "be one 'data' object.\n" + "For convenience, a dictionary, which holds the key-value " + "pairs directly, is also accepted.", + { + { + "", + RPCArg::Type::OBJ, + 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::get().ticker + ""}, + }, + }, + { + "", + RPCArg::Type::OBJ, + RPCArg::Optional::OMITTED, + "", + { + {"data", RPCArg::Type::STR_HEX, + RPCArg::Optional::NO, + "A key-value pair. The key must be \"data\", the " + "value is hex-encoded data"}, + }, + }, + }, + }, + {"options", + RPCArg::Type::OBJ, + RPCArg::Optional::OMITTED_NAMED_ARG, + "", + { + {"add_inputs", RPCArg::Type::BOOL, /* default */ "false", + "If inputs are specified, automatically include more if they " + "are not enough."}, + {"add_to_wallet", RPCArg::Type::BOOL, /* default */ "true", + "When false, returns a serialized transaction which will not " + "be added to the wallet or broadcast"}, + {"change_address", RPCArg::Type::STR_HEX, + /* default */ "pool address", + "The bitcoin address to receive the change"}, + {"change_position", RPCArg::Type::NUM, /* default */ "random", + "The index of the change output"}, + {"fee_rate", RPCArg::Type::AMOUNT, /* default */ + "not set: makes wallet determine the fee", + "Set a specific fee rate in " + Currency::get().ticker + + "/kB"}, + {"include_watching", RPCArg::Type::BOOL, + /* default */ "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, + /* default */ "empty array", + "Specify inputs instead of adding them automatically. 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, /* default */ "0", + "Raw locktime. Non-0 value also locktime-activates inputs"}, + {"lock_unspents", RPCArg::Type::BOOL, /* default */ "false", + "Lock selected unspent outputs"}, + {"psbt", RPCArg::Type::BOOL, /* default */ "automatic", + "Always return a PSBT, implies add_to_wallet=false."}, + { + "subtract_fee_from_outputs", + RPCArg::Type::ARR, + /* default */ "empty array", + "A json array of integers.\n" + "The fee will be equally deducted from the amount of each " + "specified output.\n" + "Those recipients will receive less bitcoins than you " + "enter in their corresponding amount field.\n" + "If no outputs are specified here, the sender pays the " + "fee.", + { + {"vout_index", RPCArg::Type::NUM, + RPCArg::Optional::OMITTED, + "The zero-based output index, before a change output " + "is added."}, + }, + }, + }, + "options"}, + }, + RPCResult{ + RPCResult::Type::OBJ, + "", + "", + {{RPCResult::Type::BOOL, "complete", + "If the transaction has a complete set of signatures"}, + {RPCResult::Type::STR_HEX, "txid", + "The transaction id for the send. Only 1 transaction is created " + "regardless of the number of addresses."}, + {RPCResult::Type::STR_HEX, "hex", + "If add_to_wallet is false, the hex-encoded raw transaction with " + "signature(s)"}, + {RPCResult::Type::STR, "psbt", + "If more signatures are needed, or if add_to_wallet is false, " + "the base64-encoded (partially) signed transaction"}}}, + RPCExamples{ + "" + "\nSend with a fee rate of 10 XEC/kB\n" + + HelpExampleCli( + "send", + "'{\"" + EXAMPLE_ADDRESS + + "\": 100000}' '{\"fee_rate\": 10}'\n" + + "\nCreate a transaction with a specific input, and return " + "result without adding to wallet or broadcasting to the " + "network\n") + + HelpExampleCli("send", + "'{\"" + EXAMPLE_ADDRESS + + "\": 100000}' '{\"add_to_wallet\": " + "false, \"inputs\": " + "[{\"txid\":" + "\"a08e6907dbbd3d809776dbfc5d82e371b764ed838b565" + "5e72f463568df1aadf0\", \"vout\":1}]}'")}, + [&](const RPCHelpMan &self, const Config &config, + const JSONRPCRequest &request) -> UniValue { + RPCTypeCheck(request.params, + {// ARR or OBJ, checked later + UniValueType(), UniValue::VOBJ}, + true); + + std::shared_ptr const wallet = + GetWalletForJSONRPCRequest(request); + if (!wallet) { + return NullUniValue; + } + CWallet *const pwallet = wallet.get(); + + UniValue options = request.params[1]; + if (options.exists("changeAddress")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Use change_address"); + } + if (options.exists("changePosition")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Use change_position"); + } + if (options.exists("includeWatching")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Use include_watching"); + } + if (options.exists("lockUnspents")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Use lock_unspents"); + } + if (options.exists("subtractFeeFromOutputs")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Use subtract_fee_from_outputs"); + } + if (options.exists("feeRate")) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Use fee_rate"); + } + + const bool psbt_opt_in = + options.exists("psbt") && options["psbt"].get_bool(); + + Amount fee; + int change_position; + CMutableTransaction rawTx = ConstructTransaction( + wallet->GetChainParams(), options["inputs"], request.params[0], + options["locktime"]); + CCoinControl coin_control; + // Automatically select coins, unless at least one is manually + // selected. Can be overridden by options.add_inputs. + coin_control.m_add_inputs = rawTx.vin.size() == 0; + FundTransaction(pwallet, rawTx, fee, change_position, options, + coin_control); + + bool add_to_wallet = true; + if (options.exists("add_to_wallet")) { + add_to_wallet = options["add_to_wallet"].get_bool(); + } + + // Make a blank psbt + PartiallySignedTransaction psbtx(rawTx); + + // Fill transaction with out data and sign + bool complete = true; + const TransactionError err = pwallet->FillPSBT( + psbtx, complete, SigHashType().withForkId(), true, false); + if (err != TransactionError::OK) { + throw JSONRPCTransactionError(err); + } + + CMutableTransaction mtx; + complete = FinalizeAndExtractPSBT(psbtx, mtx); + + UniValue result(UniValue::VOBJ); + + // Serialize the PSBT + CDataStream ssTx(SER_NETWORK, PROTOCOL_VERSION); + ssTx << psbtx; + const std::string result_str = EncodeBase64(ssTx.str()); + + if (psbt_opt_in || !complete || !add_to_wallet) { + result.pushKV("psbt", result_str); + } + + if (complete) { + std::string err_string; + std::string hex = EncodeHexTx(CTransaction(mtx)); + CTransactionRef tx(MakeTransactionRef(std::move(mtx))); + result.pushKV("txid", tx->GetHash().GetHex()); + if (add_to_wallet && !psbt_opt_in) { + pwallet->CommitTransaction(tx, {}, {} /* orderForm */); + } else { + result.pushKV("hex", hex); + } + } + result.pushKV("complete", complete); + + return result; + }}; +} + static UniValue sethdseed(const Config &config, const JSONRPCRequest &request) { RPCHelpMan{ "sethdseed", @@ -5019,6 +5269,7 @@ { "wallet", "loadwallet", loadwallet, {"filename", "load_on_startup"} }, { "wallet", "lockunspent", lockunspent, {"unlock","transactions"} }, { "wallet", "rescanblockchain", rescanblockchain, {"start_height", "stop_height"} }, + { "wallet", "send", send, {"outputs", "options"} }, { "wallet", "sendmany", sendmany, {"dummy","amounts","minconf","comment","subtractfeefrom"} }, { "wallet", "sendtoaddress", sendtoaddress, {"address","amount","comment","comment_to","subtractfeefromamount","avoid_reuse"} }, { "wallet", "sethdseed", sethdseed, {"newkeypool","seed"} }, diff --git a/test/functional/wallet_send.py b/test/functional/wallet_send.py new file mode 100755 --- /dev/null +++ b/test/functional/wallet_send.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +# Copyright (c) 2020 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 send RPC command.""" + +from decimal import Decimal + +from test_framework.authproxy import JSONRPCException +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_fee_amount, + assert_greater_than, + assert_raises_rpc_error, +) + + +class WalletSendTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 2 + # whitelist all peers to speed up tx relay / mempool sync + self.extra_args = [ + ["-whitelist=127.0.0.1", ], + ["-whitelist=127.0.0.1", ], + ] + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def test_send(self, from_wallet, to_wallet=None, amount=None, data=None, + add_to_wallet=None, psbt=None, inputs=None, add_inputs=None, + change_address=None, change_position=None, + include_watching=None, locktime=None, lock_unspents=None, + subtract_fee_from_outputs=None, fee_rate=None, + expect_error=None): + assert (amount is None) != (data is None) + + from_balance_before = from_wallet.getbalance() + if to_wallet is None: + assert amount is None + else: + to_untrusted_pending_before = \ + to_wallet.getbalances()["mine"]["untrusted_pending"] + + if amount: + dest = to_wallet.getnewaddress() + outputs = {dest: amount} + else: + outputs = {"data": data} + + # Construct options dictionary + options = {} + if add_to_wallet is not None: + options["add_to_wallet"] = add_to_wallet + else: + add_to_wallet = ( + False if psbt else + from_wallet.getwalletinfo()["private_keys_enabled"] + ) + if psbt is not None: + options["psbt"] = psbt + if inputs is not None: + options["inputs"] = inputs + if add_inputs is not None: + options["add_inputs"] = add_inputs + if change_address is not None: + options["change_address"] = change_address + if change_position is not None: + options["change_position"] = change_position + if include_watching is not None: + options["include_watching"] = include_watching + if locktime is not None: + options["locktime"] = locktime + if lock_unspents is not None: + options["lock_unspents"] = lock_unspents + if subtract_fee_from_outputs is not None: + options["subtract_fee_from_outputs"] = subtract_fee_from_outputs + if fee_rate is not None: + options["fee_rate"] = fee_rate + + if len(options.keys()) == 0: + options = None + + if expect_error is None: + res = from_wallet.send( + outputs=outputs, + options=options) + else: + try: + assert_raises_rpc_error( + expect_error[0], expect_error[1], from_wallet.send, + outputs=outputs, options=options) + except AssertionError: + # Provide debug info if the test fails + self.log.error("Unexpected successful result:") + self.log.error(options) + res = from_wallet.send( + outputs=outputs, + options=options) + self.log.error(res) + if "txid" in res and add_to_wallet: + self.log.error("Transaction details:") + try: + tx = from_wallet.gettransaction(res["txid"]) + self.log.error(tx) + self.log.error( + "testmempoolaccept (transaction may already be in mempool):") + self.log.error( + from_wallet.testmempoolaccept([tx["hex"]])) + except JSONRPCException as exc: + self.log.error(exc) + + raise + + return + + if locktime: + return res + + if (from_wallet.getwalletinfo()["private_keys_enabled"] + and not include_watching): + assert_equal(res["complete"], True) + assert "txid" in res + else: + assert_equal(res["complete"], False) + assert "txid" not in res + assert "psbt" in res + + if add_to_wallet and not include_watching: + # Ensure transaction exists in the wallet: + tx = from_wallet.gettransaction(res["txid"]) + assert tx + # Ensure transaction exists in the mempool: + tx = from_wallet.getrawtransaction(res["txid"], True) + assert tx + if amount: + if subtract_fee_from_outputs: + assert_equal( + from_balance_before - + from_wallet.getbalance(), + amount) + else: + assert_greater_than( + from_balance_before - from_wallet.getbalance(), amount) + else: + assert next( + (out for out in tx["vout"] if out["scriptPubKey"] + ["asm"] == "OP_RETURN 35"), + None) + else: + assert_equal(from_balance_before, from_wallet.getbalance()) + + if to_wallet: + self.sync_mempools() + if add_to_wallet: + if not subtract_fee_from_outputs: + assert_equal( + to_wallet.getbalances()["mine"]["untrusted_pending"], + to_untrusted_pending_before + + Decimal( + amount if amount else 0)) + else: + assert_equal( + to_wallet.getbalances()["mine"]["untrusted_pending"], + to_untrusted_pending_before) + + return res + + def run_test(self): + self.log.info("Setup wallets...") + # w0 is a wallet with coinbase rewards + w0 = self.nodes[0].get_wallet_rpc("") + # w1 is a regular wallet + self.nodes[1].createwallet(wallet_name="w1") + w1 = self.nodes[1].get_wallet_rpc("w1") + # w2 contains the private keys for w3 + self.nodes[1].createwallet(wallet_name="w2") + w2 = self.nodes[1].get_wallet_rpc("w2") + # w3 is a watch-only wallet, based on w2 + self.nodes[1].createwallet(wallet_name="w3", disable_private_keys=True) + w3 = self.nodes[1].get_wallet_rpc("w3") + for _ in range(3): + a2_receive = w2.getnewaddress() + # doesn't actually use change derivation + a2_change = w2.getrawchangeaddress() + res = w3.importmulti([{ + "desc": w2.getaddressinfo(a2_receive)["desc"], + "timestamp": "now", + "keypool": True, + "watchonly": True + }, { + "desc": w2.getaddressinfo(a2_change)["desc"], + "timestamp": "now", + "keypool": True, + "internal": True, + "watchonly": True + }]) + assert_equal(res, [{"success": True}, {"success": True}]) + + # fund w3 + w0.sendtoaddress(a2_receive, 10_000_000) + self.nodes[0].generate(1) + self.sync_blocks() + + # w4 has private keys enabled, but only contains watch-only keys (from + # w2) + self.nodes[1].createwallet( + wallet_name="w4", + disable_private_keys=False) + w4 = self.nodes[1].get_wallet_rpc("w4") + for _ in range(3): + a2_receive = w2.getnewaddress() + res = w4.importmulti([{ + "desc": w2.getaddressinfo(a2_receive)["desc"], + "timestamp": "now", + "keypool": False, + "watchonly": True + }]) + assert_equal(res, [{"success": True}]) + + # fund w4 + w0.sendtoaddress(a2_receive, 10_000_000) + self.nodes[0].generate(1) + self.sync_blocks() + + self.log.info("Send to address...") + self.test_send(from_wallet=w0, to_wallet=w1, amount=1_000_000) + self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + add_to_wallet=True) + + self.log.info("Don't broadcast...") + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + add_to_wallet=False) + assert(res["hex"]) + + self.log.info("Return PSBT...") + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + psbt=True) + assert(res["psbt"]) + + self.log.info( + "Create transaction that spends to address, but don't broadcast...") + self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + add_to_wallet=False) + + self.log.info("Create PSBT from watch-only wallet w3, sign with w2...") + res = self.test_send(from_wallet=w3, to_wallet=w1, amount=1_000_000) + res = w2.walletprocesspsbt(res["psbt"]) + assert res["complete"] + + self.log.info( + "Create PSBT from wallet w4 with watch-only keys, sign with w2...") + self.test_send(from_wallet=w4, to_wallet=w1, amount=1_000_000, + expect_error=(-4, "Insufficient funds")) + res = self.test_send( + from_wallet=w4, + to_wallet=w1, + amount=1_000_000, + include_watching=True, + add_to_wallet=False) + res = w2.walletprocesspsbt(res["psbt"]) + assert res["complete"] + + self.log.info("Create OP_RETURN...") + self.test_send(from_wallet=w0, to_wallet=w1, amount=1_000_000) + self.test_send(from_wallet=w0, + data="Hello World", + expect_error=(-8, + "Data must be hexadecimal string (not 'Hello World')")) + self.test_send(from_wallet=w0, data="23") + res = self.test_send(from_wallet=w3, data="23") + res = w2.walletprocesspsbt(res["psbt"]) + assert res["complete"] + + self.log.info("Set fee rate...") + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + fee_rate=Decimal("20.00"), + add_to_wallet=False) + fee = self.nodes[1].decodepsbt(res["psbt"])["fee"] + assert_fee_amount(fee, + Decimal(len(res["hex"]) / 2), + Decimal("20.00")) + self.test_send(from_wallet=w0, to_wallet=w1, amount=1_000_000, fee_rate=-1, + expect_error=(-3, "Amount out of range")) + # Fee rate of 0.1 satoshi per byte should throw an error + self.test_send(from_wallet=w0, to_wallet=w1, amount=1_000_000, + fee_rate=Decimal("1.00"), + expect_error=(-4, "Fee rate (1.00 XEC/kB) is lower than the minimum fee rate setting (10.00 XEC/kB)")) + + # TODO: Return hex if fee rate is below -maxmempool + # res = self.test_send(from_wallet=w0, to_wallet=w1, amount=1_000_000, + # feeRate=Decimal("1.00"), add_to_wallet=False) + # assert res["hex"] + # hex = res["hex"] + # res = self.nodes[0].testmempoolaccept([hex]) + # assert not res[0]["allowed"] + # assert_equal(res[0]["reject-reason"], "...") # low fee + # assert_fee_amount(fee, Decimal(len(res["hex"]) / 2), Decimal("1.00")) + + self.log.info( + "If inputs are specified, do not automatically add more...") + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=51_000_000, + inputs=[], + add_to_wallet=False) + assert res["complete"] + utxo1 = w0.listunspent()[0] + assert_equal(utxo1["amount"], 50_000_000) + self.test_send(from_wallet=w0, to_wallet=w1, amount=51_000_000, + inputs=[utxo1], expect_error=(-4, "Insufficient funds")) + self.test_send(from_wallet=w0, to_wallet=w1, amount=51_000_000, + inputs=[utxo1], add_inputs=False, + expect_error=(-4, "Insufficient funds")) + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=51_000_000, + inputs=[utxo1], + add_inputs=True, + add_to_wallet=False) + assert res["complete"] + + self.log.info("Manual change address and position...") + self.test_send(from_wallet=w0, to_wallet=w1, amount=1_000_000, change_address="not an address", + expect_error=(-5, "Change address must be a valid bitcoin address")) + change_address = w0.getnewaddress() + self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + add_to_wallet=False, + change_address=change_address) + assert res["complete"] + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + add_to_wallet=False, + change_address=change_address, + change_position=0) + assert res["complete"] + assert_equal( + self.nodes[0].decodepsbt( + res["psbt"])["tx"]["vout"][0]["scriptPubKey"]["addresses"], + [change_address]) + + self.log.info("Set lock time...") + height = self.nodes[0].getblockchaininfo()["blocks"] + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + locktime=height + 1) + assert res["complete"] + assert res["txid"] + txid = res["txid"] + # Although the wallet finishes the transaction, it can't be added to + # the mempool yet: + hex = self.nodes[0].gettransaction(res["txid"])["hex"] + res = self.nodes[0].testmempoolaccept([hex]) + assert not res[0]["allowed"] + assert_equal(res[0]["reject-reason"], "bad-txns-nonfinal") + # It shouldn't be confirmed in the next block + self.nodes[0].generate(1) + assert_equal(self.nodes[0].gettransaction(txid)["confirmations"], 0) + # The mempool should allow it now: + res = self.nodes[0].testmempoolaccept([hex]) + assert res[0]["allowed"] + # Don't wait for wallet to add it to the mempool: + res = self.nodes[0].sendrawtransaction(hex) + self.nodes[0].generate(1) + assert_equal(self.nodes[0].gettransaction(txid)["confirmations"], 1) + + self.log.info("Lock unspents...") + utxo1 = w0.listunspent()[0] + assert_greater_than(utxo1["amount"], 1) + res = self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + inputs=[utxo1], + add_to_wallet=False, + lock_unspents=True) + assert res["complete"] + locked_coins = w0.listlockunspent() + assert_equal(len(locked_coins), 1) + # Locked coins are automatically unlocked when manually selected + self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + inputs=[utxo1], + add_to_wallet=False) + + self.log.info("Subtract fee from output") + self.test_send( + from_wallet=w0, + to_wallet=w1, + amount=1_000_000, + subtract_fee_from_outputs=[0]) + + +if __name__ == '__main__': + WalletSendTest().main()