diff --git a/configure.ac b/configure.ac index 00e51fe..d512eb5 100644 --- a/configure.ac +++ b/configure.ac @@ -1,7 +1,7 @@ AC_PREREQ([2.69]) define(_CLIENT_VERSION_MAJOR, 0) define(_CLIENT_VERSION_MINOR, 2) -define(_CLIENT_VERSION_BUILD, 1) +define(_CLIENT_VERSION_BUILD, 2) define(_CLIENT_VERSION_RC, 1) define(_CLIENT_VERSION_IS_RELEASE, true) define(_COPYRIGHT_YEAR, 2026) diff --git a/src/wallet/scriptpubkeyman.cpp b/src/wallet/scriptpubkeyman.cpp index e56f920..30de953 100644 --- a/src/wallet/scriptpubkeyman.cpp +++ b/src/wallet/scriptpubkeyman.cpp @@ -1102,6 +1102,67 @@ uint256 LegacyScriptPubKeyMan::GetID() const return uint256::ONE; } +uint256 DescriptorPQScriptPubKeyMan::GetID() const +{ + // Stable wallet database identity reserved for descriptor-wallet PQ keys. + return uint256S("5051522d56455854412d44455343524950544f522d50512d4d414e4147455201"); +} + +bool DescriptorPQScriptPubKeyMan::SetupGeneration(bool force) +{ + if (m_storage.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { + return false; + } + + if (IsHDEnabled() && !force) { + return true; + } + + if (m_storage.IsLocked()) { + return false; + } + + // Create only the deterministic seed. No legacy EC keypool is generated. + SetHDSeed(GenerateNewSeed()); + return true; +} + +bool DescriptorPQScriptPubKeyMan::GetNewDestination( + const OutputType type, + CTxDestination& dest, + std::string& error) +{ + if (type != OutputType::MLDSA && type != OutputType::SLHDSA) { + error = _("Error: Descriptor PQ manager only supports ML-DSA and SLH-DSA").translated; + return false; + } + + // Existing descriptor wallets receive their PQ seed lazily on the first + // PQ address request, after the wallet has been unlocked. + if (!IsHDEnabled()) { + if (m_storage.IsLocked()) { + error = _("Error: Please enter the wallet passphrase with walletpassphrase first.").translated; + return false; + } + + if (!SetupGeneration()) { + error = _("Error: Unable to initialize post-quantum wallet seed").translated; + return false; + } + } + + return LegacyScriptPubKeyMan::GetNewDestination(type, dest, error); +} + +bool DescriptorPQScriptPubKeyMan::CanGetAddresses(bool internal) const +{ + if (internal || m_storage.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { + return false; + } + + return IsHDEnabled() || !m_storage.IsLocked(); +} + /** * Update wallet first key creation time. This should be called whenever keys * are added to the wallet, with the oldest key creation time. diff --git a/src/wallet/scriptpubkeyman.h b/src/wallet/scriptpubkeyman.h index 2fbceba..85c1623 100644 --- a/src/wallet/scriptpubkeyman.h +++ b/src/wallet/scriptpubkeyman.h @@ -570,6 +570,25 @@ public: bool GetKeyOrigin(const CKeyID& keyid, KeyOriginInfo& info) const override { return m_spk_man.GetKeyOrigin(keyid, info); } }; + +/** + * Post-quantum key manager used alongside descriptor wallets. + * + * Standard EC destinations remain managed by DescriptorScriptPubKeyMan. + * This manager owns deterministic ML-DSA / SLH-DSA receive keys only. + */ +class DescriptorPQScriptPubKeyMan final : public LegacyScriptPubKeyMan +{ +public: + using LegacyScriptPubKeyMan::LegacyScriptPubKeyMan; + + uint256 GetID() const override; + bool SetupGeneration(bool force = false) override; + bool GetNewDestination(const OutputType type, CTxDestination& dest, std::string& error) override; + bool TopUp(unsigned int size = 0) override { return true; } + bool CanGetAddresses(bool internal = false) const override; +}; + class DescriptorScriptPubKeyMan : public ScriptPubKeyMan { private: diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index fcc7e05..5b44fbc 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -390,7 +390,9 @@ void CWallet::UpgradeDescriptorCache() for (ScriptPubKeyMan* spkm : GetAllScriptPubKeyMans()) { DescriptorScriptPubKeyMan* desc_spkm = dynamic_cast(spkm); - desc_spkm->UpgradeDescriptorCache(); + if (desc_spkm) { + desc_spkm->UpgradeDescriptorCache(); + } } SetWalletFlag(WALLET_FLAG_LAST_HARDENED_XPUB_CACHED); } @@ -3093,6 +3095,41 @@ LegacyScriptPubKeyMan* CWallet::GetOrCreateLegacyScriptPubKeyMan() return GetLegacyScriptPubKeyMan(); } +DescriptorPQScriptPubKeyMan* CWallet::GetDescriptorPQScriptPubKeyMan() const +{ + if (!IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) { + return nullptr; + } + + for (const auto& item : m_spk_managers) { + if (auto* pq = dynamic_cast(item.second.get())) { + return pq; + } + } + + return nullptr; +} + +DescriptorPQScriptPubKeyMan* CWallet::GetOrCreateDescriptorPQScriptPubKeyMan() +{ + if (!IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) || + IsWalletFlagSet(WALLET_FLAG_EXTERNAL_SIGNER) || + IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { + return nullptr; + } + + if (auto* existing = GetDescriptorPQScriptPubKeyMan()) { + return existing; + } + + auto manager = std::make_unique(*this); + auto* result = manager.get(); + const uint256 id = result->GetID(); + + m_spk_managers[id] = std::move(manager); + return result; +} + void CWallet::SetupLegacyScriptPubKeyMan() { if (!m_internal_spk_managers.empty() || !m_external_spk_managers.empty() || !m_spk_managers.empty() || IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) { @@ -3174,6 +3211,24 @@ void CWallet::SetupDescriptorScriptPubKeyMans() AddActiveScriptPubKeyMan(id, t, internal); } } + + // ML-DSA and SLH-DSA are handled by a dedicated PQ manager. + // The PQ seed is initialized lazily on the first PQ address request. + if (!IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { + auto* pq_manager = GetOrCreateDescriptorPQScriptPubKeyMan(); + if (!pq_manager) { + throw std::runtime_error(std::string(__func__) + ": Could not create post-quantum ScriptPubKeyMan"); + } + + const uint256 pq_id = pq_manager->GetID(); + + if (GetScriptPubKeyMan(OutputType::MLDSA, false) != pq_manager) { + AddActiveScriptPubKeyMan(pq_id, OutputType::MLDSA, false); + } + if (GetScriptPubKeyMan(OutputType::SLHDSA, false) != pq_manager) { + AddActiveScriptPubKeyMan(pq_id, OutputType::SLHDSA, false); + } + } } else { ExternalSigner signer = ExternalSignerScriptPubKeyMan::GetExternalSigner(); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 39d49b2..919f5d3 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -856,6 +856,8 @@ public: //! Get the LegacyScriptPubKeyMan which is used for all types, internal, and external. LegacyScriptPubKeyMan* GetLegacyScriptPubKeyMan() const; LegacyScriptPubKeyMan* GetOrCreateLegacyScriptPubKeyMan(); + DescriptorPQScriptPubKeyMan* GetDescriptorPQScriptPubKeyMan() const; + DescriptorPQScriptPubKeyMan* GetOrCreateDescriptorPQScriptPubKeyMan(); //! Make a LegacyScriptPubKeyMan and set it for all types, internal, and external. void SetupLegacyScriptPubKeyMan(); diff --git a/src/wallet/walletdb.cpp b/src/wallet/walletdb.cpp index c9851ef..0773c69 100644 --- a/src/wallet/walletdb.cpp +++ b/src/wallet/walletdb.cpp @@ -463,7 +463,7 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, strErr = "Error reading wallet database: CPrivKey corrupt"; return false; } - if (!pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadKey(key, vchPubKey)) + if (!(pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadKey(key, vchPubKey)) { strErr = "Error reading wallet database: LegacyScriptPubKeyMan::LoadKey failed"; return false; @@ -506,7 +506,7 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, wss.nCKeys++; - if (!pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadCryptedKey(vchPubKey, vchPrivKey, checksum_valid)) + if (!(pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadCryptedKey(vchPubKey, vchPrivKey, checksum_valid)) { strErr = "Error reading wallet database: LegacyScriptPubKeyMan::LoadCryptedKey failed"; return false; @@ -525,7 +525,7 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, return false; } - if (!pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadPQKey( + if (!(pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadPQKey( key_id, type, record.pubkey, record.secret)) { strErr = "Error reading wallet database: LegacyScriptPubKeyMan::LoadPQKey failed"; return false; @@ -544,7 +544,7 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, return false; } - if (!pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadCryptedPQKey( + if (!(pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadCryptedPQKey( key_id, type, record.pubkey, record.secret)) { strErr = "Error reading wallet database: LegacyScriptPubKeyMan::LoadCryptedPQKey failed"; return false; @@ -557,14 +557,14 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, CKeyMetadata keyMeta; ssValue >> keyMeta; wss.nKeyMeta++; - pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadPQKeyMetadata(key_id, keyMeta); + (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadPQKeyMetadata(key_id, keyMeta); } else if (strType == DBKeys::KEYMETA) { CPubKey vchPubKey; ssKey >> vchPubKey; CKeyMetadata keyMeta; ssValue >> keyMeta; wss.nKeyMeta++; - pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadKeyMetadata(vchPubKey.GetID(), keyMeta); + (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadKeyMetadata(vchPubKey.GetID(), keyMeta); // Extract some CHDChain info from this metadata if it has any if (keyMeta.nVersion >= CKeyMetadata::VERSION_WITH_HDDATA && !keyMeta.hd_seed_id.IsNull() && keyMeta.hdKeypath.size() > 0) { @@ -669,11 +669,11 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, } else if (strType == DBKeys::HDCHAIN) { CHDChain chain; ssValue >> chain; - pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadHDChain(chain); + (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadHDChain(chain); } else if (strType == DBKeys::PQHDCHAIN) { PQHDChain chain; ssValue >> chain; - pwallet->GetOrCreateLegacyScriptPubKeyMan()->LoadPQHDChain(chain); + (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) ? static_cast(pwallet->GetOrCreateDescriptorPQScriptPubKeyMan()) : pwallet->GetOrCreateLegacyScriptPubKeyMan())->LoadPQHDChain(chain); } else if (strType == DBKeys::OLD_KEY) { strErr = "Found unsupported 'wkey' record, try loading with version 0.18"; return false; @@ -907,6 +907,15 @@ DBErrors WalletBatch::LoadWallet(CWallet* pwallet) } m_batch->CloseCursor(); + // Descriptor wallets use one dedicated receive-side PQ manager. + // Existing v0.2.1 wallets may not have any PQ records yet, so create the + // empty manager before restoring active ScriptPubKeyMan mappings. + if (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) && + !pwallet->IsWalletFlagSet(WALLET_FLAG_EXTERNAL_SIGNER) && + !pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { + pwallet->GetOrCreateDescriptorPQScriptPubKeyMan(); + } + // Set the active ScriptPubKeyMans for (auto spk_man_pair : wss.m_active_external_spks) { pwallet->LoadActiveScriptPubKeyMan(spk_man_pair.second, spk_man_pair.first, /* internal */ false); @@ -915,6 +924,17 @@ DBErrors WalletBatch::LoadWallet(CWallet* pwallet) pwallet->LoadActiveScriptPubKeyMan(spk_man_pair.second, spk_man_pair.first, /* internal */ true); } + if (auto* pq_manager = pwallet->GetDescriptorPQScriptPubKeyMan()) { + const uint256 pq_id = pq_manager->GetID(); + + if (pwallet->GetScriptPubKeyMan(OutputType::MLDSA, false) != pq_manager) { + pwallet->AddActiveScriptPubKeyMan(pq_id, OutputType::MLDSA, false); + } + if (pwallet->GetScriptPubKeyMan(OutputType::SLHDSA, false) != pq_manager) { + pwallet->AddActiveScriptPubKeyMan(pq_id, OutputType::SLHDSA, false); + } + } + // Set the descriptor caches for (auto desc_cache_pair : wss.m_descriptor_caches) { auto spk_man = pwallet->GetScriptPubKeyMan(desc_cache_pair.first); @@ -940,21 +960,16 @@ DBErrors WalletBatch::LoadWallet(CWallet* pwallet) if (result != DBErrors::LOAD_OK) return result; - // Reconcile the post-quantum HD derivation state after all wallet - // database records have been loaded. - if (LegacyScriptPubKeyMan* legacy_spkm = pwallet->GetLegacyScriptPubKeyMan()) { - std::string pq_error; - if (!legacy_spkm->ReconcilePQHDChain(*this, pq_error)) { - pwallet->WalletLogPrintf("Error reconciling post-quantum HD chain: %s\n", pq_error); - return DBErrors::CORRUPT; - } - } + // Reconcile the post-quantum derivation counters after all relevant + // wallet records have been loaded. + LegacyScriptPubKeyMan* pq_spkm = + pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) + ? static_cast(pwallet->GetDescriptorPQScriptPubKeyMan()) + : pwallet->GetLegacyScriptPubKeyMan(); - // Reconcile the post-quantum HD derivation state after all wallet - // database records have been loaded. - if (LegacyScriptPubKeyMan* legacy_spkm = pwallet->GetLegacyScriptPubKeyMan()) { + if (pq_spkm) { std::string pq_error; - if (!legacy_spkm->ReconcilePQHDChain(*this, pq_error)) { + if (!pq_spkm->ReconcilePQHDChain(*this, pq_error)) { pwallet->WalletLogPrintf("Error reconciling post-quantum HD chain: %s\n", pq_error); return DBErrors::CORRUPT; } @@ -1010,14 +1025,19 @@ DBErrors WalletBatch::LoadWallet(CWallet* pwallet) // Set the inactive chain if (wss.m_hd_chains.size() > 0) { - LegacyScriptPubKeyMan* legacy_spkm = pwallet->GetLegacyScriptPubKeyMan(); - if (!legacy_spkm) { - pwallet->WalletLogPrintf("Inactive HD Chains found but no Legacy ScriptPubKeyMan\n"); + LegacyScriptPubKeyMan* hd_spkm = + pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS) + ? static_cast(pwallet->GetDescriptorPQScriptPubKeyMan()) + : pwallet->GetLegacyScriptPubKeyMan(); + + if (!hd_spkm) { + pwallet->WalletLogPrintf("Inactive HD Chains found but no compatible ScriptPubKeyMan\n"); return DBErrors::CORRUPT; } + for (const auto& chain_pair : wss.m_hd_chains) { - if (chain_pair.first != pwallet->GetLegacyScriptPubKeyMan()->GetHDChain().seed_id) { - pwallet->GetLegacyScriptPubKeyMan()->AddInactiveHDChain(chain_pair.second); + if (chain_pair.first != hd_spkm->GetHDChain().seed_id) { + hd_spkm->AddInactiveHDChain(chain_pair.second); } } } diff --git a/test/functional/wallet_descriptor.py b/test/functional/wallet_descriptor.py index ad7e75c..de8308d 100755 --- a/test/functional/wallet_descriptor.py +++ b/test/functional/wallet_descriptor.py @@ -143,6 +143,74 @@ class WalletDescriptorTest(DigiByteTestFramework): enc_rpc = self.nodes[0].get_wallet_rpc('desc_enc') enc_rpc.getnewaddress() # Makes sure that we can get a new address from a born encrypted wallet + self.log.info("Test post-quantum descriptor wallet support") + self.nodes[0].createwallet(wallet_name='desc_pq', descriptors=True) + pq_rpc = self.nodes[0].get_wallet_rpc('desc_pq') + + mldsa_addr = pq_rpc.getnewaddress('', 'mldsa') + slhdsa_addr = pq_rpc.getnewaddress('', 'slhdsa') + + mldsa_info = pq_rpc.getaddressinfo(mldsa_addr) + slhdsa_info = pq_rpc.getaddressinfo(slhdsa_addr) + + assert_equal(mldsa_info['ismine'], True) + assert_equal(mldsa_info['iswitness'], True) + assert_equal(mldsa_info['witness_version'], 2) + assert_equal(slhdsa_info['ismine'], True) + assert_equal(slhdsa_info['iswitness'], True) + assert_equal(slhdsa_info['witness_version'], 3) + + # Exercise wallet database persistence and active PQ manager restoration. + self.nodes[0].unloadwallet('desc_pq') + self.nodes[0].loadwallet('desc_pq') + pq_rpc = self.nodes[0].get_wallet_rpc('desc_pq') + + assert_equal(pq_rpc.getaddressinfo(mldsa_addr)['ismine'], True) + assert_equal(pq_rpc.getaddressinfo(slhdsa_addr)['ismine'], True) + assert_equal(pq_rpc.getaddressinfo(pq_rpc.getnewaddress('', 'mldsa'))['witness_version'], 2) + assert_equal(pq_rpc.getaddressinfo(pq_rpc.getnewaddress('', 'slhdsa'))['witness_version'], 3) + + self.log.info("Test encrypted post-quantum descriptor wallet support") + self.nodes[0].createwallet('desc_pq_enc', False, False, 'pqpass', False, True) + pq_enc_rpc = self.nodes[0].get_wallet_rpc('desc_pq_enc') + + assert_raises_rpc_error( + -12, + 'Please enter the wallet passphrase with walletpassphrase first', + pq_enc_rpc.getnewaddress, + '', + 'mldsa', + ) + + pq_enc_rpc.walletpassphrase('pqpass', 10) + enc_mldsa_addr = pq_enc_rpc.getnewaddress('', 'mldsa') + enc_slhdsa_addr = pq_enc_rpc.getnewaddress('', 'slhdsa') + assert_equal(pq_enc_rpc.getaddressinfo(enc_mldsa_addr)['witness_version'], 2) + assert_equal(pq_enc_rpc.getaddressinfo(enc_slhdsa_addr)['witness_version'], 3) + pq_enc_rpc.walletlock() + + # Reload must restore encrypted PQ keys and leave the wallet locked. + self.nodes[0].unloadwallet('desc_pq_enc') + self.nodes[0].loadwallet('desc_pq_enc') + pq_enc_rpc = self.nodes[0].get_wallet_rpc('desc_pq_enc') + + assert_equal(pq_enc_rpc.getwalletinfo()['unlocked_until'], 0) + assert_equal(pq_enc_rpc.getaddressinfo(enc_mldsa_addr)['ismine'], True) + assert_equal(pq_enc_rpc.getaddressinfo(enc_slhdsa_addr)['ismine'], True) + + assert_raises_rpc_error( + -12, + 'Please enter the wallet passphrase with walletpassphrase first', + pq_enc_rpc.getnewaddress, + '', + 'slhdsa', + ) + + pq_enc_rpc.walletpassphrase('pqpass', 10) + assert_equal(pq_enc_rpc.getaddressinfo(pq_enc_rpc.getnewaddress('', 'mldsa'))['witness_version'], 2) + assert_equal(pq_enc_rpc.getaddressinfo(pq_enc_rpc.getnewaddress('', 'slhdsa'))['witness_version'], 3) + pq_enc_rpc.walletlock() + self.log.info("Test blank descriptor wallets") self.nodes[0].createwallet(wallet_name='desc_blank', blank=True, descriptors=True) blank_rpc = self.nodes[0].get_wallet_rpc('desc_blank')