Skip to content

Commit b67db52

Browse files
committed
RPC submitpackage: change return format to allow partial errors
Behavior prior to this commit allows some transactions to enter into the local mempool but not be reported to the user when encountering a PackageValidationResult::PCKG_TX result. This is further compounded with the fact that any transactions submitted to the mempool during this call would also not be relayed to peers, resulting in unexpected behavior. Fix this by, if encountering a package error, reporting all wtxids, along with a new error field, and broadcasting every transaction that was found in the mempool after submission. Note that this also changes fees and vsize to optional, which should also remove an issue with other-wtxid cases.
1 parent 1fdd832 commit b67db52

File tree

4 files changed

+78
-31
lines changed

4 files changed

+78
-31
lines changed

src/rpc/mempool.cpp

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ static RPCHelpMan submitpackage()
822822
return RPCHelpMan{"submitpackage",
823823
"Submit a package of raw transactions (serialized, hex-encoded) to local node.\n"
824824
"The package must consist of a child with its parents, and none of the parents may depend on one another.\n"
825-
"The package will be validated according to consensus and mempool policy rules. If all transactions pass, they will be accepted to mempool.\n"
825+
"The package will be validated according to consensus and mempool policy rules. If any transaction passes, it will be accepted to mempool.\n"
826826
"This RPC is experimental and the interface may be unstable. Refer to doc/policy/packages.md for documentation on package policies.\n"
827827
"Warning: successful submission does not mean the transactions will propagate throughout the network.\n"
828828
,
@@ -836,19 +836,21 @@ static RPCHelpMan submitpackage()
836836
RPCResult{
837837
RPCResult::Type::OBJ, "", "",
838838
{
839+
{RPCResult::Type::STR, "package_msg", "The transaction package result message. \"success\" indicates all transactions were accepted into or are already in the mempool."},
839840
{RPCResult::Type::OBJ_DYN, "tx-results", "transaction results keyed by wtxid",
840841
{
841842
{RPCResult::Type::OBJ, "wtxid", "transaction wtxid", {
842843
{RPCResult::Type::STR_HEX, "txid", "The transaction hash in hex"},
843844
{RPCResult::Type::STR_HEX, "other-wtxid", /*optional=*/true, "The wtxid of a different transaction with the same txid but different witness found in the mempool. This means the submitted transaction was ignored."},
844-
{RPCResult::Type::NUM, "vsize", "Virtual transaction size as defined in BIP 141."},
845-
{RPCResult::Type::OBJ, "fees", "Transaction fees", {
845+
{RPCResult::Type::NUM, "vsize", /*optional=*/true, "Virtual transaction size as defined in BIP 141."},
846+
{RPCResult::Type::OBJ, "fees", /*optional=*/true, "Transaction fees", {
846847
{RPCResult::Type::STR_AMOUNT, "base", "transaction fee in " + CURRENCY_UNIT},
847848
{RPCResult::Type::STR_AMOUNT, "effective-feerate", /*optional=*/true, "if the transaction was not already in the mempool, the effective feerate in " + CURRENCY_UNIT + " per KvB. For example, the package feerate and/or feerate with modified fees from prioritisetransaction."},
848849
{RPCResult::Type::ARR, "effective-includes", /*optional=*/true, "if effective-feerate is provided, the wtxids of the transactions whose fees and vsizes are included in effective-feerate.",
849850
{{RPCResult::Type::STR_HEX, "", "transaction wtxid in hex"},
850851
}},
851852
}},
853+
{RPCResult::Type::STR, "error", /*optional=*/true, "The transaction error string, if it was rejected by the mempool"},
852854
}}
853855
}},
854856
{RPCResult::Type::ARR, "replaced-transactions", /*optional=*/true, "List of txids of replaced transactions",
@@ -888,57 +890,77 @@ static RPCHelpMan submitpackage()
888890
Chainstate& chainstate = EnsureChainman(node).ActiveChainstate();
889891
const auto package_result = WITH_LOCK(::cs_main, return ProcessNewPackage(chainstate, mempool, txns, /*test_accept=*/ false));
890892

891-
// First catch any errors.
893+
std::string package_msg = "success";
894+
895+
// First catch package-wide errors, continue if we can
892896
switch(package_result.m_state.GetResult()) {
893-
case PackageValidationResult::PCKG_RESULT_UNSET: break;
894-
case PackageValidationResult::PCKG_POLICY:
897+
case PackageValidationResult::PCKG_RESULT_UNSET:
895898
{
896-
throw JSONRPCTransactionError(TransactionError::INVALID_PACKAGE,
897-
package_result.m_state.GetRejectReason());
899+
// Belt-and-suspenders check; everything should be successful here
900+
CHECK_NONFATAL(package_result.m_tx_results.size() == txns.size());
901+
for (const auto& tx : txns) {
902+
CHECK_NONFATAL(mempool.exists(GenTxid::Txid(tx->GetHash())));
903+
}
904+
break;
898905
}
899906
case PackageValidationResult::PCKG_MEMPOOL_ERROR:
900907
{
908+
// This only happens with internal bug; user should stop and report
901909
throw JSONRPCTransactionError(TransactionError::MEMPOOL_ERROR,
902910
package_result.m_state.GetRejectReason());
903911
}
912+
case PackageValidationResult::PCKG_POLICY:
904913
case PackageValidationResult::PCKG_TX:
905914
{
906-
for (const auto& tx : txns) {
907-
auto it = package_result.m_tx_results.find(tx->GetWitnessHash());
908-
if (it != package_result.m_tx_results.end() && it->second.m_state.IsInvalid()) {
909-
throw JSONRPCTransactionError(TransactionError::MEMPOOL_REJECTED,
910-
strprintf("%s failed: %s", tx->GetHash().ToString(), it->second.m_state.GetRejectReason()));
911-
}
912-
}
913-
// If a PCKG_TX error was returned, there must have been an invalid transaction.
914-
NONFATAL_UNREACHABLE();
915+
// Package-wide error we want to return, but we also want to return individual responses
916+
package_msg = package_result.m_state.GetRejectReason();
917+
CHECK_NONFATAL(package_result.m_tx_results.size() == txns.size() ||
918+
package_result.m_tx_results.empty());
919+
break;
915920
}
916921
}
922+
917923
size_t num_broadcast{0};
918924
for (const auto& tx : txns) {
925+
// We don't want to re-submit the txn for validation in BroadcastTransaction
926+
if (!mempool.exists(GenTxid::Txid(tx->GetHash()))) {
927+
continue;
928+
}
929+
930+
// We do not expect an error here; we are only broadcasting things already/still in mempool
919931
std::string err_string;
920932
const auto err = BroadcastTransaction(node, tx, err_string, /*max_tx_fee=*/0, /*relay=*/true, /*wait_callback=*/true);
921933
if (err != TransactionError::OK) {
922934
throw JSONRPCTransactionError(err,
923-
strprintf("transaction broadcast failed: %s (all transactions were submitted, %d transactions were broadcast successfully)",
935+
strprintf("transaction broadcast failed: %s (%d transactions were broadcast successfully)",
924936
err_string, num_broadcast));
925937
}
926938
num_broadcast++;
927939
}
940+
928941
UniValue rpc_result{UniValue::VOBJ};
942+
rpc_result.pushKV("package_msg", package_msg);
929943
UniValue tx_result_map{UniValue::VOBJ};
930944
std::set<uint256> replaced_txids;
931945
for (const auto& tx : txns) {
932-
auto it = package_result.m_tx_results.find(tx->GetWitnessHash());
933-
CHECK_NONFATAL(it != package_result.m_tx_results.end());
934946
UniValue result_inner{UniValue::VOBJ};
935947
result_inner.pushKV("txid", tx->GetHash().GetHex());
948+
auto it = package_result.m_tx_results.find(tx->GetWitnessHash());
949+
if (it == package_result.m_tx_results.end()) {
950+
// No results, report error and continue
951+
result_inner.pushKV("error", "unevaluated");
952+
continue;
953+
}
936954
const auto& tx_result = it->second;
937-
if (it->second.m_result_type == MempoolAcceptResult::ResultType::DIFFERENT_WITNESS) {
955+
switch(it->second.m_result_type) {
956+
case MempoolAcceptResult::ResultType::DIFFERENT_WITNESS:
938957
result_inner.pushKV("other-wtxid", it->second.m_other_wtxid.value().GetHex());
939-
}
940-
if (it->second.m_result_type == MempoolAcceptResult::ResultType::VALID ||
941-
it->second.m_result_type == MempoolAcceptResult::ResultType::MEMPOOL_ENTRY) {
958+
break;
959+
case MempoolAcceptResult::ResultType::INVALID:
960+
result_inner.pushKV("error", it->second.m_state.ToString());
961+
break;
962+
case MempoolAcceptResult::ResultType::VALID:
963+
case MempoolAcceptResult::ResultType::MEMPOOL_ENTRY:
942964
result_inner.pushKV("vsize", int64_t{it->second.m_vsize.value()});
943965
UniValue fees(UniValue::VOBJ);
944966
fees.pushKV("base", ValueFromAmount(it->second.m_base_fees.value()));
@@ -959,6 +981,7 @@ static RPCHelpMan submitpackage()
959981
replaced_txids.insert(ptx->GetHash());
960982
}
961983
}
984+
break;
962985
}
963986
tx_result_map.pushKV(tx->GetWitnessHash().GetHex(), result_inner);
964987
}

test/functional/mempool_limit.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,9 @@ def test_rbf_carveout_disallowed(self):
125125
utxo_to_spend=tx_B["new_utxo"],
126126
confirmed_only=True
127127
)
128-
129-
assert_raises_rpc_error(-26, "too-long-mempool-chain", node.submitpackage, [tx_B["hex"], tx_C["hex"]])
128+
res = node.submitpackage([tx_B["hex"], tx_C["hex"]])
129+
assert_equal(res["package_msg"], "transaction failed")
130+
assert "too-long-mempool-chain" in res["tx-results"][tx_C["wtxid"]]["error"]
130131

131132
def test_mid_package_eviction(self):
132133
node = self.nodes[0]
@@ -205,7 +206,7 @@ def test_mid_package_eviction(self):
205206

206207
# Package should be submitted, temporarily exceeding maxmempool, and then evicted.
207208
with node.assert_debug_log(expected_msgs=["rolling minimum fee bumped"]):
208-
assert_raises_rpc_error(-26, "mempool full", node.submitpackage, package_hex)
209+
assert_equal(node.submitpackage(package_hex)["package_msg"], "transaction failed")
209210

210211
# Maximum size must never be exceeded.
211212
assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"])
@@ -273,7 +274,9 @@ def test_mid_package_replacement(self):
273274
package_hex = [cpfp_parent["hex"], replacement_tx["hex"], child["hex"]]
274275

275276
# Package should be submitted, temporarily exceeding maxmempool, and then evicted.
276-
assert_raises_rpc_error(-26, "bad-txns-inputs-missingorspent", node.submitpackage, package_hex)
277+
res = node.submitpackage(package_hex)
278+
assert_equal(res["package_msg"], "transaction failed")
279+
assert len([tx_res for _, tx_res in res["tx-results"].items() if "error" in tx_res and tx_res["error"] == "bad-txns-inputs-missingorspent"])
277280

278281
# Maximum size must never be exceeded.
279282
assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"])
@@ -321,6 +324,7 @@ def run_test(self):
321324
package_txns.append(tx_child)
322325

323326
submitpackage_result = node.submitpackage([tx["hex"] for tx in package_txns])
327+
assert_equal(submitpackage_result["package_msg"], "success")
324328

325329
rich_parent_result = submitpackage_result["tx-results"][tx_rich["wtxid"]]
326330
poor_parent_result = submitpackage_result["tx-results"][tx_poor["wtxid"]]
@@ -366,7 +370,9 @@ def run_test(self):
366370
assert_greater_than(worst_feerate_btcvb, (parent_fee + child_fee) / (tx_parent_just_below["tx"].get_vsize() + tx_child_just_above["tx"].get_vsize()))
367371
assert_greater_than(mempoolmin_feerate, (parent_fee) / (tx_parent_just_below["tx"].get_vsize()))
368372
assert_greater_than((parent_fee + child_fee) / (tx_parent_just_below["tx"].get_vsize() + tx_child_just_above["tx"].get_vsize()), mempoolmin_feerate / 1000)
369-
assert_raises_rpc_error(-26, "mempool full", node.submitpackage, [tx_parent_just_below["hex"], tx_child_just_above["hex"]])
373+
res = node.submitpackage([tx_parent_just_below["hex"], tx_child_just_above["hex"]])
374+
for wtxid in [tx_parent_just_below["wtxid"], tx_child_just_above["wtxid"]]:
375+
assert_equal(res["tx-results"][wtxid]["error"], "mempool full")
370376

371377
self.log.info('Test passing a value below the minimum (5 MB) to -maxmempool throws an error')
372378
self.stop_node(0)

test/functional/mempool_sigoplimit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
assert_equal,
3535
assert_greater_than,
3636
assert_greater_than_or_equal,
37-
assert_raises_rpc_error,
3837
)
3938
from test_framework.wallet import MiniWallet
4039
from test_framework.wallet_util import generate_keypair
@@ -169,7 +168,8 @@ def create_bare_multisig_tx(utxo_to_spend=None):
169168
assert_equal([x["package-error"] for x in packet_test], ["package-mempool-limits", "package-mempool-limits"])
170169

171170
# When we actually try to submit, the parent makes it into the mempool, but the child would exceed ancestor vsize limits
172-
assert_raises_rpc_error(-26, "too-long-mempool-chain", self.nodes[0].submitpackage, [tx_parent.serialize().hex(), tx_child.serialize().hex()])
171+
res = self.nodes[0].submitpackage([tx_parent.serialize().hex(), tx_child.serialize().hex()])
172+
assert "too-long-mempool-chain" in res["tx-results"][tx_child.getwtxid()]["error"]
173173
assert tx_parent.rehash() in self.nodes[0].getrawmempool()
174174

175175
# Transactions are tiny in weight

test/functional/rpc_packages.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ def test_submit_child_with_parents(self, num_parents, partial_submit):
304304
submitpackage_result = node.submitpackage(package=[tx["hex"] for tx in package_txns])
305305

306306
# Check that each result is present, with the correct size and fees
307+
assert_equal(submitpackage_result["package_msg"], "success")
307308
for package_txn in package_txns:
308309
tx = package_txn["tx"]
309310
assert tx.getwtxid() in submitpackage_result["tx-results"]
@@ -334,9 +335,26 @@ def test_submitpackage(self):
334335

335336
self.log.info("Submitpackage only allows packages of 1 child with its parents")
336337
# Chain of 3 transactions has too many generations
338+
legacy_pool = node.getrawmempool()
337339
chain_hex = [t["hex"] for t in self.wallet.create_self_transfer_chain(chain_length=25)]
338340
assert_raises_rpc_error(-25, "package topology disallowed", node.submitpackage, chain_hex)
341+
assert_equal(legacy_pool, node.getrawmempool())
339342

343+
# Create a transaction chain such as only the parent gets accepted (by making the child's
344+
# version non-standard). Make sure the parent does get broadcast.
345+
self.log.info("If a package is partially submitted, transactions included in mempool get broadcast")
346+
peer = node.add_p2p_connection(P2PTxInvStore())
347+
txs = self.wallet.create_self_transfer_chain(chain_length=2)
348+
bad_child = tx_from_hex(txs[1]["hex"])
349+
bad_child.nVersion = -1
350+
hex_partial_acceptance = [txs[0]["hex"], bad_child.serialize().hex()]
351+
res = node.submitpackage(hex_partial_acceptance)
352+
assert_equal(res["package_msg"], "transaction failed")
353+
first_wtxid = txs[0]["tx"].getwtxid()
354+
assert "error" not in res["tx-results"][first_wtxid]
355+
sec_wtxid = bad_child.getwtxid()
356+
assert_equal(res["tx-results"][sec_wtxid]["error"], "version")
357+
peer.wait_for_broadcast([first_wtxid])
340358

341359
if __name__ == "__main__":
342360
RPCPackagesTest().main()

0 commit comments

Comments
 (0)