rpc: Run type check on decodepsbt result
What changed, and why it matters
This is a code-quality fix for Bitcoin Core's RPC help output. It changes how the documentation generator marks parts of the response as '...' (elided) so that the internal type checker can still verify the real data types. It does not change how transactions are decoded or how the network behaves, and it is not a security patch in the usual sense. The only user-visible change is a slightly clearer help message for the decodepsbt command.
No urgent action. Treat as a routine refactor/testability improvement. Reviewers may want to confirm that all RPCResult::ELISION usages are correctly migrated and that the new type checks pass in CI.
Security signals we found
Type-checking bypass removed for decodepsbt RPC result schema
New CHECK_NONFATAL assertions on elision descriptions
No change to consensus, networking, wallet, or transaction parsing logic
Evidence from the diff
The commit refactors RPCResult elision handling. Previously, nested transaction documentation in decodepsbt used RPCResult::Type::ELISION, which caused the type checker to skip validation because ELISION has no real type. The patch introduces a print_elision option in RPCResultOptions and TxDocOptions, allowing fields to be hidden in help text while retaining their actual types (STR_HEX, NUM, ARR, OBJ, etc.). This lets RPCResult::MatchesType run on decodepsbt’s nested transaction output. The change also adds CHECK_NONFATAL assertions ensuring elided groups have at least one description and updates the human-readable help text for non_witness_utxo and tx fields.
Changed components
src/rpc/rawtransaction.cppsrc/rpc/rawtransaction_util.cppsrc/rpc/rawtransaction_util.hsrc/rpc/util.cppsrc/rpc/util.hInspect captured patch +42 / −16
diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
index 3632cc49..7a202ccf 100644
--- a/src/rpc/rawtransaction.cpp
+++ b/src/rpc/rawtransaction.cpp
@@ -781,9 +781,8 @@ const RPCResult decodepsbt_inputs{
{RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::OBJ, "non_witness_utxo", /*optional=*/true, "Decoded network transaction for non-witness UTXOs",
- {
- {RPCResult::Type::ELISION, "",""},
- }},
+ TxDoc({.elision_description="The layout is the same as the output of decoderawtransaction."})
+ },
{RPCResult::Type::OBJ, "witness_utxo", /*optional=*/true, "Transaction output for witness UTXOs",
{
{RPCResult::Type::NUM, "amount", "The value in " + CURRENCY_UNIT},
@@ -1023,9 +1022,8 @@ static RPCHelpMan decodepsbt()
RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::OBJ, "tx", "The decoded network-serialized unsigned transaction.",
- {
- {RPCResult::Type::ELISION, "", "The layout is the same as the output of decoderawtransaction."},
- }},
+ TxDoc({.elision_description="The layout is the same as the output of decoderawtransaction."})
+ },
{RPCResult::Type::ARR, "global_xpubs", "",
{
{RPCResult::Type::OBJ, "", "",
diff --git a/src/rpc/rawtransaction_util.cpp b/src/rpc/rawtransaction_util.cpp
index 89fc1e27..d3a1b875 100644
--- a/src/rpc/rawtransaction_util.cpp
+++ b/src/rpc/rawtransaction_util.cpp
@@ -346,14 +346,16 @@ void SignTransactionResultToJSON(CMutableTransaction& mtx, bool complete, const
std::vector<RPCResult> TxDoc(const TxDocOptions& opts)
{
+ std::optional<std::string> maybe_skip{};
+ if (opts.elision_description) maybe_skip.emplace();
return {
- {RPCResult::Type::STR_HEX, "txid", opts.txid_field_doc},
- {RPCResult::Type::STR_HEX, "hash", "The transaction hash (differs from txid for witness transactions)"},
- {RPCResult::Type::NUM, "size", "The serialized transaction size"},
- {RPCResult::Type::NUM, "vsize", "The virtual transaction size (differs from size for witness transactions)"},
- {RPCResult::Type::NUM, "weight", "The transaction's weight (between vsize*4-3 and vsize*4)"},
- {RPCResult::Type::NUM, "version", "The version"},
- {RPCResult::Type::NUM_TIME, "locktime", "The lock time"},
+ {RPCResult::Type::STR_HEX, "txid", opts.txid_field_doc, {}, {.print_elision=opts.elision_description}},
+ {RPCResult::Type::STR_HEX, "hash", "The transaction hash (differs from txid for witness transactions)", {}, {.print_elision=maybe_skip}},
+ {RPCResult::Type::NUM, "size", "The serialized transaction size", {}, {.print_elision=maybe_skip}},
+ {RPCResult::Type::NUM, "vsize", "The virtual transaction size (differs from size for witness transactions)", {}, {.print_elision=maybe_skip}},
+ {RPCResult::Type::NUM, "weight", "The transaction's weight (between vsize*4-3 and vsize*4)", {}, {.print_elision=maybe_skip}},
+ {RPCResult::Type::NUM, "version", "The version", {}, {.print_elision=maybe_skip}},
+ {RPCResult::Type::NUM_TIME, "locktime", "The lock time", {}, {.print_elision=maybe_skip}},
{RPCResult::Type::ARR, "vin", "",
{
{RPCResult::Type::OBJ, "", "",
@@ -372,7 +374,7 @@ std::vector<RPCResult> TxDoc(const TxDocOptions& opts)
}},
{RPCResult::Type::NUM, "sequence", "The script sequence number"},
}},
- }},
+ }, {.print_elision=maybe_skip}},
{RPCResult::Type::ARR, "vout", "",
{
{RPCResult::Type::OBJ, "", "", Cat(
@@ -386,6 +388,6 @@ std::vector<RPCResult> TxDoc(const TxDocOptions& opts)
std::vector<RPCResult>{}
)
},
- }},
+ }, {.print_elision=maybe_skip}},
};
}
diff --git a/src/rpc/rawtransaction_util.h b/src/rpc/rawtransaction_util.h
index ed1efd0b..5a1bc604 100644
--- a/src/rpc/rawtransaction_util.h
+++ b/src/rpc/rawtransaction_util.h
@@ -61,6 +61,8 @@ struct TxDocOptions {
std::string txid_field_doc{"The transaction id"};
/// Include wallet-related fields (e.g. ischange on outputs)
bool wallet{false};
+ /// Treat this as an elided Result in the help
+ std::optional<std::string> elision_description{};
};
/** Explain the UniValue "decoded" transaction object, may include extra fields if processed by wallet **/
std::vector<RPCResult> TxDoc(const TxDocOptions& opts = {});
diff --git a/src/rpc/util.cpp b/src/rpc/util.cpp
index 75cfa8d3..cee8a429 100644
--- a/src/rpc/util.cpp
+++ b/src/rpc/util.cpp
@@ -1015,9 +1015,22 @@ void RPCResult::ToSections(Sections& sections, const OuterType outer_type, const
(this->m_description.empty() ? "" : " " + this->m_description);
};
+ // Ensure at least one elision description exists, if there is any elision
+ const auto elision_has_description{[](const std::vector<RPCResult>& inner) {
+ return std::ranges::none_of(inner, [](const auto& res) { return res.m_opts.print_elision.has_value(); }) ||
+ std::ranges::any_of(inner, [](const auto& res) { return res.m_opts.print_elision.has_value() && !res.m_opts.print_elision->empty(); });
+ }};
+
+ if (m_opts.print_elision) {
+ if (!m_opts.print_elision->empty()) {
+ sections.PushSection({indent + "..." + maybe_separator, *m_opts.print_elision});
+ }
+ return;
+ }
+
switch (m_type) {
case Type::ELISION: {
- // If the inner result is empty, use three dots for elision
+ // Deprecated alias of m_opts.print_elision
sections.PushSection({indent + "..." + maybe_separator, m_description});
return;
}
@@ -1059,6 +1072,7 @@ void RPCResult::ToSections(Sections& sections, const OuterType outer_type, const
i.ToSections(sections, OuterType::ARR, current_indent + 2);
}
CHECK_NONFATAL(!m_inner.empty());
+ CHECK_NONFATAL(elision_has_description(m_inner));
if (m_type == Type::ARR && m_inner.back().m_type != Type::ELISION) {
sections.PushSection({indent_next + "...", ""});
} else {
@@ -1074,6 +1088,7 @@ void RPCResult::ToSections(Sections& sections, const OuterType outer_type, const
sections.PushSection({indent + maybe_key + "{}", Description("empty JSON object")});
return;
}
+ CHECK_NONFATAL(elision_has_description(m_inner));
sections.PushSection({indent + maybe_key + "{", Description("json object")});
for (const auto& i : m_inner) {
i.ToSections(sections, OuterType::OBJ, current_indent + 2);
diff --git a/src/rpc/util.h b/src/rpc/util.h
index a3f95964..fda71072 100644
--- a/src/rpc/util.h
+++ b/src/rpc/util.h
@@ -294,6 +294,15 @@ struct RPCArg {
struct RPCResultOptions {
bool skip_type_check{false};
+ /// Whether to treat this as elided in the human-readable description, and
+ /// possibly supply a description for the elision. Normally, there will be
+ /// one string on any of the elided results, for example `Same output as
+ /// verbosity = 1`, and all other elided strings will be empty.
+ ///
+ /// - If nullopt: normal display.
+ /// - If empty string: suppress from help.
+ /// - If non-empty: show "..." with this description.
+ std::optional<std::string> print_elision{std::nullopt};
};
// NOLINTNEXTLINE(misc-no-recursion)
struct RPCResult {
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.