rpc: use null for optional parameters
What changed, and why it matters
This commit changes how several RPC commands detect missing optional arguments. Instead of checking how many arguments were supplied, the code now treats an explicit JSON 'null' as 'not provided.' This lets callers pass null as a placeholder for optional parameters, matching normal Bitcoin/Elements RPC conventions. It is a usability and consistency fix, not a security patch.
No security action required. Treat as a normal compatibility/usability improvement. Reviewers may optionally verify that no RPC now accepts an unintended default when a non-null but empty value is passed.
Security signals we found
No memory-safety, cryptographic, or authorization changes
Parameter validation logic remains equivalent: null is treated as omitted
Functional tests added for null-argument behavior
No bug class such as buffer overflow, injection, or privilege escalation introduced
Evidence from the diff
The diff replaces size-based checks (request.params.size() < N or > N) with isNull() checks for optional RPC parameters across blockchain, output_script, server, and Elements wallet RPCs. It also adds functional tests demonstrating that passing JSON null for optional arguments behaves the same as omitting them. No bounds-checking, type-checking, or authorization logic is weakened; the change only affects how omitted/optional arguments are recognized.
Changed components
src/rpc/blockchain.cppsrc/rpc/output_script.cppsrc/rpc/server.cppsrc/wallet/rpc/elements.cppRPC parameter parsing for scantxoutset, deriveaddresses, help, initpegoutwallet, sendtomainchain, createrawpegin, blindrawtransaction, issueasset, listissuancesInspect captured patch +56 / −14
diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp
index 84ceaaf..889d2fd 100644
--- a/src/rpc/blockchain.cpp
+++ b/src/rpc/blockchain.cpp
@@ -2526,7 +2526,7 @@ static RPCHelpMan scantxoutset()
throw JSONRPCError(RPC_INVALID_PARAMETER, "Scan already in progress, use action \"abort\" or \"status\"");
}
- if (request.params.size() < 2) {
+ if (request.params[1].isNull()) {
throw JSONRPCError(RPC_MISC_ERROR, "scanobjects argument is required for the start action");
}
diff --git a/src/rpc/output_script.cpp b/src/rpc/output_script.cpp
index 4605f81..19de77e 100644
--- a/src/rpc/output_script.cpp
+++ b/src/rpc/output_script.cpp
@@ -353,7 +353,7 @@ static RPCHelpMan deriveaddresses()
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, error);
}
auto& desc = descs.at(0);
- if (!desc->IsRange() && request.params.size() > 1) {
+ if (!desc->IsRange() && !request.params[1].isNull()) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Range should not be specified for an un-ranged descriptor");
}
diff --git a/src/rpc/server.cpp b/src/rpc/server.cpp
index 34f19df..a519089 100644
--- a/src/rpc/server.cpp
+++ b/src/rpc/server.cpp
@@ -139,7 +139,7 @@ static RPCHelpMan help()
[&](const RPCHelpMan& self, const JSONRPCRequest& jsonRequest) -> UniValue
{
std::string strCommand;
- if (jsonRequest.params.size() > 0) {
+ if (!jsonRequest.params[0].isNull()) {
strCommand = jsonRequest.params[0].get_str();
}
if (strCommand == "dump_all_command_conversions") {
diff --git a/src/wallet/rpc/elements.cpp b/src/wallet/rpc/elements.cpp
index b455c33..ec57d11 100644
--- a/src/wallet/rpc/elements.cpp
+++ b/src/wallet/rpc/elements.cpp
@@ -325,7 +325,7 @@ RPCHelpMan initpegoutwallet()
// Generate a new key that is added to wallet or set from argument
CPubKey online_pubkey;
- if (request.params.size() < 3) {
+ if (request.params[2].isNull()) {
std::string error;
if (!pwallet->GetOnlinePakKey(online_pubkey, error)) {
throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, error);
@@ -342,7 +342,7 @@ RPCHelpMan initpegoutwallet()
// Parse offline counter
int counter = 0;
- if (request.params.size() > 1) {
+ if (!request.params[1].isNull()) {
counter = request.params[1].getInt<int>();
if (counter < 0 || counter > 1000000000) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "bip32_counter must be between 0 and 1,000,000,000, inclusive.");
@@ -499,7 +499,7 @@ RPCHelpMan sendtomainchain_base()
throw JSONRPCError(RPC_TYPE_ERROR, "Invalid amount for send");
bool subtract_fee = false;
- if (request.params.size() > 2) {
+ if (!request.params[2].isNull()) {
subtract_fee = request.params[2].get_bool();
}
@@ -520,7 +520,7 @@ RPCHelpMan sendtomainchain_base()
EnsureWalletIsUnlocked(*pwallet);
- bool verbose = request.params[3].isNull() ? false: request.params[3].get_bool();
+ bool verbose = request.params[3].isNull() ? false : request.params[3].get_bool();
mapValue_t mapValue;
CCoinControl no_coin_control; // This is a deprecated API
return SendMoney(*pwallet, no_coin_control, recipients, std::move(mapValue), verbose, true /* ignore_blind_fail */);
@@ -612,7 +612,7 @@ RPCHelpMan sendtomainchain_pak()
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid amount for send, must send more than 0.00100000 BTC");
bool subtract_fee = false;
- if (request.params.size() > 2) {
+ if (!request.params[2].isNull()) {
subtract_fee = request.params[2].get_bool();
}
@@ -825,7 +825,7 @@ static UniValue createrawpegin(const JSONRPCRequest& request, T_tx_ref& txBTCRef
std::vector<unsigned char> txOutProofData = ParseHex(request.params[1].get_str());
std::set<CScript> claim_scripts;
- if (request.params.size() > 2) {
+ if (!request.params[2].isNull()) {
const std::string claim_script = request.params[2].get_str();
if (!IsHex(claim_script)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Given claim_script is not hex.");
@@ -1270,12 +1270,12 @@ RPCHelpMan blindrawtransaction()
}
bool ignore_blind_fail = true;
- if (request.params.size() > 1) {
+ if (!request.params[1].isNull()) {
ignore_blind_fail = request.params[1].get_bool();
}
std::vector<std::vector<unsigned char> > auxiliary_generators;
- if (request.params.size() > 2) {
+ if (!request.params[2].isNull()) {
UniValue assetCommitments = request.params[2].get_array();
if (assetCommitments.size() != 0 && assetCommitments.size() < tx.vin.size()) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Asset commitment array must have at least as many entries as transaction inputs.");
@@ -1543,11 +1543,11 @@ RPCHelpMan issueasset()
throw JSONRPCError(RPC_TYPE_ERROR, "Issuance must have one non-zero component");
}
- bool blind_issuances = request.params.size() < 3 || request.params[2].get_bool();
+ bool blind_issuances = request.params[2].isNull() || request.params[2].get_bool();
// Check for optional contract to hash into definition
uint256 contract_hash;
- if (request.params.size() >= 4) {
+ if (!request.params[3].isNull()) {
contract_hash = ParseHashV(request.params[3], "contract_hash");
}
@@ -1739,7 +1739,7 @@ RPCHelpMan listissuances()
std::string assetstr;
CAsset asset_filter;
- if (request.params.size() > 0) {
+ if (!request.params[0].isNull()) {
assetstr = request.params[0].get_str();
asset_filter = GetAssetFromString(assetstr);
}
diff --git a/test/functional/feature_fedpeg.py b/test/functional/feature_fedpeg.py
index 646f19d..f30ab5f 100755
--- a/test/functional/feature_fedpeg.py
+++ b/test/functional/feature_fedpeg.py
@@ -601,6 +601,12 @@ class FedPegTest(BitcoinTestFramework):
peg_out_txid = sidechain.sendtomainchain(some_btc_addr, 1)
+ self.log.info("sendtomainchain with null argument")
+ verbose_result = sidechain.sendtomainchain(some_btc_addr, 1, None, True)
+ assert isinstance(verbose_result, dict)
+ assert 'txid' in verbose_result
+ assert 'fee_reason' in verbose_result
+
peg_out_details = sidechain.decoderawtransaction(sidechain.getrawtransaction(peg_out_txid))
# peg-out, change, fee
assert len(peg_out_details["vout"]) == 3
diff --git a/test/functional/feature_issuance.py b/test/functional/feature_issuance.py
index 5fe0785..42bbbc8 100755
--- a/test/functional/feature_issuance.py
+++ b/test/functional/feature_issuance.py
@@ -115,12 +115,19 @@ class IssuanceTest(BitcoinTestFramework):
# Make sure test starts with no initial issuance.
assert_equal(len(self.nodes[0].listissuances()), 0)
+ self.log.info("listissuances with null argument")
+ assert_equal(self.nodes[0].listissuances(None), [])
+
# Unblinded issuance of asset
contract_hash = "deadbeef"*8
issued = self.nodes[0].issueasset(1, 1, False, contract_hash)
balance = self.nodes[0].getwalletinfo()["balance"]
assert_equal(balance[issued["asset"]], 1)
assert_equal(balance[issued["token"]], 1)
+
+ self.log.info("issueasset with null argument")
+ assert_equal(len(self.nodes[0].listissuances(None)), len(self.nodes[0].listissuances()))
+
# Quick unblinded reissuance check, making 2*COIN total
self.nodes[0].reissueasset(issued["asset"], 1)
diff --git a/test/functional/feature_pak.py b/test/functional/feature_pak.py
index 1ed0338..a3e609e 100755
--- a/test/functional/feature_pak.py
+++ b/test/functional/feature_pak.py
@@ -76,6 +76,11 @@ class PAKTest (BitcoinTestFramework):
assert_equal(new_init["address_lookahead"][0], init_results[1]["address_lookahead"][2])
assert new_init["liquid_pak"] != init_results[1]["liquid_pak"]
+ self.log.info("initpegoutwallet with null argument")
+ null_pak_init = self.nodes[2].initpegoutwallet(xpub, 5, None)
+ assert_equal(self.nodes[2].getwalletpakinfo()["bip32_counter"], "5")
+ assert null_pak_init["liquid_pak"]
+
# Restart and connect peers to check wallet persistence
self.stop_nodes()
self.start_nodes()
@@ -192,6 +197,12 @@ class PAKTest (BitcoinTestFramework):
wpkh_stmc = self.nodes[1].sendtomainchain("", 1)
wpkh_txid = wpkh_stmc['txid']
+ self.log.info("sendtomainchain with null argument")
+ verbose_stmc = self.nodes[1].sendtomainchain("", 1, None, True)
+ assert isinstance(verbose_stmc, dict)
+ assert 'txid' in verbose_stmc
+ assert 'fee_reason' in verbose_stmc
+
# Also check some basic return fields of sendtomainchain with pak
assert_equal(wpkh_stmc["bitcoin_address"], wpkh_info["address_lookahead"][0])
validata = self.nodes[1].validateaddress(wpkh_stmc["bitcoin_address"])
diff --git a/test/functional/feature_pegin_subsidy.py b/test/functional/feature_pegin_subsidy.py
index 49e8758..04120d4 100755
--- a/test/functional/feature_pegin_subsidy.py
+++ b/test/functional/feature_pegin_subsidy.py
@@ -340,6 +340,16 @@ class PeginSubsidyTest(BitcoinTestFramework):
assert_equal(len(pegin_tx["decoded"]["vout"]), 2)
self.generate(sidechain2, 1, sync_fun=sync_sidechain)
+ self.log.info("createrawpegin with null argument")
+ txid, vout, txoutproof, bitcoin_txhex, claim_script = parent_pegin(parent, sidechain2, amount=1.0, feerate=2.0)
+ pegintx = sidechain2.createrawpegin(bitcoin_txhex, txoutproof, None, 2.0)
+ signed = sidechain2.signrawtransactionwithwallet(pegintx["hex"])
+ assert_equal(signed["complete"], True)
+ pegin_txid = sidechain2.sendrawtransaction(signed["hex"])
+ pegin_tx = sidechain2.gettransaction(pegin_txid, True, True)
+ assert_equal(len(pegin_tx["decoded"]["vout"]), 2)
+ self.generate(sidechain2, 1, sync_fun=sync_sidechain)
+
self.log.info("claimpegin before enforcement, with validatepegin, below threshold")
txid, vout, txoutproof, bitcoin_txhex, claim_script = parent_pegin(parent, sidechain, amount=1.0, feerate=2.0)
pegin_txid = sidechain.claimpegin(bitcoin_txhex, txoutproof, claim_script)
diff --git a/test/functional/rpc_deriveaddresses.py b/test/functional/rpc_deriveaddresses.py
index 96b8444..042a644 100755
--- a/test/functional/rpc_deriveaddresses.py
+++ b/test/functional/rpc_deriveaddresses.py
@@ -17,6 +17,7 @@ class DeriveaddressesTest(BitcoinTestFramework):
descriptor = "wpkh(tprv8ZgxMBicQKsPd7Uf69XL1XwhmjHopUGep8GuEiJDZmbQz6o58LninorQAfcKZWARbtRtfnLcJ5MQ2AtHcQJCCRUcMRvmDUjyEmNUWwx8UbK/1/1/0)#t6wfjs64"
address = "ert1qjqmxmkpmxt80xz4y3746zgt0q3u3ferrfpgxn5"
assert_equal(self.nodes[0].deriveaddresses(descriptor), [address])
+ assert_equal(self.nodes[0].deriveaddresses(descriptor, None), [address])
descriptor = descriptor[:-9]
assert_raises_rpc_error(-5, "Missing checksum", self.nodes[0].deriveaddresses, descriptor)
diff --git a/test/functional/rpc_help.py b/test/functional/rpc_help.py
index db4f895..5143e29 100755
--- a/test/functional/rpc_help.py
+++ b/test/functional/rpc_help.py
@@ -97,6 +97,9 @@ class HelpRpcTest(BitcoinTestFramework):
# invalid argument
assert_raises_rpc_error(-3, "JSON value of type number is not of expected type string", node.help, 0)
+ # null argument
+ assert_equal(node.help(None), node.help())
+
# help of unknown command
assert_equal(node.help('foo'), 'help: unknown command: foo')
diff --git a/test/functional/rpc_scantxoutset.py b/test/functional/rpc_scantxoutset.py
index 9c74ea0..428763e 100755
--- a/test/functional/rpc_scantxoutset.py
+++ b/test/functional/rpc_scantxoutset.py
@@ -140,6 +140,7 @@ class ScantxoutsetTest(BitcoinTestFramework):
# Check that second arg is needed for start
assert_raises_rpc_error(-1, "scanobjects argument is required for the start action", self.nodes[0].scantxoutset, "start")
+ assert_raises_rpc_error(-1, "scanobjects argument is required for the start action", self.nodes[0].scantxoutset, "start", None)
# Check that invalid command give error
assert_raises_rpc_error(-8, "Invalid action 'invalid_command'", self.nodes[0].scantxoutset, "invalid_command")
diff --git a/test/functional/wallet_fundrawtransaction.py b/test/functional/wallet_fundrawtransaction.py
index 8d6d324..15326fc 100755
--- a/test/functional/wallet_fundrawtransaction.py
+++ b/test/functional/wallet_fundrawtransaction.py
@@ -706,6 +706,9 @@ class RawTransactionsTest(BitcoinTestFramework):
fundedTx = wallet.fundrawtransaction(rawtx)
blindedTx = wallet.blindrawtransaction(fundedTx['hex'])
assert fundedTx["changepos"] != -1
+ self.log.info("blindrawtransaction with null argument")
+ blindedTx_null_commitments = wallet.blindrawtransaction(fundedTx['hex'], None, None, False)
+ assert blindedTx_null_commitments
# Now we need to unlock.
with WalletUnlock(wallet, "test"):
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.