From daba63c58922e6694679e6006f233c4bf5e6d9b3 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Wed, 21 Oct 2026 01:57:03 +0700 Subject: [PATCH 1/5] feat(wallet): derive DIP-3 operator keys from the BIP39 seed Dash Core only produces masternode operator keys at random (bls generate), so every operator secret has to be backed up on its own and a lost one can only be replaced by an on-chain rotation. The DashPay mobile wallets instead derive operator keys from the recovery phrase, and a wallet backup that is not interchangeable with theirs forces operators to keep two backups. Add wallet::platformkeys::DeriveOperatorKey, the pure derivation used by DashSync and dashj: bls::ExtendedPrivateKey::FromSeed over the 64-byte BIP39 seed, then m/9'/coin'/3'/3'/index with legacy child derivation at every step (the soft leaf hashes the parent public key, whose serialization differs between the legacy and basic schemes). The secp256k1 BIP32 root cannot stand in for the seed because both trees are one-way siblings of it. The leaf step is soft, so OperatorKeyChain returns the extended public key at m/9'/coin'/3'/3' and OperatorPubKey derives any operator public key from it without the seed; this is what lets a locked wallet follow its operator keys. The constants join the existing DIP-9 feature list; the functions live in the Platform key module because it is the same style of seed-rooted, cross-wallet derivation. Verified by the new operator_key_vectors case: the published dashsync-iOS and dashj testnet answers for index 0, plus mainnet, further indexes and a passphrase computed from the same scheme, and for each the public key derived from the chain equals the secret key's public key. Co-authored-by: pasta --- src/wallet/platformkeys.cpp | 47 +++++++++++++++++++++ src/wallet/platformkeys.h | 28 ++++++++++++- src/wallet/test/platformkeys_tests.cpp | 58 ++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/wallet/platformkeys.cpp b/src/wallet/platformkeys.cpp index e887ba32c7af..15e7533a7245 100644 --- a/src/wallet/platformkeys.cpp +++ b/src/wallet/platformkeys.cpp @@ -4,10 +4,12 @@ #include +#include #include #include #include #include +#include #include #include @@ -83,6 +85,51 @@ Path FriendshipPath(uint32_t coin_type, uint32_t account, Span us }; } +//! m/9'/coin'/3'/3'. Soft derivation below it hashes the parent public key, +//! and only its legacy serialization reproduces the keys DashSync and dashj +//! derive. +static bls::ExtendedPrivateKey OperatorKeyParent(Span seed, uint32_t coin_type) +{ + constexpr uint32_t HARDENED{0x80000000u}; + return bls::ExtendedPrivateKey::FromSeed(bls::Bytes(seed.data(), seed.size())) + .PrivateChild(FEATURE_PURPOSE | HARDENED, /*fLegacy=*/true) + .PrivateChild(coin_type | HARDENED, /*fLegacy=*/true) + .PrivateChild(FEATURE_MASTERNODE | HARDENED, /*fLegacy=*/true) + .PrivateChild(MASTERNODE_KEY_OPERATOR | HARDENED, /*fLegacy=*/true); +} + +CBLSSecretKey DeriveOperatorKey(Span seed, uint32_t coin_type, uint32_t index) +{ + if (index >> 31) return {}; + const auto leaf{OperatorKeyParent(seed, coin_type).PrivateChild(index, /*fLegacy=*/true)}; + std::array bytes; + leaf.GetPrivateKey().Serialize(bytes.data()); + const CBLSSecretKey secret(bytes); + memory_cleanse(bytes.data(), bytes.size()); + return secret; +} + +std::vector OperatorKeyChain(Span seed, uint32_t coin_type) +{ + return OperatorKeyParent(seed, coin_type).GetExtendedPublicKey(/*fLegacy=*/true).Serialize(/*fLegacy=*/true); +} + +CBLSPublicKey OperatorPubKey(Span chain, uint32_t index) +{ + CBLSPublicKey pubkey; + if (index >> 31 || chain.size() != bls::ExtendedPublicKey::SIZE) return pubkey; + try { + const auto child{bls::ExtendedPublicKey::FromBytes(bls::Bytes(chain.data(), chain.size()), /*fLegacy=*/true) + .PublicChild(index, /*fLegacy=*/true) + .GetPublicKey() + .Serialize(/*fLegacy=*/false)}; + pubkey.SetBytes(child, /*specificLegacyScheme=*/false); + } catch (const std::exception&) { + // Bytes that do not decode as an extended public key yield no key. + } + return pubkey; +} + bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out) { out = {}; diff --git a/src/wallet/platformkeys.h b/src/wallet/platformkeys.h index 4136195a80f4..2dfeb050e6e3 100644 --- a/src/wallet/platformkeys.h +++ b/src/wallet/platformkeys.h @@ -16,6 +16,9 @@ #include #include +class CBLSPublicKey; +class CBLSSecretKey; + /** * Dash Platform (DIP-9/13/14/15) key derivation for the wallet. * @@ -28,7 +31,9 @@ * - DIP-13 identity keys (m/9'/coin'/5'/...) * - DIP-14 256-bit child indexes (CKey::Derive256) * - DIP-15 DashPay friendship keychains (m/9'/coin'/15'/account'/idA/idB) - * - dashj DerivationPathFactory.java / FriendKeyChain.java (reference impl) + * - DIP-3 masternode operator keys (m/9'/coin'/3'/3'/index, BLS HD scheme) + * - dashj DerivationPathFactory.java / FriendKeyChain.java / + * BLSDeterministicKey.java (reference impl) */ namespace wallet::platformkeys { @@ -44,11 +49,15 @@ struct PathElement { }; using Path = std::vector; -// DIP-9 feature purpose, and DIP-13/DIP-15 feature types under it. +// DIP-9 feature purpose, and DIP-3/DIP-13/DIP-15 feature types under it. inline constexpr uint32_t FEATURE_PURPOSE{9}; +inline constexpr uint32_t FEATURE_MASTERNODE{3}; // DIP-3 inline constexpr uint32_t FEATURE_IDENTITIES{5}; // DIP-13 inline constexpr uint32_t FEATURE_DASHPAY{15}; // DIP-15 +// DIP-3 masternode key sub-features: m/9'/coin'/3'/' +inline constexpr uint32_t MASTERNODE_KEY_OPERATOR{3}; + // DIP-13 sub-features: m/9'/coin'/5'/' inline constexpr uint32_t IDENTITY_AUTHENTICATION{0}; inline constexpr uint32_t IDENTITY_REGISTRATION_FUNDING{1}; @@ -91,6 +100,21 @@ Path PlatformKeyPath(uint32_t coin_type, const PlatformKeyRequest& request); //! (userA = my id, userB = their id), the sending chain is the reverse. Path FriendshipPath(uint32_t coin_type, uint32_t account, Span user_a_id, Span user_b_id); +//! m/9'/coin'/3'/3'/ — DIP-3 masternode operator key, derived the way +//! DashSync and dashj do it: bls::ExtendedPrivateKey from the 64-byte BIP39 +//! seed with legacy child derivation at every step. The leaf index is soft +//! and must be below 2^31; an invalid key is returned otherwise. +CBLSSecretKey DeriveOperatorKey(Span seed, uint32_t coin_type, uint32_t index); + +//! Serialized bls::ExtendedPublicKey at m/9'/coin'/3'/3', the parent of every +//! operator key. Its soft children are the operator public keys, so it stands +//! in for the seed wherever only public keys are needed. +std::vector OperatorKeyChain(Span seed, uint32_t coin_type); + +//! Operator public key at `index` under a chain from OperatorKeyChain; invalid +//! for a malformed chain or an index of 2^31 or above. +CBLSPublicKey OperatorPubKey(Span chain, uint32_t index); + //! Derive the extended key at `path` from a BIP32 extended private key. //! Returns false if the parent key or any derivation step is invalid. [[nodiscard]] bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out); diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 088b7c84ad3c..4f504b706a88 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -2,6 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include #include @@ -225,6 +226,63 @@ BOOST_AUTO_TEST_CASE(account_reference_uses_ios_ask28) BOOST_CHECK_EQUAL(AccountReferenceFromMac(mac), 0x01c1d1e1U); } +//! Phrase that test/functional/wallet_upgradetohd.py restores. +static constexpr const char* REGTEST_MNEMONIC{ + "similar behave slot swim scissors throw planet view ghost laugh drift calm"}; + +//! Operator keys must come out identical to DashSync and dashj for the same +//! recovery phrase: published answers for m/9'/coin'/3'/3'/0 on testnet, plus +//! mainnet, further indexes and a passphrase computed with the same scheme. +BOOST_AUTO_TEST_CASE(operator_key_vectors) +{ + struct Vector { + const char* mnemonic; + const char* passphrase; + uint32_t coin_type; + uint32_t index; + const char* secret; + const char* public_legacy; + const char* public_basic; + }; + static constexpr const char* DASHSYNC_MNEMONIC{ + "enemy check owner stumble unaware debris suffer peanut good fabric bleak outside"}; + static constexpr const char* DASHJ_MNEMONIC{ + "bundle liberty enlist super solution elder minor shell fold vivid multiply throw"}; + const Vector vectors[]{ + {DASHSYNC_MNEMONIC, "", 1, 0, "344e7bf67fddf1c2d2f629f2392ce2b4af99393cd8a4c70d4bbaef7aaf4c364b", + "157b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfa", + "957b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfa"}, + {DASHJ_MNEMONIC, "", 1, 0, "392e5db8a1fb59608723c9d77320909924dbb06e397ce195429a1c4a7fcefb45", + "0cb6db5b5827e24138abb32e3476c5c7768bda0431c78030d62e9c5b107d3d73f5c78043401c5e8ad8f21a460a02bbbe", + "8cb6db5b5827e24138abb32e3476c5c7768bda0431c78030d62e9c5b107d3d73f5c78043401c5e8ad8f21a460a02bbbe"}, + {DASHSYNC_MNEMONIC, "", 5, 0, "0461b8370ac28e7d7c453ab769d6edc97e9a79e400ae100bfbbf7ea9d834fd2c", + "8efa464e7d58cfd90f3bb1bac18598928ffc7c112d4c64455c0ec72915fcab770a402c439ccc728288f3369ebbd530a5", + "aefa464e7d58cfd90f3bb1bac18598928ffc7c112d4c64455c0ec72915fcab770a402c439ccc728288f3369ebbd530a5"}, + {DASHSYNC_MNEMONIC, "", 1, 1, "25abfe0eeb4535a8e07d6c56f524296111b52bf42751e0d27809d1b991b3ea7e", "", ""}, + {DASHSYNC_MNEMONIC, "", 1, 2, "385f03e9082612ff2db91b0d6eeddbe6c4e810deb867aaebcbd2976e7d762d57", "", ""}, + {REGTEST_MNEMONIC, "", 1, 0, "60a5ae2824bf8dcd7b37b751b73ba419d0ebfe6f6f58aaf6a26eaa9ec43fd686", "", + "a5776ac29c3f02a57157d05303248615ce4676a8fc06e61602ecc5cf3d6c0ecbf6a438edd5b6f3d51941cea51894065b"}, + {REGTEST_MNEMONIC, "", 1, 1, "51a5ec2e84ef20a7ab8cb6b7d591711604973abc2528ebb7cd2ae835833eb1c5", "", ""}, + {REGTEST_MNEMONIC, "custom-passphrase", 1, 0, + "6ab36f50b340904e85d52fbab8223459b1d0b415ac950d7c02eb2b1503d808a4", "", ""}, + }; + for (const auto& vector : vectors) { + SecureVector seed; + CMnemonic::ToSeed(SecureString{vector.mnemonic}, SecureString{vector.passphrase}, seed); + const CBLSSecretKey key{DeriveOperatorKey(seed, vector.coin_type, vector.index)}; + BOOST_CHECK_EQUAL(key.ToString(), vector.secret); + BOOST_CHECK(OperatorPubKey(OperatorKeyChain(seed, vector.coin_type), vector.index) == key.GetPublicKey()); + if (*vector.public_legacy) { + BOOST_CHECK_EQUAL(key.GetPublicKey().ToString(/*specificLegacyScheme=*/true), vector.public_legacy); + } + if (*vector.public_basic) { + BOOST_CHECK_EQUAL(key.GetPublicKey().ToString(/*specificLegacyScheme=*/false), vector.public_basic); + } + } + BOOST_CHECK(!DeriveOperatorKey(SecureVector(64, 0), 1, 0x80000000u).IsValid()); + BOOST_CHECK(!OperatorPubKey(OperatorKeyChain(SecureVector(64, 0), 1), 0x80000000u).IsValid()); +} + //! Mnemonic behind Dip14Seed() above. static constexpr const char* DIP14_MNEMONIC{ "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer"}; From 993ca1956d59606b4bc3e0af02c6806e6ebce860 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Wed, 21 Oct 2026 01:59:08 +0700 Subject: [PATCH 2/5] wallet: issue and re-derive operator keys from the wallet mnemonic The derivation alone does not help a user: the BIP39 seed is not persisted by descriptor wallets, only the mnemonic text is, and nothing decides which wallets may hand out operator keys, remembers which indexes were handed out, or keeps a restored wallet from reissuing a key that already runs a masternode. Add CWallet::IssueOperatorKey and CWallet::GetOperatorKey. Both reuse the Platform key gating (descriptor wallet, private keys, external signer, single root) and then ask the selected ScriptPubKeyMan for the keys through two new virtuals that only DescriptorScriptPubKeyMan implements, DeriveOperatorKeys for secret keys and OperatorKeyChain for the extended public key over them, so the mnemonic never leaves the manager that stores it. The manager refuses with NOT_SUPPORTED when it holds no mnemonic, so wallets created from an imported extended key are not given Core-only keys that no other Dash wallet would find after a restore. The seed is rebuilt with CMnemonic::ToSeed after the lock check, so no secret is produced once Lock() has taken effect. A mnemonic whose secp256k1 master key is not the descriptor root is rejected as INVALID_SOURCE; that state cannot be created through this code (AddKey asserts the pairing), so it has no test, but a wallet file written by other software could carry it. Issuance hands out the lowest index not issued before whose public key the caller's in_use predicate accepts, which is how a mnemonic-only restore skips keys currently in the masternode list. The only persisted state is a 4-byte little-endian "next index" in the existing PLATFORM_DATA record family, so older releases open the wallet file through their unknown-record path and no new record type is needed. The counter is written before the key is returned and issuance fails if the write fails. Retrieval covers issued indexes only and never advances the counter. Legacy wallets stay unsupported: they are not deprecated yet, but the mobile wallets only interoperate with mnemonic-backed descriptor wallets. Tests: a seed-only restore rederives the same keys as the original wallet and they match the regtest vectors, skipping only an index reported in use; an encrypted wallet returns WALLET_LOCKED while locked and the pre-encryption key after unlock; the existing xprv-only, legacy and ambiguous-root scenarios assert the matching refusal. Co-authored-by: pasta --- src/wallet/platformtypes.h | 1 + src/wallet/scriptpubkeyman.cpp | 53 ++++++++++++++++ src/wallet/scriptpubkeyman.h | 17 ++++++ src/wallet/test/platformkeys_tests.cpp | 85 ++++++++++++++++++++++++++ src/wallet/wallet.cpp | 53 ++++++++++++++++ src/wallet/wallet.h | 12 ++++ 6 files changed, 221 insertions(+) diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 64aeb0b3f21e..4a1fa9b553e4 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -26,6 +26,7 @@ enum class PlatformKeyStatus : uint8_t { INVALID_ARGUMENT, IMPORT_ERROR, DERIVATION_ERROR, + DATABASE_ERROR, }; template diff --git a/src/wallet/scriptpubkeyman.cpp b/src/wallet/scriptpubkeyman.cpp index 5f17ef369fa5..d4142b46e436 100644 --- a/src/wallet/scriptpubkeyman.cpp +++ b/src/wallet/scriptpubkeyman.cpp @@ -2,6 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include #include @@ -2781,6 +2782,58 @@ PlatformKeyStatus DescriptorScriptPubKeyMan::DerivePlatformKey(const platformkey return platformkeys::DeriveExtKey(root, path, out) ? PlatformKeyStatus::SUCCESS : PlatformKeyStatus::DERIVATION_ERROR; } +PlatformKeyStatus DescriptorScriptPubKeyMan::OperatorKeySeed(SecureVector& seed) const +{ + std::vector identifier; + if (const auto status{GetPlatformKeySource(identifier)}; status != PlatformKeyStatus::SUCCESS) return status; + if (m_storage.IsLocked(false)) return PlatformKeyStatus::WALLET_LOCKED; + + // Only the mnemonic reproduces the BLS tree the mobile wallets derive + // from; a wallet built from an imported extended key has no BIP39 seed. + // The encrypted path of GetMnemonicString appends one decrypted character + // at a time; reserving keeps them out of the small-string buffer, which + // the secure allocator does not cover. + SecureString mnemonic; + SecureString passphrase; + mnemonic.reserve(256); + passphrase.reserve(256); + if (!GetMnemonicString(mnemonic, passphrase) || mnemonic.empty()) return PlatformKeyStatus::NOT_SUPPORTED; + CMnemonic::ToSeed(mnemonic, passphrase, seed); + + // The mnemonic is stored beside the descriptor root key; one that does not + // reproduce it would hand out keys no backup of this wallet recovers. + CExtKey master; + master.SetSeed(MakeByteSpan(seed)); + CExtPubKey root; + { + LOCK(cs_desc_man); + if (!m_wallet_descriptor.descriptor->GetRootExtPubKey(root)) return PlatformKeyStatus::INVALID_SOURCE; + } + return master.Neuter() == root ? PlatformKeyStatus::SUCCESS : PlatformKeyStatus::INVALID_SOURCE; +} + +PlatformKeyStatus DescriptorScriptPubKeyMan::DeriveOperatorKeys(uint32_t coin_type, uint32_t first, uint32_t count, + std::vector& out) const +{ + out.clear(); + SecureVector seed; + if (const auto status{OperatorKeySeed(seed)}; status != PlatformKeyStatus::SUCCESS) return status; + for (uint32_t i{0}; i < count; ++i) { + out.push_back(platformkeys::DeriveOperatorKey(seed, coin_type, first + i)); + if (!out.back().IsValid()) return PlatformKeyStatus::DERIVATION_ERROR; + } + return PlatformKeyStatus::SUCCESS; +} + +PlatformKeyStatus DescriptorScriptPubKeyMan::OperatorKeyChain(uint32_t coin_type, std::vector& out) const +{ + out.clear(); + SecureVector seed; + if (const auto status{OperatorKeySeed(seed)}; status != PlatformKeyStatus::SUCCESS) return status; + out = platformkeys::OperatorKeyChain(seed, coin_type); + return out.empty() ? PlatformKeyStatus::DERIVATION_ERROR : PlatformKeyStatus::SUCCESS; +} + TransactionError DescriptorScriptPubKeyMan::FillPSBT(PartiallySignedTransaction& psbtx, const PrecomputedTransactionData& txdata, int sighash_type, bool sign, bool bip32derivs, int* n_signed, bool finalize) const { if (n_signed) { diff --git a/src/wallet/scriptpubkeyman.h b/src/wallet/scriptpubkeyman.h index 4fae8272ebbc..f237498edcaa 100644 --- a/src/wallet/scriptpubkeyman.h +++ b/src/wallet/scriptpubkeyman.h @@ -251,6 +251,17 @@ class ScriptPubKeyMan { return PlatformKeyStatus::NOT_SUPPORTED; } + //! Derive the DIP-3 operator keys at indexes [first, first + count) from the mnemonic behind the root key. + virtual PlatformKeyStatus DeriveOperatorKeys(uint32_t coin_type, uint32_t first, uint32_t count, + std::vector& out) const + { + return PlatformKeyStatus::NOT_SUPPORTED; + } + //! Extended public key over the operator keys, see platformkeys::OperatorKeyChain. + virtual PlatformKeyStatus OperatorKeyChain(uint32_t coin_type, std::vector& out) const + { + return PlatformKeyStatus::NOT_SUPPORTED; + } /** Adds script and derivation path information to a PSBT, and optionally signs it. */ virtual TransactionError FillPSBT(PartiallySignedTransaction& psbt, const PrecomputedTransactionData& txdata, int sighash_type = 1 /* SIGHASH_ALL */, bool sign = true, bool bip32derivs = false, int* n_signed = nullptr, bool finalize = true) const { return TransactionError::INVALID_PSBT; } @@ -599,6 +610,9 @@ class DescriptorScriptPubKeyMan : public ScriptPubKeyMan bool AddDescriptorKeyWithDB(WalletBatch& batch, const CKey& key, const CPubKey &pubkey, const SecureString& mnemonic, const SecureString& mnemonic_passphrase) EXCLUSIVE_LOCKS_REQUIRED(cs_desc_man); + //! Rebuild the BIP39 seed from the stored mnemonic once it is checked to reproduce the descriptor root. + PlatformKeyStatus OperatorKeySeed(SecureVector& seed) const; + KeyMap GetKeys() const EXCLUSIVE_LOCKS_REQUIRED(cs_desc_man); // Cached FlatSigningProviders to avoid regenerating them each time they are needed. @@ -681,6 +695,9 @@ class DescriptorScriptPubKeyMan : public ScriptPubKeyMan bool SignSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector& vchSig) const override; PlatformKeyStatus GetPlatformKeySource(std::vector& identifier) const override; PlatformKeyStatus DerivePlatformKey(const platformkeys::Path& path, platformkeys::ExtKey256& out) const override; + PlatformKeyStatus DeriveOperatorKeys(uint32_t coin_type, uint32_t first, uint32_t count, + std::vector& out) const override; + PlatformKeyStatus OperatorKeyChain(uint32_t coin_type, std::vector& out) const override; TransactionError FillPSBT(PartiallySignedTransaction& psbt, const PrecomputedTransactionData& txdata, int sighash_type = 1 /* SIGHASH_ALL */, bool sign = true, bool bip32derivs = false, int* n_signed = nullptr, bool finalize = true) const override; uint256 GetID() const override; diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 4f504b706a88..8b0d3de637a7 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -230,6 +230,8 @@ BOOST_AUTO_TEST_CASE(account_reference_uses_ios_ask28) static constexpr const char* REGTEST_MNEMONIC{ "similar behave slot swim scissors throw planet view ghost laugh drift calm"}; +static bool NeverInUse(const CBLSPublicKey&) { return false; } + //! Operator keys must come out identical to DashSync and dashj for the same //! recovery phrase: published answers for m/9'/coin'/3'/3'/0 on testnet, plus //! mainnet, further indexes and a passphrase computed with the same scheme. @@ -468,6 +470,11 @@ BOOST_FIXTURE_TEST_CASE(platform_key_source_must_be_unambiguous, Dip14WalletSetu const auto ambiguous{m_iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(ambiguous.status == PlatformKeyStatus::AMBIGUOUS_SOURCE); BOOST_CHECK(m_iface->platformKeySourceStatus() == PlatformKeyStatus::AMBIGUOUS_SOURCE); + + uint32_t index; + CBLSSecretKey operator_key; + LOCK(m_wallet->cs_wallet); + BOOST_CHECK(m_wallet->IssueOperatorKey(NeverInUse, index, operator_key) == PlatformKeyStatus::AMBIGUOUS_SOURCE); } //! An imported root xprv is sufficient for Platform derivation. The mnemonic @@ -482,6 +489,13 @@ BOOST_FIXTURE_TEST_CASE(xprv_only_wallet_derives_platform_keys, FriendshipWallet ExtKey256 expected; BOOST_REQUIRE(DeriveExtKey(master, IdentityAuthKeyPath(static_cast(Params().ExtCoinType()), 0, 0), expected)); BOOST_CHECK(result.value == expected.key.GetPubKey()); + + // Operator keys are the exception: the mobile wallets derive them from + // the BIP39 seed, which an imported xprv does not carry. + uint32_t index; + CBLSSecretKey operator_key; + LOCK(wallet->cs_wallet); + BOOST_CHECK(wallet->IssueOperatorKey(NeverInUse, index, operator_key) == PlatformKeyStatus::NOT_SUPPORTED); } //! The standardized Platform paths are absolute paths from m. An imported @@ -588,6 +602,77 @@ BOOST_FIXTURE_TEST_CASE(legacy_wallet_has_no_platform_keys, FriendshipWalletSetu const auto result{iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(result.status == PlatformKeyStatus::NOT_SUPPORTED); BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::NOT_SUPPORTED); + + uint32_t index; + CBLSSecretKey operator_key; + LOCK(wallet->cs_wallet); + BOOST_CHECK(wallet->IssueOperatorKey(NeverInUse, index, operator_key) == PlatformKeyStatus::NOT_SUPPORTED); +} + +//! Descriptor wallet restored from the phrase behind the regtest operator-key +//! vectors. +struct OperatorKeyWalletSetup : public FriendshipWalletSetup { + OperatorKeyWalletSetup() : + FriendshipWalletSetup(SecureString{REGTEST_MNEMONIC}) + { + } +}; + +//! Issuance is lowest-index-first and retrieval covers issued indexes only. A +//! seed-only restore (fresh database) hands out the same keys in the same +//! order, skipping only indexes reported in use, and they are the vector keys +//! a mobile wallet shows for this phrase. +BOOST_FIXTURE_TEST_CASE(operator_keys_rederive_after_restore, OperatorKeyWalletSetup) +{ + uint32_t index; + CBLSSecretKey key_0, key_1, key; + { + LOCK(m_wallet->cs_wallet); + BOOST_CHECK(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::INVALID_ARGUMENT); + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_0) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 0U); + BOOST_CHECK_EQUAL(key_0.ToString(), "60a5ae2824bf8dcd7b37b751b73ba419d0ebfe6f6f58aaf6a26eaa9ec43fd686"); + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_1) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1U); + BOOST_CHECK_EQUAL(key_1.ToString(), "51a5ec2e84ef20a7ab8cb6b7d591711604973abc2528ebb7cd2ae835833eb1c5"); + BOOST_REQUIRE(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == key_0); + BOOST_CHECK(m_wallet->GetOperatorKey(2, key) == PlatformKeyStatus::INVALID_ARGUMENT); + } + + // The restored wallet has no issuance record; only the masternode list + // tells it that key 0 is taken. + const auto [wallet_b, iface_b]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; + const auto key_0_in_use{[&key_0](const CBLSPublicKey& pubkey) { return pubkey == key_0.GetPublicKey(); }}; + LOCK(wallet_b->cs_wallet); + BOOST_REQUIRE(wallet_b->IssueOperatorKey(key_0_in_use, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1U); + BOOST_CHECK(key == key_1); + BOOST_REQUIRE(wallet_b->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == key_0); +} + +//! A locked wallet neither issues nor re-derives, and unlocking gives back +//! the key issued before encryption. +BOOST_FIXTURE_TEST_CASE(operator_keys_need_unlocked_wallet, OperatorKeyWalletSetup) +{ + uint32_t index; + CBLSSecretKey before, key; + { + LOCK(m_wallet->cs_wallet); + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, before) == PlatformKeyStatus::SUCCESS); + } + BOOST_REQUIRE(m_wallet->EncryptWallet("passphrase")); + BOOST_REQUIRE(m_wallet->IsLocked()); + + LOCK(m_wallet->cs_wallet); + BOOST_CHECK(m_wallet->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::WALLET_LOCKED); + BOOST_CHECK(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::WALLET_LOCKED); + BOOST_CHECK(!key.IsValid()); + + BOOST_REQUIRE(m_wallet->Unlock("passphrase")); + BOOST_REQUIRE(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == before); } //! Two independent wallet instances restored from the same recovery phrase: diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 2a328d3de670..2de92be611d6 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -4107,6 +4108,58 @@ PlatformKeyStatus CWallet::GetPlatformKeySourceStatus() const return GetPlatformKeySource(source); } +static const std::string OPERATOR_KEY_NEXT_INDEX{"operatorkeys/next_index"}; + +PlatformKeyStatus CWallet::DeriveOperatorKeys(uint32_t first, uint32_t count, std::vector& out) const +{ + AssertLockHeld(cs_wallet); + const ScriptPubKeyMan* source{nullptr}; + if (const auto status{GetPlatformKeySource(source)}; status != PlatformKeyStatus::SUCCESS) return status; + return source->DeriveOperatorKeys(static_cast(Params().ExtCoinType()), first, count, out); +} + +std::optional CWallet::OperatorKeysIssued() const +{ + AssertLockHeld(cs_wallet); + const auto it{m_platform_data.find(OPERATOR_KEY_NEXT_INDEX)}; + if (it == m_platform_data.end()) return 0; + if (it->second.size() != sizeof(uint32_t)) return std::nullopt; + return ReadLE32(it->second.data()); +} + +PlatformKeyStatus CWallet::IssueOperatorKey(const std::function& in_use, uint32_t& index, + CBLSSecretKey& secret) +{ + AssertLockHeld(cs_wallet); + secret.Reset(); + const auto issued{OperatorKeysIssued()}; + if (!issued) return PlatformKeyStatus::DATABASE_ERROR; + std::vector keys; + for (index = *issued;; ++index) { + if (const auto status{DeriveOperatorKeys(index, 1, keys)}; status != PlatformKeyStatus::SUCCESS) return status; + if (!in_use(keys.front().GetPublicKey())) break; + } + secret = keys.front(); + std::vector next_index(sizeof(uint32_t)); + WriteLE32(next_index.data(), index + 1); + if (WritePlatformData(OPERATOR_KEY_NEXT_INDEX, next_index)) return PlatformKeyStatus::SUCCESS; + secret.Reset(); + return PlatformKeyStatus::DATABASE_ERROR; +} + +PlatformKeyStatus CWallet::GetOperatorKey(uint32_t index, CBLSSecretKey& secret) const +{ + AssertLockHeld(cs_wallet); + secret.Reset(); + const auto issued{OperatorKeysIssued()}; + if (!issued) return PlatformKeyStatus::DATABASE_ERROR; + if (index >= *issued) return PlatformKeyStatus::INVALID_ARGUMENT; + std::vector keys; + if (const auto status{DeriveOperatorKeys(index, 1, keys)}; status != PlatformKeyStatus::SUCCESS) return status; + secret = keys.front(); + return PlatformKeyStatus::SUCCESS; +} + PlatformKeyResult CWallet::GetPlatformPubKey(const PlatformKeyRequest& request) const { LOCK(cs_wallet); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 02af32e21391..15b4f996f93e 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -52,6 +52,7 @@ #include +class CBLSPublicKey; class CKey; class CScript; class CTxDSIn; @@ -503,6 +504,10 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); PlatformKeyStatus DerivePlatformKey(const platformkeys::Path& path, platformkeys::ExtKey256& out) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + PlatformKeyStatus DeriveOperatorKeys(uint32_t first, uint32_t count, std::vector& out) const + EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Operator key indexes below this were issued; nullopt for an unreadable counter record. + std::optional OperatorKeysIssued() const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); public: /** @@ -1111,6 +1116,13 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati PlatformKeyResult PlatformECDHSecret(const IdentityAuthKey& key, const CPubKey& counterparty) const; PlatformKeyResult PlatformAccountReference(const IdentityAuthKey& key, const CompactXpub& compact_xpub) const; PlatformKeyResult EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request); + /** Issue the next DIP-3 operator key m/9'/coin'/3'/3'/index from the wallet mnemonic + * (platformkeys::DeriveOperatorKey): the lowest index not issued before whose public key `in_use` rejects. + * The index is recorded as issued before the key is returned. */ + PlatformKeyStatus IssueOperatorKey(const std::function& in_use, uint32_t& index, + CBLSSecretKey& secret) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Re-derive an operator key issued earlier; INVALID_ARGUMENT for an index not issued yet. */ + PlatformKeyStatus GetOperatorKey(uint32_t index, CBLSSecretKey& secret) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); /* Returns the time of the first created key or, in case of an import, it could be the time of the first received transaction */ int64_t GetBirthTime() const { return m_birth_time; } From e76fc85b734caa921cca135e9e97687cb29ccc64 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Wed, 21 Oct 2026 02:03:14 +0700 Subject: [PATCH 3/5] feat(rpc): add `bls operatorkey` to issue mnemonic-derived operator keys CWallet can issue and re-derive operator keys, but nothing lets a user obtain one. Add the wallet RPC `bls operatorkey ( index )` next to the protx commands. Without an index it issues the next key; with one it returns a key issued earlier and rejects any other index. The masternode list at the chain tip is read before cs_wallet is taken, and issuance skips every index whose public key is in that list, which is what a mnemonic-only restore on a fresh machine needs to avoid a key that is currently assigned. Reuse of keys revoked long ago stays possible at this point: DIP3 enforces uniqueness only against the current list. Issuance and the lock check happen under cs_wallet, so a wallet that locked in between returns RPC_WALLET_UNLOCK_NEEDED instead of a secret. The public key is reported in the basic scheme; `bls fromsecret true` gives the legacy serialization, so no legacy argument is added. Tests: wallet_upgradetohd.py checks the vector keys for the phrase it already restores (with and without passphrase), retrieval by index, rejection of an unissued index, the locked-wallet error and the legacy-wallet refusal. feature_dip3_deterministicmns.py registers one masternode with a wallet-issued key, then shows a wallet restored from the same mnemonic issues index 1 while that key is in the list and keeps its counter across a reload (that node runs without -usehd=0, which refuses to load HD wallets in wallet_upgradetohd.py). Co-authored-by: pasta --- doc/release-notes-7594.md | 10 ++ src/rpc/client.cpp | 1 + src/rpc/evo.cpp | 99 +++++++++++++++++++ src/test/rpc_tests.cpp | 4 + .../feature_dip3_deterministicmns.py | 24 ++++- test/functional/wallet_upgradetohd.py | 19 ++++ 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 doc/release-notes-7594.md diff --git a/doc/release-notes-7594.md b/doc/release-notes-7594.md new file mode 100644 index 000000000000..76837e67e9c8 --- /dev/null +++ b/doc/release-notes-7594.md @@ -0,0 +1,10 @@ +Wallet +------ + +- New wallet RPC `bls operatorkey` issues masternode operator keys derived from + the wallet mnemonic at `m/9'/coin'/3'/3'/index`, the keys the DashPay mobile + wallets derive from the same recovery phrase, so that phrase is the only + backup an operator needs. Each call issues the next key; `bls operatorkey + ` returns a key issued earlier. A wallet restored from the phrase + skips indexes whose key is in the masternode list at the chain tip. Requires + an unlocked descriptor wallet with a mnemonic. (#7594) diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 1bef88730638..9903b99f30dc 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -276,6 +276,7 @@ static const CRPCConvertParam vRPCConvertParams[] = // Compound RPCs (note: index position is offset by one to account for subcommand) { "bls generate", 1, "legacy" }, { "bls fromsecret", 2, "legacy" }, + { "bls operatorkey", 1, "index" }, { "coinjoinsalt generate", 1, "overwrite" }, { "coinjoinsalt set", 2, "overwrite" }, { "gobject list-prepared", 1, "count" }, diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index 212feea5839c..57e728f35a3a 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -54,6 +54,7 @@ using wallet::DEFAULT_DISABLE_WALLET; using wallet::GetWalletForJSONRPCRequest; using wallet::HELP_REQUIRING_PASSPHRASE; using wallet::isminetype; +using wallet::PlatformKeyStatus; #endif // ENABLE_WALLET // Defined here rather than with the other ToJson() in evo/core_write.cpp: dash-tx never prints @@ -2462,6 +2463,100 @@ static RPCHelpMan bls_fromsecret() }; } +#ifdef ENABLE_WALLET +[[noreturn]] static void ThrowOperatorKeyError(PlatformKeyStatus status, uint32_t index) +{ + switch (status) { + case PlatformKeyStatus::WALLET_LOCKED: + throw JSONRPCError(RPC_WALLET_UNLOCK_NEEDED, + "Error: Please enter the wallet passphrase with walletpassphrase first."); + case PlatformKeyStatus::NOT_SUPPORTED: + throw JSONRPCError(RPC_WALLET_ERROR, "Operator keys need a descriptor wallet with private keys and a mnemonic"); + case PlatformKeyStatus::AMBIGUOUS_SOURCE: + throw JSONRPCError(RPC_WALLET_ERROR, "Active descriptors of this wallet come from different root keys"); + case PlatformKeyStatus::INVALID_SOURCE: + throw JSONRPCError(RPC_WALLET_ERROR, "Wallet mnemonic does not match the descriptor root key"); + case PlatformKeyStatus::INVALID_ARGUMENT: + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("operator key %d has not been issued by this wallet", index)); + case PlatformKeyStatus::DATABASE_ERROR: + throw JSONRPCError(RPC_WALLET_ERROR, "Operator key records could not be read or written"); + case PlatformKeyStatus::DERIVATION_ERROR: + throw JSONRPCError(RPC_WALLET_ERROR, "Operator key derivation failed"); + case PlatformKeyStatus::SUCCESS: + case PlatformKeyStatus::IMPORT_ERROR: + break; + } + NONFATAL_UNREACHABLE(); +} + +static RPCHelpMan bls_operatorkey() +{ + return RPCHelpMan{ + "bls operatorkey", + "\nIssues the next masternode operator key derived from the wallet mnemonic, or returns a key issued earlier.\n" + "Keys follow m/9'/coin'/3'/3'/index with the BLS HD scheme of the DashPay mobile wallets, so restoring\n" + "the mnemonic anywhere reproduces the same keys in the same order. Indexes whose key is in the masternode\n" + "list at the chain tip are skipped.\n" + + HELP_REQUIRING_PASSPHRASE, + { + {"index", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, + "Return the key issued earlier at this index instead of issuing a new one"}, + }, + RPCResult{RPCResult::Type::OBJ, + "", + "", + { + {RPCResult::Type::NUM, "index", "Derivation index of the key"}, + {RPCResult::Type::STR_HEX, "secret", "BLS secret key"}, + {RPCResult::Type::STR_HEX, "public", "BLS public key"}, + {RPCResult::Type::STR, "scheme", "BLS scheme, always basic (use bls fromsecret for legacy)"}, + }}, + RPCExamples{HelpExampleCli("bls operatorkey", "") + HelpExampleCli("bls operatorkey", "0")}, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { + std::shared_ptr const wallet{GetWalletForJSONRPCRequest(request)}; + if (!wallet) return UniValue::VNULL; + // 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(); + EnsureWalletIsUnlocked(*wallet); + + uint32_t index{0}; + CBLSSecretKey secret; + PlatformKeyStatus status; + if (request.params[0].isNull()) { + const NodeContext& node{EnsureAnyNodeContext(request.context)}; + std::set> operator_keys; + CHECK_NONFATAL(node.dmnman)->GetListAtChainTip().ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + operator_keys.insert(dmn.pdmnState->pubKeyOperator.Get().ToByteVector(/*specificLegacyScheme=*/false)); + }); + LOCK(wallet->cs_wallet); + status = wallet->IssueOperatorKey( + [&operator_keys](const CBLSPublicKey& pubkey) { + return operator_keys.count(pubkey.ToByteVector(/*specificLegacyScheme=*/false)) != 0; + }, + index, secret); + } else { + const int64_t requested{request.params[0].getInt()}; + if (requested < 0 || requested >> 31) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Operator key index out of range"); + } + index = requested; + LOCK(wallet->cs_wallet); + status = wallet->GetOperatorKey(index, secret); + } + if (status != PlatformKeyStatus::SUCCESS) ThrowOperatorKeyError(status, index); + + UniValue ret(UniValue::VOBJ); + ret.pushKV("index", index); + ret.pushKV("secret", secret.ToString()); + ret.pushKV("public", secret.GetPublicKey().ToString(/*specificLegacyScheme=*/false)); + ret.pushKV("scheme", "basic"); + return ret; + }, + }; +} +#endif // ENABLE_WALLET + static RPCHelpMan bls_help() { return RPCHelpMan{"bls", @@ -2469,6 +2564,9 @@ static RPCHelpMan bls_help() "To get help on individual commands, use \"help bls command\".\n" "\nAvailable commands:\n" " generate - Create a BLS secret/public key pair\n" +#ifdef ENABLE_WALLET + " operatorkey - Issue or return a masternode operator key derived from the wallet mnemonic\n" +#endif " fromsecret - Parse a BLS secret key and return the secret/public key pair\n", { {"command", RPCArg::Type::STR, RPCArg::Optional::NO, "The command to execute"}, @@ -2507,6 +2605,7 @@ Span GetWalletEvoRPCCommands() {"evo", &protx_shared_dissolve_prepare}, {"evo", &protx_shared_update_share}, {"evo", &protx_shared_update_registrar_prepare}, + {"evo", &bls_operatorkey}, {"hidden", &protx_register_legacy}, {"hidden", &protx_register_fund_legacy}, {"hidden", &protx_register_prepare_legacy}, diff --git a/src/test/rpc_tests.cpp b/src/test/rpc_tests.cpp index 0501a4f264fb..1d30bcc92474 100644 --- a/src/test/rpc_tests.cpp +++ b/src/test/rpc_tests.cpp @@ -590,6 +590,10 @@ BOOST_AUTO_TEST_CASE(rpc_convert_composite_commands) BOOST_CHECK_EQUAL(result[3][1].get_str(), "1.0.0.1:19999"); BOOST_CHECK_EQUAL(result[4].get_str(), "yhq7ifNCtTKEpY4Yu5XPCcztQco6Fh6JsZ"); + BOOST_CHECK_NO_THROW(result = RPCConvertValues("bls", {"operatorkey", "0"})); + BOOST_CHECK_EQUAL(result[0].get_str(), "operatorkey"); + BOOST_CHECK_EQUAL(result[1].getInt(), 0); + // Validate that array syntax is not interpreted as string literal (named parameter) BOOST_CHECK_NO_THROW(result = RPCConvertNamedValues("protx", { "register_prepare", diff --git a/test/functional/feature_dip3_deterministicmns.py b/test/functional/feature_dip3_deterministicmns.py index 602a9db6301a..adc2bee43b34 100755 --- a/test/functional/feature_dip3_deterministicmns.py +++ b/test/functional/feature_dip3_deterministicmns.py @@ -17,7 +17,7 @@ BitcoinTestFramework, MasternodeInfo, ) -from test_framework.util import assert_equal, force_finish_mnsync, p2p_port, softfork_active +from test_framework.util import assert_equal, force_finish_mnsync, get_mnemonic, p2p_port, softfork_active class DIP3Test(BitcoinTestFramework): def add_options(self, parser): @@ -120,6 +120,11 @@ def dip3_inactive_error(): self.log.info("registering MNs") for i in range(self.num_initial_mn): mn: MasternodeInfo = self.prepare_mn(self.nodes[0], i + 2, "mn-%d" % i) + if i == 0 and self.options.descriptors: + self.log.info(f"{mn.friendlyName} takes its operator key from the controller wallet") + operator_key = self.nodes[0].bls('operatorkey') + mn.keyOperator, mn.pubKeyOperator = operator_key['secret'], operator_key['public'] + wallet_key_mn = mn mns.append(mn) # start a few MNs before they are registered and a few after they are registered @@ -146,6 +151,23 @@ def dip3_inactive_error(): self.sync_all() self.assert_mnlists(mns) + if self.options.descriptors: + self.log.info("A wallet restored from the controller mnemonic skips operator keys that are in the masternode list") + mnemonic, passphrase = get_mnemonic(self.nodes[0]) + controller = self.nodes[0].get_wallet_rpc(self.default_wallet_name) + self.nodes[0].createwallet("restored", blank=True) + restored = self.nodes[0].get_wallet_rpc("restored") + restored.upgradetohd(mnemonic, passphrase) + restored_key = restored.bls('operatorkey') + assert_equal(restored_key['index'], 1) + assert_equal(restored_key, controller.bls('operatorkey')) + assert_equal(controller.bls('operatorkey', 0)['secret'], wallet_key_mn.keyOperator) + self.log.info("The issued-key counter survives a wallet reload") + restored.unloadwallet() + self.nodes[0].loadwallet("restored") + assert_equal(restored.bls('operatorkey')['index'], 2) + restored.unloadwallet() + self.log.info("test that MNs disappear from the list when the ProTx collateral is spent") # also make sure "protx info" returns correct historical data for spent collaterals spend_mns_count = 3 diff --git a/test/functional/wallet_upgradetohd.py b/test/functional/wallet_upgradetohd.py index 2f00de4fa35f..ec5347770477 100755 --- a/test/functional/wallet_upgradetohd.py +++ b/test/functional/wallet_upgradetohd.py @@ -214,6 +214,7 @@ def run_test(self): assert_raises_rpc_error(-13, "Error: Wallet encrypted but passphrase not supplied to RPC.", node.upgradetohd, mnemonic[0]) assert_raises_rpc_error(-14, "Error: The wallet passphrase entered was incorrect", node.upgradetohd, mnemonic[0], "", "111") assert node.upgradetohd(mnemonic[0], "", walletpass) + assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first.", node.bls, 'operatorkey') if not self.options.descriptors: assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first.", node.dumphdinfo) else: @@ -245,6 +246,21 @@ def run_test(self): w11 = node.get_wallet_rpc("wallet-11") w11.upgradetohd(custom_mnemonic) assert_equal(11, w11.getbalance()) + + self.log.info("Operator keys derive from the restored mnemonic in order and are retrievable by index; legacy wallets are refused") + if self.options.descriptors: + key_0 = w11.bls('operatorkey') + assert_equal(key_0, { + 'index': 0, + 'secret': '60a5ae2824bf8dcd7b37b751b73ba419d0ebfe6f6f58aaf6a26eaa9ec43fd686', + 'public': 'a5776ac29c3f02a57157d05303248615ce4676a8fc06e61602ecc5cf3d6c0ecbf6a438edd5b6f3d51941cea51894065b', + 'scheme': 'basic', + }) + assert_equal(w11.bls('operatorkey')['secret'], '51a5ec2e84ef20a7ab8cb6b7d591711604973abc2528ebb7cd2ae835833eb1c5') + assert_equal(w11.bls('operatorkey', 0), key_0) + assert_raises_rpc_error(-8, "operator key 5 has not been issued by this wallet", w11.bls, 'operatorkey', 5) + else: + assert_raises_rpc_error(-4, "Operator keys need a descriptor wallet", w11.bls, 'operatorkey') w11.unloadwallet() node.createwallet("wallet-12", blank=True) @@ -254,6 +270,9 @@ def run_test(self): assert_equal(get_mnemonic(w12)[0], custom_mnemonic) assert_equal(get_mnemonic(w12)[1], "custom-passphrase") assert_equal(12, w12.getbalance()) + if self.options.descriptors: + self.log.info("The mnemonic passphrase changes the operator keys") + assert_equal(w12.bls('operatorkey')['secret'], '6ab36f50b340904e85d52fbab8223459b1d0b415ac950d7c02eb2b1503d808a4') w12.unloadwallet() self.log.info("Check if null character at the end of mnemonic-passphrase matters") From 29db58d2131408a194af969dfcdc4cc468690223 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Wed, 21 Oct 2026 03:10:18 +0700 Subject: [PATCH 4/5] feat: advance the operator key counter from synced provider transactions A mnemonic-only restore starts the operator key counter at zero and knows only what the masternode list at the chain tip says. A key that was rotated away or revoked, possibly because it leaked, is absent from that list and would be issued again for the next registration. Payment addresses do not have this problem because the keypool is a lookahead and a seen payment moves next_index past it. Give the operator key chain the same mechanism. The wallet persists the extended public key at m/9'/coin'/3'/3' as one PLATFORM_DATA record and keeps the basic-scheme public keys of the next 50 indexes above the counter in memory, derived from that record at load and after every counter move; no seed is involved, so the window follows the counter with the wallet locked or not. Every synced transaction, from a rescan or live, is checked in AddToWalletIfInvolvingMe before the IsMine gate, because a provider transaction assigning one of these keys is normally not "ours": a ProRegTx, ProUpRegTx or ProUpSharedRegTx whose operator key is in the window moves the counter past that index. A counter write that fails there throws, like a failed transaction write in the same function, so the sync does not silently drop a used key. The record is written where a user-supplied mnemonic enters a descriptor wallet (upgradetohd and wallet creation with -mnemonic), before any encryption step and before the rescan, and at the first issuance. Accepted limits: rescans driven by BIP158 filters skip blocks whose filter does not match, and operator keys are not filter elements, so such a rescan can miss registrations; a pruned node cannot rescan pruned blocks at all, so issuing on one returns a warning in the RPC result; two used indexes more than 50 apart leave the gap unrecognised; a legacy wallet migrated to descriptors gets its record at the first issuance. Tests: a unit case feeds a ProUpRegTx carrying key 0 through transactionAddedToMempool into a freshly restored, encrypted and locked wallet and checks that key 0 becomes retrievable and the next issued index is 1; a second locked wallet sees keys 49 and 50, the latter outside the initial window, and issues 51. feature_dip3_deterministicmns.py now restores twice: without rescan, index 0 is unknown and skipped through the masternode list; with rescan, index 0 is recognised without the list. Co-authored-by: pasta --- ...se-notes-7594.md => release-notes-8002.md} | 8 +- src/rpc/evo.cpp | 15 ++ src/wallet/rpc/wallet.cpp | 6 + src/wallet/test/platformkeys_tests.cpp | 52 ++++++ src/wallet/wallet.cpp | 150 ++++++++++++++++-- src/wallet/wallet.h | 18 ++- .../feature_dip3_deterministicmns.py | 21 ++- 7 files changed, 246 insertions(+), 24 deletions(-) rename doc/{release-notes-7594.md => release-notes-8002.md} (56%) diff --git a/doc/release-notes-7594.md b/doc/release-notes-8002.md similarity index 56% rename from doc/release-notes-7594.md rename to doc/release-notes-8002.md index 76837e67e9c8..07491463d2a6 100644 --- a/doc/release-notes-7594.md +++ b/doc/release-notes-8002.md @@ -6,5 +6,9 @@ Wallet wallets derive from the same recovery phrase, so that phrase is the only backup an operator needs. Each call issues the next key; `bls operatorkey ` returns a key issued earlier. A wallet restored from the phrase - skips indexes whose key is in the masternode list at the chain tip. Requires - an unlocked descriptor wallet with a mnemonic. (#7594) + skips indexes whose key is in the masternode list at the chain tip, and a + rescan moves past every key it sees assigned by a ProRegTx or ProUpRegTx, + so keys rotated away earlier are not issued again. Rescans driven by block + filters cannot see operator keys, and a pruned node cannot rescan at all, + so issuing on a pruned node returns a warning. Requires an unlocked + descriptor wallet with a mnemonic. (#8002) diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index 57e728f35a3a..69dfd82eee25 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -2510,6 +2510,13 @@ static RPCHelpMan bls_operatorkey() {RPCResult::Type::STR_HEX, "secret", "BLS secret key"}, {RPCResult::Type::STR_HEX, "public", "BLS public key"}, {RPCResult::Type::STR, "scheme", "BLS scheme, always basic (use bls fromsecret for legacy)"}, + {RPCResult::Type::ARR, + "warnings", + /*optional=*/true, + "Why the key may have been used before", + { + {RPCResult::Type::STR, "", ""}, + }}, }}, RPCExamples{HelpExampleCli("bls operatorkey", "") + HelpExampleCli("bls operatorkey", "0")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { @@ -2551,6 +2558,14 @@ static RPCHelpMan bls_operatorkey() ret.pushKV("secret", secret.ToString()); ret.pushKV("public", secret.GetPublicKey().ToString(/*specificLegacyScheme=*/false)); ret.pushKV("scheme", "basic"); + if (request.params[0].isNull() && wallet->chain().havePruned()) { + UniValue warnings(UniValue::VARR); + warnings.push_back( + "This node is pruned, so provider transactions in pruned blocks were not scanned: a key " + "rotated away before this wallet was restored may be issued again. Issue the key on a " + "node with full blocks or rotate the operator key after registering."); + PushWarnings(warnings, ret); + } return ret; }, }; diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp index e0a85beb6d8a..93029e27f33c 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -431,6 +431,12 @@ static RPCHelpMan upgradetohd() if (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) { pwallet->SetupDescriptorScriptPubKeyMans(mnemonic, mnemonic_passphrase); + // A supplied mnemonic may already have operator keys on-chain; the + // chain record lets the rescan below recognise them and must exist + // before encryption locks the seed away. + if (!generate_mnemonic && pwallet->SetupOperatorKeyChain() != PlatformKeyStatus::SUCCESS) { + throw JSONRPCError(RPC_WALLET_ERROR, "Failed to store the operator key chain"); + } } else { auto spk_man = pwallet->GetLegacyScriptPubKeyMan(); if (!spk_man) { diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 8b0d3de637a7..2a601e64560b 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include #include @@ -618,6 +620,13 @@ struct OperatorKeyWalletSetup : public FriendshipWalletSetup { } }; +static CBLSPublicKey RegtestOperatorPubKey(uint32_t index) +{ + SecureVector seed; + CMnemonic::ToSeed(SecureString{REGTEST_MNEMONIC}, SecureString{}, seed); + return DeriveOperatorKey(seed, /*coin_type=*/1, index).GetPublicKey(); +} + //! Issuance is lowest-index-first and retrieval covers issued indexes only. A //! seed-only restore (fresh database) hands out the same keys in the same //! order, skipping only indexes reported in use, and they are the vector keys @@ -650,6 +659,15 @@ BOOST_FIXTURE_TEST_CASE(operator_keys_rederive_after_restore, OperatorKeyWalletS BOOST_CHECK(key == key_1); BOOST_REQUIRE(wallet_b->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); BOOST_CHECK(key == key_0); + + // A key of a registered masternode anywhere in the window marks every + // index up to it as used, so the next key lands above it. + const auto key_5{RegtestOperatorPubKey(5)}; + const auto key_5_in_use{[&key_5](const CBLSPublicKey& pubkey) { return pubkey == key_5; }}; + BOOST_REQUIRE(wallet_b->IssueOperatorKey(key_5_in_use, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 6U); + BOOST_REQUIRE(wallet_b->GetOperatorKey(5, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key.GetPublicKey() == key_5); } //! A locked wallet neither issues nor re-derives, and unlocking gives back @@ -675,6 +693,40 @@ BOOST_FIXTURE_TEST_CASE(operator_keys_need_unlocked_wallet, OperatorKeyWalletSet BOOST_CHECK(key == before); } +//! A provider transaction seen on the sync path that assigns one of the +//! lookahead keys moves the counter past it, so a restored wallet learns its +//! used indexes from a rescan rather than from the masternode list, even +//! while locked. +BOOST_FIXTURE_TEST_CASE(operator_key_counter_advances_from_synced_protx, OperatorKeyWalletSetup) +{ + uint32_t index; + CBLSSecretKey key_0, key; + WITH_LOCK(m_wallet->cs_wallet, + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_0) == PlatformKeyStatus::SUCCESS)); + + CProUpRegTx payload; + payload.nVersion = ProTxVersion::BasicBLS; + payload.pubKeyOperator.Set(key_0.GetPublicKey(), /*specificLegacyScheme=*/false); + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_PROVIDER_UPDATE_REGISTRAR; + SetTxPayload(tx, payload); + + // Restore: the chain is stored while the seed is at hand, then the wallet + // is encrypted and locked before the chain is synced. + const auto [wallet_b, iface_b]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; + WITH_LOCK(wallet_b->cs_wallet, BOOST_REQUIRE(wallet_b->SetupOperatorKeyChain() == PlatformKeyStatus::SUCCESS)); + BOOST_REQUIRE(wallet_b->EncryptWallet("passphrase")); + wallet_b->transactionAddedToMempool(MakeTransactionRef(tx), /*nAcceptTime=*/0); + + BOOST_REQUIRE(wallet_b->Unlock("passphrase")); + LOCK(wallet_b->cs_wallet); + BOOST_REQUIRE(wallet_b->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == key_0); + BOOST_REQUIRE(wallet_b->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1U); +} + //! Two independent wallet instances restored from the same recovery phrase: //! the second is what a seed-only restore produces (fresh wallet database, no //! platform records). Everything DashPay derives from the seed must come out diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 2de92be611d6..b1eabb06991a 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -54,6 +54,7 @@ #include #include +#include #include #include @@ -1332,6 +1333,10 @@ bool CWallet::AddToWalletIfInvolvingMe(const CTransactionRef& ptx, const SyncTxS } } + // A provider transaction assigning one of this wallet's operator keys + // is normally not "ours" by IsMine, so the counter is checked first. + AdvanceOperatorKeyCounter(tx); + bool fExisted = mapWallet.count(tx.GetHash()) != 0; if (fExisted && !fUpdate) return false; if (fExisted || IsMine(tx) || IsFromMe(tx)) @@ -1617,7 +1622,13 @@ void CWallet::blockConnected(const interfaces::BlockInfo& block) // No need to scan block if it was created before the wallet birthday. // Uses chain max time and twice the grace period to adjust time for block time variability. - if (block.chain_time_max < m_birth_time.load() - (TIMESTAMP_WINDOW * 2)) return; + if (block.chain_time_max < m_birth_time.load() - (TIMESTAMP_WINDOW * 2)) { + // Operator keys of a restored mnemonic may predate the wallet itself. + for (const auto& tx : block.data->vtx) { + AdvanceOperatorKeyCounter(*tx); + } + return; + } // Scan block WalletBatch batch(GetDatabase()); @@ -2083,8 +2094,15 @@ CWallet::ScanResult CWallet::ScanForWalletTransactions(const uint256& start_bloc uint256 block_hash = start_block; ScanResult result; + // Operator keys are not block filter elements, so a wallet that follows + // them has to inspect every block. + // TODO: add operator public keys to ExtractSpecialTxFilterElements and to + // the FastWalletRescanFilter query so such wallets can use the fast variant. + const bool follows_operator_keys{WITH_LOCK(cs_wallet, return !m_operator_key_window.empty())}; std::unique_ptr fast_rescan_filter; - if (!IsLegacy() && chain().hasBlockFilterIndex(BlockFilterType::BASIC_FILTER)) fast_rescan_filter = std::make_unique(*this); + if (!IsLegacy() && !follows_operator_keys && chain().hasBlockFilterIndex(BlockFilterType::BASIC_FILTER)) { + fast_rescan_filter = std::make_unique(*this); + } WalletLogPrintf("Rescan started from block %s... (%s)\n", start_block.ToString(), fast_rescan_filter ? "fast variant using block filters" : "slow variant inspecting all blocks"); @@ -2635,6 +2653,7 @@ DBErrors CWallet::LoadWallet() nKeysLeftSinceAutoBackup = 0; } } + RebuildOperatorKeyWindow(); if (m_spk_managers.empty()) { assert(m_external_spk_managers == nullptr); @@ -3450,6 +3469,10 @@ std::shared_ptr CWallet::Create(WalletContext& context, const std::stri args.ForceRemoveArg("mnemonic"); args.ForceRemoveArg("mnemonicpassphrase"); walletInstance->SetupDescriptorScriptPubKeyMans(mnemonic, mnemonic_passphrase); + if (!mnemonic.empty() && walletInstance->SetupOperatorKeyChain() != PlatformKeyStatus::SUCCESS) { + error = _("Unable to store the operator key chain"); + return nullptr; + } // SetupDescriptorScriptPubKeyMans already calls SetupGeneration for us so we don't need to call SetupGeneration separately } else { // Top up the keypool // Legacy wallets need SetupGeneration here. @@ -4109,6 +4132,9 @@ PlatformKeyStatus CWallet::GetPlatformKeySourceStatus() const } static const std::string OPERATOR_KEY_NEXT_INDEX{"operatorkeys/next_index"}; +//! Serialized bls::ExtendedPublicKey at m/9'/coin'/3'/3', see platformkeys::OperatorKeyChain. +static const std::string OPERATOR_KEY_CHAIN{"operatorkeys/chain"}; +static constexpr uint32_t OPERATOR_KEY_WINDOW_SIZE{1000}; PlatformKeyStatus CWallet::DeriveOperatorKeys(uint32_t first, uint32_t count, std::vector& out) const { @@ -4127,24 +4153,118 @@ std::optional CWallet::OperatorKeysIssued() const return ReadLE32(it->second.data()); } +bool CWallet::WriteOperatorKeyCounter(uint32_t next_index) +{ + AssertLockHeld(cs_wallet); + std::vector value(sizeof(uint32_t)); + WriteLE32(value.data(), next_index); + if (!WritePlatformData(OPERATOR_KEY_NEXT_INDEX, value)) return false; + RebuildOperatorKeyWindow(); + return true; +} + PlatformKeyStatus CWallet::IssueOperatorKey(const std::function& in_use, uint32_t& index, CBLSSecretKey& secret) { AssertLockHeld(cs_wallet); secret.Reset(); - const auto issued{OperatorKeysIssued()}; - if (!issued) return PlatformKeyStatus::DATABASE_ERROR; - std::vector keys; - for (index = *issued;; ++index) { - if (const auto status{DeriveOperatorKeys(index, 1, keys)}; status != PlatformKeyStatus::SUCCESS) return status; - if (!in_use(keys.front().GetPublicKey())) break; + if (const auto status{SetupOperatorKeyChain()}; status != PlatformKeyStatus::SUCCESS) return status; + + // Masternodes registered from another wallet on this mnemonic use their + // indexes even when no rescan has seen the registration: the counter moves + // past the highest such key in the window until the window holds none. + constexpr size_t KEY_SIZE{CBLSPublicKey::SerSize}; + while (true) { + const auto issued{OperatorKeysIssued()}; + if (!issued) return PlatformKeyStatus::DATABASE_ERROR; + std::optional used; + for (size_t offset{0}; offset + KEY_SIZE <= m_operator_key_window.size(); offset += KEY_SIZE) { + CBLSPublicKey pubkey; + pubkey.SetBytes(Span{m_operator_key_window}.subspan(offset, KEY_SIZE), /*specificLegacyScheme=*/false); + if (in_use(pubkey)) used = *issued + static_cast(offset / KEY_SIZE); + } + if (!used) { + index = *issued; + break; + } + if (!WriteOperatorKeyCounter(*used + 1)) return PlatformKeyStatus::DATABASE_ERROR; } + + std::vector keys; + if (const auto status{DeriveOperatorKeys(index, 1, keys)}; status != PlatformKeyStatus::SUCCESS) return status; + if (!WriteOperatorKeyCounter(index + 1)) return PlatformKeyStatus::DATABASE_ERROR; secret = keys.front(); - std::vector next_index(sizeof(uint32_t)); - WriteLE32(next_index.data(), index + 1); - if (WritePlatformData(OPERATOR_KEY_NEXT_INDEX, next_index)) return PlatformKeyStatus::SUCCESS; - secret.Reset(); - return PlatformKeyStatus::DATABASE_ERROR; + return PlatformKeyStatus::SUCCESS; +} + +PlatformKeyStatus CWallet::SetupOperatorKeyChain() +{ + AssertLockHeld(cs_wallet); + if (m_platform_data.find(OPERATOR_KEY_CHAIN) == m_platform_data.end()) { + const ScriptPubKeyMan* source{nullptr}; + if (const auto status{GetPlatformKeySource(source)}; status != PlatformKeyStatus::SUCCESS) return status; + std::vector chain; + const auto status{source->OperatorKeyChain(static_cast(Params().ExtCoinType()), chain)}; + if (status != PlatformKeyStatus::SUCCESS) return status; + if (!WritePlatformData(OPERATOR_KEY_CHAIN, chain)) return PlatformKeyStatus::DATABASE_ERROR; + } + RebuildOperatorKeyWindow(); + return PlatformKeyStatus::SUCCESS; +} + +void CWallet::RebuildOperatorKeyWindow() +{ + AssertLockHeld(cs_wallet); + m_operator_key_window.clear(); + const auto chain{m_platform_data.find(OPERATOR_KEY_CHAIN)}; + const auto issued{OperatorKeysIssued()}; + if (chain == m_platform_data.end() || !issued) return; + for (uint32_t i{0}; i < OPERATOR_KEY_WINDOW_SIZE; ++i) { + const auto pubkey{platformkeys::OperatorPubKey(chain->second, *issued + i)}; + if (!pubkey.IsValid()) return; + const auto bytes{pubkey.ToByteVector(/*specificLegacyScheme=*/false)}; + m_operator_key_window.insert(m_operator_key_window.end(), bytes.begin(), bytes.end()); + } +} + +static std::optional ProviderOperatorKey(const CTransaction& tx) +{ + switch (tx.nType) { + case TRANSACTION_PROVIDER_REGISTER: + if (const auto payload{GetTxPayload(tx)}) return payload->pubKeyOperator.Get(); + break; + case TRANSACTION_PROVIDER_UPDATE_REGISTRAR: + if (const auto payload{GetTxPayload(tx)}) return payload->pubKeyOperator.Get(); + break; + case TRANSACTION_PROVIDER_UPDATE_SHARED_REGISTRAR: + if (const auto payload{GetTxPayload(tx)}) return payload->pubKeyOperator.Get(); + break; + } + return std::nullopt; +} + +void CWallet::AdvanceOperatorKeyCounter(const CTransaction& tx) +{ + AssertLockHeld(cs_wallet); + if (m_operator_key_window.empty()) return; + const auto pubkey{ProviderOperatorKey(tx)}; + if (!pubkey) return; + + constexpr size_t KEY_SIZE{CBLSPublicKey::SerSize}; + const auto target{pubkey->ToByteVector(/*specificLegacyScheme=*/false)}; + for (size_t offset{0}; offset + KEY_SIZE <= m_operator_key_window.size(); offset += KEY_SIZE) { + if (!std::equal(target.begin(), target.end(), m_operator_key_window.begin() + offset)) continue; + // The window is rebuilt from the counter whenever the counter moves. + const auto issued{OperatorKeysIssued()}; + if (!Assume(issued)) return; + const uint32_t index{*issued + static_cast(offset / KEY_SIZE)}; + if (!WriteOperatorKeyCounter(index + 1)) { + throw std::runtime_error(strprintf("DB error recording operator key %u as used, write failed", index)); + } + WalletLogPrintf("Operator key %u is used by %s; next operator key index is %u\n", index, + tx.GetHash().ToString(), index + 1); + return; + } } PlatformKeyStatus CWallet::GetOperatorKey(uint32_t index, CBLSSecretKey& secret) const @@ -4968,6 +5088,10 @@ bool CWallet::ApplyMigrationData(MigrationData& data, bilingual_str& error) // Use the existing master key if we have it if (data.master_key.key.IsValid()) { SetupDescriptorScriptPubKeyMans(data.master_key, data.mnemonic, data.mnemonic_passphrase); + if (!data.mnemonic.empty() && SetupOperatorKeyChain() != PlatformKeyStatus::SUCCESS) { + error = _("Error: Failed to store the operator key chain during migration."); + return false; + } // Advance the active pkh() descriptors past the legacy chain // counters so post-migration getnewaddress doesn't hand back diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 15b4f996f93e..a49f26c523b3 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -508,6 +508,12 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); //! Operator key indexes below this were issued; nullopt for an unreadable counter record. std::optional OperatorKeysIssued() const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Move the counter past a window key that a synced provider transaction assigns. + void AdvanceOperatorKeyCounter(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Derive the window above the counter from the stored chain; empty without a chain or a readable counter. + void RebuildOperatorKeyWindow() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Persist the next operator key index and rebuild the window above it. + bool WriteOperatorKeyCounter(uint32_t next_index) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); public: /** @@ -551,6 +557,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati // Generic per-wallet key/value records used by Dash Platform integration // (opaque to the wallet; persisted as DBKeys::PLATFORM_DATA). std::map> m_platform_data GUARDED_BY(cs_wallet); + //! Basic-scheme public keys of the next operator key indexes above the counter, the keypool of this key + //! chain. Derived from the stored chain, so it follows the counter with the wallet locked or not. + std::vector m_operator_key_window GUARDED_BY(cs_wallet); typedef std::map MasterKeyMap; MasterKeyMap mapMasterKeys; @@ -1117,12 +1126,17 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati PlatformKeyResult PlatformAccountReference(const IdentityAuthKey& key, const CompactXpub& compact_xpub) const; PlatformKeyResult EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request); /** Issue the next DIP-3 operator key m/9'/coin'/3'/3'/index from the wallet mnemonic - * (platformkeys::DeriveOperatorKey): the lowest index not issued before whose public key `in_use` rejects. - * The index is recorded as issued before the key is returned. */ + * (platformkeys::DeriveOperatorKey): the lowest index above every key in the window that `in_use` accepts, + * so keys of masternodes registered elsewhere consume their indexes. The index is recorded as issued before + * the key is returned. */ PlatformKeyStatus IssueOperatorKey(const std::function& in_use, uint32_t& index, CBLSSecretKey& secret) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); /** Re-derive an operator key issued earlier; INVALID_ARGUMENT for an index not issued yet. */ PlatformKeyStatus GetOperatorKey(uint32_t index, CBLSSecretKey& secret) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Store the extended public key over the operator keys (platformkeys::OperatorKeyChain) unless the wallet + * has one, and build the window of public keys that synced provider transactions are matched against. + * Storing needs the seed, so this runs where a mnemonic enters the wallet and at the first issuance. */ + PlatformKeyStatus SetupOperatorKeyChain() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); /* Returns the time of the first created key or, in case of an import, it could be the time of the first received transaction */ int64_t GetBirthTime() const { return m_birth_time; } diff --git a/test/functional/feature_dip3_deterministicmns.py b/test/functional/feature_dip3_deterministicmns.py index adc2bee43b34..c0709fec6cc3 100755 --- a/test/functional/feature_dip3_deterministicmns.py +++ b/test/functional/feature_dip3_deterministicmns.py @@ -17,7 +17,7 @@ BitcoinTestFramework, MasternodeInfo, ) -from test_framework.util import assert_equal, force_finish_mnsync, get_mnemonic, p2p_port, softfork_active +from test_framework.util import assert_equal, assert_raises_rpc_error, force_finish_mnsync, get_mnemonic, p2p_port, softfork_active class DIP3Test(BitcoinTestFramework): def add_options(self, parser): @@ -152,22 +152,29 @@ def dip3_inactive_error(): self.assert_mnlists(mns) if self.options.descriptors: - self.log.info("A wallet restored from the controller mnemonic skips operator keys that are in the masternode list") + self.log.info("A wallet restored without rescan skips operator keys that are in the masternode list") mnemonic, passphrase = get_mnemonic(self.nodes[0]) controller = self.nodes[0].get_wallet_rpc(self.default_wallet_name) self.nodes[0].createwallet("restored", blank=True) restored = self.nodes[0].get_wallet_rpc("restored") - restored.upgradetohd(mnemonic, passphrase) - restored_key = restored.bls('operatorkey') - assert_equal(restored_key['index'], 1) - assert_equal(restored_key, controller.bls('operatorkey')) - assert_equal(controller.bls('operatorkey', 0)['secret'], wallet_key_mn.keyOperator) + restored.upgradetohd(mnemonic, passphrase, "", False) + assert_raises_rpc_error(-8, "operator key 0 has not been issued by this wallet", restored.bls, 'operatorkey', 0) + assert_equal(restored.bls('operatorkey'), controller.bls('operatorkey')) + assert_equal(restored.bls('operatorkey', 0)['secret'], wallet_key_mn.keyOperator) self.log.info("The issued-key counter survives a wallet reload") restored.unloadwallet() self.nodes[0].loadwallet("restored") assert_equal(restored.bls('operatorkey')['index'], 2) restored.unloadwallet() + self.log.info("A restore with rescan recognises the registered operator key and moves the counter past it") + self.nodes[0].createwallet("rescanned", blank=True) + rescanned = self.nodes[0].get_wallet_rpc("rescanned") + rescanned.upgradetohd(mnemonic, passphrase) + assert_equal(rescanned.bls('operatorkey', 0)['secret'], wallet_key_mn.keyOperator) + assert_equal(rescanned.bls('operatorkey')['index'], 1) + rescanned.unloadwallet() + self.log.info("test that MNs disappear from the list when the ProTx collateral is spent") # also make sure "protx info" returns correct historical data for spent collaterals spend_mns_count = 3 From 98ea89655ef35e9110d21f6cebdb1bef82932b5a Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Sun, 1 Nov 2026 02:07:22 +0700 Subject: [PATCH 5/5] test(wallet): cover operator key failure paths and the lookahead window rebuild The operator key cases only exercise success paths: a counter write that fails, a lookahead record whose base lies above the counter, and a used key just outside the initial 50-key window were all handled by the code but never observed by a test, so a regression in any of them would pass CI. Extend the two existing scenarios. Issuance against a database that refuses writes returns DATABASE_ERROR with no key and leaves the next index unchanged. A synced provider transaction whose counter write fails throws instead of being dropped. A window rebased above the counter is ignored. A hit on key 49 rebuilds the window so key 50, outside the initial window, is still recognised and issuance continues at 51. The ProUpRegTx construction moves into a helper shared by the new checks, and the regtest public keys are derived from the fixture mnemonic. --- src/wallet/test/platformkeys_tests.cpp | 50 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 2a601e64560b..b5c451380596 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -627,6 +627,18 @@ static CBLSPublicKey RegtestOperatorPubKey(uint32_t index) return DeriveOperatorKey(seed, /*coin_type=*/1, index).GetPublicKey(); } +static CTransactionRef ProUpRegAssigning(const CBLSPublicKey& operator_key) +{ + CProUpRegTx payload; + payload.nVersion = ProTxVersion::BasicBLS; + payload.pubKeyOperator.Set(operator_key, /*specificLegacyScheme=*/false); + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_PROVIDER_UPDATE_REGISTRAR; + SetTxPayload(tx, payload); + return MakeTransactionRef(tx); +} + //! Issuance is lowest-index-first and retrieval covers issued indexes only. A //! seed-only restore (fresh database) hands out the same keys in the same //! order, skipping only indexes reported in use, and they are the vector keys @@ -647,6 +659,14 @@ BOOST_FIXTURE_TEST_CASE(operator_keys_rederive_after_restore, OperatorKeyWalletS BOOST_REQUIRE(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); BOOST_CHECK(key == key_0); BOOST_CHECK(m_wallet->GetOperatorKey(2, key) == PlatformKeyStatus::INVALID_ARGUMENT); + + // No key leaves the wallet unless its consumption is on disk first. + GetMockableDatabase(*m_wallet).m_pass = false; + BOOST_CHECK(m_wallet->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::DATABASE_ERROR); + BOOST_CHECK(!key.IsValid()); + GetMockableDatabase(*m_wallet).m_pass = true; + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 2U); } // The restored wallet has no issuance record; only the masternode list @@ -704,20 +724,12 @@ BOOST_FIXTURE_TEST_CASE(operator_key_counter_advances_from_synced_protx, Operato WITH_LOCK(m_wallet->cs_wallet, BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_0) == PlatformKeyStatus::SUCCESS)); - CProUpRegTx payload; - payload.nVersion = ProTxVersion::BasicBLS; - payload.pubKeyOperator.Set(key_0.GetPublicKey(), /*specificLegacyScheme=*/false); - CMutableTransaction tx; - tx.nVersion = 3; - tx.nType = TRANSACTION_PROVIDER_UPDATE_REGISTRAR; - SetTxPayload(tx, payload); - // Restore: the chain is stored while the seed is at hand, then the wallet // is encrypted and locked before the chain is synced. const auto [wallet_b, iface_b]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; WITH_LOCK(wallet_b->cs_wallet, BOOST_REQUIRE(wallet_b->SetupOperatorKeyChain() == PlatformKeyStatus::SUCCESS)); BOOST_REQUIRE(wallet_b->EncryptWallet("passphrase")); - wallet_b->transactionAddedToMempool(MakeTransactionRef(tx), /*nAcceptTime=*/0); + wallet_b->transactionAddedToMempool(ProUpRegAssigning(key_0.GetPublicKey()), /*nAcceptTime=*/0); BOOST_REQUIRE(wallet_b->Unlock("passphrase")); LOCK(wallet_b->cs_wallet); @@ -725,6 +737,26 @@ BOOST_FIXTURE_TEST_CASE(operator_key_counter_advances_from_synced_protx, Operato BOOST_CHECK(key == key_0); BOOST_REQUIRE(wallet_b->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); BOOST_CHECK_EQUAL(index, 1U); + + // A used key whose consumption cannot be recorded aborts the sync instead + // of being dropped silently. + GetMockableDatabase(*wallet_b).m_pass = false; + BOOST_CHECK_THROW(wallet_b->transactionAddedToMempool(ProUpRegAssigning(RegtestOperatorPubKey(2)), /*nAcceptTime=*/0), + std::runtime_error); + GetMockableDatabase(*wallet_b).m_pass = true; + + // Key 1000 lies outside the initial window and is seen only because the hit + // on key 999 rebuilt the window from the new counter, which needs no seed: + // the wallet stays locked throughout the sync. + const auto [wallet_c, iface_c]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; + WITH_LOCK(wallet_c->cs_wallet, BOOST_REQUIRE(wallet_c->SetupOperatorKeyChain() == PlatformKeyStatus::SUCCESS)); + BOOST_REQUIRE(wallet_c->EncryptWallet("passphrase")); + wallet_c->transactionAddedToMempool(ProUpRegAssigning(RegtestOperatorPubKey(999)), /*nAcceptTime=*/0); + wallet_c->transactionAddedToMempool(ProUpRegAssigning(RegtestOperatorPubKey(1000)), /*nAcceptTime=*/0); + BOOST_REQUIRE(wallet_c->Unlock("passphrase")); + LOCK(wallet_c->cs_wallet); + BOOST_REQUIRE(wallet_c->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1001U); } //! Two independent wallet instances restored from the same recovery phrase: