Merge bitcoin/bitcoin#25344: New outputs argument for bumpfee/psbtbumpfee

4c8ecccdcd test: add tests for `outputs` argument to `bumpfee`/`psbtbumpfee` (Seibart Nedor)
c0ebb98382 wallet: add `outputs` arguments to `bumpfee` and `psbtbumpfee` (Seibart Nedor)
a804f3cfc0 wallet: extract and reuse RPC argument format definition for outputs (Seibart Nedor)

Pull request description:

  This implements a modification of the proposal in #22007: instead of **adding** outputs to the set of outputs in the original transaction, the outputs given by `outputs` argument **completely replace** the outputs in the original transaction.

  As noted below, this makes it easier to "cancel" a transaction or to reduce the amounts in the outputs, which is not the case with the original proposal in #22007, but it seems from the discussion in this PR that the **replace** behavior is more desirable than **add** one.

ACKs for top commit:
  achow101:
    ACK 4c8ecccdcd
  1440000bytes:
    Code Review ACK 4c8ecccdcd
  ishaanam:
    reACK 4c8ecccdcd

Tree-SHA512: 31361f4a9b79c162bda7929583b0a3fd200e09f4c1a5378b12007576d6b14e02e9e4f0bab8aa209f08f75ac25a1f4805ad16ebff4a0334b07ad2378cc0090103
This commit is contained in:
Andrew Chow 2023-02-16 13:12:20 -05:00
commit 73966f75f6
No known key found for this signature in database
GPG key ID: 17565732E08E5E41
7 changed files with 112 additions and 56 deletions

View file

@ -21,12 +21,8 @@
#include <util/strencodings.h> #include <util/strencodings.h>
#include <util/translation.h> #include <util/translation.h>
CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf) void AddInputs(CMutableTransaction& rawTx, const UniValue& inputs_in, std::optional<bool> rbf)
{ {
if (outputs_in.isNull()) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, output argument must be non-null");
}
UniValue inputs; UniValue inputs;
if (inputs_in.isNull()) { if (inputs_in.isNull()) {
inputs = UniValue::VARR; inputs = UniValue::VARR;
@ -34,18 +30,6 @@ CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniVal
inputs = inputs_in.get_array(); inputs = inputs_in.get_array();
} }
const bool outputs_is_obj = outputs_in.isObject();
UniValue outputs = outputs_is_obj ? outputs_in.get_obj() : outputs_in.get_array();
CMutableTransaction rawTx;
if (!locktime.isNull()) {
int64_t nLockTime = locktime.getInt<int64_t>();
if (nLockTime < 0 || nLockTime > LOCKTIME_MAX)
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, locktime out of range");
rawTx.nLockTime = nLockTime;
}
for (unsigned int idx = 0; idx < inputs.size(); idx++) { for (unsigned int idx = 0; idx < inputs.size(); idx++) {
const UniValue& input = inputs[idx]; const UniValue& input = inputs[idx];
const UniValue& o = input.get_obj(); const UniValue& o = input.get_obj();
@ -84,6 +68,16 @@ CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniVal
rawTx.vin.push_back(in); rawTx.vin.push_back(in);
} }
}
void AddOutputs(CMutableTransaction& rawTx, const UniValue& outputs_in)
{
if (outputs_in.isNull()) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, output argument must be non-null");
}
const bool outputs_is_obj = outputs_in.isObject();
UniValue outputs = outputs_is_obj ? outputs_in.get_obj() : outputs_in.get_array();
if (!outputs_is_obj) { if (!outputs_is_obj) {
// Translate array of key-value pairs into dict // Translate array of key-value pairs into dict
@ -132,6 +126,21 @@ CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniVal
rawTx.vout.push_back(out); rawTx.vout.push_back(out);
} }
} }
}
CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf)
{
CMutableTransaction rawTx;
if (!locktime.isNull()) {
int64_t nLockTime = locktime.getInt<int64_t>();
if (nLockTime < 0 || nLockTime > LOCKTIME_MAX)
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, locktime out of range");
rawTx.nLockTime = nLockTime;
}
AddInputs(rawTx, inputs_in, rbf);
AddOutputs(rawTx, outputs_in);
if (rbf.has_value() && rbf.value() && rawTx.vin.size() > 0 && !SignalsOptInRBF(CTransaction(rawTx))) { if (rbf.has_value() && rbf.value() && rawTx.vin.size() > 0 && !SignalsOptInRBF(CTransaction(rawTx))) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter combination: Sequence number(s) contradict replaceable option"); throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter combination: Sequence number(s) contradict replaceable option");

View file

@ -38,6 +38,13 @@ void SignTransactionResultToJSON(CMutableTransaction& mtx, bool complete, const
*/ */
void ParsePrevouts(const UniValue& prevTxsUnival, FillableSigningProvider* keystore, std::map<COutPoint, Coin>& coins); void ParsePrevouts(const UniValue& prevTxsUnival, FillableSigningProvider* keystore, std::map<COutPoint, Coin>& coins);
/** Normalize univalue-represented inputs and add them to the transaction */
void AddInputs(CMutableTransaction& rawTx, const UniValue& inputs_in, bool rbf);
/** Normalize univalue-represented outputs and add them to the transaction */
void AddOutputs(CMutableTransaction& rawTx, const UniValue& outputs_in);
/** Create a transaction from univalue parameters */ /** Create a transaction from univalue parameters */
CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf); CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf);

View file

@ -155,7 +155,7 @@ bool TransactionCanBeBumped(const CWallet& wallet, const uint256& txid)
} }
Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCoinControl& coin_control, std::vector<bilingual_str>& errors, Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCoinControl& coin_control, std::vector<bilingual_str>& errors,
CAmount& old_fee, CAmount& new_fee, CMutableTransaction& mtx, bool require_mine) CAmount& old_fee, CAmount& new_fee, CMutableTransaction& mtx, bool require_mine, const std::vector<CTxOut>& outputs)
{ {
// We are going to modify coin control later, copy to re-use // We are going to modify coin control later, copy to re-use
CCoinControl new_coin_control(coin_control); CCoinControl new_coin_control(coin_control);
@ -222,11 +222,19 @@ Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCo
return result; return result;
} }
// Fill in recipients(and preserve a single change key if there is one) // Calculate the old output amount.
// While we're here, calculate the output amount
std::vector<CRecipient> recipients;
CAmount output_value = 0; CAmount output_value = 0;
for (const auto& output : wtx.tx->vout) { for (const auto& old_output : wtx.tx->vout) {
output_value += old_output.nValue;
}
old_fee = input_value - output_value;
// Fill in recipients (and preserve a single change key if there
// is one). If outputs vector is non-empty, replace original
// outputs with its contents, otherwise use original outputs.
std::vector<CRecipient> recipients;
for (const auto& output : outputs.empty() ? wtx.tx->vout : outputs) {
if (!OutputIsChange(wallet, output)) { if (!OutputIsChange(wallet, output)) {
CRecipient recipient = {output.scriptPubKey, output.nValue, false}; CRecipient recipient = {output.scriptPubKey, output.nValue, false};
recipients.push_back(recipient); recipients.push_back(recipient);
@ -235,11 +243,8 @@ Result CreateRateBumpTransaction(CWallet& wallet, const uint256& txid, const CCo
ExtractDestination(output.scriptPubKey, change_dest); ExtractDestination(output.scriptPubKey, change_dest);
new_coin_control.destChange = change_dest; new_coin_control.destChange = change_dest;
} }
output_value += output.nValue;
} }
old_fee = input_value - output_value;
if (coin_control.m_feerate) { if (coin_control.m_feerate) {
// The user provided a feeRate argument. // The user provided a feeRate argument.
// We calculate this here to avoid compiler warning on the cs_wallet lock // We calculate this here to avoid compiler warning on the cs_wallet lock

View file

@ -51,7 +51,8 @@ Result CreateRateBumpTransaction(CWallet& wallet,
CAmount& old_fee, CAmount& old_fee,
CAmount& new_fee, CAmount& new_fee,
CMutableTransaction& mtx, CMutableTransaction& mtx,
bool require_mine); bool require_mine,
const std::vector<CTxOut>& outputs);
//! Sign the new transaction, //! Sign the new transaction,
//! @return false if the tx couldn't be found or if it was //! @return false if the tx couldn't be found or if it was

View file

@ -291,7 +291,8 @@ public:
CAmount& new_fee, CAmount& new_fee,
CMutableTransaction& mtx) override CMutableTransaction& mtx) override
{ {
return feebumper::CreateRateBumpTransaction(*m_wallet.get(), txid, coin_control, errors, old_fee, new_fee, mtx, /* require_mine= */ true) == feebumper::Result::OK; std::vector<CTxOut> outputs; // just an empty list of new recipients for now
return feebumper::CreateRateBumpTransaction(*m_wallet.get(), txid, coin_control, errors, old_fee, new_fee, mtx, /* require_mine= */ true, outputs) == feebumper::Result::OK;
} }
bool signBumpTransaction(CMutableTransaction& mtx) override { return feebumper::SignTransaction(*m_wallet.get(), mtx); } bool signBumpTransaction(CMutableTransaction& mtx) override { return feebumper::SignTransaction(*m_wallet.get(), mtx); }
bool commitBumpTransaction(const uint256& txid, bool commitBumpTransaction(const uint256& txid,

View file

@ -956,6 +956,26 @@ RPCHelpMan signrawtransactionwithwallet()
}; };
} }
// Definition of allowed formats of specifying transaction outputs in
// `bumpfee`, `psbtbumpfee`, `send` and `walletcreatefundedpsbt` RPCs.
static std::vector<RPCArg> OutputsDoc()
{
return
{
{"", 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,\n"
"the value (float or string) is the amount in " + CURRENCY_UNIT + ""},
},
},
{"", 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"},
},
},
};
}
static RPCHelpMan bumpfee_helper(std::string method_name) static RPCHelpMan bumpfee_helper(std::string method_name)
{ {
const bool want_psbt = method_name == "psbtbumpfee"; const bool want_psbt = method_name == "psbtbumpfee";
@ -992,7 +1012,12 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
"still be replaceable in practice, for example if it has unconfirmed ancestors which\n" "still be replaceable in practice, for example if it has unconfirmed ancestors which\n"
"are replaceable).\n"}, "are replaceable).\n"},
{"estimate_mode", RPCArg::Type::STR, RPCArg::Default{"unset"}, "The fee estimate mode, must be one of (case insensitive):\n" {"estimate_mode", RPCArg::Type::STR, RPCArg::Default{"unset"}, "The fee estimate mode, must be one of (case insensitive):\n"
"\"" + FeeModes("\"\n\"") + "\""}, "\"" + FeeModes("\"\n\"") + "\""},
{"outputs", RPCArg::Type::ARR, RPCArg::Default{UniValue::VARR}, "New outputs (key-value pairs) which will replace\n"
"the original ones, if provided. Each address can only appear once and there can\n"
"only be one \"data\" object.\n",
OutputsDoc(),
RPCArgOptions{.skip_type_check = true}},
}, },
RPCArgOptions{.oneline_description="options"}}, RPCArgOptions{.oneline_description="options"}},
}, },
@ -1029,6 +1054,7 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
coin_control.fAllowWatchOnly = pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS); coin_control.fAllowWatchOnly = pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS);
// optional parameters // optional parameters
coin_control.m_signal_bip125_rbf = true; coin_control.m_signal_bip125_rbf = true;
std::vector<CTxOut> outputs;
if (!request.params[1].isNull()) { if (!request.params[1].isNull()) {
UniValue options = request.params[1]; UniValue options = request.params[1];
@ -1039,6 +1065,7 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
{"fee_rate", UniValueType()}, // will be checked by AmountFromValue() in SetFeeEstimateMode() {"fee_rate", UniValueType()}, // will be checked by AmountFromValue() in SetFeeEstimateMode()
{"replaceable", UniValueType(UniValue::VBOOL)}, {"replaceable", UniValueType(UniValue::VBOOL)},
{"estimate_mode", UniValueType(UniValue::VSTR)}, {"estimate_mode", UniValueType(UniValue::VSTR)},
{"outputs", UniValueType()}, // will be checked by AddOutputs()
}, },
true, true); true, true);
@ -1052,6 +1079,16 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
coin_control.m_signal_bip125_rbf = options["replaceable"].get_bool(); coin_control.m_signal_bip125_rbf = options["replaceable"].get_bool();
} }
SetFeeEstimateMode(*pwallet, coin_control, conf_target, options["estimate_mode"], options["fee_rate"], /*override_min_fee=*/false); SetFeeEstimateMode(*pwallet, coin_control, conf_target, options["estimate_mode"], options["fee_rate"], /*override_min_fee=*/false);
// Prepare new outputs by creating a temporary tx and calling AddOutputs().
if (!options["outputs"].isNull()) {
if (options["outputs"].isArray() && options["outputs"].empty()) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, output argument cannot be an empty array");
}
CMutableTransaction tempTx;
AddOutputs(tempTx, options["outputs"]);
outputs = tempTx.vout;
}
} }
// Make sure the results are valid at least up to the most recent block // Make sure the results are valid at least up to the most recent block
@ -1069,7 +1106,7 @@ static RPCHelpMan bumpfee_helper(std::string method_name)
CMutableTransaction mtx; CMutableTransaction mtx;
feebumper::Result res; feebumper::Result res;
// Targeting feerate bump. // Targeting feerate bump.
res = feebumper::CreateRateBumpTransaction(*pwallet, hash, coin_control, errors, old_fee, new_fee, mtx, /*require_mine=*/ !want_psbt); res = feebumper::CreateRateBumpTransaction(*pwallet, hash, coin_control, errors, old_fee, new_fee, mtx, /*require_mine=*/ !want_psbt, outputs);
if (res != feebumper::Result::OK) { if (res != feebumper::Result::OK) {
switch(res) { switch(res) {
case feebumper::Result::INVALID_ADDRESS_OR_KEY: case feebumper::Result::INVALID_ADDRESS_OR_KEY:
@ -1144,18 +1181,7 @@ RPCHelpMan send()
{"outputs", RPCArg::Type::ARR, RPCArg::Optional::NO, "The outputs (key-value pairs), where none of the keys are duplicated.\n" {"outputs", RPCArg::Type::ARR, RPCArg::Optional::NO, "The 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" "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.", "For convenience, a dictionary, which holds the key-value pairs directly, is also accepted.",
{ OutputsDoc(),
{"", 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 + ""},
},
},
{"", 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"},
},
},
},
RPCArgOptions{.skip_type_check = true}}, RPCArgOptions{.skip_type_check = true}},
{"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"}, {"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks"},
{"estimate_mode", RPCArg::Type::STR, RPCArg::Default{"unset"}, "The fee estimate mode, must be one of (case insensitive):\n" {"estimate_mode", RPCArg::Type::STR, RPCArg::Default{"unset"}, "The fee estimate mode, must be one of (case insensitive):\n"
@ -1606,19 +1632,8 @@ RPCHelpMan walletcreatefundedpsbt()
"That is, each address can only appear once and there can only be one 'data' object.\n" "That is, each address can only appear once and there can only be one 'data' object.\n"
"For compatibility reasons, a dictionary, which holds the key-value pairs directly, is also\n" "For compatibility reasons, a dictionary, which holds the key-value pairs directly, is also\n"
"accepted as second parameter.", "accepted as second parameter.",
{ OutputsDoc(),
{"", RPCArg::Type::OBJ_USER_KEYS, RPCArg::Optional::OMITTED, "", RPCArgOptions{.skip_type_check = true}},
{
{"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 + ""},
},
},
{"", 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"},
},
},
},
RPCArgOptions{.skip_type_check = true}},
{"locktime", RPCArg::Type::NUM, RPCArg::Default{0}, "Raw locktime. Non-0 value also locktime-activates inputs"}, {"locktime", RPCArg::Type::NUM, RPCArg::Default{0}, "Raw locktime. Non-0 value also locktime-activates inputs"},
{"options", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED, "", {"options", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED, "",
Cat<std::vector<RPCArg>>( Cat<std::vector<RPCArg>>(

View file

@ -81,7 +81,7 @@ class BumpFeeTest(BitcoinTestFramework):
self.log.info("Running tests") self.log.info("Running tests")
dest_address = peer_node.getnewaddress() dest_address = peer_node.getnewaddress()
for mode in ["default", "fee_rate"]: for mode in ["default", "fee_rate", "new_outputs"]:
test_simple_bumpfee_succeeds(self, mode, rbf_node, peer_node, dest_address) test_simple_bumpfee_succeeds(self, mode, rbf_node, peer_node, dest_address)
self.test_invalid_parameters(rbf_node, peer_node, dest_address) self.test_invalid_parameters(rbf_node, peer_node, dest_address)
test_segwit_bumpfee_succeeds(self, rbf_node, dest_address) test_segwit_bumpfee_succeeds(self, rbf_node, dest_address)
@ -157,6 +157,14 @@ class BumpFeeTest(BitcoinTestFramework):
assert_raises_rpc_error(-8, 'Invalid estimate_mode parameter, must be one of: "unset", "economical", "conservative"', assert_raises_rpc_error(-8, 'Invalid estimate_mode parameter, must be one of: "unset", "economical", "conservative"',
rbf_node.bumpfee, rbfid, {"estimate_mode": mode}) rbf_node.bumpfee, rbfid, {"estimate_mode": mode})
self.log.info("Test invalid outputs values")
assert_raises_rpc_error(-8, "Invalid parameter, output argument cannot be an empty array",
rbf_node.bumpfee, rbfid, {"outputs": []})
assert_raises_rpc_error(-8, "Invalid parameter, duplicated address: " + dest_address,
rbf_node.bumpfee, rbfid, {"outputs": [{dest_address: 0.1}, {dest_address: 0.2}]})
assert_raises_rpc_error(-8, "Invalid parameter, duplicate key: data",
rbf_node.bumpfee, rbfid, {"outputs": [{"data": "deadbeef"}, {"data": "deadbeef"}]})
self.clear_mempool() self.clear_mempool()
@ -169,6 +177,10 @@ def test_simple_bumpfee_succeeds(self, mode, rbf_node, peer_node, dest_address):
if mode == "fee_rate": if mode == "fee_rate":
bumped_psbt = rbf_node.psbtbumpfee(rbfid, {"fee_rate": str(NORMAL)}) bumped_psbt = rbf_node.psbtbumpfee(rbfid, {"fee_rate": str(NORMAL)})
bumped_tx = rbf_node.bumpfee(rbfid, {"fee_rate": NORMAL}) bumped_tx = rbf_node.bumpfee(rbfid, {"fee_rate": NORMAL})
elif mode == "new_outputs":
new_address = peer_node.getnewaddress()
bumped_psbt = rbf_node.psbtbumpfee(rbfid, {"outputs": {new_address: 0.0003}})
bumped_tx = rbf_node.bumpfee(rbfid, {"outputs": {new_address: 0.0003}})
else: else:
bumped_psbt = rbf_node.psbtbumpfee(rbfid) bumped_psbt = rbf_node.psbtbumpfee(rbfid)
bumped_tx = rbf_node.bumpfee(rbfid) bumped_tx = rbf_node.bumpfee(rbfid)
@ -192,6 +204,10 @@ def test_simple_bumpfee_succeeds(self, mode, rbf_node, peer_node, dest_address):
bumpedwtx = rbf_node.gettransaction(bumped_tx["txid"]) bumpedwtx = rbf_node.gettransaction(bumped_tx["txid"])
assert_equal(oldwtx["replaced_by_txid"], bumped_tx["txid"]) assert_equal(oldwtx["replaced_by_txid"], bumped_tx["txid"])
assert_equal(bumpedwtx["replaces_txid"], rbfid) assert_equal(bumpedwtx["replaces_txid"], rbfid)
# if this is a new_outputs test, check that outputs were indeed replaced
if mode == "new_outputs":
assert len(bumpedwtx["details"]) == 1
assert bumpedwtx["details"][0]["address"] == new_address
self.clear_mempool() self.clear_mempool()
@ -628,12 +644,14 @@ def test_change_script_match(self, rbf_node, dest_address):
self.clear_mempool() self.clear_mempool()
def spend_one_input(node, dest_address, change_size=Decimal("0.00049000")): def spend_one_input(node, dest_address, change_size=Decimal("0.00049000"), data=None):
tx_input = dict( tx_input = dict(
sequence=MAX_BIP125_RBF_SEQUENCE, **next(u for u in node.listunspent() if u["amount"] == Decimal("0.00100000"))) sequence=MAX_BIP125_RBF_SEQUENCE, **next(u for u in node.listunspent() if u["amount"] == Decimal("0.00100000")))
destinations = {dest_address: Decimal("0.00050000")} destinations = {dest_address: Decimal("0.00050000")}
if change_size > 0: if change_size > 0:
destinations[node.getrawchangeaddress()] = change_size destinations[node.getrawchangeaddress()] = change_size
if data:
destinations['data'] = data
rawtx = node.createrawtransaction([tx_input], destinations) rawtx = node.createrawtransaction([tx_input], destinations)
signedtx = node.signrawtransactionwithwallet(rawtx) signedtx = node.signrawtransactionwithwallet(rawtx)
txid = node.sendrawtransaction(signedtx["hex"]) txid = node.sendrawtransaction(signedtx["hex"])