From fd9b5c81f07e888bc196f4cde3b29129805bb769 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 08:25:36 -0500 Subject: [PATCH 1/3] feat(wallet): DIP-15 compact xpub and accountReference seams FriendshipXpub carries the BIP32 parent fingerprint of the friendship leaf, so CompactXpubBytes() yields the 69-byte DIP-15 compact form (parentFingerprint || chainCode || pubKey) that a contactRequest's encryptedPublicKey and accountReference are computed over. The fingerprint is that of the key one step above the final 256-bit derivation, as rust-dashcore's key-wallet reports it. interfaces::Wallet::platformAccountReference returns the finished accountReference for a contact request: HMAC-SHA256 keyed by the derived identity ENCRYPTION key over the compact xpub, masked as rs-platform-encryption's calculate_account_reference does with iOS's ASK28 reading (be32(mac[28..32]) >> 4), for account 0 and version 0 as DashPay iOS sends it. The key never leaves the wallet. Tests: the parent fingerprint, compact xpub, accountReference and DIP-15 payment-address vectors, generated with key-wallet e4208c90786a and rs-platform-encryption from the DIP-14 test seed; and the iOS ASK28 known answer from rs-platform-encryption (digest 00..1f gives 0x01c1d1e1). Co-Authored-By: Claude Opus 5.5 --- src/interfaces/wallet.h | 6 +++ src/wallet/interfaces.cpp | 5 ++ src/wallet/platformkeys.cpp | 36 +++++++++++++- src/wallet/platformkeys.h | 16 +++++- src/wallet/platformtypes.h | 14 ++++++ src/wallet/test/platformkeys_tests.cpp | 67 ++++++++++++++++++++++++++ src/wallet/wallet.cpp | 18 ++++++- src/wallet/wallet.h | 1 + 8 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 641579241315..150f2198ccf2 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -156,6 +156,12 @@ class Wallet virtual wallet::PlatformKeyResult platformECDHSecret(const wallet::IdentityAuthKey& key, const CPubKey& counterparty) = 0; + //! DIP-15 accountReference of a contact request sending compact_xpub, + //! keyed by the identity ENCRYPTION key, for account 0 and version 0 as + //! DashPay iOS sends it. The key never leaves the wallet. + virtual wallet::PlatformKeyResult platformAccountReference(const wallet::IdentityAuthKey& key, + const wallet::CompactXpub& compact_xpub) = 0; + //! Ensure our private DIP-15 receiving chain is imported as a ranged //! descriptor and return the corresponding public chain. Derivation, //! descriptor update and result publication are one wallet-locked action. diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index c4c6d4502fbf..45cdfb5bfbc9 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -262,6 +262,11 @@ class WalletImpl : public Wallet { return m_wallet->PlatformECDHSecret(key, counterparty); } + wallet::PlatformKeyResult platformAccountReference(const wallet::IdentityAuthKey& key, + const wallet::CompactXpub& compact_xpub) override + { + return m_wallet->PlatformAccountReference(key, compact_xpub); + } wallet::PlatformKeyResult ensureFriendshipReceivingKeychain( const wallet::FriendshipKeychainRequest& request) override { diff --git a/src/wallet/platformkeys.cpp b/src/wallet/platformkeys.cpp index aa188969c7ed..47288b690280 100644 --- a/src/wallet/platformkeys.cpp +++ b/src/wallet/platformkeys.cpp @@ -4,6 +4,8 @@ #include +#include +#include #include #include @@ -88,8 +90,15 @@ bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out) CKey key{parent.key}; ChainCode chaincode{parent.chaincode}; + std::array parent_fingerprint{}; + + for (size_t i{0}; i < path.size(); ++i) { + const auto& element{path[i]}; + if (i + 1 == path.size()) { + const CKeyID parent_id{key.GetPubKey().GetID()}; + std::copy_n(parent_id.begin(), parent_fingerprint.size(), parent_fingerprint.begin()); + } - for (const auto& element : path) { CKey child_key; ChainCode child_cc; bool ok{false}; @@ -107,6 +116,7 @@ bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out) out.key = key; out.chaincode = chaincode; + out.parent_fingerprint = parent_fingerprint; return true; } @@ -144,10 +154,34 @@ bool ComputeECDHSecret(const CKey& key, const CPubKey& counterparty, SecureVecto return true; } +bool ComputeAccountReferenceMac(const CKey& key, const CompactXpub& compact_xpub, uint256& mac_out) +{ + if (!key.IsValid()) return false; + CHMAC_SHA256{key.begin(), key.size()}.Write(compact_xpub.data(), compact_xpub.size()).Finalize(mac_out.begin()); + return true; +} + +uint32_t AccountReferenceFromMac(const uint256& mac) +{ + // DIP-15: (version << 28) | (ASK28 ^ account). With version 0 and + // account 0, the reference is ASK28, the top 28 bits of the MAC's last + // four bytes. + return ReadBE32(mac.begin() + 28) >> 4; +} + } // namespace wallet::platformkeys namespace wallet { +bool CompactXpubBytes(const FriendshipXpub& xpub, CompactXpub& compact_out) +{ + if (!xpub.pubkey.IsCompressed()) return false; + auto it{std::copy(xpub.parent_fingerprint.begin(), xpub.parent_fingerprint.end(), compact_out.begin())}; + it = std::copy(xpub.chaincode.begin(), xpub.chaincode.end(), it); + std::copy(xpub.pubkey.begin(), xpub.pubkey.end(), it); + return true; +} + bool DeriveFriendshipPaymentDestination(const FriendshipXpub& xpub, uint32_t index, CTxDestination& destination_out) { platformkeys::ExtPubKey256 child; diff --git a/src/wallet/platformkeys.h b/src/wallet/platformkeys.h index aeeeebc59fff..4136195a80f4 100644 --- a/src/wallet/platformkeys.h +++ b/src/wallet/platformkeys.h @@ -60,11 +60,15 @@ inline constexpr uint32_t AUTH_KEY_TYPE_ECDSA{0}; inline constexpr uint32_t AUTH_KEY_TYPE_BLS{1}; //! An extended key produced by walking a (possibly 256-bit) derivation path. -//! Unlike CExtKey this does not carry BIP32 serialization metadata; DIP-14 -//! extended-key serialization tracks the path separately. +//! Unlike CExtKey this does not carry BIP32 serialization metadata beyond the +//! parent fingerprint; DIP-14 extended-key serialization tracks the path +//! separately. struct ExtKey256 { CKey key; ChainCode chaincode; + //! BIP32 fingerprint of the key the last path step was derived from + //! (Hash160 prefix of its public key); the DIP-15 compact xpub carries it. + std::array parent_fingerprint{}; ExtKey256() = default; }; @@ -100,6 +104,14 @@ Path FriendshipPath(uint32_t coin_type, uint32_t account, Span us //! encryption. Returns a 32-byte secret. [[nodiscard]] bool ComputeECDHSecret(const CKey& key, const CPubKey& counterparty, SecureVector& secret_out); +//! DIP-15 accountReference MAC (ASK): HMAC-SHA256 keyed by the sender's +//! ENCRYPTION private key over the compact xpub sent in the contact request. +[[nodiscard]] bool ComputeAccountReferenceMac(const CKey& key, const CompactXpub& compact_xpub, uint256& mac_out); + +//! The accountReference for account 0 and version 0, as DashPay iOS sends it +//! (rs-platform-encryption calculate_account_reference with iOS's ASK28 extraction). +uint32_t AccountReferenceFromMac(const uint256& mac); + } // namespace wallet::platformkeys #endif // BITCOIN_WALLET_PLATFORMKEYS_H diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 2c9e9913be00..146dff682995 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include @@ -63,8 +65,20 @@ struct FriendshipKeychainRequest { struct FriendshipXpub { CPubKey pubkey; ChainCode chaincode; + //! BIP32 fingerprint (Hash160 prefix) of the parent key, m/9'/coin'/15'/account'/. + std::array parent_fingerprint{}; }; +//! DIP-15 compact xpub: parentFingerprint(4) || chainCode(32) || pubKey(33). +//! It is the contactRequest encryptedPublicKey plaintext and the +//! accountReference MAC input, with no version, depth or child number. +inline constexpr size_t COMPACT_XPUB_SIZE{4 + 32 + 33}; +using CompactXpub = std::array; + +//! Serialize a friendship xpub in the compact form. Fails for a xpub whose +//! key is not compressed, as externally supplied contact xpubs may be. +[[nodiscard]] bool CompactXpubBytes(const FriendshipXpub& xpub, CompactXpub& compact_out); + //! Derive one contact payment destination from a DIP-15 friendship xpub. //! This is public-only key math and does not require a wallet instance. bool DeriveFriendshipPaymentDestination(const FriendshipXpub& xpub, uint32_t index, CTxDestination& destination_out); diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 791b46c785f6..033b1ae4e5a1 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -192,6 +193,8 @@ BOOST_AUTO_TEST_CASE(friendship_derivation_matches_key_wallet) "1ed00e776865c0308fddae8d22fb3ae47ffd51ce409b298d1987aafdec6bef6b"); BOOST_CHECK_EQUAL(HexStr(out.key.GetPubKey()), "025b4ff1b10b9e1990df46dfa2f986300a7e6eb4a85289f4419581950edd3760bf"); + // key-wallet's parent_fingerprint: Hash160 prefix of m/9'/1'/15'/0'/(id_a). + BOOST_CHECK_EQUAL(HexStr(out.parent_fingerprint), "a582fa1d"); } //! ECDH secrets must be symmetric and match the libsecp256k1 KDF @@ -209,6 +212,18 @@ BOOST_AUTO_TEST_CASE(ecdh_symmetry) BOOST_CHECK(s_ab == s_ba); } +//! iOS ASK28 known answer from rs-platform-encryption's +//! ask28_extraction_matches_ios_and_diverges_from_others: digest 0x00..0x1f +//! gives 0x01c1d1e1, unlike the Android and DIP-literal readings. +BOOST_AUTO_TEST_CASE(account_reference_uses_ios_ask28) +{ + uint256 mac; + for (size_t i{0}; i < mac.size(); ++i) { + *(mac.begin() + i) = static_cast(i); + } + BOOST_CHECK_EQUAL(AccountReferenceFromMac(mac), 0x01c1d1e1U); +} + //! Mnemonic behind Dip14Seed() above. static constexpr const char* DIP14_MNEMONIC{ "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer"}; @@ -290,6 +305,8 @@ struct FriendshipWalletSetup : public TestChain100Setup { } static constexpr uint32_t ACCOUNT{0}; + //! Identity key 2 is the DashPay ENCRYPTION key. + static constexpr IdentityAuthKey ENCRYPTION_KEY{0, 2}; const uint256 m_my_id{uint256S("1111111111111111111111111111111111111111111111111111111111111111")}; const uint256 m_their_id{uint256S("2222222222222222222222222222222222222222222222222222222222222222")}; @@ -643,6 +660,56 @@ BOOST_FIXTURE_TEST_CASE(recovery_ecdh_symmetry_across_restore, SeededWalletPair) BOOST_CHECK(secret_contact_side == secret_original.value); } +//! Cross-implementation vectors generated with rs-platform-encryption and +//! rust-dashcore's key-wallet (rev e4208c90786a) from Dip14Seed(), coin type +//! 1, account 0, identity ids id_a = 000102..1f and id_b = fffefd..e0 (the +//! same friendship as friendship_derivation_matches_key_wallet): +//! - the 69-byte DIP-15 compact xpub parentFingerprint || chainCode || pubKey +//! is the contactRequest encryptedPublicKey plaintext; +//! - calculate_account_reference(ENCRYPTION key at m/9'/1'/5'/0'/0'/0'/2', +//! compact xpub, account 0, version 0) is the request's accountReference; +//! - contact payment addresses are P2PKH of xpub/i, as +//! derive_contact_payment_address computes them. +BOOST_FIXTURE_TEST_CASE(dip15_compact_xpub_account_reference_and_payment_vectors, Dip14WalletSetup) +{ + std::array id_a, id_b; + for (size_t i{0}; i < 32; ++i) { + id_a[i] = static_cast(i); + id_b[i] = static_cast(0xff - i); + } + const auto keychain{m_iface->ensureFriendshipReceivingKeychain( + FriendshipKeychainRequest{ACCOUNT, uint256{id_a}, uint256{id_b}, /*birth_time=*/0})}; + BOOST_REQUIRE(keychain); + BOOST_CHECK_EQUAL(HexStr(keychain.value.parent_fingerprint), "a582fa1d"); + + CompactXpub compact; + BOOST_CHECK(!CompactXpubBytes(FriendshipXpub{}, compact)); + BOOST_REQUIRE(CompactXpubBytes(keychain.value, compact)); + BOOST_CHECK_EQUAL(HexStr(compact), "a582fa1d" + "1ed00e776865c0308fddae8d22fb3ae47ffd51ce409b298d1987aafdec6bef6b" + "025b4ff1b10b9e1990df46dfa2f986300a7e6eb4a85289f4419581950edd3760bf"); + + const auto enc_key{m_iface->getPlatformPubKey(ENCRYPTION_KEY)}; + BOOST_REQUIRE(enc_key); + BOOST_CHECK_EQUAL(HexStr(enc_key.value), "0320e04238fc44be0b4c33768afe0b218e95986f0b77bef2caf6db39fd0c389fe4"); + + // HMAC-SHA256 digest cd9f746d8c7b362670e75fabe05ddde6b35b14fd653d5ce7bf2188da929295ac + const auto account_reference{m_iface->platformAccountReference(ENCRYPTION_KEY, compact)}; + BOOST_REQUIRE(account_reference); + BOOST_CHECK_EQUAL(account_reference.value, 0x0929295aU); + + const char* expected_addresses[]{ + "yYToW2c6ZprW2ZjfbnV8LCEjvFZWjueBEV", + "ycuANvWuH6NozPfYzERcdhuAsq7wR88TfG", + "yifetSUKVcVfgvmFWxqw3vqjA1bAGokvVJ", + }; + for (uint32_t index{0}; index < std::size(expected_addresses); ++index) { + CTxDestination destination; + BOOST_REQUIRE(DeriveFriendshipPaymentDestination(keychain.value, index, destination)); + BOOST_CHECK_EQUAL(EncodeDestination(destination), expected_addresses[index]); + } +} + //! A payment received before the loss must become spendable again once the //! restored wallet re-imports the friendship keychain: the recovery contract //! for DIP-15 funds. diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 467bb4ba432c..3db0617bea28 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4056,6 +4056,22 @@ PlatformKeyResult CWallet::PlatformECDHSecret(const IdentityAuthKe return result; } +PlatformKeyResult CWallet::PlatformAccountReference(const IdentityAuthKey& request, const CompactXpub& compact_xpub) const +{ + LOCK(cs_wallet); + PlatformKeyResult result; + platformkeys::ExtKey256 key; + uint256 mac; + result.status = DerivePlatformKey(PlatformKeyRequest{request}, key); + if (result.status != PlatformKeyStatus::SUCCESS) return result; + if (!platformkeys::ComputeAccountReferenceMac(key.key, compact_xpub, mac)) { + result.status = PlatformKeyStatus::DERIVATION_ERROR; + return result; + } + result.value = platformkeys::AccountReferenceFromMac(mac); + return result; +} + PlatformKeyResult CWallet::EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request) { LOCK(cs_wallet); @@ -4096,7 +4112,7 @@ PlatformKeyResult CWallet::EnsureFriendshipReceivingKeychain(con return result; } - result.value = {key.key.GetPubKey(), key.chaincode}; + result.value = {key.key.GetPubKey(), key.chaincode, key.parent_fingerprint}; return result; } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 4216297a7fcf..b4a3fc4f5ca4 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1111,6 +1111,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati PlatformKeyResult> SignPlatformDigest(const PlatformKeyRequest& request, const uint256& digest) const; PlatformKeyResult PlatformECDHSecret(const IdentityAuthKey& key, const CPubKey& counterparty) const; + PlatformKeyResult PlatformAccountReference(const IdentityAuthKey& key, const CompactXpub& compact_xpub) const; PlatformKeyResult EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request); /** From 74cdbb43477b686b68ac5404b3001e19b9cecc57 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:46:45 -0500 Subject: [PATCH 2/3] feat(wallet): asset-lock creation and broadcast-error seams for DashPay interfaces::Wallet::createAssetLockTransaction builds, funds and signs a version 1 asset lock whose single P2PKH credit output pays the key the wallet derives for a typed funding key (registration, top-up or invitation), so the credits stay recoverable from the wallet's seed, and reports the fee it pays. Version 1 is the only payload version CheckAssetLockTx accepts before v24 and IsStandardSpecialTx relays after it. The seam goes through the asset-lock builder CreateTransactionInternal already uses for Platform recipients: CCoinControl gains m_asset_lock_version, which selects the payload version and defaults to CAssetLockPayload::CURRENT_VERSION, so sendtoaddress and every other existing caller build the same transaction and get the same error text as before. The seam requires a fully unlocked wallet: a wallet unlocked for mixing only is refused. CWallet::CommitTransaction returns an error when the transaction could not be broadcast, carrying the mempool's reason, and interfaces::Wallet::commitTransaction passes it on. The transaction stays committed to the wallet, so the caller can abandon it to release its inputs. With -walletbroadcast=0 nothing is offered to the mempool and the commit succeeds, as before. Existing callers ignore the result and behave as before. Committing a transaction the wallet holds abandoned, such as an identical retry of a refused send, first takes it back as not abandoned and spends its inputs again with ReconcileWalletUTXOs(), the transition the wallet already makes when an abandoned transaction re-enters the mempool; before, AddToWallet()'s state assertion aborted. Committing a transaction the wallet already holds as confirmed or in the mempool returns success without touching its state (it still records the commit's metadata, which it lacks if the wallet first saw it relayed or mined through another path), instead of downgrading it to inactive, which after the broadcast's already-known refusal would let the caller abandon it and release its spent inputs. A transaction the broadcast refuses because it is already in the chain (mined through another path before the wallet processed the block) is reported as committed, not rejected. The create_asset_lock_transaction case builds asset locks for a registration and a top-up funding key on a DIP0003-active regtest chain and checks that each pays its own derived key, that the reported fee is the fee the mempool charges, and that the lock passes CheckAssetLockTx on both sides of the v24 boundary; that a Platform recipient without a version selection still gets a version 2 payload; that commitTransaction accepts the lock and reports a conflicting second lock as rejected with its reason; and the zero, over-MAX_MONEY, locked, mixing-only and unlocked cases. Co-Authored-By: Claude Opus 5.5 --- src/interfaces/wallet.h | 14 +++- src/wallet/coincontrol.h | 4 + src/wallet/interfaces.cpp | 35 ++++++++- src/wallet/platformtypes.h | 4 + src/wallet/spend.cpp | 18 +++-- src/wallet/test/wallet_tests.cpp | 121 +++++++++++++++++++++++++++++++ src/wallet/wallet.cpp | 58 +++++++++++++-- src/wallet/wallet.h | 4 +- 8 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 150f2198ccf2..2d38ac90e803 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -244,6 +244,15 @@ class Wallet int& change_pos, CAmount& fee) = 0; + //! Create a signed, uncommitted asset lock converting credit_amount duffs + //! into Platform credits. Its single P2PKH credit output pays the key the + //! wallet derives for funding_key, so the credits stay recoverable from + //! the wallet's seed. + virtual util::Result createAssetLockTransaction(CAmount credit_amount, + const wallet::AssetLockFundingKey& funding_key, + const wallet::CCoinControl& coin_control, + CAmount& fee) = 0; + //! Fund a transaction template per the provider-transaction funding policy: //! inputs are selected only from coins received at the single specified //! fee-source destination, change is returned to that same destination, and @@ -256,8 +265,9 @@ class Wallet //! Sign every wallet-owned input of a transaction and report whether signing is complete. virtual util::Result signTransaction(const CMutableTransaction& tx) = 0; - //! Commit transaction. - virtual void commitTransaction(CTransactionRef tx, + //! Commit transaction to the wallet and broadcast it. Returns an error + //! when it could not be broadcast; it stays committed to the wallet. + virtual util::Result commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) = 0; diff --git a/src/wallet/coincontrol.h b/src/wallet/coincontrol.h index 83257d7841a4..6bea8283783f 100644 --- a/src/wallet/coincontrol.h +++ b/src/wallet/coincontrol.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -61,6 +62,9 @@ class CCoinControl FlatSigningProvider m_external_provider; //! Controls which types of coins are allowed to be used (default: ALL_COINS) CoinType nCoinType = CoinType::ALL_COINS; + //! Payload version of the asset lock built for Platform recipients, if not + //! set CAssetLockPayload::CURRENT_VERSION + std::optional m_asset_lock_version; CCoinControl(CoinType coin_type = CoinType::ALL_COINS); diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 45cdfb5bfbc9..e6b817870e4e 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -455,6 +456,36 @@ class WalletImpl : public Wallet return txr.tx; } + util::Result createAssetLockTransaction(CAmount credit_amount, + const AssetLockFundingKey& funding_key, + const CCoinControl& coin_control, + CAmount& fee) override + { + // CreateTransaction accepts a zero Platform credit, which CheckAssetLockTx refuses, + // and bounds no amount from above before coin selection doubles it. + if (credit_amount <= 0 || !MoneyRange(credit_amount)) return util::Error{_("Invalid amount")}; + + LOCK(m_wallet->cs_wallet); + // A wallet unlocked only for mixing keeps its keys but must not spend. + if (m_wallet->IsLocked()) { + return util::Error{_("Please enter the wallet passphrase with walletpassphrase first.")}; + } + const auto funding_pubkey{m_wallet->GetPlatformPubKey( + std::visit([](const auto& key) { return PlatformKeyRequest{key}; }, funding_key))}; + if (!funding_pubkey) return util::Error{_("Could not derive the asset lock funding key")}; + + // Version 1 stays consensus-valid after v24 and is the only version the + // mempool relays today (IsStandardSpecialTx). + CCoinControl version_1_control{coin_control}; + version_1_control.m_asset_lock_version = CAssetLockPayload::INITIAL_VERSION; + auto res{CreateTransaction(*m_wallet, + {CRecipient{GetScriptForDestination(PKHash{funding_pubkey.value}), credit_amount, + /*fSubtractFeeFromAmount=*/false, /*fPlatformTransfer=*/true}}, + RANDOM_CHANGE_POSITION, version_1_control, /*sign=*/true)}; + if (!res) return util::Error{util::ErrorString(res)}; + fee = res->fee; + return res->tx; + } util::Result fundTransaction(const CMutableTransaction& tx_template, const CTxDestination& fund_destination) override { @@ -538,12 +569,12 @@ class WalletImpl : public Wallet errors.push_back(std::move(error)); return WalletTxSignResult{MakeTransactionRef(std::move(signed_tx)), complete, std::move(errors)}; } - void commitTransaction(CTransactionRef tx, + util::Result commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) override { LOCK(m_wallet->cs_wallet); - m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form)); + return m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form)); } bool transactionCanBeAbandoned(const uint256& txid) override { return m_wallet->TransactionCanBeAbandoned(txid); } bool transactionCanBeResent(const uint256& txid) override { return m_wallet->TransactionCanBeResent(txid); } diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 146dff682995..64aeb0b3f21e 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -55,6 +55,10 @@ struct InvitationFundingKey { using PlatformKeyRequest = std::variant; +//! The keys an asset lock's credit output may pay: every one is derived from +//! the wallet's seed, so the credits it funds can be recovered. +using AssetLockFundingKey = std::variant; + struct FriendshipKeychainRequest { uint32_t account{0}; uint256 local_id; diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index e3721a2c8238..1f9d9fbc4da3 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -810,7 +810,7 @@ static util::Result CreateTransactionInternal( } } - opt_assetLockPayload.emplace(creditOutputs, CAssetLockPayload::CURRENT_VERSION); + opt_assetLockPayload.emplace(creditOutputs, coin_control.m_asset_lock_version.value_or(CAssetLockPayload::CURRENT_VERSION)); CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); ss << *opt_assetLockPayload; nExtraPayloadSize = ss.size(); @@ -1130,13 +1130,17 @@ static util::Result CreateTransactionInternal( return util::Error{_("Transaction too large")}; } - // An asset lock for Platform recipients carries payload version 2, which is - // deliberately non-standard until Platform can process it. Returning such a - // transaction hands the caller a txid for something the local mempool drops on - // submission and that therefore never relays, so fail before it is committed. + // An asset lock for Platform recipients carries payload version 2 by default, + // which is deliberately non-standard until Platform can process it. Returning + // a non-standard transaction hands the caller a txid for something the local + // mempool drops on submission and that therefore never relays, so fail before + // it is committed. if (std::string reason; opt_assetLockPayload && wallet.chain().isNonStandardSpecialTx(tx, reason)) { - return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay; sending to Platform " - "addresses currently requires a node started with -acceptnonstdtxn=1"), reason)}; + if (opt_assetLockPayload->getVersion() == CAssetLockPayload::CURRENT_VERSION) { + return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay; sending to Platform " + "addresses currently requires a node started with -acceptnonstdtxn=1"), reason)}; + } + return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay"), reason)}; } if (current_fee > wallet.m_default_max_tx_fee) { diff --git a/src/wallet/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp index 9e7e340b3e83..cd80bb62dc17 100644 --- a/src/wallet/test/wallet_tests.cpp +++ b/src/wallet/test/wallet_tests.cpp @@ -15,9 +15,13 @@ #include #include #include +#include +#include #include #include +#include #include +#include #include #include #include @@ -811,6 +815,123 @@ BOOST_FIXTURE_TEST_CASE(BasicOutputTypesTest, ListCoinsTestingSetup) } } +//! Special transactions are only accepted once DIP0003 is active, which the +//! default regtest parameters place far beyond the pinned fixture chains. The +//! same chain as the evo tests: activation at 109, tip at 108. +struct AssetLockTestingSetup : public TestChainSetup { + AssetLockTestingSetup() : TestChainSetup{107, CBaseChainParams::REGTEST, {"-dip3params=109:500"}} + { + CreateAndProcessBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey())); + wallet = CreateSyncedWallet(*m_node.chain, *Assert(m_node.chainman), coinbaseKey); + } + + ~AssetLockTestingSetup() { wallet.reset(); } + + std::unique_ptr wallet; +}; + +//! An asset lock built through the wallet interface must be a consensus-valid +//! special transaction the local mempool accepts, paying the key the wallet +//! derives for the requested funding key at the fee it reports, and +//! commitTransaction must report a mempool rejection with its reason. +BOOST_FIXTURE_TEST_CASE(create_asset_lock_transaction, AssetLockTestingSetup) +{ + WalletContext context; + context.args = &m_args; + context.chain = m_node.chain.get(); + const auto wallet_ref{std::shared_ptr(wallet.get(), [](CWallet*) {})}; + auto iface{interfaces::MakeWallet(context, wallet_ref)}; + wallet->SetBroadcastTransactions(true); + + const RegistrationFundingKey registration_key{0}; + const TopupFundingKey topup_key{0}; + const auto registration_pubkey{iface->getPlatformPubKey(registration_key)}; + const auto topup_pubkey{iface->getPlatformPubKey(topup_key)}; + BOOST_REQUIRE(registration_pubkey && topup_pubkey); + BOOST_REQUIRE(registration_pubkey.value != topup_pubkey.value); + const CAmount credit_amount{COIN}; + CCoinControl coin_control; + CAmount fee{0}; + + const auto zero_amount{iface->createAssetLockTransaction(0, registration_key, coin_control, fee)}; + BOOST_REQUIRE(!zero_amount); + // Refused like a zero amount, before coin selection, not as insufficient funds. + const auto over_max_money{iface->createAssetLockTransaction(MAX_MONEY + 1, registration_key, coin_control, fee)}; + BOOST_REQUIRE(!over_max_money); + BOOST_CHECK_EQUAL(util::ErrorString(over_max_money).original, util::ErrorString(zero_amount).original); + + const auto res{iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)}; + BOOST_REQUIRE_MESSAGE(res, util::ErrorString(res).original); + const CTransactionRef& tx{*res}; + BOOST_CHECK_EQUAL(tx->nVersion, 3); + BOOST_CHECK_EQUAL(tx->nType, TRANSACTION_ASSET_LOCK); + + const auto payload{GetTxPayload(*tx)}; + BOOST_REQUIRE(payload); + BOOST_CHECK_EQUAL(payload->getVersion(), CAssetLockPayload::INITIAL_VERSION); + BOOST_REQUIRE_EQUAL(payload->getCreditOutputs().size(), 1U); + BOOST_CHECK_EQUAL(payload->getCreditOutputs()[0].nValue, credit_amount); + BOOST_CHECK(payload->getCreditOutputs()[0].scriptPubKey == GetScriptForDestination(PKHash{registration_pubkey.value})); + + // Each funding key request pays its own wallet-derived key. + CAmount topup_fee{0}; + const auto topup{iface->createAssetLockTransaction(credit_amount, topup_key, coin_control, topup_fee)}; + BOOST_REQUIRE_MESSAGE(topup, util::ErrorString(topup).original); + const auto topup_payload{GetTxPayload(**topup)}; + BOOST_REQUIRE(topup_payload); + BOOST_REQUIRE_EQUAL(topup_payload->getCreditOutputs().size(), 1U); + BOOST_CHECK(topup_payload->getCreditOutputs()[0].scriptPubKey == GetScriptForDestination(PKHash{topup_pubkey.value})); + + TxValidationState tx_state; + BOOST_CHECK_MESSAGE(CheckAssetLockTx(*tx, tx_state, /*is_v24_active=*/false), tx_state.ToString()); + BOOST_CHECK_MESSAGE(CheckAssetLockTx(*tx, tx_state, /*is_v24_active=*/true), tx_state.ToString()); + + // Platform recipients without a version selection still get the current + // payload version, which the standard mempool refuses to relay. + const auto default_version{CreateTransaction(*wallet, + {CRecipient{GetScriptForDestination(PKHash{registration_pubkey.value}), credit_amount, + /*fSubtractFeeFromAmount=*/false, /*fPlatformTransfer=*/true}}, + RANDOM_CHANGE_POSITION, CCoinControl{}, /*sign=*/true)}; + BOOST_REQUIRE(!default_version); + BOOST_CHECK(util::ErrorString(default_version).original.find("assetlocktx-version-2") != std::string::npos); + BOOST_CHECK(util::ErrorString(default_version).original.find("-acceptnonstdtxn=1") != std::string::npos); + + // Both spend the same coin: the first reaches the mempool, the second is + // a conflict the wallet must report and let the caller abandon. + CCoinControl same_input; + same_input.Select(tx->vin[0].prevout); + same_input.m_allow_other_inputs = false; + CAmount conflicting_fee{0}; + const auto conflicting{iface->createAssetLockTransaction(credit_amount, registration_key, same_input, conflicting_fee)}; + BOOST_REQUIRE_MESSAGE(conflicting, util::ErrorString(conflicting).original); + const auto accepted{iface->commitTransaction(tx, {}, {})}; + BOOST_CHECK_MESSAGE(accepted, util::ErrorString(accepted).original); + BOOST_CHECK(m_node.mempool->exists(tx->GetHash())); + // The reported fee is the one the mempool charges. + BOOST_CHECK_GT(fee, 0); + BOOST_CHECK_EQUAL(m_node.mempool->info(tx->GetHash()).fee, fee); + + const auto rejected{iface->commitTransaction(*conflicting, {}, {})}; + BOOST_REQUIRE(!rejected); + BOOST_CHECK(util::ErrorString(rejected).original.find("txn-mempool-conflict") != std::string::npos); + BOOST_CHECK(!m_node.mempool->exists((*conflicting)->GetHash())); + BOOST_CHECK(iface->abandonTransaction((*conflicting)->GetHash())); + + // An encrypted wallet builds asset locks only when fully unlocked: not + // while locked, and not while unlocked for mixing only. + const SecureString passphrase{"asset lock"}; + BOOST_REQUIRE(wallet->EncryptWallet(passphrase)); + BOOST_REQUIRE(wallet->IsLocked()); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); + BOOST_REQUIRE(wallet->Unlock(passphrase, /*fForMixingOnly=*/true)); + BOOST_REQUIRE(wallet->IsLocked() && !wallet->IsLocked(/*fForMixing=*/true)); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); + BOOST_REQUIRE(wallet->Lock()); + BOOST_REQUIRE(wallet->Unlock(passphrase)); + const auto unlocked{iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)}; + BOOST_CHECK_MESSAGE(unlocked, util::ErrorString(unlocked).original); +} + BOOST_FIXTURE_TEST_CASE(wallet_disableprivkeys, TestChain100Setup) { { diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 3db0617bea28..69d570be7530 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2487,14 +2487,13 @@ bool CWallet::SignGovernanceVote(const CKeyID& keyID, CGovernanceVote& vote) con return true; } -void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm) +util::Result CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm) { LOCK(cs_wallet); WalletLogPrintf("CommitTransaction:\n%s", tx->ToString()); /* Continued */ - // Add tx to wallet, because if it has change it's also ours, - // otherwise just for transaction history. - CWalletTx* wtx = AddToWallet(tx, TxStateInactive{}, [&](CWalletTx& wtx, bool new_tx) { + // What this commit records about the transaction. + const auto record_commit{[&](CWalletTx& wtx, bool new_tx) { CHECK_NONFATAL(wtx.mapValue.empty()); CHECK_NONFATAL(wtx.vOrderForm.empty()); wtx.mapValue = std::move(mapValue); @@ -2502,7 +2501,42 @@ void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::ve wtx.fTimeReceivedIsTxTime = true; wtx.fFromMe = true; return true; - }); + }}; + + // A transaction the wallet already holds as confirmed or in the mempool was + // sent. Committing it again must not hand AddToWallet() an inactive state, + // which would undo that and, once the broadcast is refused as already + // known, let the caller abandon it and release its spent inputs. It still + // gets this commit's record, which it lacks if the wallet first saw it + // relayed or mined through another path. + if (const auto it{mapWallet.find(tx->GetHash())}; it != mapWallet.end() && (it->second.isConfirmed() || it->second.InMempool())) { + CWalletTx& sent{it->second}; + sent.mapValue.clear(); + sent.vOrderForm.clear(); + record_commit(sent, /*new_tx=*/false); + if (!WalletBatch{GetDatabase()}.WriteTx(sent)) { + throw std::runtime_error(std::string(__func__) + ": Wallet db error, transaction commit failed"); + } + NotifyTransactionChanged(sent.GetHash(), CT_UPDATED); + return {}; + } + + // Committing a transaction the wallet holds abandoned, such as an identical + // retry of a send whose broadcast was refused, takes it back as if new: its + // inputs are spent again, as when an abandoned transaction re-enters the + // mempool, and AddToWallet() finds the state it is given. + if (const auto it{mapWallet.find(tx->GetHash())}; it != mapWallet.end() && it->second.isAbandoned()) { + CWalletTx& abandoned{it->second}; + abandoned.m_state = TxStateInactive{}; + abandoned.mapValue.clear(); + abandoned.vOrderForm.clear(); + WalletBatch batch{GetDatabase()}; + ReconcileWalletUTXOs(abandoned.tx, batch); + } + + // Add tx to wallet, because if it has change it's also ours, + // otherwise just for transaction history. + CWalletTx* wtx = AddToWallet(tx, TxStateInactive{}, record_commit); // wtx can only be null if the db write failed. if (!wtx) { @@ -2523,14 +2557,26 @@ void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::ve if (!fBroadcastTransactions) { // Don't submit tx to the mempool - return; + return {}; } bilingual_str err_string; if (!SubmitTxMemoryPoolAndRelay(*wtx, err_string, true)) { + // A transaction already in the chain, e.g. mined through another path + // before this wallet processed its block, was sent. BroadcastTransaction + // refuses it without a reason, having found its outputs. + if (err_string.empty()) { + std::map outputs; + for (uint32_t i{0}; i < tx->vout.size(); ++i) outputs.emplace(COutPoint{tx->GetHash(), i}, Coin{}); + chain().findCoins(outputs); + if (std::any_of(outputs.begin(), outputs.end(), [](const auto& output) { return !output.second.IsSpent(); })) return {}; + } WalletLogPrintf("CommitTransaction(): Transaction cannot be broadcast immediately, %s\n", err_string.original); // TODO: if we expect the failure to be long term or permanent, instead delete wtx from the wallet and return failure. + // Not every refusal carries a reason, e.g. the -maxtxfee check in BroadcastTransaction. + return util::Error{err_string.empty() ? _("Transaction could not be broadcast") : err_string}; } + return {}; } DBErrors CWallet::LoadWallet() diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index b4a3fc4f5ca4..da5c42751130 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -847,8 +847,10 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati * @param[in] tx The transaction to be broadcast. * @param[in] mapValue key-values to be set on the transaction. * @param[in] orderForm BIP 70 / BIP 21 order form details to be set on the transaction. + * @return an error when the transaction could not be broadcast. It stays committed to + * the wallet either way, so the caller may abandon it to release its inputs. */ - void CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm); + util::Result CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm); /** Will SubmitTxMemoryPoolAndRelay() consider wtx if supplied */ bool CanTxBeResent(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); From ba24142ae59309d7980c82a0726d725db428f3f1 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:52:25 -0500 Subject: [PATCH 3/3] feat(wallet): Platform key eligibility, pruned-rescan start and a movable UnlockContext interfaces::Wallet::platformKeySourceStatus tells the GUI whether the wallet can derive Platform keys at all (a descriptor wallet with private keys and a single HD root) without deriving one, so it also answers while the wallet is locked, where getPlatformPubKey reports WALLET_LOCKED. interfaces::Wallet::rescanPrunedFrom returns the height a rescan from the birth of the wallet's first key would start at when this node has pruned blocks it would need to read, and nullopt when it has them all. startRescan(false) computes its start height through the same helper. WalletModel::UnlockContext becomes movable, so an unlock can be held across an asynchronous operation; the moved-from context no longer relocks, so the wallet relocks once, when its last owner goes away. Tests: platformKeySourceStatus rows in the ambiguous-source, child-xprv and legacy-wallet scenarios, and on a locked wallet in create_asset_lock_transaction; rescanPrunedFrom before and after pruning the oldest block file in scan_for_wallet_transactions; and WalletTests::unlockContextMove, which hands an unlock to a second owner and checks the wallet stays unlocked until that owner is gone. Co-Authored-By: Claude Opus 5.5 --- src/interfaces/wallet.h | 9 +++++++ src/qt/test/wallettests.cpp | 28 +++++++++++++++++++++ src/qt/test/wallettests.h | 1 + src/qt/walletmodel.cpp | 9 +++++++ src/qt/walletmodel.h | 10 ++++---- src/wallet/interfaces.cpp | 35 ++++++++++++++++++-------- src/wallet/test/platformkeys_tests.cpp | 4 +++ src/wallet/test/wallet_tests.cpp | 23 +++++++++++++++++ src/wallet/wallet.cpp | 7 ++++++ src/wallet/wallet.h | 2 ++ 10 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 2d38ac90e803..7f08ba98b510 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -115,6 +116,10 @@ class Wallet //! Abort a rescan. virtual void abortRescan() = 0; + //! The height startRescan(false) starts at, when this node has pruned + //! blocks it would read; nullopt when it has them all. + virtual std::optional rescanPrunedFrom() = 0; + //! Lock masternode collaterals virtual void autoLockMasternodeCollaterals() = 0; @@ -142,6 +147,10 @@ class Wallet //! Sign special transaction payload virtual bool signSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector& vchSig) = 0; + //! Whether this wallet can derive Platform keys: SUCCESS for a descriptor + //! wallet with private keys and a single HD root, whether locked or not. + virtual wallet::PlatformKeyStatus platformKeySourceStatus() = 0; + //! Derive and return a public Platform key. The mnemonic-owning key //! manager performs derivation without exposing the wallet's root seed. virtual wallet::PlatformKeyResult getPlatformPubKey(const wallet::PlatformKeyRequest& request) = 0; diff --git a/src/qt/test/wallettests.cpp b/src/qt/test/wallettests.cpp index f31adf43f1d9..768692e6759b 100644 --- a/src/qt/test/wallettests.cpp +++ b/src/qt/test/wallettests.cpp @@ -3,6 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include #include @@ -321,3 +322,30 @@ void WalletTests::walletTests() #endif TestGUI(m_node); } + +//! An unlock handed from one owner to another relocks the wallet once, when +//! its last owner goes away. +void WalletTests::unlockContextMove() +{ + TestChain100Setup test; + m_node.setContext(&test.m_node); + WalletContext& context{*m_node.walletLoader().context()}; + const auto wallet{MasternodeTestUtil::MakeCoinbaseWallet(m_node, context, test, "unlock", /*encrypt=*/true)}; + QVERIFY(wallet != nullptr); + MasternodeTestUtil::WalletGuard guard{context, wallet}; + MasternodeTestUtil::GuiModels models{m_node}; + QVERIFY(models.ok); + WalletModel wallet_model(interfaces::MakeWallet(context, wallet), models.client); + connect(&wallet_model, &WalletModel::requireUnlock, [&wallet_model] { + wallet_model.setWalletLocked(/*locked=*/false, "test passphrase"); + }); + QCOMPARE(wallet_model.getEncryptionStatus(), WalletModel::Locked); + + // emplace moves the returned context; the moved-from one must not relock. + std::optional held; + held.emplace(wallet_model.requestUnlock()); + QVERIFY(held->isValid()); + QCOMPARE(wallet_model.getEncryptionStatus(), WalletModel::Unlocked); + held.reset(); + QCOMPARE(wallet_model.getEncryptionStatus(), WalletModel::Locked); +} diff --git a/src/qt/test/wallettests.h b/src/qt/test/wallettests.h index 6044bedb1d0f..5cf843782b20 100644 --- a/src/qt/test/wallettests.h +++ b/src/qt/test/wallettests.h @@ -22,6 +22,7 @@ class WalletTests : public QObject private Q_SLOTS: void walletTests(); + void unlockContextMove(); }; #endif // BITCOIN_QT_TEST_WALLETTESTS_H diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index aa7b61d89d8f..5e146b6106dc 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -618,6 +618,15 @@ WalletModel::UnlockContext::UnlockContext(WalletModel *_wallet, bool _valid, boo { } +WalletModel::UnlockContext::UnlockContext(UnlockContext&& other) noexcept : + wallet(other.wallet), + valid(other.valid), + was_locked(other.was_locked), + was_mixing(other.was_mixing) +{ + other.valid = false; +} + WalletModel::UnlockContext::~UnlockContext() { if(valid && (was_locked || was_mixing)) diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index 61666da45cb4..c540b0a0a2e4 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -123,17 +123,17 @@ class WalletModel : public QObject bool isValid() const { return valid; } - // Disable unused copy/move constructors/assignments explicitly. + // The moved-from context no longer relocks: exactly one owner does. + UnlockContext(UnlockContext&& other) noexcept; UnlockContext(const UnlockContext&) = delete; - UnlockContext(UnlockContext&&) = delete; UnlockContext& operator=(const UnlockContext&) = delete; UnlockContext& operator=(UnlockContext&&) = delete; private: WalletModel *wallet; - const bool valid; - const bool was_locked; - const bool was_mixing; + bool valid; + bool was_locked; + bool was_mixing; }; UnlockContext requestUnlock(bool fForMixingOnly = false); diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index e6b817870e4e..f03fd57b3d22 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -188,20 +188,24 @@ class WalletImpl : public Wallet { return m_wallet->ChangeWalletPassphrase(old_wallet_passphrase, new_wallet_passphrase); } - wallet::RescanStatus startRescan(bool from_genesis) override + //! Where a rescan from the birth of the wallet's first key starts. + int rescanStartHeight() { int rescan_height{0}; - if (!from_genesis) { - std::optional time_first_key; - for (auto spk_man : m_wallet->GetAllScriptPubKeyMans()) { - int64_t time = spk_man->GetTimeFirstKey(); - if (!time_first_key || time < *time_first_key) time_first_key = time; - } - if (time_first_key) { - m_wallet->chain().findFirstBlockWithTimeAndHeight(*time_first_key - TIMESTAMP_WINDOW, rescan_height, - FoundBlock().height(rescan_height)); - } + std::optional time_first_key; + for (auto spk_man : m_wallet->GetAllScriptPubKeyMans()) { + int64_t time = spk_man->GetTimeFirstKey(); + if (!time_first_key || time < *time_first_key) time_first_key = time; } + if (time_first_key) { + m_wallet->chain().findFirstBlockWithTimeAndHeight(*time_first_key - TIMESTAMP_WINDOW, rescan_height, + FoundBlock().height(rescan_height)); + } + return rescan_height; + } + wallet::RescanStatus startRescan(bool from_genesis) override + { + const int rescan_height{from_genesis ? 0 : rescanStartHeight()}; WalletRescanReserver reserver(*m_wallet); if (!reserver.reserve()) { @@ -220,6 +224,14 @@ class WalletImpl : public Wallet return wallet::RescanStatus::FAILURE; // fallback for release builds } void abortRescan() override { m_wallet->AbortRescan(); } + std::optional rescanPrunedFrom() override + { + if (!m_wallet->chain().havePruned()) return std::nullopt; + LOCK(m_wallet->cs_wallet); + const int rescan_height{rescanStartHeight()}; + if (m_wallet->chain().hasBlocks(m_wallet->GetLastBlockHash(), rescan_height)) return std::nullopt; + return rescan_height; + } void autoLockMasternodeCollaterals() override { m_wallet->AutoLockMasternodeCollaterals(); } bool backupWallet(const std::string& filename) override { return m_wallet->BackupWallet(filename); } bool autoBackupWallet(const fs::path& wallet_path, bilingual_str& error_string, std::vector& warnings) override @@ -249,6 +261,7 @@ class WalletImpl : public Wallet { return m_wallet->SignSpecialTxPayload(hash, keyid, vchSig); } + wallet::PlatformKeyStatus platformKeySourceStatus() override { return m_wallet->GetPlatformKeySourceStatus(); } wallet::PlatformKeyResult getPlatformPubKey(const wallet::PlatformKeyRequest& request) override { return m_wallet->GetPlatformPubKey(request); diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 033b1ae4e5a1..6c7631b6537a 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -377,6 +377,7 @@ BOOST_FIXTURE_TEST_CASE(platform_key_source_must_be_unambiguous, Dip14WalletSetu { const auto before{m_iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_REQUIRE(before); + BOOST_CHECK(m_iface->platformKeySourceStatus() == PlatformKeyStatus::SUCCESS); // The internal hook must not permit an empty path to expose the BIP32 // master key, even though the typed public requests cannot form one. @@ -407,6 +408,7 @@ 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); } //! An imported root xprv is sufficient for Platform derivation. The mnemonic @@ -433,6 +435,7 @@ BOOST_FIXTURE_TEST_CASE(child_xprv_has_no_platform_keys, FriendshipWalletSetup) const auto [wallet, iface]{MakeXprvWallet(child)}; const auto result{iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(result.status == PlatformKeyStatus::NOT_SUPPORTED); + BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::NOT_SUPPORTED); } //! A root source is the complete extended public key, including its chain @@ -525,6 +528,7 @@ BOOST_FIXTURE_TEST_CASE(legacy_wallet_has_no_platform_keys, FriendshipWalletSetu auto iface = interfaces::MakeWallet(m_context, wallet); const auto result{iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(result.status == PlatformKeyStatus::NOT_SUPPORTED); + BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::NOT_SUPPORTED); } //! Two independent wallet instances restored from the same recovery phrase: diff --git a/src/wallet/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp index cd80bb62dc17..f5a379ddd896 100644 --- a/src/wallet/test/wallet_tests.cpp +++ b/src/wallet/test/wallet_tests.cpp @@ -266,6 +266,25 @@ BOOST_FIXTURE_TEST_CASE(scan_for_wallet_transactions, TestChain100Setup) } } + // Where a rescan from the birth of a wallet holding a genesis-era key + // would need blocks this node no longer has. + const auto rescan_pruned_from{[&] { + const auto wallet{std::make_shared(m_node.chain.get(), "", CreateMockWalletDatabase())}; + { + LOCK(wallet->cs_wallet); + wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS); + wallet->SetLastBlockProcessed(newTip->nHeight, newTip->GetBlockHash()); + } + AddKey(*wallet, coinbaseKey); + WalletContext context; + return interfaces::MakeWallet(context, wallet)->rescanPrunedFrom(); + }}; + BOOST_CHECK(!rescan_pruned_from()); + // A pruning node that still has every block the rescan reads. The flag is + // set as Chainstate::FlushStateToDisk does once it prunes. + WITH_LOCK(::cs_main, m_node.chainman->m_blockman.m_have_pruned = true); + BOOST_CHECK(!rescan_pruned_from()); + // Prune the older block file. int file_number; { @@ -274,6 +293,7 @@ BOOST_FIXTURE_TEST_CASE(scan_for_wallet_transactions, TestChain100Setup) Assert(m_node.chainman)->m_blockman.PruneOneBlockFile(file_number); } UnlinkPrunedFiles({file_number}); + BOOST_CHECK(rescan_pruned_from() == std::optional{0}); // Verify ScanForWalletTransactions only picks transactions in the new block // file. @@ -923,6 +943,9 @@ BOOST_FIXTURE_TEST_CASE(create_asset_lock_transaction, AssetLockTestingSetup) BOOST_REQUIRE(wallet->EncryptWallet(passphrase)); BOOST_REQUIRE(wallet->IsLocked()); BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); + // A locked wallet can still tell whether it is able to fund an identity. + BOOST_CHECK(iface->getPlatformPubKey(registration_key).status == PlatformKeyStatus::WALLET_LOCKED); + BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::SUCCESS); BOOST_REQUIRE(wallet->Unlock(passphrase, /*fForMixingOnly=*/true)); BOOST_REQUIRE(wallet->IsLocked() && !wallet->IsLocked(/*fForMixing=*/true)); BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 69d570be7530..9c28f611f528 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4060,6 +4060,13 @@ PlatformKeyStatus CWallet::DerivePlatformKey(const PlatformKeyRequest& request, return DerivePlatformKey(platformkeys::PlatformKeyPath(static_cast(Params().ExtCoinType()), request), out); } +PlatformKeyStatus CWallet::GetPlatformKeySourceStatus() const +{ + LOCK(cs_wallet); + const ScriptPubKeyMan* source{nullptr}; + return GetPlatformKeySource(source); +} + PlatformKeyResult CWallet::GetPlatformPubKey(const PlatformKeyRequest& request) const { LOCK(cs_wallet); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index da5c42751130..ee16c533d2de 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1109,6 +1109,8 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati /** All Platform data records whose key starts with prefix. */ std::map> GetPlatformData(const std::string& prefix) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Whether Platform keys can be derived, without deriving one (works while locked). */ + PlatformKeyStatus GetPlatformKeySourceStatus() const; PlatformKeyResult GetPlatformPubKey(const PlatformKeyRequest& request) const; PlatformKeyResult> SignPlatformDigest(const PlatformKeyRequest& request, const uint256& digest) const;