diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 78c71ffbdf41..98a48313e425 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -14,6 +14,7 @@ #include #include #include +#include // For bilingual_str #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -35,7 +37,6 @@ class CKey; class CRPCCommand; enum class FeeReason; enum class TransactionError; -struct bilingual_str; struct PartiallySignedTransaction; namespace node { struct NodeContext; @@ -152,9 +153,17 @@ class Wallet //! ECDH shared secret between the identity authentication key //! and a counterparty public key, using the libsecp256k1 ECDH KDF. + //! Refuses key_index 0 (the identity MASTER key). virtual wallet::PlatformKeyResult platformECDHSecret(const wallet::IdentityAuthKey& key, const CPubKey& counterparty) = 0; + //! DIP-15 accountReference MAC: HMAC-SHA256 keyed by the identity + //! authentication (ENCRYPTION) private key over the compact xpub sent in + //! a contact request. Refuses key_index 0. The caller masks the account + //! index with the result; the key never leaves the wallet. + virtual wallet::PlatformKeyResult platformAccountReferenceMac(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. @@ -249,8 +258,21 @@ 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, + //! Create a signed (uncommitted) asset lock transaction converting + //! credit_amount duffs into Platform credits, with a single P2PKH credit + //! output to credit_pubkey (typically a registration funding key from + //! getPlatformPubKey). Broadcast the result with commitTransaction(). + virtual util::Result createAssetLockTransaction(CAmount credit_amount, + const CPubKey& credit_pubkey, + const wallet::CCoinControl& coin_control) = 0; + + //! Commit transaction. Returns the mempool rejection reason when the + //! transaction was committed to the wallet but could not be accepted to + //! the mempool for broadcast; the caller may abandon it to release its + //! inputs. Returns std::nullopt when the transaction was accepted or when + //! wallet broadcasting is disabled (-walletbroadcast=0), which the caller + //! cannot distinguish here. + virtual std::optional commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) = 0; @@ -560,6 +582,10 @@ struct WalletTxStatus bool is_in_main_chain; bool is_chainlocked; bool is_islocked; + //! A conflicting transaction is in a ChainLocked block, so this one can + //! never confirm; a conflict in a block not ChainLocked yet may still be + //! reorganized away. + bool is_conflict_chainlocked; }; //! Wallet transaction output. diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 5b33cf44d599..a10740944cee 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -35,7 +35,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -137,6 +139,10 @@ WalletTxStatus MakeWalletTxStatus(const CWallet& wallet, const CWalletTx& wtx) result.is_in_main_chain = wallet.IsTxInMainChain(wtx); result.is_chainlocked = wallet.IsTxChainLocked(wtx); result.is_islocked = wallet.IsTxLockedByInstantSend(wtx); + const auto* conflict{wtx.state()}; + result.is_conflict_chainlocked = conflict && wallet.HaveChain() && + wallet.chain().hasChainLock(conflict->conflicting_block_height, + conflict->conflicting_block_hash); return result; } @@ -262,6 +268,11 @@ class WalletImpl : public Wallet { return m_wallet->PlatformECDHSecret(key, counterparty); } + wallet::PlatformKeyResult platformAccountReferenceMac(const wallet::IdentityAuthKey& key, + const wallet::CompactXpub& compact_xpub) override + { + return m_wallet->PlatformAccountReferenceMac(key, compact_xpub); + } wallet::PlatformKeyResult ensureFriendshipReceivingKeychain( const wallet::FriendshipKeychainRequest& request) override { @@ -526,12 +537,67 @@ 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 createAssetLockTransaction(CAmount credit_amount, + const CPubKey& credit_pubkey, + const CCoinControl& coin_control) override + { + // Platform recovers a compressed key from the identity registration + // signature and matches its hash against the credit output, so any + // other key would lock funds that can never be claimed. + if (credit_amount <= 0 || !credit_pubkey.IsFullyValid() || !credit_pubkey.IsCompressed()) { + return util::Error{Untranslated("invalid asset lock parameters")}; + } + + 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.")}; + } + + CMutableTransaction mtx; + mtx.nVersion = 3; + mtx.nType = TRANSACTION_ASSET_LOCK; + // Version 1 admits exactly the P2PKH credit output built here, stays + // consensus-valid after v24 and is the only version the mempool + // relays today (IsStandardSpecialTx), so it is used unconditionally. + const CAssetLockPayload payload{{CTxOut{credit_amount, GetScriptForDestination(PKHash{credit_pubkey})}}, + CAssetLockPayload::INITIAL_VERSION}; + SetTxPayload(mtx, payload); + + // The single OP_RETURN "burn" output must carry the total credit + // amount (see CheckAssetLockTx). + const std::vector recipients{{CScript() << OP_RETURN << OP_0, credit_amount, /*fSubtractFeeFromAmount=*/false}}; + auto res = CreateTransaction(*m_wallet, recipients, RANDOM_CHANGE_POSITION, coin_control, + /*sign=*/false, static_cast(mtx.vExtraPayload.size())); + if (!res) return util::Error{util::ErrorString(res)}; + + // Graft the funded inputs/outputs and the anti-fee-sniping locktime + // onto the special tx and sign it as a whole (input signatures commit + // to nType and vExtraPayload). + mtx.vin = res->tx->vin; + mtx.vout = res->tx->vout; + mtx.nLockTime = res->tx->nLockTime; + if (!m_wallet->SignTransaction(mtx)) { + return util::Error{_("Signing transaction failed")}; + } + CTransactionRef tx{MakeTransactionRef(std::move(mtx))}; + // Too many inputs or too large a transaction would be committed but + // never relayed; fail here like CreateTransaction does for Platform + // recipients. + if (std::string reason; m_wallet->chain().isNonStandardSpecialTx(tx, reason)) { + return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay"), reason)}; + } + return tx; + } + std::optional 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)); + bilingual_str broadcast_error; + m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form), &broadcast_error); + if (broadcast_error.empty()) return std::nullopt; + return broadcast_error; } 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/platformkeys.cpp b/src/wallet/platformkeys.cpp index aa188969c7ed..842ce53d02e1 100644 --- a/src/wallet/platformkeys.cpp +++ b/src/wallet/platformkeys.cpp @@ -4,6 +4,7 @@ #include +#include #include #include @@ -88,8 +89,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 +115,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 +153,26 @@ 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; +} + } // 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..3b57717221a0 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,13 @@ 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: HMAC-SHA256 keyed by the sender's ENCRYPTION +//! private key over the compact xpub sent in the contact request. Matches +//! rs-platform-encryption's calculate_account_reference, which then masks +//! the account index with the low bits of this digest; the masking itself +//! is not done here. +[[nodiscard]] bool ComputeAccountReferenceMac(const CKey& key, const CompactXpub& compact_xpub, uint256& mac_out); + } // namespace wallet::platformkeys #endif // BITCOIN_WALLET_PLATFORMKEYS_H diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 2c9e9913be00..880d281b284a 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include @@ -63,8 +65,22 @@ struct FriendshipKeychainRequest { struct FriendshipXpub { CPubKey pubkey; ChainCode chaincode; + //! BIP32 fingerprint (Hash160 prefix) of the key one level up, i.e. of + //! m/9'/coin'/15'/account'/ for the receiving chain. + std::array parent_fingerprint{}; }; +//! DIP-15 compact extended public key: parentFingerprint(4) || chainCode(32) +//! || pubKey(33). This is the contactRequest encryptedPublicKey plaintext and +//! the accountReference MAC input; unlike a BIP32/DIP-14 serialization it +//! carries 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..6c5d7f20f5a8 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -3,6 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include +#include #include #include #include @@ -192,6 +194,10 @@ BOOST_AUTO_TEST_CASE(friendship_derivation_matches_key_wallet) "1ed00e776865c0308fddae8d22fb3ae47ffd51ce409b298d1987aafdec6bef6b"); BOOST_CHECK_EQUAL(HexStr(out.key.GetPubKey()), "025b4ff1b10b9e1990df46dfa2f986300a7e6eb4a85289f4419581950edd3760bf"); + // key_wallet::bip32::ExtendedPubKey::parent_fingerprint of the same leaf: + // Hash160 prefix of the key at m/9'/1'/15'/0'/(id_a), the parent of the + // final 256-bit step. + BOOST_CHECK_EQUAL(HexStr(out.parent_fingerprint), "a582fa1d"); } //! ECDH secrets must be symmetric and match the libsecp256k1 KDF @@ -209,6 +215,28 @@ BOOST_AUTO_TEST_CASE(ecdh_symmetry) BOOST_CHECK(s_ab == s_ba); } +//! Known-answer test ported from rs-platform-encryption/src/ecdh.rs +//! (ecdh_matches_sha256_y_parity_prefix_convention): fixed scalars 0xC0.. and +//! 0x0D.., shared key SHA256((0x02 | y&1) || x) of the shared point. Pins the +//! KDF layout against the Rust side that decrypts our contact requests. +BOOST_AUTO_TEST_CASE(ecdh_known_answer) +{ + const std::vector raw_a(32, 0xC0); + const std::vector raw_b(32, 0x0D); + CKey a, b; + a.Set(raw_a.begin(), raw_a.end(), /*fCompressedIn=*/true); + b.Set(raw_b.begin(), raw_b.end(), /*fCompressedIn=*/true); + BOOST_REQUIRE(a.IsValid() && b.IsValid()); + BOOST_CHECK_EQUAL(HexStr(a.GetPubKey()), "038a3ba5c99568d26602f4cf8038371da3c86057a96eb1b6a8de1b4f1be723c236"); + BOOST_CHECK_EQUAL(HexStr(b.GetPubKey()), "022f1b310f4c065331bc0d79ba4661bb9822d67d7c4a1b0a1892e1fd0cd23aa68d"); + + SecureVector s_ab, s_ba; + BOOST_REQUIRE(ComputeECDHSecret(a, b.GetPubKey(), s_ab)); + BOOST_REQUIRE(ComputeECDHSecret(b, a.GetPubKey(), s_ba)); + BOOST_CHECK(s_ab == s_ba); + BOOST_CHECK_EQUAL(HexStr(s_ab), "4144a9eeca1b8cd1884c7fdb14a56a23a5e763954b21966aa5a862ea279d8d13"); +} + //! Mnemonic behind Dip14Seed() above. static constexpr const char* DIP14_MNEMONIC{ "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer"}; @@ -290,6 +318,9 @@ struct FriendshipWalletSetup : public TestChain100Setup { } static constexpr uint32_t ACCOUNT{0}; + //! Identity key 2 is the DashPay ENCRYPTION key that keys both the + //! contact-request ECDH secret and the accountReference MAC. + static constexpr IdentityAuthKey ENCRYPTION_KEY{0, 2}; const uint256 m_my_id{uint256S("1111111111111111111111111111111111111111111111111111111111111111")}; const uint256 m_their_id{uint256S("2222222222222222222222222222222222222222222222222222222222222222")}; @@ -627,22 +658,92 @@ BOOST_FIXTURE_TEST_CASE(recovery_ecdh_symmetry_across_restore, SeededWalletPair) CKey contact_key; contact_key.MakeNewKey(/*fCompressed=*/true); - const auto invalid{m_iface->platformECDHSecret(IdentityAuthKey{0, 0}, CPubKey{})}; + const auto invalid{m_iface->platformECDHSecret(ENCRYPTION_KEY, CPubKey{})}; BOOST_CHECK(invalid.status == PlatformKeyStatus::INVALID_ARGUMENT); - const auto secret_original{m_iface->platformECDHSecret(IdentityAuthKey{0, 0}, contact_key.GetPubKey())}; - const auto secret_restored{m_iface_b->platformECDHSecret(IdentityAuthKey{0, 0}, contact_key.GetPubKey())}; + const auto secret_original{m_iface->platformECDHSecret(ENCRYPTION_KEY, contact_key.GetPubKey())}; + const auto secret_restored{m_iface_b->platformECDHSecret(ENCRYPTION_KEY, contact_key.GetPubKey())}; BOOST_REQUIRE(secret_original); BOOST_REQUIRE(secret_restored); BOOST_CHECK(secret_original.value == secret_restored.value); - const auto our_auth_key{m_iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; + const auto our_auth_key{m_iface->getPlatformPubKey(ENCRYPTION_KEY)}; BOOST_REQUIRE(our_auth_key); SecureVector secret_contact_side; BOOST_REQUIRE(ComputeECDHSecret(contact_key, our_auth_key.value, secret_contact_side)); BOOST_CHECK(secret_contact_side == secret_original.value); } +//! Identity key 0 is the MASTER key. DIP-15 never uses it for ECDH or for the +//! accountReference MAC, and the wallet must refuse rather than trust callers. +BOOST_FIXTURE_TEST_CASE(master_key_refused_for_ecdh_and_mac, Dip14WalletSetup) +{ + CKey contact_key; + contact_key.MakeNewKey(/*fCompressed=*/true); + const IdentityAuthKey master_key{0, 0}; + BOOST_REQUIRE(m_iface->getPlatformPubKey(master_key)); + + BOOST_CHECK(m_iface->platformECDHSecret(master_key, contact_key.GetPubKey()).status == + PlatformKeyStatus::INVALID_ARGUMENT); + BOOST_CHECK(m_iface->platformAccountReferenceMac(master_key, CompactXpub{}).status == + PlatformKeyStatus::INVALID_ARGUMENT); + BOOST_CHECK(m_iface->platformECDHSecret(ENCRYPTION_KEY, contact_key.GetPubKey())); + BOOST_CHECK(m_iface->platformAccountReferenceMac(ENCRYPTION_KEY, CompactXpub{})); +} + +//! 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; +//! - HMAC-SHA256(ENCRYPTION key at m/9'/1'/5'/0'/0'/0'/2', compact xpub) is +//! what calculate_account_reference masks the account index with; +//! - 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"); + + const auto mac{m_iface->platformAccountReferenceMac(ENCRYPTION_KEY, compact)}; + BOOST_REQUIRE(mac); + BOOST_CHECK_EQUAL(HexStr(mac.value), "cd9f746d8c7b362670e75fabe05ddde6b35b14fd653d5ce7bf2188da929295ac"); + // calculate_account_reference(key, compact, account, version) = + // (version << 28) | ((be(mac[28..32]) >> 4) ^ account); e.g. 0x0929295a + // for account 0 / version 0 and 0x3929295f for account 5 / version 3. + BOOST_CHECK_EQUAL(ReadBE32(mac.value.begin() + 28) >> 4, 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/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp index 5a042e6eb41d..f357788d76cd 100644 --- a/src/wallet/test/wallet_tests.cpp +++ b/src/wallet/test/wallet_tests.cpp @@ -12,17 +12,23 @@ #include #include +#include #include #include +#include +#include #include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -810,6 +816,141 @@ 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, and a mempool rejection on +//! commit must reach the caller so the transaction can be abandoned. +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); + + CKey credit_key; + credit_key.MakeNewKey(/*fCompressed=*/true); + const CAmount credit_amount{COIN}; + CCoinControl coin_control; + + BOOST_CHECK(!iface->createAssetLockTransaction(0, credit_key.GetPubKey(), coin_control)); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, CPubKey{}, coin_control)); + CKey uncompressed_key; + uncompressed_key.Set(credit_key.begin(), credit_key.end(), /*fCompressedIn=*/false); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, uncompressed_key.GetPubKey(), coin_control)); + + const auto res{iface->createAssetLockTransaction(credit_amount, credit_key.GetPubKey(), coin_control)}; + 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{credit_key.GetPubKey()})); + + // Funded like any wallet transaction: the anti-fee-sniping locktime + // (tip height, occasionally lowered by up to 100) is kept. + const int tip_height{WITH_LOCK(cs_main, return m_node.chainman->ActiveChain().Height())}; + BOOST_CHECK_GT(tx->nLockTime, 0U); + BOOST_CHECK_LE(tx->nLockTime, static_cast(tip_height)); + + 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()); + + // 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; + const auto conflicting{iface->createAssetLockTransaction(credit_amount, credit_key.GetPubKey(), same_input)}; + BOOST_REQUIRE_MESSAGE(conflicting, util::ErrorString(conflicting).original); + BOOST_CHECK(!iface->commitTransaction(tx, {}, {})); + BOOST_CHECK(m_node.mempool->exists(tx->GetHash())); + + const auto error{iface->commitTransaction(*conflicting, {}, {})}; + BOOST_REQUIRE(error); + BOOST_CHECK(!m_node.mempool->exists((*conflicting)->GetHash())); + BOOST_CHECK(iface->transactionCanBeAbandoned((*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, credit_key.GetPubKey(), coin_control)); + BOOST_REQUIRE(wallet->Unlock(passphrase, /*fForMixingOnly=*/true)); + BOOST_REQUIRE(wallet->IsLocked() && !wallet->IsLocked(/*fForMixing=*/true)); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, credit_key.GetPubKey(), coin_control)); + BOOST_REQUIRE(wallet->Lock()); + BOOST_REQUIRE(wallet->Unlock(passphrase)); + const auto unlocked{iface->createAssetLockTransaction(credit_amount, credit_key.GetPubKey(), coin_control)}; + BOOST_CHECK_MESSAGE(unlocked, util::ErrorString(unlocked).original); +} + +//! A transaction conflicted by a block reports whether that block is +//! ChainLocked: only then can the conflict no longer be reorganized away. +BOOST_FIXTURE_TEST_CASE(conflict_chainlock_status, TestChain100Setup) +{ + WalletContext context; + context.args = &m_args; + context.chain = m_node.chain.get(); + const std::shared_ptr wallet{CreateSyncedWallet(*m_node.chain, *Assert(m_node.chainman), coinbaseKey)}; + auto iface{interfaces::MakeWallet(context, wallet)}; + + CMutableTransaction payment; + payment.vin.emplace_back(COutPoint{m_coinbase_txns.back()->GetHash(), 0}); + payment.vout.emplace_back(COIN, GetScriptForRawPubKey(coinbaseKey.GetPubKey())); + const CTransactionRef tx{MakeTransactionRef(payment)}; + const CBlockIndex* tip{WITH_LOCK(cs_main, return m_node.chainman->ActiveChain().Tip())}; + { + LOCK(wallet->cs_wallet); + wallet->AddToWallet(tx, TxStateConflicted{tip->GetBlockHash(), tip->nHeight}); + } + interfaces::WalletTxStatus status; + int num_blocks{0}; + int64_t block_time{0}; + BOOST_REQUIRE(iface->tryGetTxStatus(tx->GetHash(), status, num_blocks, block_time)); + BOOST_CHECK_LT(status.depth_in_main_chain, 0); + BOOST_CHECK(!status.is_conflict_chainlocked); + + m_node.sporkman->SetSporkAddress(Params().SporkAddress()); + m_node.sporkman->SetPrivKey("cP4EKFyJsHT39LDqgdcB43Y3YXjNyjb5Fuas1GQSeAtjnZWmZEQK"); + m_node.sporkman->UpdateSpork(SPORK_19_CHAINLOCKS_ENABLED, 0); + m_node.chainlocks->UpdateBestChainlock(uint256::ONE, chainlock::ChainLockSig{tip->nHeight, tip->GetBlockHash(), CBLSSignature{}}, tip); + BOOST_REQUIRE(iface->tryGetTxStatus(tx->GetHash(), status, num_blocks, block_time)); + BOOST_CHECK(status.is_conflict_chainlocked); + + // A transaction confirmed in that block is ChainLocked, not conflicted. + { + LOCK(wallet->cs_wallet); + wallet->AddToWallet(tx, TxStateConfirmed{tip->GetBlockHash(), tip->nHeight, /*index=*/1}); + } + BOOST_REQUIRE(iface->tryGetTxStatus(tx->GetHash(), status, num_blocks, block_time)); + BOOST_CHECK(status.is_chainlocked); + BOOST_CHECK(!status.is_conflict_chainlocked); +} + BOOST_FIXTURE_TEST_CASE(wallet_disableprivkeys, TestChain100Setup) { { diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index d4fd87fb938c..2ea24c4ec3f8 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2472,8 +2472,10 @@ bool CWallet::SignGovernanceVote(const CKeyID& keyID, CGovernanceVote& vote) con return true; } -void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm) +void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm, + bilingual_str* broadcast_error) { + if (broadcast_error) broadcast_error->clear(); LOCK(cs_wallet); WalletLogPrintf("CommitTransaction:\n%s", tx->ToString()); /* Continued */ @@ -2515,6 +2517,9 @@ void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::ve if (!SubmitTxMemoryPoolAndRelay(*wtx, err_string, true)) { 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), but the caller must still learn of it. + if (broadcast_error) *broadcast_error = err_string.empty() ? _("Transaction could not be broadcast") : err_string; } } @@ -4023,7 +4028,8 @@ PlatformKeyResult CWallet::PlatformECDHSecret(const IdentityAuthKe { LOCK(cs_wallet); PlatformKeyResult result; - if (!counterparty.IsFullyValid()) { + // Key 0 is the identity MASTER key; DIP-15 never uses it for ECDH. + if (request.key_index == 0 || !counterparty.IsFullyValid()) { result.status = PlatformKeyStatus::INVALID_ARGUMENT; return result; } @@ -4037,6 +4043,25 @@ PlatformKeyResult CWallet::PlatformECDHSecret(const IdentityAuthKe return result; } +PlatformKeyResult CWallet::PlatformAccountReferenceMac(const IdentityAuthKey& request, const CompactXpub& compact_xpub) const +{ + LOCK(cs_wallet); + PlatformKeyResult result; + // The MAC is keyed by the same ENCRYPTION key as the ECDH secret; never MASTER. + if (request.key_index == 0) { + result.status = PlatformKeyStatus::INVALID_ARGUMENT; + return result; + } + platformkeys::ExtKey256 key; + result.status = DerivePlatformKey(PlatformKeyRequest{request}, key); + if (result.status == PlatformKeyStatus::SUCCESS && + !platformkeys::ComputeAccountReferenceMac(key.key, compact_xpub, result.value)) { + result.status = PlatformKeyStatus::DERIVATION_ERROR; + result.value.SetNull(); + } + return result; +} + PlatformKeyResult CWallet::EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request) { LOCK(cs_wallet); @@ -4077,7 +4102,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 87c57ac3a54a..030a3c18897d 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -820,8 +820,12 @@ 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. + * @param[out] broadcast_error If given, cleared, then set to the reason when the mempool + * rejected the transaction. 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); + void CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm, + bilingual_str* broadcast_error = nullptr); /** Will SubmitTxMemoryPoolAndRelay() consider wtx if supplied */ bool CanTxBeResent(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); @@ -1084,6 +1088,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 PlatformAccountReferenceMac(const IdentityAuthKey& key, const CompactXpub& compact_xpub) const; PlatformKeyResult EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request); /**