descriptor: Add unused(KEY) descriptor
What changed, and why it matters
This commit adds a new wallet descriptor type called unused(KEY). It lets Bitcoin Core wallets store a key without watching any blockchain addresses for it. The change is a feature addition, not a fix for an active security bug. It includes parsing rules, tests, and a small wallet tweak to skip address labeling for descriptors that do not produce output scripts. There is no evidence in the commit or supplied references of a disclosed vulnerability or exploit.
No immediate security action required. Treat as a normal feature commit. Reviewers may want to verify that HasScripts() is consulted consistently wherever code assumes a descriptor yields scriptPubKeys, and that the unused() descriptor cannot be misused to bypass existing key-derivation or watch-only invariants.
Security signals we found
New descriptor parser with explicit rejection of ranged/nested/empty/multiple-key inputs
MakeScripts returns empty vector for unused() descriptors
HasScripts() virtual added to descriptor interface
Wallet label logic guarded by HasScripts() to avoid acting on empty scriptPubKey set
No mention of vulnerability, CVE, bug bounty, or security fix in commit message or diff
Evidence from the diff
The patch introduces an UnusedDescriptor class whose MakeScripts returns an empty vector and whose HasScripts() returns false. It registers a top-level unused(KEY) parser that rejects ranged keys, x-only pubkeys, multiple keys, and nested usage. A new virtual HasScripts() method is added to the Descriptor interface, defaulting to true for existing descriptors. CWallet::AddWalletDescriptor is updated so labels are only applied when the descriptor is non-ranged and HasScripts() is true, preventing a label-from-empty-scriptPubKeys error path. The change is defensive and feature-oriented.
Changed components
src/script/descriptor.cppsrc/script/descriptor.hsrc/wallet/wallet.cppBitcoin Core descriptor parserBitcoin Core wallet descriptor import/labelingInspect captured patch +108 / −3
diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp
index 19315dd6..67770ada 100644
--- a/src/script/descriptor.cpp
+++ b/src/script/descriptor.cpp
@@ -1038,6 +1038,8 @@ public:
virtual std::unique_ptr<DescriptorImpl> Clone() const = 0;
+ bool HasScripts() const override { return true; }
+
// NOLINTNEXTLINE(misc-no-recursion)
std::vector<std::string> Warnings() const override {
std::vector<std::string> all = m_warnings;
@@ -1738,6 +1740,23 @@ public:
}
};
+/** A parsed unused(KEY) descriptor */
+class UnusedDescriptor final : public DescriptorImpl
+{
+protected:
+ std::vector<CScript> MakeScripts(const std::vector<CPubKey>& keys, std::span<const CScript> scripts, FlatSigningProvider& out) const override { return {}; }
+public:
+ UnusedDescriptor(std::unique_ptr<PubkeyProvider> prov) : DescriptorImpl(Vector(std::move(prov)), "unused") {}
+ bool IsSingleType() const final { return true; }
+ bool HasScripts() const override { return false; }
+
+ std::unique_ptr<DescriptorImpl> Clone() const override
+ {
+ return std::make_unique<UnusedDescriptor>(m_pubkey_args.at(0)->Clone());
+ }
+};
+
+
////////////////////////////////////////////////////////////////////////////
// Parser //
////////////////////////////////////////////////////////////////////////////
@@ -2575,6 +2594,27 @@ std::vector<std::unique_ptr<DescriptorImpl>> ParseScript(uint32_t& key_exp_index
error = "Can only have rawtr at top level";
return {};
}
+ if (ctx == ParseScriptContext::TOP && Func("unused", expr)) {
+ // Check for only one expression, should not find commas, brackets, or parentheses
+ auto arg = Expr(expr);
+ if (expr.size()) {
+ error = strprintf("unused(): only one key expected");
+ return {};
+ }
+ auto keys = ParsePubkey(key_exp_index, arg, ctx, out, error);
+ if (keys.empty()) return {};
+ for (auto& pubkey : keys) {
+ if (pubkey->IsRange()) {
+ error = "unused(): key cannot be ranged";
+ return {};
+ }
+ ret.emplace_back(std::make_unique<UnusedDescriptor>(std::move(pubkey)));
+ }
+ return ret;
+ } else if (Func("unused", expr)) {
+ error = "Can only have unused at top level";
+ return {};
+ }
if (ctx == ParseScriptContext::TOP && Func("raw", expr)) {
std::string str(expr.begin(), expr.end());
if (!IsHex(str)) {
diff --git a/src/script/descriptor.h b/src/script/descriptor.h
index 3ac748a1..0f1e799e 100644
--- a/src/script/descriptor.h
+++ b/src/script/descriptor.h
@@ -108,7 +108,7 @@ struct Descriptor {
/** Convert the descriptor back to a string, undoing parsing. */
virtual std::string ToString(bool compat_format=false) const = 0;
- /** Whether this descriptor will return one scriptPubKey or multiple (aka is or is not combo) */
+ /** Whether this descriptor will return at most one scriptPubKey or multiple (aka is or is not combo) */
virtual bool IsSingleType() const = 0;
/** Whether the given provider has all private keys required by this descriptor.
@@ -179,6 +179,9 @@ struct Descriptor {
*/
virtual void GetPubKeys(std::set<CPubKey>& pubkeys, std::set<CExtPubKey>& ext_pubs) const = 0;
+ /** Whether this descriptor produces any scripts with the Expand functions */
+ virtual bool HasScripts() const = 0;
+
/** Semantic/safety warnings (includes subdescriptors). */
virtual std::vector<std::string> Warnings() const = 0;
diff --git a/src/test/descriptor_tests.cpp b/src/test/descriptor_tests.cpp
index 0ede4dc9..347efd77 100644
--- a/src/test/descriptor_tests.cpp
+++ b/src/test/descriptor_tests.cpp
@@ -1334,4 +1334,65 @@ BOOST_AUTO_TEST_CASE(descriptor_older_warnings)
}
}
+void CheckSingleUnparsable(const std::string& desc, const std::string& expected_error)
+{
+ FlatSigningProvider keys;
+ std::string error;
+ auto parsed = Parse(desc, keys, error);
+ BOOST_CHECK_MESSAGE(parsed.empty(), desc);
+ BOOST_CHECK_EQUAL(error, expected_error);
+}
+
+void CheckUnused(const std::string& prv, const std::string& pub)
+{
+ FlatSigningProvider keys_priv, keys_pub;
+ std::string error;
+
+ std::unique_ptr<Descriptor> parse_priv;
+ std::unique_ptr<Descriptor> parse_pub;
+ parse_priv = std::move(Parse(prv, keys_priv, error).at(0));
+ parse_pub = std::move(Parse(pub, keys_pub, error).at(0));
+ BOOST_CHECK_MESSAGE(parse_priv, error);
+ BOOST_CHECK_MESSAGE(parse_pub, error);
+
+ BOOST_CHECK(parse_priv->GetOutputType() == std::nullopt);
+ BOOST_CHECK(parse_pub->GetOutputType() == std::nullopt);
+
+ // Check private keys are extracted from the private version but not the public one.
+ BOOST_CHECK(keys_priv.keys.size());
+ BOOST_CHECK(!keys_pub.keys.size());
+
+ // Check that both versions serialize back to the public version.
+ std::string pub1 = parse_priv->ToString();
+ std::string pub2 = parse_pub->ToString();
+ BOOST_CHECK_MESSAGE(EqualDescriptor(pub, pub1), "Private ser: " + pub1 + " Public desc: " + pub);
+ BOOST_CHECK_MESSAGE(EqualDescriptor(pub, pub2), "Public ser: " + pub2 + " Public desc: " + pub);
+
+ // Check both only have one pubkey
+ std::set<CPubKey> prv_pubkeys;
+ std::set<CExtPubKey> prv_extpubs;
+ parse_pub->GetPubKeys(prv_pubkeys, prv_extpubs);
+ BOOST_CHECK_EQUAL(prv_pubkeys.size() + prv_extpubs.size(), 1);
+ std::set<CPubKey> pub_pubkeys;
+ std::set<CExtPubKey> pub_extpubs;
+ parse_pub->GetPubKeys(pub_pubkeys, pub_extpubs);
+ BOOST_CHECK_EQUAL(pub_pubkeys.size() + pub_extpubs.size(), 1);
+}
+
+// unused() descriptors don't produce scripts, so these need to be tested separately
+BOOST_AUTO_TEST_CASE(unused_descriptor_test)
+{
+ CheckUnparsable("unused(L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1,5KYZdUEo39z3FPrtuX2QbbwGnNP5zTd7yyr2SC1j299sBCnWjss)", "unused(03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235)", "unused(): only one key expected");
+ CheckUnparsable("wsh(unused(L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1))", "wsh(unused(03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd))", "Can only have unused at top level");
+ CheckUnparsable("unused(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/*)", "unused(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/*)", "unused(): key cannot be ranged");
+ CheckUnparsable("unused()", "unused()", "No key provided");
+
+ // x-only keys cannot be used in unused()
+ CheckSingleUnparsable("unused(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd)", "Pubkey 'a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd' is invalid");
+
+ CheckUnused("unused(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc)", "unused(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL)");
+ CheckUnused("unused(L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1)", "unused(03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd)");
+ CheckUnused("unused(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/0h/0h/1)", "unused(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0h/0h/1)");
+}
+
BOOST_AUTO_TEST_SUITE_END()
diff --git a/src/wallet/test/walletload_tests.cpp b/src/wallet/test/walletload_tests.cpp
index 46b0ab16..8648af6b 100644
--- a/src/wallet/test/walletload_tests.cpp
+++ b/src/wallet/test/walletload_tests.cpp
@@ -37,6 +37,7 @@ public:
std::optional<int64_t> MaxSatisfactionWeight(bool) const override { return {}; }
std::optional<int64_t> MaxSatisfactionElems() const override { return {}; }
void GetPubKeys(std::set<CPubKey>& pubkeys, std::set<CExtPubKey>& ext_pubs) const override {}
+ bool HasScripts() const override { return true; }
std::vector<std::string> Warnings() const override { return {}; }
uint32_t GetMaxKeyExpr() const override { return 0; }
size_t GetKeyCount() const override { return 0; }
diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
index 823e1b48..f3f6e8f4 100644
--- a/src/wallet/wallet.cpp
+++ b/src/wallet/wallet.cpp
@@ -3813,8 +3813,8 @@ util::Result<std::reference_wrapper<DescriptorScriptPubKeyMan>> CWallet::AddWall
}
// Apply the label if necessary
- // Note: we disable labels for ranged descriptors
- if (!desc.descriptor->IsRange()) {
+ // Note: we disable labels for descriptors that are ranged or that don't produce output scripts (i.e. unused())
+ if (!desc.descriptor->IsRange() && desc.descriptor->HasScripts()) {
auto script_pub_keys = spk_man->GetScriptPubKeys();
if (script_pub_keys.empty()) {
return util::Error{_("Could not generate scriptPubKeys (cache is empty)")};
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.