Skip to content

Commit 6adae27

Browse files
committed
Merge bitcoin/bitcoin#24836: add RPC (-regtest only) for testing package policy
e866f0d [functional test] submitrawpackage RPC (glozow) fa07651 [rpc] add new submitpackage RPC (glozow) Pull request description: It would be nice for LN/wallet/app devs to test out package policy, package RBF, etc., but the only interface to do so right now is through unit tests. This PR adds a `-regtest` only RPC interface so people can test by submitting raw transaction data. It is regtest-only, as it would be unsafe/confusing to create an actual mainnet interface while package relay doesn't exist. Note that the functional tests are there to ensure the RPC interface is working properly; they aren't for testing policy itself. See src/test/txpackage_tests.cpp. ACKs for top commit: t-bast: Tested ACK against eclair bitcoin/bitcoin@e866f0d ariard: Code Review ACK e866f0d instagibbs: code review ACK e866f0d Tree-SHA512: 824a26b10d2240e0fd85e5dd25bf499ee3dd9ba8ef4f522533998fcf767ddded9f001f7a005fe3ab07ec95e696448484e26599803e6034ed2733125c8c376c84
2 parents 1ee5978 + e866f0d commit 6adae27

File tree

7 files changed

+281
-5
lines changed

7 files changed

+281
-5
lines changed

src/rpc/client.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
110110
{ "sendrawtransaction", 1, "maxfeerate" },
111111
{ "testmempoolaccept", 0, "rawtxs" },
112112
{ "testmempoolaccept", 1, "maxfeerate" },
113+
{ "submitpackage", 0, "package" },
113114
{ "combinerawtransaction", 0, "txs" },
114115
{ "fundrawtransaction", 1, "options" },
115116
{ "fundrawtransaction", 2, "iswitness" },

src/rpc/mempool.cpp

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
#include <rpc/blockchain.h>
77

8+
#include <chainparams.h>
89
#include <core_io.h>
910
#include <fs.h>
1011
#include <policy/rbf.h>
@@ -730,6 +731,150 @@ static RPCHelpMan savemempool()
730731
};
731732
}
732733

734+
static RPCHelpMan submitpackage()
735+
{
736+
return RPCHelpMan{"submitpackage",
737+
"Submit a package of raw transactions (serialized, hex-encoded) to local node (-regtest only).\n"
738+
"The package will be validated according to consensus and mempool policy rules. If all transactions pass, they will be accepted to mempool.\n"
739+
"This RPC is experimental and the interface may be unstable. Refer to doc/policy/packages.md for documentation on package policies.\n"
740+
"Warning: until package relay is in use, successful submission does not mean the transaction will propagate to other nodes on the network.\n"
741+
"Currently, each transaction is broadcasted individually after submission, which means they must meet other nodes' feerate requirements alone.\n"
742+
,
743+
{
744+
{"package", RPCArg::Type::ARR, RPCArg::Optional::NO, "An array of raw transactions.",
745+
{
746+
{"rawtx", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, ""},
747+
},
748+
},
749+
},
750+
RPCResult{
751+
RPCResult::Type::OBJ, "", "",
752+
{
753+
{RPCResult::Type::OBJ_DYN, "tx-results", "transaction results keyed by wtxid",
754+
{
755+
{RPCResult::Type::OBJ, "wtxid", "transaction wtxid", {
756+
{RPCResult::Type::STR_HEX, "txid", "The transaction hash in hex"},
757+
{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."},
758+
{RPCResult::Type::NUM, "vsize", "Virtual transaction size as defined in BIP 141."},
759+
{RPCResult::Type::OBJ, "fees", "Transaction fees", {
760+
{RPCResult::Type::STR_AMOUNT, "base", "transaction fee in " + CURRENCY_UNIT},
761+
}},
762+
}}
763+
}},
764+
{RPCResult::Type::STR_AMOUNT, "package-feerate", /*optional=*/true, "package feerate used for feerate checks in " + CURRENCY_UNIT + " per KvB. Excludes transactions which were deduplicated or accepted individually."},
765+
{RPCResult::Type::ARR, "replaced-transactions", /*optional=*/true, "List of txids of replaced transactions",
766+
{
767+
{RPCResult::Type::STR_HEX, "", "The transaction id"},
768+
}},
769+
},
770+
},
771+
RPCExamples{
772+
HelpExampleCli("testmempoolaccept", "[rawtx1, rawtx2]") +
773+
HelpExampleCli("submitpackage", "[rawtx1, rawtx2]")
774+
},
775+
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
776+
{
777+
if (!Params().IsMockableChain()) {
778+
throw std::runtime_error("submitpackage is for regression testing (-regtest mode) only");
779+
}
780+
RPCTypeCheck(request.params, {
781+
UniValue::VARR,
782+
});
783+
const UniValue raw_transactions = request.params[0].get_array();
784+
if (raw_transactions.size() < 1 || raw_transactions.size() > MAX_PACKAGE_COUNT) {
785+
throw JSONRPCError(RPC_INVALID_PARAMETER,
786+
"Array must contain between 1 and " + ToString(MAX_PACKAGE_COUNT) + " transactions.");
787+
}
788+
789+
std::vector<CTransactionRef> txns;
790+
txns.reserve(raw_transactions.size());
791+
for (const auto& rawtx : raw_transactions.getValues()) {
792+
CMutableTransaction mtx;
793+
if (!DecodeHexTx(mtx, rawtx.get_str())) {
794+
throw JSONRPCError(RPC_DESERIALIZATION_ERROR,
795+
"TX decode failed: " + rawtx.get_str() + " Make sure the tx has at least one input.");
796+
}
797+
txns.emplace_back(MakeTransactionRef(std::move(mtx)));
798+
}
799+
800+
NodeContext& node = EnsureAnyNodeContext(request.context);
801+
CTxMemPool& mempool = EnsureMemPool(node);
802+
CChainState& chainstate = EnsureChainman(node).ActiveChainstate();
803+
const auto package_result = WITH_LOCK(::cs_main, return ProcessNewPackage(chainstate, mempool, txns, /*test_accept=*/ false));
804+
805+
// First catch any errors.
806+
switch(package_result.m_state.GetResult()) {
807+
case PackageValidationResult::PCKG_RESULT_UNSET: break;
808+
case PackageValidationResult::PCKG_POLICY:
809+
{
810+
throw JSONRPCTransactionError(TransactionError::INVALID_PACKAGE,
811+
package_result.m_state.GetRejectReason());
812+
}
813+
case PackageValidationResult::PCKG_MEMPOOL_ERROR:
814+
{
815+
throw JSONRPCTransactionError(TransactionError::MEMPOOL_ERROR,
816+
package_result.m_state.GetRejectReason());
817+
}
818+
case PackageValidationResult::PCKG_TX:
819+
{
820+
for (const auto& tx : txns) {
821+
auto it = package_result.m_tx_results.find(tx->GetWitnessHash());
822+
if (it != package_result.m_tx_results.end() && it->second.m_state.IsInvalid()) {
823+
throw JSONRPCTransactionError(TransactionError::MEMPOOL_REJECTED,
824+
strprintf("%s failed: %s", tx->GetHash().ToString(), it->second.m_state.GetRejectReason()));
825+
}
826+
}
827+
// If a PCKG_TX error was returned, there must have been an invalid transaction.
828+
NONFATAL_UNREACHABLE();
829+
}
830+
}
831+
for (const auto& tx : txns) {
832+
size_t num_submitted{0};
833+
std::string err_string;
834+
const auto err = BroadcastTransaction(node, tx, err_string, 0, true, true);
835+
if (err != TransactionError::OK) {
836+
throw JSONRPCTransactionError(err,
837+
strprintf("transaction broadcast failed: %s (all transactions were submitted, %d transactions were broadcast successfully)",
838+
err_string, num_submitted));
839+
}
840+
}
841+
UniValue rpc_result{UniValue::VOBJ};
842+
UniValue tx_result_map{UniValue::VOBJ};
843+
std::set<uint256> replaced_txids;
844+
for (const auto& tx : txns) {
845+
auto it = package_result.m_tx_results.find(tx->GetWitnessHash());
846+
CHECK_NONFATAL(it != package_result.m_tx_results.end());
847+
UniValue result_inner{UniValue::VOBJ};
848+
result_inner.pushKV("txid", tx->GetHash().GetHex());
849+
if (it->second.m_result_type == MempoolAcceptResult::ResultType::DIFFERENT_WITNESS) {
850+
result_inner.pushKV("other-wtxid", it->second.m_other_wtxid.value().GetHex());
851+
}
852+
if (it->second.m_result_type == MempoolAcceptResult::ResultType::VALID ||
853+
it->second.m_result_type == MempoolAcceptResult::ResultType::MEMPOOL_ENTRY) {
854+
result_inner.pushKV("vsize", int64_t{it->second.m_vsize.value()});
855+
UniValue fees(UniValue::VOBJ);
856+
fees.pushKV("base", ValueFromAmount(it->second.m_base_fees.value()));
857+
result_inner.pushKV("fees", fees);
858+
if (it->second.m_replaced_transactions.has_value()) {
859+
for (const auto& ptx : it->second.m_replaced_transactions.value()) {
860+
replaced_txids.insert(ptx->GetHash());
861+
}
862+
}
863+
}
864+
tx_result_map.pushKV(tx->GetWitnessHash().GetHex(), result_inner);
865+
}
866+
rpc_result.pushKV("tx-results", tx_result_map);
867+
if (package_result.m_package_feerate.has_value()) {
868+
rpc_result.pushKV("package-feerate", ValueFromAmount(package_result.m_package_feerate.value().GetFeePerK()));
869+
}
870+
UniValue replaced_list(UniValue::VARR);
871+
for (const uint256& hash : replaced_txids) replaced_list.push_back(hash.ToString());
872+
rpc_result.pushKV("replaced-transactions", replaced_list);
873+
return rpc_result;
874+
},
875+
};
876+
}
877+
733878
void RegisterMempoolRPCCommands(CRPCTable& t)
734879
{
735880
static const CRPCCommand commands[]{
@@ -742,6 +887,7 @@ void RegisterMempoolRPCCommands(CRPCTable& t)
742887
{"blockchain", &getmempoolinfo},
743888
{"blockchain", &getrawmempool},
744889
{"blockchain", &savemempool},
890+
{"hidden", &submitpackage},
745891
};
746892
for (const auto& c : commands) {
747893
t.appendCommand(c.name, &c);

src/test/fuzz/rpc.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const std::vector<std::string> RPC_COMMANDS_SAFE_FOR_FUZZING{
159159
"signrawtransactionwithkey",
160160
"submitblock",
161161
"submitheader",
162+
"submitpackage",
162163
"syncwithvalidationinterfacequeue",
163164
"testmempoolaccept",
164165
"uptime",

src/util/error.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ bilingual_str TransactionErrorString(const TransactionError err)
3535
return Untranslated("External signer not found");
3636
case TransactionError::EXTERNAL_SIGNER_FAILED:
3737
return Untranslated("External signer failed to sign");
38+
case TransactionError::INVALID_PACKAGE:
39+
return Untranslated("Transaction rejected due to invalid package");
3840
// no default case, so the compiler can warn about missing cases
3941
}
4042
assert(false);

src/util/error.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ enum class TransactionError {
3232
MAX_FEE_EXCEEDED,
3333
EXTERNAL_SIGNER_NOT_FOUND,
3434
EXTERNAL_SIGNER_FAILED,
35+
INVALID_PACKAGE,
3536
};
3637

3738
bilingual_str TransactionErrorString(const TransactionError error);

test/functional/rpc_packages.py

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,20 @@
1515
CTxInWitness,
1616
tx_from_hex,
1717
)
18+
from test_framework.p2p import P2PTxInvStore
1819
from test_framework.script import (
1920
CScript,
2021
OP_TRUE,
2122
)
2223
from test_framework.util import (
2324
assert_equal,
25+
assert_fee_amount,
26+
assert_raises_rpc_error,
2427
)
2528
from test_framework.wallet import (
2629
create_child_with_parents,
2730
create_raw_chain,
31+
DEFAULT_FEE,
2832
make_chain,
2933
)
3034

@@ -51,7 +55,7 @@ def run_test(self):
5155
self.address = node.get_deterministic_priv_key().address
5256
self.coins = []
5357
# The last 100 coinbase transactions are premature
54-
for b in self.generatetoaddress(node, 200, self.address)[:100]:
58+
for b in self.generatetoaddress(node, 220, self.address)[:-100]:
5559
coinbase = node.getblock(blockhash=b, verbosity=2)["tx"][0]
5660
self.coins.append({
5761
"txid": coinbase["txid"],
@@ -82,7 +86,7 @@ def run_test(self):
8286
self.test_multiple_parents()
8387
self.test_conflicting()
8488
self.test_rbf()
85-
89+
self.test_submitpackage()
8690

8791
def test_independent(self):
8892
self.log.info("Test multiple independent transactions in a package")
@@ -132,8 +136,7 @@ def test_independent(self):
132136

133137
def test_chain(self):
134138
node = self.nodes[0]
135-
first_coin = self.coins.pop()
136-
(chain_hex, chain_txns) = create_raw_chain(node, first_coin, self.address, self.privkeys)
139+
(chain_hex, chain_txns) = create_raw_chain(node, self.coins.pop(), self.address, self.privkeys)
137140
self.log.info("Check that testmempoolaccept requires packages to be sorted by dependency")
138141
assert_equal(node.testmempoolaccept(rawtxs=chain_hex[::-1]),
139142
[{"txid": tx.rehash(), "wtxid": tx.getwtxid(), "package-error": "package-not-sorted"} for tx in chain_txns[::-1]])
@@ -306,5 +309,127 @@ def test_rbf(self):
306309
}]
307310
self.assert_testres_equal(self.independent_txns_hex + [signed_replacement_tx["hex"]], testres_rbf_package)
308311

312+
def assert_equal_package_results(self, node, testmempoolaccept_result, submitpackage_result):
313+
"""Assert that a successful submitpackage result is consistent with testmempoolaccept
314+
results and getmempoolentry info. Note that the result structs are different and, due to
315+
policy differences between testmempoolaccept and submitpackage (i.e. package feerate),
316+
some information may be different.
317+
"""
318+
for testres_tx in testmempoolaccept_result:
319+
# Grab this result from the submitpackage_result
320+
submitres_tx = submitpackage_result["tx-results"][testres_tx["wtxid"]]
321+
assert_equal(submitres_tx["txid"], testres_tx["txid"])
322+
# No "allowed" if the tx was already in the mempool
323+
if "allowed" in testres_tx and testres_tx["allowed"]:
324+
assert_equal(submitres_tx["vsize"], testres_tx["vsize"])
325+
assert_equal(submitres_tx["fees"]["base"], testres_tx["fees"]["base"])
326+
entry_info = node.getmempoolentry(submitres_tx["txid"])
327+
assert_equal(submitres_tx["vsize"], entry_info["vsize"])
328+
assert_equal(submitres_tx["fees"]["base"], entry_info["fees"]["base"])
329+
330+
def test_submit_child_with_parents(self, num_parents, partial_submit):
331+
node = self.nodes[0]
332+
peer = node.add_p2p_connection(P2PTxInvStore())
333+
# Test a package with num_parents parents and 1 child transaction.
334+
package_hex = []
335+
package_txns = []
336+
values = []
337+
scripts = []
338+
for _ in range(num_parents):
339+
parent_coin = self.coins.pop()
340+
value = parent_coin["amount"]
341+
(tx, txhex, value, spk) = make_chain(node, self.address, self.privkeys, parent_coin["txid"], value)
342+
package_hex.append(txhex)
343+
package_txns.append(tx)
344+
values.append(value)
345+
scripts.append(spk)
346+
if partial_submit and random.choice([True, False]):
347+
node.sendrawtransaction(txhex)
348+
child_hex = create_child_with_parents(node, self.address, self.privkeys, package_txns, values, scripts)
349+
package_hex.append(child_hex)
350+
package_txns.append(tx_from_hex(child_hex))
351+
352+
testmempoolaccept_result = node.testmempoolaccept(rawtxs=package_hex)
353+
submitpackage_result = node.submitpackage(package=package_hex)
354+
355+
# Check that each result is present, with the correct size and fees
356+
for i in range(num_parents + 1):
357+
tx = package_txns[i]
358+
wtxid = tx.getwtxid()
359+
assert wtxid in submitpackage_result["tx-results"]
360+
tx_result = submitpackage_result["tx-results"][wtxid]
361+
assert_equal(tx_result, {
362+
"txid": tx.rehash(),
363+
"vsize": tx.get_vsize(),
364+
"fees": {
365+
"base": DEFAULT_FEE,
366+
}
367+
})
368+
369+
# submitpackage result should be consistent with testmempoolaccept and getmempoolentry
370+
self.assert_equal_package_results(node, testmempoolaccept_result, submitpackage_result)
371+
372+
# Package feerate is calculated for the remaining transactions after deduplication and
373+
# individual submission. If only 0 or 1 transaction is left, e.g. because all transactions
374+
# had high-feerates or were already in the mempool, no package feerate is provided.
375+
# In this case, since all of the parents have high fees, each is accepted individually.
376+
assert "package-feerate" not in submitpackage_result
377+
378+
# The node should announce each transaction. No guarantees for propagation.
379+
peer.wait_for_broadcast([tx.getwtxid() for tx in package_txns])
380+
self.generate(node, 1)
381+
382+
383+
def test_submit_cpfp(self):
384+
node = self.nodes[0]
385+
peer = node.add_p2p_connection(P2PTxInvStore())
386+
387+
# 2 parent 1 child CPFP. First parent pays high fees, second parent pays 0 fees and is
388+
# fee-bumped by the child.
389+
coin_rich = self.coins.pop()
390+
coin_poor = self.coins.pop()
391+
tx_rich, hex_rich, value_rich, spk_rich = make_chain(node, self.address, self.privkeys, coin_rich["txid"], coin_rich["amount"])
392+
tx_poor, hex_poor, value_poor, spk_poor = make_chain(node, self.address, self.privkeys, coin_poor["txid"], coin_poor["amount"], fee=0)
393+
package_txns = [tx_rich, tx_poor]
394+
hex_child = create_child_with_parents(node, self.address, self.privkeys, package_txns, [value_rich, value_poor], [spk_rich, spk_poor])
395+
tx_child = tx_from_hex(hex_child)
396+
package_txns.append(tx_child)
397+
398+
submitpackage_result = node.submitpackage([hex_rich, hex_poor, hex_child])
399+
400+
rich_parent_result = submitpackage_result["tx-results"][tx_rich.getwtxid()]
401+
poor_parent_result = submitpackage_result["tx-results"][tx_poor.getwtxid()]
402+
child_result = submitpackage_result["tx-results"][tx_child.getwtxid()]
403+
assert_equal(rich_parent_result["fees"]["base"], DEFAULT_FEE)
404+
assert_equal(poor_parent_result["fees"]["base"], 0)
405+
assert_equal(child_result["fees"]["base"], DEFAULT_FEE)
406+
# Package feerate is calculated for the remaining transactions after deduplication and
407+
# individual submission. Since this package had a 0-fee parent, package feerate must have
408+
# been used and returned.
409+
assert "package-feerate" in submitpackage_result
410+
assert_fee_amount(DEFAULT_FEE, rich_parent_result["vsize"] + child_result["vsize"], submitpackage_result["package-feerate"])
411+
412+
# The node will broadcast each transaction, still abiding by its peer's fee filter
413+
peer.wait_for_broadcast([tx.getwtxid() for tx in package_txns])
414+
self.generate(node, 1)
415+
416+
417+
def test_submitpackage(self):
418+
node = self.nodes[0]
419+
420+
self.log.info("Submitpackage valid packages with 1 child and some number of parents")
421+
for num_parents in [1, 2, 24]:
422+
self.test_submit_child_with_parents(num_parents, False)
423+
self.test_submit_child_with_parents(num_parents, True)
424+
425+
self.log.info("Submitpackage valid packages with CPFP")
426+
self.test_submit_cpfp()
427+
428+
self.log.info("Submitpackage only allows packages of 1 child with its parents")
429+
# Chain of 3 transactions has too many generations
430+
chain_hex, _ = create_raw_chain(node, self.coins.pop(), self.address, self.privkeys, 3)
431+
assert_raises_rpc_error(-25, "not-child-with-parents", node.submitpackage, chain_hex)
432+
433+
309434
if __name__ == "__main__":
310435
RPCPackagesTest().main()

test/functional/test_runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
'wallet_address_types.py --descriptors',
131131
'feature_bip68_sequence.py',
132132
'p2p_feefilter.py',
133+
'rpc_packages.py',
133134
'feature_reindex.py',
134135
'feature_abortnode.py',
135136
# vv Tests less than 30s vv
@@ -231,7 +232,6 @@
231232
'mempool_packages.py',
232233
'mempool_package_onemore.py',
233234
'rpc_createmultisig.py',
234-
'rpc_packages.py',
235235
'mempool_package_limits.py',
236236
'feature_versionbits_warning.py',
237237
'rpc_preciousblock.py',

0 commit comments

Comments
 (0)