What changed, and why it matters
This commit adds a new read-only RPC command called getopenrpcinfo to Bitcoin Core. It lets users ask the running node for a machine-readable catalog of all available RPC commands, similar to an API documentation page. The command does not change wallet balances, network rules, or consensus behavior. It only exposes information that was already present in the node's source code and help system. There is no indication in the commit that this fixes a security bug or introduces a dangerous capability.
Treat as a routine feature addition. Reviewers should confirm that hidden RPC exposure via show_hidden is acceptable under the node's existing authentication model, and that the recursion depth in OpenRPCArgSchema/OpenRPCResultSchema cannot be driven to stack exhaustion by malformed metadata. No security patch or incident response is indicated by the supplied materials.
Security signals we found
New read-only RPC command added; no state mutation or consensus code touched
show_hidden parameter can expose hidden RPC names and argument metadata to authenticated callers
Existing RPC authentication and authorization layers still apply
No input parsing of untrusted network data beyond the existing JSON-RPC layer
No memory safety issues evident in the diff; uses standard containers and recursion with depth bounded by RPC metadata
Evidence from the diff
The patch introduces getopenrpcinfo, which builds and returns an OpenRPC 1.4.1 document from existing RPC metadata. It adds helper functions that convert RPCArg and RPCResult metadata into JSON Schema fragments, a CRPCTable::buildOpenRPCDoc method, registration of the command under the ‘control’ category, a client-side parameter conversion entry for show_hidden, a fuzzing allowlist entry, and a unit test verifying that removing a command leaves the table in a state where buildOpenRPCDoc still works. The command is read-only and gated by existing RPC authentication.
Changed components
src/rpc/server.cppsrc/rpc/server.hsrc/rpc/client.cppsrc/test/fuzz/rpc.cppsrc/test/rpc_tests.cppInspect captured patch +452 / −0
diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp
index a28543fb..803c96e8 100644
--- a/src/rpc/client.cpp
+++ b/src/rpc/client.cpp
@@ -335,6 +335,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "logging", 0, "include" },
{ "logging", 1, "exclude" },
{ "disconnectnode", 1, "nodeid" },
+ { "getopenrpcinfo", 0, "show_hidden" },
{ "gethdkeys", 0, "active_only" },
{ "gethdkeys", 0, "options" },
{ "gethdkeys", 0, "private" },
diff --git a/src/rpc/server.cpp b/src/rpc/server.cpp
index b551c467..ead9733d 100644
--- a/src/rpc/server.cpp
+++ b/src/rpc/server.cpp
@@ -26,8 +26,11 @@
#include <chrono>
#include <memory>
#include <mutex>
+#include <span>
#include <string_view>
+#include <unordered_set>
#include <unordered_map>
+#include <variant>
using util::SplitString;
@@ -235,8 +238,338 @@ static RPCMethod getrpcinfo()
};
}
+namespace {
+UniValue OpenRPCArgSchema(const RPCArg& arg, bool include_hidden);
+UniValue OpenRPCResultSchema(const RPCResult& result);
+
+UniValue MakeObject(std::initializer_list<std::pair<std::string, UniValue>> entries)
+{
+ UniValue obj{UniValue::VOBJ};
+ for (const auto& [key, value] : entries) {
+ obj.pushKV(key, value);
+ }
+ return obj;
+}
+
+void PushUniqueSchema(UniValue& schemas, std::unordered_set<std::string>& seen, UniValue schema)
+{
+ const std::string serialized{schema.write()};
+ if (seen.insert(serialized).second) schemas.push_back(std::move(schema));
+}
+
+// NOLINTNEXTLINE(misc-no-recursion)
+UniValue DedupArrayItemsSchema(std::span<const RPCArg> inner, bool include_hidden)
+{
+ if (inner.empty()) return UniValue{UniValue::VOBJ};
+ if (inner.size() == 1) return OpenRPCArgSchema(inner.front(), include_hidden);
+
+ UniValue one_of{UniValue::VARR};
+ std::unordered_set<std::string> seen;
+ for (const auto& item : inner) {
+ PushUniqueSchema(one_of, seen, OpenRPCArgSchema(item, include_hidden));
+ }
+
+ if (one_of.size() == 1) return one_of[0];
+
+ UniValue items{UniValue::VOBJ};
+ items.pushKV("oneOf", std::move(one_of));
+ return items;
+}
+
+// NOLINTNEXTLINE(misc-no-recursion)
+UniValue DedupArrayItemsSchema(std::span<const RPCResult> inner)
+{
+ if (inner.empty()) return UniValue{UniValue::VOBJ};
+ if (inner.size() == 1) return OpenRPCResultSchema(inner.front());
+
+ UniValue one_of{UniValue::VARR};
+ std::unordered_set<std::string> seen;
+ for (const auto& item : inner) {
+ PushUniqueSchema(one_of, seen, OpenRPCResultSchema(item));
+ }
+
+ if (one_of.size() == 1) return one_of[0];
+
+ UniValue items{UniValue::VOBJ};
+ items.pushKV("oneOf", std::move(one_of));
+ return items;
+}
+
+void ApplyTypeStrOverride(UniValue& schema, const RPCArg& arg)
+{
+ if (arg.m_opts.type_str.size() != 2) return;
+ const std::string& type_label{arg.m_opts.type_str[1]};
+ if (type_label.empty()) return;
+
+ static const std::unordered_set<std::string> number_or_string{
+ "integer / string",
+ "string or numeric",
+ };
+ if (number_or_string.contains(type_label)) {
+ UniValue one_of{UniValue::VARR};
+ one_of.push_back(MakeObject({{"type", "number"}}));
+ one_of.push_back(MakeObject({{"type", "string"}}));
+ schema = UniValue{UniValue::VOBJ};
+ schema.pushKV("oneOf", std::move(one_of));
+ } else {
+ schema.pushKV("x-bitcoin-type-override", type_label);
+ }
+}
+
+void ApplyArgFallback(UniValue& schema, const RPCArg& arg)
+{
+ if (const auto* def = std::get_if<RPCArg::Default>(&arg.m_fallback)) {
+ schema.pushKV("default", *def);
+ } else if (const auto* hint = std::get_if<RPCArg::DefaultHint>(&arg.m_fallback)) {
+ schema.pushKV("x-bitcoin-default-hint", *hint);
+ }
+}
+
+// NOLINTNEXTLINE(misc-no-recursion)
+UniValue OpenRPCArgSchema(const RPCArg& arg, bool include_hidden)
+{
+ UniValue schema{UniValue::VOBJ};
+ if (arg.m_opts.skip_type_check) {
+ ApplyTypeStrOverride(schema, arg);
+ if (schema.empty() && arg.m_type == RPCArg::Type::ARR) {
+ UniValue one_of{UniValue::VARR};
+ one_of.push_back(MakeObject({{"type", "array"}}));
+ one_of.push_back(MakeObject({{"type", "object"}}));
+ schema.pushKV("oneOf", std::move(one_of));
+ }
+ ApplyArgFallback(schema, arg);
+ return schema;
+ }
+
+ switch (arg.m_type) {
+ case RPCArg::Type::STR:
+ schema = MakeObject({{"type", "string"}});
+ break;
+ case RPCArg::Type::STR_HEX:
+ schema = MakeObject({{"type", "string"}, {"pattern", "^[0-9a-fA-F]+$"}});
+ break;
+ case RPCArg::Type::NUM:
+ schema = MakeObject({{"type", "number"}});
+ break;
+ case RPCArg::Type::BOOL:
+ schema = MakeObject({{"type", "boolean"}});
+ break;
+ case RPCArg::Type::AMOUNT: {
+ UniValue one_of{UniValue::VARR};
+ one_of.push_back(MakeObject({{"type", "number"}}));
+ one_of.push_back(MakeObject({{"type", "string"}}));
+ schema.pushKV("oneOf", std::move(one_of));
+ break;
+ }
+ case RPCArg::Type::RANGE: {
+ UniValue items{UniValue::VARR};
+ items.push_back(MakeObject({{"type", "number"}}));
+ items.push_back(MakeObject({{"type", "number"}}));
+ UniValue range_schema{UniValue::VOBJ};
+ range_schema.pushKV("type", "array");
+ range_schema.pushKV("items", std::move(items));
+ range_schema.pushKV("additionalItems", false);
+ range_schema.pushKV("minItems", 2);
+ range_schema.pushKV("maxItems", 2);
+ UniValue one_of{UniValue::VARR};
+ one_of.push_back(MakeObject({{"type", "number"}}));
+ one_of.push_back(std::move(range_schema));
+ schema.pushKV("oneOf", std::move(one_of));
+ break;
+ }
+ case RPCArg::Type::ARR: {
+ UniValue items{DedupArrayItemsSchema(arg.m_inner, include_hidden)};
+ schema.pushKV("type", "array");
+ schema.pushKV("items", std::move(items));
+ break;
+ }
+ case RPCArg::Type::OBJ:
+ case RPCArg::Type::OBJ_NAMED_PARAMS: {
+ UniValue properties{UniValue::VOBJ};
+ UniValue required{UniValue::VARR};
+ for (const auto& inner : arg.m_inner) {
+ if (!include_hidden && inner.m_opts.hidden) continue;
+ UniValue prop{OpenRPCArgSchema(inner, include_hidden)};
+ if (!inner.m_description.empty()) prop.pushKV("description", inner.m_description);
+ if (inner.m_opts.placeholder) prop.pushKV("x-bitcoin-placeholder", true);
+ if (inner.m_opts.also_positional) prop.pushKV("x-bitcoin-also-positional", true);
+ properties.pushKV(inner.GetFirstName(), std::move(prop));
+ if (!inner.IsOptional()) required.push_back(inner.GetFirstName());
+ }
+ schema.pushKV("type", "object");
+ schema.pushKV("properties", std::move(properties));
+ schema.pushKV("additionalProperties", false);
+ if (!required.empty()) schema.pushKV("required", std::move(required));
+ break;
+ }
+ case RPCArg::Type::OBJ_USER_KEYS: {
+ schema.pushKV("type", "object");
+ if (!arg.m_inner.empty()) {
+ schema.pushKV("additionalProperties", OpenRPCArgSchema(arg.m_inner[0], include_hidden));
+ } else {
+ schema.pushKV("additionalProperties", true);
+ }
+ break;
+ }
+ } // no default case, so the compiler can warn about missing cases
+ ApplyTypeStrOverride(schema, arg);
+ ApplyArgFallback(schema, arg);
+ return schema;
+}
+
+// NOLINTNEXTLINE(misc-no-recursion)
+UniValue OpenRPCResultSchema(const RPCResult& result)
+{
+ if (result.m_opts.skip_type_check) {
+ RPCResultOptions opts{result.m_opts};
+ opts.skip_type_check = false;
+ if (result.m_type == RPCResult::Type::OBJ) {
+ UniValue obj_schema{OpenRPCResultSchema(RPCResult{result, std::move(opts)})};
+ if (result.m_key_name.empty()) return obj_schema;
+
+ UniValue one_of{UniValue::VARR};
+ one_of.push_back(std::move(obj_schema));
+ one_of.push_back(MakeObject({{"const", false}}));
+ UniValue schema{UniValue::VOBJ};
+ schema.pushKV("oneOf", std::move(one_of));
+ return schema;
+ }
+ if (result.m_type == RPCResult::Type::ARR) return OpenRPCResultSchema(RPCResult{result, std::move(opts)});
+ return UniValue{UniValue::VOBJ};
+ }
+
+ switch (result.m_type) {
+ case RPCResult::Type::STR:
+ return MakeObject({{"type", "string"}});
+ case RPCResult::Type::STR_AMOUNT:
+ return MakeObject({{"type", "string"}, {"x-bitcoin-unit", "amount"}});
+ case RPCResult::Type::STR_HEX:
+ return MakeObject({{"type", "string"}, {"pattern", "^[0-9a-fA-F]+$"}});
+ case RPCResult::Type::NUM:
+ return MakeObject({{"type", "number"}});
+ case RPCResult::Type::NUM_TIME: {
+ UniValue schema{UniValue::VOBJ};
+ schema.pushKV("type", "number");
+ schema.pushKV("x-bitcoin-unit", "unix-time");
+ return schema;
+ }
+ case RPCResult::Type::BOOL:
+ return MakeObject({{"type", "boolean"}});
+ case RPCResult::Type::NONE:
+ return MakeObject({{"type", "null"}});
+ case RPCResult::Type::ARR: {
+ UniValue items{DedupArrayItemsSchema(result.m_inner)};
+ UniValue schema{UniValue::VOBJ};
+ schema.pushKV("type", "array");
+ schema.pushKV("items", std::move(items));
+ return schema;
+ }
+ case RPCResult::Type::ARR_FIXED: {
+ UniValue items{UniValue::VARR};
+ for (const auto& inner : result.m_inner) {
+ items.push_back(OpenRPCResultSchema(inner));
+ }
+ UniValue schema{UniValue::VOBJ};
+ schema.pushKV("type", "array");
+ schema.pushKV("items", std::move(items));
+ schema.pushKV("additionalItems", false);
+ schema.pushKV("minItems", uint64_t(result.m_inner.size()));
+ schema.pushKV("maxItems", uint64_t(result.m_inner.size()));
+ return schema;
+ }
+ case RPCResult::Type::OBJ: {
+ UniValue properties{UniValue::VOBJ};
+ UniValue required{UniValue::VARR};
+ for (const auto& inner : result.m_inner) {
+ if (inner.m_key_name.empty()) continue;
+ UniValue prop{OpenRPCResultSchema(inner)};
+ if (!inner.m_description.empty()) prop.pushKV("description", inner.m_description);
+ properties.pushKV(inner.m_key_name, std::move(prop));
+ if (!inner.m_optional) required.push_back(inner.m_key_name);
+ }
+ UniValue schema{UniValue::VOBJ};
+ schema.pushKV("type", "object");
+ schema.pushKV("properties", std::move(properties));
+ schema.pushKV("additionalProperties", false);
+ if (!required.empty()) schema.pushKV("required", std::move(required));
+ return schema;
+ }
+ case RPCResult::Type::OBJ_DYN: {
+ UniValue schema{UniValue::VOBJ};
+ schema.pushKV("type", "object");
+ if (!result.m_inner.empty()) {
+ schema.pushKV("additionalProperties", OpenRPCResultSchema(result.m_inner[0]));
+ } else {
+ schema.pushKV("additionalProperties", UniValue{UniValue::VOBJ});
+ }
+ return schema;
+ }
+ case RPCResult::Type::ANY:
+ return UniValue{UniValue::VOBJ};
+ } // no default case, so the compiler can warn about missing cases
+ NONFATAL_UNREACHABLE();
+}
+} // namespace
+
+static RPCMethod getopenrpcinfo()
+{
+ return RPCMethod{
+ "getopenrpcinfo",
+ "Returns an OpenRPC document for currently available RPC commands.\n",
+ {
+ {"show_hidden", RPCArg::Type::BOOL, RPCArg::Default{false}, "Also include hidden RPC commands and arguments."},
+ },
+ RPCResult{
+ RPCResult::Type::OBJ, "", "",
+ {
+ {RPCResult::Type::STR, "openrpc", "OpenRPC specification version."},
+ {RPCResult::Type::OBJ, "info", "Metadata about this JSON-RPC interface.",
+ {
+ {RPCResult::Type::STR, "title", "API title."},
+ {RPCResult::Type::STR, "version", "Bitcoin Core version string."},
+ {RPCResult::Type::STR, "description", "API description."},
+ }},
+ {RPCResult::Type::ARR, "methods", "Documented RPC methods.",
+ {{RPCResult::Type::OBJ, "", "An RPC method description object.",
+ {
+ {RPCResult::Type::STR, "name", "Method name."},
+ {RPCResult::Type::STR, "description", "Method description."},
+ {RPCResult::Type::ARR, "params", "Method parameters.",
+ {{RPCResult::Type::OBJ, "", "A parameter.",
+ {
+ {RPCResult::Type::STR, "name", "Parameter name."},
+ {RPCResult::Type::BOOL, "required", "Whether the parameter is required."},
+ {RPCResult::Type::ANY, "schema", "JSON Schema for the parameter."},
+ {RPCResult::Type::STR, "description", /*optional=*/true, "Parameter description."},
+ {RPCResult::Type::ARR, "x-bitcoin-aliases", /*optional=*/true, "Alternative parameter names.",
+ {{RPCResult::Type::STR, "", "An alias."}}},
+ {RPCResult::Type::BOOL, "x-bitcoin-placeholder", /*optional=*/true, "Whether the parameter is retained only for compatibility."},
+ {RPCResult::Type::BOOL, "x-bitcoin-also-positional", /*optional=*/true, "Whether the parameter can also be passed positionally."},
+ }}}},
+ {RPCResult::Type::OBJ, "result", "Method result.",
+ {
+ {RPCResult::Type::STR, "name", "Result name."},
+ {RPCResult::Type::ANY, "schema", "JSON Schema for the result."},
+ }},
+ {RPCResult::Type::STR, "x-bitcoin-category", "RPC category."},
+ }}}},
+ },
+ {.skip_type_check = true}},
+ RPCExamples{
+ HelpExampleCli("getopenrpcinfo", "")
+ + HelpExampleRpc("getopenrpcinfo", "")
+ },
+ [](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue
+{
+ const bool include_hidden{!request.params[0].isNull() && request.params[0].get_bool()};
+ return tableRPC.buildOpenRPCDoc(include_hidden);
+},
+ };
+}
+
static const CRPCCommand vRPCCommands[]{
/* Overall control/query calls */
+ {"control", &getopenrpcinfo},
{"control", &getrpcinfo},
{"control", &help},
{"control", &stop},
@@ -527,6 +860,88 @@ std::vector<std::string> CRPCTable::listCommands() const
return commandList;
}
+UniValue CRPCTable::buildOpenRPCDoc(bool include_hidden) const
+{
+ std::vector<std::string> method_names;
+ for (const auto& [name, cmds] : mapCommands) {
+ if (cmds.empty()) continue;
+ const CRPCCommand* cmd{cmds.front()};
+ if ((!include_hidden && cmd->category == "hidden") || !cmd->metadata_fn) continue;
+ method_names.push_back(name);
+ }
+ std::sort(method_names.begin(), method_names.end());
+
+ UniValue methods{UniValue::VARR};
+ for (const auto& method_name : method_names) {
+ const CRPCCommand* cmd{mapCommands.at(method_name).front()};
+ RPCMethod helpman{cmd->metadata_fn()};
+
+ UniValue params{UniValue::VARR};
+ for (const auto& arg : helpman.GetArgs()) {
+ if (!include_hidden && arg.m_opts.hidden) continue;
+ UniValue param{UniValue::VOBJ};
+ param.pushKV("name", arg.GetFirstName());
+ param.pushKV("required", !arg.IsOptional());
+ param.pushKV("schema", OpenRPCArgSchema(arg, include_hidden));
+
+ std::vector<std::string> names{SplitString(arg.m_names, '|')};
+ if (names.size() > 1) {
+ UniValue aliases{UniValue::VARR};
+ for (size_t i{1}; i < names.size(); ++i) aliases.push_back(names[i]);
+ param.pushKV("x-bitcoin-aliases", std::move(aliases));
+ }
+ if (arg.m_opts.placeholder) param.pushKV("x-bitcoin-placeholder", true);
+ if (arg.m_opts.also_positional) param.pushKV("x-bitcoin-also-positional", true);
+ if (!arg.m_description.empty()) param.pushKV("description", arg.m_description);
+ params.push_back(std::move(param));
+ }
+
+ UniValue result_schema{UniValue::VOBJ};
+ const auto& results{helpman.GetResults().m_results};
+ if (results.size() == 1 && results[0].m_type != RPCResult::Type::ANY) {
+ result_schema = OpenRPCResultSchema(results[0]);
+ } else if (results.size() > 1) {
+ UniValue one_of{UniValue::VARR};
+ for (const auto& r : results) {
+ if (r.m_type == RPCResult::Type::ANY) continue;
+ UniValue schema{OpenRPCResultSchema(r)};
+ if (!r.m_cond.empty()) schema.pushKV("description", r.m_cond);
+ one_of.push_back(std::move(schema));
+ }
+ if (one_of.size() == 1) {
+ result_schema = one_of[0];
+ } else if (one_of.size() > 1) {
+ result_schema.pushKV("oneOf", std::move(one_of));
+ }
+ }
+
+ UniValue method{UniValue::VOBJ};
+ method.pushKV("name", method_name);
+ method.pushKV("description", util::TrimString(helpman.GetDescription()));
+ method.pushKV("params", std::move(params));
+ UniValue result{UniValue::VOBJ};
+ result.pushKV("name", "result");
+ result.pushKV("schema", std::move(result_schema));
+ method.pushKV("result", std::move(result));
+ method.pushKV("x-bitcoin-category", cmd->category);
+ methods.push_back(std::move(method));
+ }
+
+ std::string version{"v" CLIENT_VERSION_STRING};
+ if (!CLIENT_VERSION_IS_RELEASE) version += "-dev";
+
+ UniValue info{UniValue::VOBJ};
+ info.pushKV("title", "Bitcoin Core JSON-RPC");
+ info.pushKV("version", version);
+ info.pushKV("description", "Autogenerated from Bitcoin Core RPC metadata.");
+
+ UniValue doc{UniValue::VOBJ};
+ doc.pushKV("openrpc", "1.4.1");
+ doc.pushKV("info", std::move(info));
+ doc.pushKV("methods", std::move(methods));
+ return doc;
+}
+
UniValue CRPCTable::dumpArgMap(const JSONRPCRequest& args_request) const
{
JSONRPCRequest request = args_request;
diff --git a/src/rpc/server.h b/src/rpc/server.h
index 147c8cf6..208dd1be 100644
--- a/src/rpc/server.h
+++ b/src/rpc/server.h
@@ -107,6 +107,8 @@ public:
* @returns List of registered commands.
*/
std::vector<std::string> listCommands() const;
+ /** Return a complete OpenRPC 1.4.1 document for registered commands. */
+ UniValue buildOpenRPCDoc(bool include_hidden = false) const;
/**
* Return all named arguments that need to be converted by the client from string to another JSON type
diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp
index e3d3e540..74fbb1c5 100644
--- a/src/test/fuzz/rpc.cpp
+++ b/src/test/fuzz/rpc.cpp
@@ -147,6 +147,7 @@ const std::vector<std::string> RPC_COMMANDS_SAFE_FOR_FUZZING{
"getnetworkhashps",
"getnetworkinfo",
"getnodeaddresses",
+ "getopenrpcinfo",
"getorphantxs",
"getpeerinfo",
"getprioritisedtransactions",
diff --git a/src/test/rpc_tests.cpp b/src/test/rpc_tests.cpp
index 390cd50c..d574e1e2 100644
--- a/src/test/rpc_tests.cpp
+++ b/src/test/rpc_tests.cpp
@@ -136,6 +136,39 @@ BOOST_AUTO_TEST_CASE(rpc_namedonlyparams)
HasJSON(R"({"code":-8,"message":"Parameter options specified twice both as positional and named argument"})"));
}
+BOOST_AUTO_TEST_CASE(rpc_remove_command_cleans_up_empty_entry)
+{
+ CRPCTable table;
+ RpcMethodFnType method{
+ []() -> RPCMethod {
+ return RPCMethod{
+ "method",
+ "Test RPC method.\n",
+ {},
+ RPCResult{RPCResult::Type::STR, "", ""},
+ RPCExamples{""},
+ [](const RPCMethod&, const JSONRPCRequest&) -> UniValue { return "ok"; },
+ };
+ }
+ };
+ CRPCCommand command{"test", method};
+
+ table.appendCommand(command.name, &command);
+ BOOST_CHECK(table.removeCommand(command.name, &command));
+
+ bool found{false};
+ for (const auto& name : table.listCommands()) {
+ if (name == command.name) {
+ found = true;
+ break;
+ }
+ }
+ BOOST_CHECK(!found);
+
+ UniValue doc{table.buildOpenRPCDoc()};
+ BOOST_CHECK(doc.isObject());
+}
+
BOOST_AUTO_TEST_CASE(rpc_rawparams)
{
// Test raw transaction API argument handling
Why this scored 21/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.