Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/release-notes-7594.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Wallet
------

- Mnemonic-backed descriptor wallets can now derive DashSync-compatible
masternode operator BLS keys from the wallet seed, so the recovery phrase
also backs up operator keys. Restored wallets avoid keys that are currently
registered, but may reuse a key that was retired in the past. Other wallet
types remain unchanged and can continue using `bls generate`. (#7594)
2 changes: 2 additions & 0 deletions src/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ BITCOIN_CORE_H = \
interfaces/handler.h \
interfaces/init.h \
interfaces/ipc.h \
interfaces/masternode_operator.h \
interfaces/node.h \
interfaces/providertx.h \
interfaces/wallet.h \
Expand Down Expand Up @@ -478,6 +479,7 @@ BITCOIN_CORE_H = \
wallet/hdchain.h \
wallet/ismine.h \
wallet/load.h \
wallet/masternode_operator.h \
wallet/receive.h \
wallet/rpc/util.h \
wallet/rpc/wallet.h \
Expand Down
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ if ENABLE_WALLET
BITCOIN_TESTS += \
wallet/test/bip39_tests.cpp \
wallet/test/coinjoin_tests.cpp \
wallet/test/masternode_operator_tests.cpp \
wallet/test/psbt_wallet_tests.cpp \
wallet/test/spend_tests.cpp \
wallet/test/wallet_tests.cpp \
Expand Down
7 changes: 7 additions & 0 deletions src/bls/bls.h
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ class CBLSSecretKey : public CBLSWrapper<bls::PrivateKey, BLS_CURVE_SECKEY_SIZE,
CBLSSecretKey(const CBLSSecretKey&) = default;
CBLSSecretKey& operator=(const CBLSSecretKey&) = default;

bool SerializeTo(Span<unsigned char> bytes) const
{
if (!IsValid() || bytes.size() != SerSize) return false;
impl.Serialize(bytes.data());
return true;
}

void AggregateInsecure(const CBLSSecretKey& o);
static CBLSSecretKey AggregateInsecure(Span<CBLSSecretKey> sks);

Expand Down
43 changes: 43 additions & 0 deletions src/interfaces/masternode_operator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026 The Dash Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#ifndef BITCOIN_INTERFACES_MASTERNODE_OPERATOR_H
#define BITCOIN_INTERFACES_MASTERNODE_OPERATOR_H

#include <support/allocators/secure.h>

#include <cstdint>
#include <string>
#include <vector>

namespace interfaces {

//! Result of a deterministic masternode operator-key operation.
enum class MasternodeOperatorKeyStatus : uint8_t {
SUCCESS,
NOT_SUPPORTED,
WALLET_LOCKED,
EXHAUSTED,
INVALID_KEY,
NOT_FOUND,
DATABASE_ERROR,
DERIVATION_ERROR,
};

//! A deterministic masternode operator key returned by the wallet. The public
//! key uses the canonical basic-scheme serialization.
struct MasternodeOperatorKey {
SecureVector secret_key;
std::vector<unsigned char> public_key;
std::string path;
};

struct MasternodeOperatorKeyResult {
MasternodeOperatorKeyStatus status{MasternodeOperatorKeyStatus::DERIVATION_ERROR};
MasternodeOperatorKey key;
};

} // namespace interfaces

#endif // BITCOIN_INTERFACES_MASTERNODE_OPERATOR_H
10 changes: 10 additions & 0 deletions src/interfaces/node.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

class BanMan;
class CBlockIndex;
class CBLSPublicKey;
class CDeterministicMNList;
class CFeeRate;
class CGovernanceObject;
Expand Down Expand Up @@ -151,6 +152,15 @@ class EVO
Wallet& wallet, const ProviderUpdateRegistrarRequest& request) = 0;
virtual ProviderTxResult<ProviderTxSubmission> revokeMasternode(Wallet& wallet,
const ProviderRevokeRequest& request) = 0;
/**
* Whether an operator public key is assigned to any masternode in the
* deterministic list at the current chain tip, under either BLS scheme
* encoding. This is a UX guard for skipping keys that would be rejected
* by DIP3 duplicate-key checks, not a safety mechanism: when the node is
* not ready to answer (no tip or no masternode manager yet), it returns
* false. Keys used only historically also return false.
*/
virtual bool isMasternodeOperatorKeyInUse(const CBLSPublicKey& public_key) = 0;
virtual void setContext(node::NodeContext* context) {}
};

Expand Down
18 changes: 18 additions & 0 deletions src/interfaces/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <consensus/amount.h> // For CAmount
#include <governance/common.h>
#include <interfaces/chain.h> // For ChainClient
#include <interfaces/masternode_operator.h>
#include <pubkey.h> // For CKeyID and CScriptID (definitions needed in CTxDestination instantiation)
#include <script/standard.h> // For CTxDestination
#include <support/allocators/secure.h> // For SecureString
Expand All @@ -28,6 +29,7 @@
#include <utility>
#include <vector>

class CBLSPublicKey;
class CFeeRate;
class CGovernanceVote;
class CKey;
Expand Down Expand Up @@ -142,6 +144,22 @@ class Wallet
//! Sign special transaction payload
virtual bool signSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector<unsigned char>& vchSig) = 0;

//! Whether this wallet is a descriptor wallet with exactly one
//! mnemonic-backed operator-key source. Legacy wallets are not supported.
virtual bool hasMasternodeOperatorKeySource() = 0;
//! Derive and permanently consume the lowest operator-key index at or
//! above the consumption watermark that is not in use. The watermark is

@knst knst Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is meaning of "watermark" in this context? Is it counter for used indexes in derivation path?

if so, I assume that's a bad naming because I hadn't seen watermark anywhere in wallet's codebase or any DIP / BIP

//! persisted before the secret is returned and never rolled back.
//! is_in_use may be empty; when set it is queried without wallet locks
//! held so the caller can supply interfaces::Node's EVO predicate, and
//! issuance scans gap-limit style past every index it reports in use.
virtual MasternodeOperatorKeyResult getNewMasternodeOperatorKey(
const std::function<bool(const CBLSPublicKey&)>& is_in_use) = 0;
//! Re-derive a previously consumed operator key (an index below the
//! watermark) by its basic-scheme public key. Read-only; keys never
//! exposed are not addressable.
virtual MasternodeOperatorKeyResult getMasternodeOperatorKey(const std::vector<unsigned char>& public_key) = 0;

//! Return whether wallet has private key.
virtual bool isSpendable(const CScript& script) = 0;
virtual bool isSpendable(const CTxDestination& dest) = 0;
Expand Down
19 changes: 19 additions & 0 deletions src/node/interfaces.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,25 @@ class EVOImpl : public EVO
{
return evo::provider::Revoke(context(), wallet, request);
}
bool isMasternodeOperatorKeyInUse(const CBLSPublicKey& public_key) override
{
if (!m_context || !m_context->chainman || !m_context->dmnman) return false;
const CBlockIndex* tip{WITH_LOCK(::cs_main, return chainman().ActiveChain().Tip())};
if (!tip) return false;
CDeterministicMNList mn_list;
try {
mn_list = m_context->dmnman->GetListForBlock(tip);
} catch (const BlockDataUnavailableError& e) {
// Expected while a snapshot's background chainstate is still
// catching up; this predicate fails open by design. Any other
// exception means local EvoDB/list corruption and must not be
// hidden, so it deliberately stays unhandled.
LogPrintf("%s -- masternode list unavailable: %s\n", __func__, e.what());
return false;
}
if (mn_list.GetBlockHash().IsNull()) return false;
return mn_list.HasOperatorKeyUnderAnyScheme(public_key, /*self=*/uint256());
}
void setContext(NodeContext* context) override
{
m_context = context;
Expand Down
38 changes: 38 additions & 0 deletions src/test/evo_deterministicmns_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include <evo/simplifiedmns.h>
#include <evo/specialtx.h>
#include <evo/specialtxman.h>
#include <interfaces/node.h>
#include <llmq/context.h>
#include <node/mempool_args.h>
#include <messagesigner.h>
Expand Down Expand Up @@ -1720,6 +1721,43 @@ BOOST_AUTO_TEST_CASE(v19_activation_legacy)
FuncV19Activation(setup);
}

BOOST_AUTO_TEST_CASE(operator_key_in_use_follows_current_list)
{
TestMNChainSetup setup(DIP3_ACTIVATION_HEIGHT - 2, {"-dip3params=109:500"});
setup.ProcessBlock(); // The next block may contain DIP3 transactions.

auto node{interfaces::MakeNode(setup.m_node)};
auto in_use = [&](const CBLSSecretKey& key) { return node->evo().isMasternodeOperatorKeyInUse(key.GetPublicKey()); };

BOOST_CHECK(!node->evo().isMasternodeOperatorKeyInUse(CBLSPublicKey{}));

CKey owner_key;
CBLSSecretKey registered_key;
auto tx_reg{CreateProRegTx(setup.chainman, setup.utxos, 19999, GenerateRandomAddress(), setup.coinbaseKey,
owner_key, registered_key)};
BOOST_CHECK(!in_use(registered_key));
setup.ProcessBlock({tx_reg});
BOOST_CHECK(in_use(registered_key));

// Rotating the operator key makes the old key immediately reusable: the
// predicate answers for the current list, not for historical assignments.
CBLSSecretKey rotated_key;
rotated_key.MakeNewKey();
auto tx_upreg{CreateProUpRegTx(setup.chainman, setup.utxos, tx_reg.GetHash(), owner_key,
rotated_key.GetPublicKey(), owner_key.GetPubKey().GetID(), GenerateRandomAddress(),
setup.coinbaseKey)};
setup.ProcessBlock({tx_upreg});
BOOST_CHECK(!in_use(registered_key));
BOOST_CHECK(in_use(rotated_key));

// Revocation clears the operator key in the list while the masternode entry remains.
auto tx_revoke{CreateProUpRevTx(setup.chainman, setup.utxos, tx_reg.GetHash(), rotated_key, setup.coinbaseKey)};
setup.ProcessBlock({tx_revoke});
BOOST_REQUIRE(setup.dmnman.GetListAtChainTip().HasMN(tx_reg.GetHash()));
BOOST_CHECK(!in_use(registered_key));
BOOST_CHECK(!in_use(rotated_key));
}

// The invariant this whole change rests on: a stored operator key never advertises a scheme its own
// state version contradicts, so the live list and the same list reloaded from disk agree — including
// mnUniquePropertyMap, which IsEqual() compares directly.
Expand Down
18 changes: 18 additions & 0 deletions src/wallet/interfaces.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include <interfaces/wallet.h>

#include <bls/bls.h>
#include <chain.h>
#include <coinjoin/client.h>
#include <consensus/amount.h>
Expand Down Expand Up @@ -239,6 +240,23 @@ class WalletImpl : public Wallet
{
return m_wallet->SignSpecialTxPayload(hash, keyid, vchSig);
}
bool hasMasternodeOperatorKeySource() override { return m_wallet->HasMasternodeOperatorKeySource(); }
interfaces::MasternodeOperatorKeyResult getNewMasternodeOperatorKey(
const std::function<bool(const CBLSPublicKey&)>& is_in_use) override
{
return m_wallet->GetNewMasternodeOperatorKey(is_in_use);
}
interfaces::MasternodeOperatorKeyResult getMasternodeOperatorKey(const std::vector<unsigned char>& public_key) override
{
CBLSPublicKey parsed;
parsed.SetBytes(public_key, /*specificLegacyScheme=*/false);
if (!parsed.IsValid() || parsed.ToByteVector(/*specificLegacyScheme=*/false) != public_key) {
interfaces::MasternodeOperatorKeyResult result;
result.status = interfaces::MasternodeOperatorKeyStatus::INVALID_KEY;
return result;
}
return m_wallet->GetMasternodeOperatorKey(parsed);
}
bool isSpendable(const CScript& script) override
{
LOCK(m_wallet->cs_wallet);
Expand Down
90 changes: 90 additions & 0 deletions src/wallet/masternode_operator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 The Dash 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_MASTERNODE_OPERATOR_H
#define BITCOIN_WALLET_MASTERNODE_OPERATOR_H

#include <interfaces/masternode_operator.h>
#include <serialize.h>
#include <tinyformat.h>
#include <wallet/hdchain.h>

#include <array>
#include <cstdint>
#include <string>
#include <vector>

namespace wallet {

/**
* Number of derivation indexes kept ahead of the consumption watermark. It is
* both the size of the persisted recognition lookahead (so provider
* transactions seen during sync can be matched without the seed) and the
* BIP44-style gap limit at issuance: candidates are scanned against the
* current masternode list until this many consecutive indexes are not in use,
* and the key issued is the first index above every in-use hit.
*/
inline constexpr uint32_t MASTERNODE_OPERATOR_GAP_LIMIT{50};
/** Sanity bound on derivation depth; issuance and recognition never walk past it. */
inline constexpr uint32_t MASTERNODE_OPERATOR_MAX_INDEX{1000000};
inline constexpr uint32_t MASTERNODE_OPERATOR_PURPOSE{BIP32_PURPOSE_FEATURE};
inline constexpr uint32_t MASTERNODE_OPERATOR_PROVIDER_FEATURE{3};
inline constexpr uint32_t MASTERNODE_OPERATOR_SUBFEATURE{3};

using MasternodeOperatorKeyStatus = interfaces::MasternodeOperatorKeyStatus;

//! DashSync-compatible operator-key path m/9'/coin'/3'/3'/index. The first
//! four levels are hardened; the leaf is not.
inline std::array<uint32_t, 5> MasternodeOperatorDerivationPath(uint32_t coin_type, uint32_t index)
{
constexpr uint32_t hardened{0x80000000};
assert(index < hardened);
return {
hardened | MASTERNODE_OPERATOR_PURPOSE,
hardened | coin_type,
hardened | MASTERNODE_OPERATOR_PROVIDER_FEATURE,
hardened | MASTERNODE_OPERATOR_SUBFEATURE,
index,
};
}

inline std::string MasternodeOperatorKeyPath(uint32_t coin_type, uint32_t index)
{
const auto path{MasternodeOperatorDerivationPath(coin_type, index)};
constexpr uint32_t hardened{0x80000000};
return strprintf("m/%d'/%d'/%d'/%d'/%d", path[0] & ~hardened, path[1] & ~hardened, path[2] & ~hardened,
path[3] & ~hardened, path[4]);
}

/** Advisory consumption watermark: every derivation index below next_index is
* permanently consumed (issuance is strictly lowest-index-first, so the
* consumed set is always a prefix). Tagged with the seed-source identifier
* so a record left behind by a different seed is ignored rather than
* trusted. Holds no secret. */
struct MasternodeOperatorWatermark {
std::vector<unsigned char> source_id;
uint32_t next_index{0};

SERIALIZE_METHODS(MasternodeOperatorWatermark, obj) { READWRITE(obj.source_id, obj.next_index); }
};

/** Advisory recognition lookahead: the basic-scheme public keys of the
* derivation indexes starting at base_index (normally the watermark), kept
* so the transaction-sync path can match provider transactions against
* upcoming keys without the seed - including while the wallet is locked.
* Rebuilt from the seed whenever it goes stale; holds no secret. */
struct MasternodeOperatorLookahead {
std::vector<unsigned char> source_id;
uint32_t base_index{0};
std::vector<std::vector<unsigned char>> public_keys;

SERIALIZE_METHODS(MasternodeOperatorLookahead, obj)
{
READWRITE(obj.source_id, obj.base_index, obj.public_keys);
}
};

} // namespace wallet

#endif // BITCOIN_WALLET_MASTERNODE_OPERATOR_H
17 changes: 17 additions & 0 deletions src/wallet/rpc/wallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,23 @@ static RPCHelpMan upgradetohd()
}
}

// A user-supplied mnemonic is a restore: provider transactions using
// its derived operator keys may already exist in history. Build the
// recognition lookahead while the freshly installed seed is still
// accessible - an encrypted wallet is relocked below - so a rescan
// that follows can match them. A generated mnemonic is new entropy
// that cannot appear in history.
if (!generate_mnemonic) {
const auto lookahead_status{pwallet->TopUpMasternodeOperatorLookahead()};
// NOT_SUPPORTED is the norm here: legacy wallets have no
// operator-key source.
if (lookahead_status != MasternodeOperatorKeyStatus::SUCCESS &&
lookahead_status != MasternodeOperatorKeyStatus::NOT_SUPPORTED) {
pwallet->WalletLogPrintf("upgradetohd: failed to build operator-key lookahead (status %d)\n",
static_cast<int>(lookahead_status));
}
}

if (pwallet->IsCrypted()) {
// Relock encrypted wallet
pwallet->Lock();
Expand Down
2 changes: 1 addition & 1 deletion src/wallet/salvage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ typedef std::pair<std::vector<unsigned char>, std::vector<unsigned char> > KeyVa

static bool KeyFilter(const std::string& type)
{
return WalletBatch::IsKeyType(type) || type == DBKeys::HDCHAIN;
return WalletBatch::IsKeyType(type) || type == DBKeys::HDCHAIN || type == DBKeys::MASTERNODE_OPERATOR_NEXT;
}

bool RecoverDatabaseFile(const ArgsManager& args, const fs::path& file_path, bilingual_str& error, std::vector<bilingual_str>& warnings)
Expand Down
Loading
Loading