Merge bitcoin/bitcoin#34861: wallet: Add importdescriptors interface
What changed, and why it matters
This commit refactors Bitcoin Core's wallet descriptor import feature so the same logic can be used by both the RPC command and a new GUI-facing interface. It also tightens one input rule: negative timestamps are now rejected, and the minimum allowed timestamp is changed from 1 to 0. There is no direct evidence in the commit of a security vulnerability; it appears to be a normal feature/refactor change. The small behavior change around timestamps is a stricter validation, not a weakening.
Treat as a routine feature/refactor commit. Reviewers should verify that the new shared ImportDescriptor path preserves all original validation (range checks, active/ranged constraints, private-key wallet checks, unused() key checks) and that the new GUI interface receives the same error handling and rescan behavior as the RPC path. No urgent security action is indicated by the supplied materials.
Security signals we found
Refactor of security-sensitive wallet import code into shared CWallet path
New input validation: negative timestamps rejected for importdescriptors
Centralization of descriptor range bound checks in CheckDescriptorRangeBounds
New WalletErrorCode enum and error mapping function HandleWalletErrorCode
No explicit security bug or CVE mentioned in commit or references
Evidence from the diff
The change moves descriptor-import logic out of the RPC layer into new wallet functions ImportDescriptor() and ProcessDescriptorsImport(), adds result/error structs (ImportDescriptorRequest, ImportResult, ImportError, WalletErrorCode), and exposes the functionality via interfaces::Wallet::importDescriptors(). The RPC path is rewritten to parse UniValue into ImportDescriptorRequest, call the shared implementation, and map WalletErrorCode back to RPC error codes. A shared CheckDescriptorRangeBounds() helper is introduced in src/script/descriptor.cpp. The minimum import timestamp is lowered from 1 to 0, and negative timestamps are explicitly rejected with RPC_INVALID_PARAMETER. Existing range/parameter validation is preserved and centralized.
Changed components
src/wallet/imports.cppsrc/wallet/imports.hsrc/wallet/interfaces.cppsrc/wallet/rpc/backup.cppsrc/wallet/rpc/util.cppsrc/wallet/rpc/util.hsrc/script/descriptor.cppsrc/script/descriptor.hsrc/wallet/types.hsrc/interfaces/wallet.htest/functional/wallet_importdescriptors.pysrc/wallet/test/wallet_tests.cppInspect captured patch +619 / −264
### doc/release-notes-34861.md
@@ -0,0 +1,4 @@
+RPC
+---
+
+- The minimum timestamp for `importdescriptors` is set to `0` instead of `1`. Additionally negative timestamps are now invalid in `importdescriptors`, passing a timestamp below `0` will throw with error code `-8` (`RPC_INVALID_PARAMETER`).
### src/interfaces/wallet.h
@@ -44,6 +44,8 @@ struct CExtKey;
namespace wallet {
class CCoinControl;
class CWallet;
+struct ImportDescriptorRequest;
+struct ImportResult;
struct CRecipient;
struct WalletContext;
} // namespace wallet
@@ -210,6 +212,9 @@ class Wallet
PartiallySignedTransaction& psbtx,
bool& complete) = 0;
+ //! Import descriptors
+ virtual std::vector<wallet::ImportResult> importDescriptors(std::vector<wallet::ImportDescriptorRequest>& requests) = 0;
+
//! Get balances.
virtual WalletBalances getBalances() = 0;
### src/rpc/util.cpp
@@ -23,6 +23,7 @@
#include <univalue.h>
#include <util/bip32.h>
#include <util/check.h>
+#include <util/expected.h>
#include <util/result.h>
#include <util/strencodings.h>
#include <util/string.h>
@@ -1319,7 +1320,6 @@ static std::pair<int64_t, int64_t> ParseRange(const UniValue& value)
if (value.isArray() && value.size() == 2 && value[0].isNum() && value[1].isNum()) {
int64_t low = value[0].getInt<int64_t>();
int64_t high = value[1].getInt<int64_t>();
- if (low > high) throw JSONRPCError(RPC_INVALID_PARAMETER, "Range specified as [begin,end] must not have begin after end");
return {low, high};
}
throw JSONRPCError(RPC_INVALID_PARAMETER, "Range must be specified as end or as [begin,end]");
@@ -1329,14 +1329,8 @@ std::pair<int64_t, int64_t> ParseDescriptorRange(const UniValue& value)
{
int64_t low, high;
std::tie(low, high) = ParseRange(value);
- if (low < 0) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Range should be greater or equal than 0");
- }
- if ((high >> 31) != 0) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "End of range is too high");
- }
- if (high >= low + 1000000) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Range is too large");
+ if (auto res = CheckDescriptorRangeBounds(low, high); !res) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, res.error());
}
return {low, high};
}
### src/script/descriptor.cpp
@@ -49,6 +49,23 @@
using util::Split;
+util::Expected<void, std::string> CheckDescriptorRangeBounds(int64_t low, int64_t high)
+{
+ if (low < 0) {
+ return util::Unexpected<std::string>("Range should be greater or equal than 0");
+ }
+ if ((high >> 31) != 0) {
+ return util::Unexpected<std::string>("End of range is too high");
+ }
+ if (high >= low + 1000000) {
+ return util::Unexpected<std::string>("Range is too large");
+ }
+ if (low > high) {
+ return util::Unexpected<std::string>("Range specified as [begin,end] must not have begin after end");
+ }
+ return {};
+}
+
namespace {
////////////////////////////////////////////////////////////////////////////
### src/script/descriptor.h
@@ -8,6 +8,7 @@
#include <outputtype.h>
#include <pubkey.h>
#include <uint256.h>
+#include <util/expected.h>
#include <cstddef>
#include <cstdint>
@@ -210,6 +211,13 @@ struct Descriptor {
virtual size_t GetKeyCount() const = 0;
};
+/** Validate the numeric bounds of a descriptor key-expression range
+ * [low, high] (high inclusive). On success returns an Expected with no
+ * value; on failure returns the first violated invariant's user-facing
+ * message.
+ */
+util::Expected<void, std::string> CheckDescriptorRangeBounds(int64_t low, int64_t high);
+
/** Parse a `descriptor` string. Included private keys are put in `out`.
*
* If the descriptor has a checksum, it must be valid. If `require_checksum`
### src/wallet/CMakeLists.txt
@@ -14,6 +14,7 @@ add_library(bitcoin_wallet STATIC EXCLUDE_FROM_ALL
external_signer_scriptpubkeyman.cpp
feebumper.cpp
fees.cpp
+ imports.cpp
interfaces.cpp
load.cpp
migrate.cpp
### src/wallet/imports.cpp
@@ -0,0 +1,330 @@
+// Copyright (c) 2026-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <chain.h>
+#include <wallet/imports.h>
+#include <wallet/scan.h>
+
+namespace wallet {
+
+ImportResult ImportDescriptor(CWallet& wallet, const ImportDescriptorRequest& request) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
+{
+ AssertLockHeld(wallet.cs_wallet);
+
+ std::vector<std::string> warnings;
+
+ // Parse descriptor string
+ FlatSigningProvider keys;
+ std::string error;
+ auto parsed_descs = Parse(request.descriptor, keys, error, /*require_checksum=*/true);
+ if (parsed_descs.empty()) {
+ return ImportResult(WalletErrorCode::InvalidDescriptor, error, warnings);
+ }
+
+ if (request.internal.has_value() && parsed_descs.size() > 1) {
+ return ImportResult(
+ WalletErrorCode::InvalidDescriptor,
+ "Cannot have multipath descriptor while also specifying 'internal'",
+ warnings
+ );
+ }
+
+ // Range check
+ bool is_ranged{false};
+ int64_t range_start = 0, range_end = 1, next_index = 0;
+ if (!parsed_descs.at(0)->IsRange() && request.range.has_value()) {
+ return ImportResult(
+ WalletErrorCode::InvalidParameter,
+ "Range should not be specified for an un-ranged descriptor",
+ warnings
+ );
+ } else if (parsed_descs.at(0)->IsRange()) {
+ if (request.range.has_value()) {
+ int64_t low = request.range->first;
+ int64_t high = request.range->second;
+ if (auto res = CheckDescriptorRangeBounds(low, high); !res) {
+ return ImportResult(WalletErrorCode::InvalidParameter,
+ res.error());
+ }
+ range_start = low;
+ range_end = high + 1; // Specified range end is inclusive, but we need range end as exclusive
+ } else {
+ warnings.emplace_back("Range not given, using default keypool range");
+ range_start = 0;
+ range_end = wallet.m_keypool_size;
+ }
+ next_index = request.next_index.value_or(range_start);
+ is_ranged = true;
+
+ if (next_index < range_start || next_index >= range_end) {
+ return ImportResult(
+ WalletErrorCode::InvalidParameter,
+ "next_index is out of range",
+ warnings
+ );
+ }
+ }
+
+ // Active descriptors must be ranged
+ if (request.active && !parsed_descs.at(0)->IsRange()) {
+ return ImportResult(
+ WalletErrorCode::InvalidParameter,
+ "Active descriptors must be ranged",
+ warnings
+ );
+ }
+
+ // Multipath descriptors should not have a label
+ if (parsed_descs.size() > 1 && !request.label.empty()) {
+ return ImportResult(
+ WalletErrorCode::InvalidParameter,
+ "Multipath descriptors should not have a label",
+ warnings
+ );
+ }
+
+ // Ranged descriptors should not have a label
+ if (is_ranged && !request.label.empty()) {
+ return ImportResult(
+ WalletErrorCode::InvalidParameter,
+ "Ranged descriptors should not have a label",
+ warnings
+ );
+ }
+
+ bool desc_internal = request.internal.value_or(false);
+ // Internal addresses should not have a label either
+ if (desc_internal && !request.label.empty()) {
+ return ImportResult(
+ WalletErrorCode::InvalidParameter,
+ "Internal addresses should not have a label",
+ warnings
+ );
+ }
+
+ // Combo descriptor check
+ if (request.active && !parsed_descs.at(0)->IsSingleType()) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ "Combo descriptors cannot be set to active",
+ warnings
+ );
+ }
+
+ // If the wallet disabled private keys, abort if private keys exist
+ if (wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS) && !keys.keys.empty()) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ "Cannot import private keys to a wallet with private keys disabled",
+ warnings
+ );
+ }
+
+ for (size_t j = 0; j < parsed_descs.size(); ++j) {
+ auto parsed_desc = std::move(parsed_descs[j]);
+ if (parsed_descs.size() == 2) {
+ desc_internal = j == 1;
+ } else if (parsed_descs.size() > 2) {
+ CHECK_NONFATAL(!desc_internal);
+ }
+ // ExpandPrivate to whether the descriptor can be derived at the first index.
+ FlatSigningProvider expand_keys;
+ std::vector<CScript> scripts;
+ if (!parsed_desc->Expand(0, keys, scripts, expand_keys)) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ "Cannot expand descriptor. Probably because of hardened derivations without private keys provided",
+ warnings
+ );
+ }
+
+ for (const auto& w : parsed_desc->Warnings()) {
+ warnings.push_back(w);
+ }
+
+ // If private keys are enabled, check some things.
+ if (!wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
+ if (keys.keys.empty()) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ "Cannot import descriptor without private keys to a wallet with private keys enabled",
+ warnings
+ );
+ }
+ if (!parsed_desc->HavePrivateKeys(keys)) {
+ warnings.emplace_back("Not all private keys provided. Some wallet functionality may return unexpected errors");
+ }
+ }
+ // If this is an unused(KEY) descriptor, check that the wallet doesn't already have other descriptors with this key
+ if (!parsed_desc->HasScripts()) {
+ if (wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ "Cannot import unused() to wallet without private keys enabled",
+ warnings
+ );
+ }
+ // Unused descriptors must contain a single key.
+ // Earlier checks will have enforced that this key is either a private key when private keys are enabled,
+ // or that this key is a public key when private keys are disabled.
+ // If we can retrieve the corresponding private key from the wallet, then this key is already in the wallet
+ // and we should not import it.
+ std::set<CPubKey> pubkeys;
+ std::set<CExtPubKey> extpubs;
+ parsed_desc->GetPubKeys(pubkeys, extpubs);
+ std::transform(extpubs.begin(), extpubs.end(), std::inserter(pubkeys, pubkeys.begin()), [](const CExtPubKey& xpub) { return xpub.pubkey; });
+ CHECK_NONFATAL(pubkeys.size() == 1);
+ if (wallet.GetKey(pubkeys.begin()->GetID())) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ "Cannot import an unused() descriptor when its private key is already in the wallet",
+ warnings
+ );
+ }
+ }
+
+ Assume(request.timestamp.has_value());
+ WalletDescriptor w_desc(std::move(parsed_desc), request.timestamp.value(), range_start, range_end, next_index);
+
+ // Add descriptor to the wallet
+ auto spk_manager_res = wallet.AddWalletDescriptor(w_desc, keys, request.label, desc_internal);
+
+ if (!spk_manager_res) {
+ return ImportResult(
+ WalletErrorCode::GenericError,
+ strprintf("Could not add descriptor '%s': %s", request.descriptor, util::ErrorString(spk_manager_res).original),
+ warnings
+ );
+ }
+
+ auto& spk_manager = spk_manager_res.value().get();
+
+ // Set descriptor as active if necessary
+ if (request.active) {
+ if (!w_desc.descriptor->GetOutputType()) {
+ warnings.emplace_back("Unknown output type, cannot set descriptor to active.");
+ } else {
+ wallet.AddActiveScriptPubKeyMan(spk_manager.GetID(), *w_desc.descriptor->GetOutputType(), desc_internal);
+ }
+ } else {
+ if (w_desc.descriptor->GetOutputType()) {
+ wallet.DeactivateScriptPubKeyMan(spk_manager.GetID(), *w_desc.descriptor->GetOutputType(), desc_internal);
+ }
+ }
+ }
+
+ ImportResult result;
+ result.warnings = warnings;
+ return result;
+}
+
+std::vector<ImportResult> ProcessDescriptorsImport(CWallet& wallet,
+ std::vector<ImportDescriptorRequest>& requests)
+{
+ std::vector<ImportResult> response;
+
+ WalletRescanReserver reserver(wallet);
+ if (!reserver.reserve(/*with_passphrase=*/true)) {
+ return {ImportResult{
+ WalletErrorCode::GenericError,
+ "Wallet is currently rescanning. Abort existing rescan or wait.",
+ /*warnings=*/{},
+ /*general_error=*/true
+ }};
+ }
+
+ // Make sure the results are valid at least up to the most recent block
+ // the user could have gotten from another RPC command prior to now
+ wallet.BlockUntilSyncedToCurrentChain();
+
+ // Ensure that the wallet is not locked for the remainder of this call,
+ // as the passphrase is used to top up the keypool.
+ LOCK(wallet.m_relock_mutex);
+ int64_t now = 0;
+ int64_t lowest_timestamp = 0;
+ bool rescan = false;
+ {
+ LOCK(wallet.cs_wallet);
+ if (wallet.IsLocked()) {
+ return {ImportResult{
+ WalletErrorCode::UnlockNeeded,
+ "Error: Please enter the wallet passphrase with walletpassphrase first.",
+ /*warnings=*/{},
+ /*general_error=*/true
+ }};
+ }
+
+ CHECK_NONFATAL(wallet.chain().findBlock(wallet.GetLastBlockHash(), interfaces::FoundBlock().time(lowest_timestamp).mtpTime(now)));
+
+ for (ImportDescriptorRequest& request : requests) {
+ request.timestamp = request.timestamp.value_or(now);
+ const ImportResult& import_result = ImportDescriptor(wallet, request);
+
+ if (lowest_timestamp > request.timestamp.value()) {
+ lowest_timestamp = request.timestamp.value();
+ }
+ if (!import_result.has_error()) {
+ // At least one request succeeded, so we need to rescan
+ rescan = true;
+ }
+ response.push_back(import_result);
+ }
+ wallet.ConnectScriptPubKeyManNotifiers();
+ wallet.RefreshAllTXOs();
+ }
+
+ if (rescan) {
+ const int64_t scanned_time = wallet.Scanner().ScanFromTime(lowest_timestamp, reserver);
+ wallet.ResubmitWalletTransactions(node::TxBroadcast::MEMPOOL_NO_BROADCAST, /*force=*/true);
+
+ if (wallet.Scanner().IsAborting()) {
+ return {ImportResult{
+ WalletErrorCode::MiscError,
+ "Rescan aborted by user.",
+ /*warnings=*/{},
+ /*general_error=*/true
+ }};
+ }
+
+ if (scanned_time > lowest_timestamp) {
+ // Compose the response
+ for (size_t i = 0; i < requests.size(); ++i) {
+ ImportResult& result = response.at(i);
+
+ // If the descriptor timestamp is within the successfully scanned
+ // range, or if the import result already has an error set, let
+ // the result stand unmodified. Otherwise replace the result
+ // with an error message.
+ const int64_t timestamp{requests.at(i).timestamp.value()};
+ if (scanned_time > timestamp && !result.has_error()) {
+ std::string error_msg = strprintf("Rescan failed for descriptor with timestamp %d. There "
+ "was an error reading a block from time %d, which is after or within %d seconds "
+ "of key creation, and could contain transactions pertaining to the desc. As a "
+ "result, transactions and coins using this desc may not appear in the wallet.",
+ timestamp, scanned_time - TIMESTAMP_WINDOW - 1, TIMESTAMP_WINDOW);
+ if (wallet.chain().havePruned()) {
+ error_msg += strprintf(" This error could be caused by pruning or data corruption "
+ "(see bitcoind log for details) and could be dealt with by downloading and "
+ "rescanning the relevant blocks (see -reindex option and rescanblockchain RPC).");
+ } else if (wallet.chain().hasAssumedValidChain()) {
+ error_msg += strprintf(" This error is likely caused by an in-progress assumeutxo "
+ "background sync. Check logs or getchainstates RPC for assumeutxo background "
+ "sync progress and try again later.");
+ } else {
+ error_msg += strprintf(" This error could potentially caused by data corruption. If "
+ "the issue persists you may want to reindex (see -reindex option).");
+ }
+ result.error = ImportError{
+ WalletErrorCode::MiscError,
+ Untranslated(error_msg),
+ /*is_wallet_error=*/false
+ };
+ }
+ }
+ }
+ }
+ return response;
+}
+
+} // namespace wallet
### src/wallet/imports.h
@@ -0,0 +1,64 @@
+// Copyright (c) 2026-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#ifndef BITCOIN_WALLET_IMPORTS_H
+#define BITCOIN_WALLET_IMPORTS_H
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <util/translation.h>
+#include <wallet/types.h>
+#include <wallet/wallet.h>
+
+namespace wallet {
+
+struct ImportError {
+ WalletError wallet_error;
+ //! Set to true when a wallet-wide precondition failed before any descriptor was
+ //! processed (e.g. wallet is already rescanning, or wallet is locked).
+ //! Callers that support top-level errors should surface this as a
+ //! top-level / call-wide error rather than a per-descriptor failure.
+ bool is_general_error;
+
+ ImportError(WalletErrorCode r, bilingual_str e, bool is_wallet_error)
+ : wallet_error{r, std::move(e)},
+ is_general_error{is_wallet_error}
+ {};
+};
+
+struct ImportResult {
+ std::vector<std::string> warnings;
+ std::optional<ImportError> error;
+
+ bool has_error() const {
+ return error.has_value();
+ }
+
+ ImportResult() = default;
+ ImportResult(WalletErrorCode code, std::string message, std::vector<std::string> warnings = {}, bool general_error = false)
+ : warnings{std::move(warnings)},
+ error{ImportError{code, Untranslated(message), general_error}}
+ {}
+};
+
+//! Information about a descriptor to be imported.
+struct ImportDescriptorRequest {
+ std::string descriptor;
+ std::string label;
+ std::optional<int64_t> timestamp; // Unset timestamps are treated as now.
+ bool active{false};
+ std::optional<bool> internal;
+ std::optional<std::pair<int64_t, int64_t>> range;
+ std::optional<int64_t> next_index;
+};
+
+std::vector<ImportResult> ProcessDescriptorsImport(CWallet& wallet,
+ std::vector<ImportDescriptorRequest>& requests);
+
+} // namespace wallet
+
+#endif // BITCOIN_WALLET_IMPORTS_H
### src/wallet/interfaces.cpp
@@ -24,6 +24,7 @@
#include <wallet/export.h>
#include <wallet/feebumper.h>
#include <wallet/fees.h>
+#include <wallet/imports.h>
#include <wallet/load.h>
#include <wallet/receive.h>
#include <wallet/rpc/wallet.h>
@@ -380,6 +381,10 @@ class WalletImpl : public Wallet
{
return m_wallet->FillPSBT(psbtx, options, complete, n_signed);
}
+ std::vector<wallet::ImportResult> importDescriptors(std::vector<wallet::ImportDescriptorRequest>& requests) override
+ {
+ return wallet::ProcessDescriptorsImport(*m_wallet, requests);
+ }
WalletBalances getBalances() override
{
const auto bal = GetBalance(*m_wallet);
### src/wallet/rpc/backup.cpp
@@ -22,6 +22,7 @@
#include <util/time.h>
#include <util/translation.h>
#include <wallet/export.h>
+#include <wallet/imports.h>
#include <wallet/rpc/util.h>
#include <wallet/scan.h>
#include <wallet/wallet.h>
@@ -131,187 +132,44 @@ RPCMethod removeprunedfunds()
};
}
-static int64_t GetImportTimestamp(const UniValue& data, int64_t now)
+
+/**
+ * Converts the timestamp from UniValue data to an int64_t.
+ *
+ * @returns The import timestamp in int64_t, or std::nullopt if now was provided.
+ */
+static std::optional<int64_t> GetImportTimestamp(const UniValue& data)
{
if (data.exists("timestamp")) {
const UniValue& timestamp = data["timestamp"];
if (timestamp.isNum()) {
- return timestamp.getInt<int64_t>();
+ const int64_t value{timestamp.getInt<int64_t>()};
+ if (value < 0) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Timestamp must not be negative");
+ }
+ return value;
} else if (timestamp.isStr() && timestamp.get_str() == "now") {
- return now;
+ return std::nullopt; // std::nullopt means use the current best block's MTP time
}
throw JSONRPCError(RPC_TYPE_ERROR, strprintf("Expected number or \"now\" timestamp value for key. got type %s", uvTypeName(timestamp.type())));
}
throw JSONRPCError(RPC_TYPE_ERROR, "Missing required timestamp field for key");
}
-static UniValue ProcessDescriptorImport(CWallet& wallet, const UniValue& data, const int64_t timestamp) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
+static ImportDescriptorRequest ProcessUniValueDescriptor(const UniValue& data, std::optional<int64_t> timestamp)
{
- UniValue warnings(UniValue::VARR);
- UniValue result(UniValue::VOBJ);
-
- try {
- if (!data.exists("desc")) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Descriptor not found.");
- }
-
- const std::string& descriptor = data["desc"].get_str();
- const bool active = data.exists("active") ? data["active"].get_bool() : false;
- const std::string label{LabelFromValue(data["label"])};
-
- // Parse descriptor string
- FlatSigningProvider keys;
- std::string error;
- auto parsed_descs = Parse(descriptor, keys, error, /* require_checksum = */ true);
- if (parsed_descs.empty()) {
- throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, error);
- }
- std::optional<bool> internal;
- if (data.exists("internal")) {
- if (parsed_descs.size() > 1) {
- throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Cannot have multipath descriptor while also specifying \'internal\'");
- }
- internal = data["internal"].get_bool();
- }
-
- // Range check
- std::optional<bool> is_ranged;
- int64_t range_start = 0, range_end = 1, next_index = 0;
- if (!parsed_descs.at(0)->IsRange() && data.exists("range")) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Range should not be specified for an un-ranged descriptor");
- } else if (parsed_descs.at(0)->IsRange()) {
- if (data.exists("range")) {
- auto range = ParseDescriptorRange(data["range"]);
- range_start = range.first;
- range_end = range.second + 1; // Specified range end is inclusive, but we need range end as exclusive
- } else {
- warnings.push_back("Range not given, using default keypool range");
- range_start = 0;
- range_end = wallet.m_keypool_size;
- }
- next_index = range_start;
- is_ranged = true;
-
- if (data.exists("next_index")) {
- next_index = data["next_index"].getInt<int64_t>();
- // bound checks
- if (next_index < range_start || next_index >= range_end) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "next_index is out of range");
- }
- }
- }
-
- // Active descriptors must be ranged
- if (active && !parsed_descs.at(0)->IsRange()) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Active descriptors must be ranged");
- }
-
- // Multipath descriptors should not have a label
- if (parsed_descs.size() > 1 && data.exists("label")) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Multipath descriptors should not have a label");
- }
-
- // Ranged descriptors should not have a label
- if (is_ranged.has_value() && is_ranged.value() && data.exists("label")) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Ranged descriptors should not have a label");
- }
-
- bool desc_internal = internal.has_value() && internal.value();
- // Internal addresses should not have a label either
- if (desc_internal && data.exists("label")) {
- throw JSONRPCError(RPC_INVALID_PARAMETER, "Internal addresses should not have a label");
- }
-
- // Combo descriptor check
- if (active && !parsed_descs.at(0)->IsSingleType()) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Combo descriptors cannot be set to active");
- }
-
- // If the wallet disabled private keys, abort if private keys exist
- if (wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS) && !keys.keys.empty()) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Cannot import private keys to a wallet with private keys disabled");
- }
-
- for (size_t j = 0; j < parsed_descs.size(); ++j) {
- auto parsed_desc = std::move(parsed_descs[j]);
- if (parsed_descs.size() == 2) {
- desc_internal = j == 1;
- } else if (parsed_descs.size() > 2) {
- CHECK_NONFATAL(!desc_internal);
- }
- // Expand to check whether the descriptor can be derived at the first index.
- FlatSigningProvider expand_keys;
- std::vector<CScript> scripts;
- if (!parsed_desc->Expand(0, keys, scripts, expand_keys)) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Cannot expand descriptor. Probably because of hardened derivations without private keys provided");
- }
-
- for (const auto& w : parsed_desc->Warnings()) {
- warnings.push_back(w);
- }
-
- // If private keys are enabled, check some things.
- if (!wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
- if (keys.keys.empty()) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Cannot import descriptor without private keys to a wallet with private keys enabled");
- }
- if (!parsed_desc->HavePrivateKeys(keys)) {
- warnings.push_back("Not all private keys provided. Some wallet functionality may return unexpected errors");
- }
- }
-
- // If this is an unused(KEY) descriptor, check that the wallet doesn't already have other descriptors with this key
- if (!parsed_desc->HasScripts()) {
- if (wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Cannot import unused() to wallet without private keys enabled");
- }
- // Unused descriptors must contain a single key.
- // Earlier checks will have enforced that this key is either a private key when private keys are enabled,
- // or that this key is a public key when private keys are disabled.
- // If we can retrieve the corresponding private key from the wallet, then this key is already in the wallet
- // and we should not import it.
- std::set<CPubKey> pubkeys;
- std::set<CExtPubKey> extpubs;
- parsed_desc->GetPubKeys(pubkeys, extpubs);
- std::transform(extpubs.begin(), extpubs.end(), std::inserter(pubkeys, pubkeys.begin()), [](const CExtPubKey& xpub) { return xpub.pubkey; });
- CHECK_NONFATAL(pubkeys.size() == 1);
- if (wallet.GetKey(pubkeys.begin()->GetID())) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Cannot import an unused() descriptor when its private key is already in the wallet");
- }
- }
-
- WalletDescriptor w_desc(std::move(parsed_desc), timestamp, range_start, range_end, next_index);
-
- // Add descriptor to the wallet
- auto spk_manager_res = wallet.AddWalletDescriptor(w_desc, keys, label, desc_internal);
-
- if (!spk_manager_res) {
- throw JSONRPCError(RPC_WALLET_ERROR, strprintf("Could not add descriptor '%s': %s", descriptor, util::ErrorString(spk_manager_res).original));
- }
-
- auto& spk_manager = spk_manager_res.value().get();
-
- // Set descriptor as active if necessary
- if (active) {
- if (!w_desc.descriptor->GetOutputType()) {
- warnings.push_back("Unknown output type, cannot set descriptor to active.");
- } else {
- wallet.AddActiveScriptPubKeyMan(spk_manager.GetID(), *w_desc.descriptor->GetOutputType(), desc_internal);
- }
- } else {
- if (w_desc.descriptor->GetOutputType()) {
- wallet.DeactivateScriptPubKeyMan(spk_manager.GetID(), *w_desc.descriptor->GetOutputType(), desc_internal);
- }
- }
- }
-
- result.pushKV("success", UniValue(true));
- } catch (const UniValue& e) {
- result.pushKV("success", UniValue(false));
- result.pushKV("error", e);
+ ImportDescriptorRequest request;
+ if (!data.exists("desc")) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Descriptor not found.");
}
- PushWarnings(warnings, result);
- return result;
+ request.descriptor = data["desc"].get_str();
+ request.label = LabelFromValue(data["label"]);
+ request.timestamp = timestamp;
+ if (data.exists("active")) request.active = data["active"].get_bool();
+ if (data.exists("internal")) request.internal = data["internal"].get_bool();
+ if (data.exists("range")) request.range = ParseDescriptorRange(data["range"]);
+ if (data.exists("next_index")) request.next_index = data["next_index"].getInt<int64_t>();
+ return request;
}
RPCMethod importdescriptors()
@@ -375,103 +233,78 @@ RPCMethod importdescriptors()
if (!pwallet) return UniValue::VNULL;
CWallet& wallet{*pwallet};
- WalletRescanReserver reserver(*pwallet);
- if (!reserver.reserve(/*with_passphrase=*/true)) {
- throw JSONRPCError(RPC_WALLET_ERROR, "Wallet is currently rescanning. Abort existing rescan or wait.");
+ const UniValue& univalue_requests = main_request.params[0];
+ // One result per input, in input order.
+ std::vector<UniValue> results(univalue_requests.size());
+ // Successfully parsed requests, each with the index of the input it came from.
+ struct ParsedRequest {
+ size_t input_index;
+ ImportDescriptorRequest request;
+ };
+ std::vector<ParsedRequest> requests;
+
+ // Malformed inputs (e.g. invalid label) are returned as per-item failures.
+ for (size_t i = 0; i < univalue_requests.size(); ++i) {
+ // Throws a top-level RPC error if "timestamp" is missing or invalid
+ std::optional<int64_t> timestamp = GetImportTimestamp(univalue_requests[i]);
+ try {
+ requests.push_back({i, ProcessUniValueDescriptor(univalue_requests[i], timestamp)});
+ } catch (const UniValue& e) {
+ results[i] = UniValue(UniValue::VOBJ);
+ results[i].pushKV("success", UniValue(false));
+ results[i].pushKV("error", e);
+ }
}
- // Make sure the results are valid at least up to the most recent block
- // the user could have gotten from another RPC command prior to now
- wallet.BlockUntilSyncedToCurrentChain();
-
- // Ensure that the wallet is not locked for the remainder of this RPC, as
- // the passphrase is used to top up the keypool.
- LOCK(pwallet->m_relock_mutex);
-
- const UniValue& requests = main_request.params[0];
- const int64_t minimum_timestamp = 1;
- int64_t now = 0;
- int64_t lowest_timestamp = 0;
- bool rescan = false;
- UniValue response(UniValue::VARR);
- {
- LOCK(pwallet->cs_wallet);
- EnsureWalletIsUnlocked(*pwallet);
-
- CHECK_NONFATAL(pwallet->chain().findBlock(pwallet->GetLastBlockHash(), FoundBlock().time(lowest_timestamp).mtpTime(now)));
-
- // Get all timestamps and extract the lowest timestamp
- for (const UniValue& request : requests.getValues()) {
- // This throws an error if "timestamp" doesn't exist
- const int64_t timestamp = std::max(GetImportTimestamp(request, now), minimum_timestamp);
- const UniValue result = ProcessDescriptorImport(*pwallet, request, timestamp);
- response.push_back(result);
+ // Hand off the successfully parsed requests to the batch importer, which
+ // handles wallet locking, rescanning and rescan-failure error composition.
+ std::vector<ImportDescriptorRequest> descriptor_requests;
+ descriptor_requests.reserve(requests.size());
+ for (auto& parsed : requests) {
+ descriptor_requests.push_back(std::move(parsed.request));
+ }
- if (lowest_timestamp > timestamp ) {
- lowest_timestamp = timestamp;
- }
+ std::vector<ImportResult> import_results{ProcessDescriptorsImport(wallet, descriptor_requests)};
- // If we know the chain tip, and at least one request was successful then allow rescan
- if (!rescan && result["success"].get_bool()) {
- rescan = true;
- }
- }
- pwallet->ConnectScriptPubKeyManNotifiers();
- pwallet->RefreshAllTXOs();
+ // Wallet-wide precondition failure (e.g. already rescanning, or locked):
+ // surface as a top-level RPC error.
+ if (import_results.size() == 1 && import_results[0].has_error() && import_results[0].error->is_general_error) {
+ const ImportError& import_error = import_results[0].error.value();
+ RPCErrorCode rpc_error_code{HandleWalletErrorCode(import_error.wallet_error.code)};
+ throw JSONRPCError(rpc_error_code, import_error.wallet_error.message.original);
}
- // Rescan the blockchain using the lowest timestamp
- if (rescan) {
- int64_t scanned_time = pwallet->Scanner().ScanFromTime(lowest_timestamp, reserver);
- pwallet->ResubmitWalletTransactions(node::TxBroadcast::MEMPOOL_NO_BROADCAST, /*force=*/true);
-
- if (pwallet->Scanner().IsAborting()) {
- throw JSONRPCError(RPC_MISC_ERROR, "Rescan aborted by user.");
+ // Translate each ImportResult into the per-input UniValue result. Inputs
+ // that failed to parse already hold an error in results[] and are not
+ // part of `requests`, so use input_index to map each import_results[k]
+ // back to its slot.
+ CHECK_NONFATAL(import_results.size() == requests.size());
+ for (size_t k = 0; k < requests.size(); ++k) {
+ UniValue& result = results[requests[k].input_index];
+ const ImportResult& import_result = import_results[k];
+ result = UniValue(UniValue::VOBJ);
+ UniValue warnings(UniValue::VARR);
+ if (import_result.has_error()) {
+ const WalletError& error = import_result.error.value().wallet_error;
+ auto write_error = [&result, &error](int code) {
+ result.pushKV("success", false);
+ result.pushKV("error", JSONRPCError(code, error.message.original));
+ };
+ if (error.code == WalletErrorCode::UnlockNeeded) NONFATAL_UNREACHABLE();
+ write_error(HandleWalletErrorCode(error.code));
+ } else {
+ result.pushKV("success", true);
}
-
- if (scanned_time > lowest_timestamp) {
- std::vector<UniValue> results = response.getValues();
- response.clear();
- response.setArray();
-
- // Compose the response
- for (unsigned int i = 0; i < requests.size(); ++i) {
- const UniValue& request = requests.getValues().at(i);
-
- // If the descriptor timestamp is within the successfully scanned
- // range, or if the import result already has an error set, let
- // the result stand unmodified. Otherwise replace the result
- // with an error message.
- if (scanned_time <= GetImportTimestamp(request, now) || results.at(i).exists("error")) {
- response.push_back(results.at(i));
- } else {
- std::string error_msg{strprintf("Rescan failed for descriptor with timestamp %d. There "
- "was an error reading a block from time %d, which is after or within %d seconds "
- "of key creation, and could contain transactions pertaining to the desc. As a "
- "result, transactions and coins using this desc may not appear in the wallet.",
- GetImportTimestamp(request, now), scanned_time - TIMESTAMP_WINDOW - 1, TIMESTAMP_WINDOW)};
- if (pwallet->chain().havePruned()) {
- error_msg += strprintf(" This error could be caused by pruning or data corruption "
- "(see bitcoind log for details) and could be dealt with by downloading and "
- "rescanning the relevant blocks (see -reindex option and rescanblockchain RPC).");
- } else if (pwallet->chain().hasAssumedValidChain()) {
- error_msg += strprintf(" This error is likely caused by an in-progress assumeutxo "
- "background sync. Check logs or getchainstates RPC for assumeutxo background "
- "sync progress and try again later.");
- } else {
- error_msg += strprintf(" This error could potentially caused by data corruption. If "
- "the issue persists you may want to reindex (see -reindex option).");
- }
-
- UniValue result = UniValue(UniValue::VOBJ);
- result.pushKV("success", UniValue(false));
- result.pushKV("error", JSONRPCError(RPC_MISC_ERROR, error_msg));
- response.push_back(std::move(result));
- }
- }
+ for (const auto& w : import_result.warnings) {
+ warnings.push_back(w);
}
+ PushWarnings(warnings, result);
}
+ UniValue response(UniValue::VARR);
+ for (UniValue& result : results) {
+ response.push_back(std::move(result));
+ }
return response;
},
};
### src/wallet/rpc/util.cpp
@@ -153,6 +153,28 @@ void HandleWalletError(const std::shared_ptr<CWallet>& wallet, DatabaseStatus& s
}
}
+RPCErrorCode HandleWalletErrorCode(const WalletErrorCode code)
+{
+ RPCErrorCode res = RPC_WALLET_ERROR;
+ switch(code) {
+ case WalletErrorCode::UnlockNeeded:
+ res = RPC_WALLET_UNLOCK_NEEDED;
+ break;
+ case WalletErrorCode::InvalidDescriptor:
+ res = RPC_INVALID_ADDRESS_OR_KEY;
+ break;
+ case WalletErrorCode::InvalidParameter:
+ res = RPC_INVALID_PARAMETER;
+ break;
+ case WalletErrorCode::MiscError:
+ res = RPC_MISC_ERROR;
+ break;
+ default: // RPC_WALLET_ERROR is returned for all other cases.
+ break;
+ }
+ return res;
+}
+
void AppendLastProcessedBlock(UniValue& entry, const CWallet& wallet)
{
AssertLockHeld(wallet.cs_wallet);
### src/wallet/rpc/util.h
@@ -54,6 +54,7 @@ std::string LabelFromValue(const UniValue& value);
void PushParentDescriptors(const CWallet& wallet, const CScript& script_pubkey, UniValue& entry);
void HandleWalletError(const std::shared_ptr<CWallet>& wallet, DatabaseStatus& status, bilingual_str& error);
+RPCErrorCode HandleWalletErrorCode(WalletErrorCode code);
void AppendLastProcessedBlock(UniValue& entry, const CWallet& wallet) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
} // namespace wallet
### src/wallet/test/wallet_tests.cpp
@@ -5,9 +5,15 @@
#include <wallet/scan.h>
#include <wallet/wallet.h>
+#include <array>
+#include <cstddef>
#include <cstdint>
#include <future>
+#include <limits>
#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
#include <vector>
#include <addresstype.h>
@@ -22,6 +28,7 @@
#include <node/types.h>
#include <policy/policy.h>
#include <rpc/server.h>
+#include <script/descriptor.h>
#include <script/solver.h>
#include <test/util/common.h>
#include <test/util/logging.h>
@@ -33,6 +40,7 @@
#include <validationinterface.h>
#include <wallet/coincontrol.h>
#include <wallet/context.h>
+#include <wallet/imports.h>
#include <wallet/receive.h>
#include <wallet/spend.h>
#include <wallet/test/util.h>
@@ -78,6 +86,47 @@ static void AddKey(CWallet& wallet, const CKey& key)
Assert(wallet.AddWalletDescriptor(w_desc, provider, "", false));
}
+BOOST_AUTO_TEST_CASE(reject_invalid_descriptor_ranges)
+{
+ const int height{*Assert(m_node.chain->getHeight())};
+ {
+ LOCK(m_wallet.cs_wallet);
+ m_wallet.SetWalletFlag(WALLET_FLAG_DESCRIPTORS);
+ m_wallet.SetLastBlockProcessed(height, m_node.chain->getBlockHash(height));
+ }
+
+ CExtKey ext_key;
+ ext_key.SetSeed(std::array<std::byte, 32>{});
+ const std::string descriptor_without_checksum{"wpkh(" + EncodeExtKey(ext_key) + "/*)"};
+ const std::string descriptor{descriptor_without_checksum + "#" + GetDescriptorChecksum(descriptor_without_checksum)};
+
+ const std::array invalid_ranges{
+ std::pair{std::pair<int64_t, int64_t>{2, 1}, "Range specified as [begin,end] must not have begin after end"},
+ std::pair{std::pair<int64_t, int64_t>{-1, 10}, "Range should be greater or equal than 0"},
+ std::pair{std::pair<int64_t, int64_t>{0, 1'000'000}, "Range is too large"},
+ std::pair{std::pair<int64_t, int64_t>{0, std::numeric_limits<int64_t>::max()}, "End of range is too high"},
+ std::pair{std::pair<int64_t, int64_t>{0, 1LL << 31}, "End of range is too high"},
+ };
+
+ for (const auto& [range, expected_error] : invalid_ranges) {
+ std::vector requests{ImportDescriptorRequest{
+ .descriptor = descriptor,
+ .label = {},
+ .timestamp = 0,
+ .active = false,
+ .internal = std::nullopt,
+ .range = range,
+ .next_index = std::nullopt,
+ }};
+ const auto results{ProcessDescriptorsImport(m_wallet, requests)};
+ BOOST_REQUIRE_EQUAL(results.size(), 1U);
+ BOOST_REQUIRE(results.front().error.has_value());
+ BOOST_CHECK(results.front().error->wallet_error.code == WalletErrorCode::InvalidParameter);
+ BOOST_CHECK_EQUAL(results.front().error->wallet_error.message.original, expected_error);
+ BOOST_CHECK(!results.front().error->is_general_error);
+ }
+}
+
BOOST_FIXTURE_TEST_CASE(update_non_range_descriptor, TestingSetup)
{
CWallet wallet(m_node.chain.get(), "", CreateMockableWalletDatabase());
### src/wallet/types.h
@@ -71,6 +71,12 @@ enum class WalletErrorCode {
//! The wallet is locked and the operation requires access to private keys.
//! Callers may ask the user to unlock the wallet and retry the operation.
UnlockNeeded,
+
+ //! TODO Add correct descriptions to each error.
+ //! At the moment only used by ImportDescriptors.
+ InvalidDescriptor,
+ InvalidParameter,
+ MiscError,
};
//! Wallet-layer error with both programmatic and user-facing information.
### test/functional/wallet_importdescriptors.py
@@ -330,6 +330,15 @@ def run_test(self):
error_code=-3,
error_message='Expected number or "now" timestamp value for key. got type string')
+ import_request = {"desc": descsum_create("pkh(" + key.pubkey + ")"),
+ "timestamp": -1,
+ "label": "Descriptor import test"}
+ self.test_importdesc(import_request,
+ success=False,
+ global_error=True,
+ error_code=-8,
+ error_message="Timestamp must not be negative")
+
# # Test importing of a P2PKH descriptor
key = get_generate_key()
self.log.info("Should import a p2pkh descriptor")
@@ -466,6 +475,13 @@ def run_test(self):
error_code=-8,
error_message='Ranged descriptors should not have a label')
+ self.log.info("Ranged descriptors can have an explicitly empty label")
+ self.test_importdesc({"desc":descsum_create("sh(wpkh(" + xpub + "/0/1/*))"),
+ "timestamp": "now",
+ "range": [0, 100],
+ "label": ""},
+ success=True)
+
self.log.info("Ranged descriptors cannot have labels - even if range not provided by user and only implied by asterisk (*)")
self.test_importdesc({"desc":descsum_create("wpkh(" + xpub + "/100/0/*)"),
"timestamp": "now",Why this scored 23/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.