From c9fa3095668144a705642dd767981872be81edef Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 11 Aug 2026 10:24:44 -0500 Subject: [PATCH] fix(rpc): refuse to pay Platform addresses before the v24 hard fork sendtoaddress and sendmany built a version 2 asset lock for Platform recipients unconditionally, without checking whether the v24 hard fork had activated. On a node running with -acceptnonstdtxn=1 (the only configuration where Platform sends currently relay at all), paying a Platform address before v24 activation built, signed and committed a consensus-invalid transaction (bad-assetlocktx-version-2). CommitTransaction returns void and only logs the broadcast failure, so the caller still got a txid back and the user was left with spent inputs, a permanently pending wallet entry and abandontransaction as the only way out. Gate this the way ProTx RPCs already gate on DIP0003 in SignAndSendSpecialTx(): check activation in the RPC layer rather than in the wallet, which has never consulted deployment state. The check goes in ParseRecipients next to the other Platform-address validation, and throws RPC_INVALID_PARAMETER with wording matching the existing "requires " refusals in rpc/evo_util.cpp. The accessor still has to live on interfaces::Chain because wallet RPCs have no direct ChainstateManager access. --- src/interfaces/chain.h | 4 ++++ src/node/interfaces.cpp | 5 +++++ src/wallet/rpc/spend.cpp | 13 ++++++++++--- test/functional/feature_asset_locks.py | 6 ++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h index 3ee88596f400..de2ce3bd3bb3 100644 --- a/src/interfaces/chain.h +++ b/src/interfaces/chain.h @@ -267,6 +267,10 @@ class Chain //! Always false when non-standard transactions are accepted. virtual bool isNonStandardSpecialTx(const CTransactionRef& tx, std::string& reason) = 0; + //! Whether v24 hard fork rules apply to the next block, which is what + //! enables version 2 asset lock transactions by consensus. + virtual bool isV24Active() = 0; + //! Check if any block has been pruned. virtual bool havePruned() = 0; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 6207931927db..99e66ac5b232 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -1441,6 +1441,11 @@ class ChainImpl : public Chain if (!m_node.mempool || !m_node.mempool->m_require_standard) return false; return !IsStandardSpecialTx(*tx, reason); } + bool isV24Active() override + { + LOCK(::cs_main); + return DeploymentActiveAfter(chainman().ActiveChain().Tip(), chainman(), Consensus::DEPLOYMENT_V24); + } bool havePruned() override { LOCK(::cs_main); diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp index 4dc79f8aea26..90f25dc2f154 100644 --- a/src/wallet/rpc/spend.cpp +++ b/src/wallet/rpc/spend.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -22,7 +23,7 @@ #include namespace wallet { -static void ParseRecipients(const UniValue& address_amounts, const UniValue& subtract_fee_outputs, std::vector& recipients) +static void ParseRecipients(interfaces::Chain& chain, const UniValue& address_amounts, const UniValue& subtract_fee_outputs, std::vector& recipients) { // A Platform address and a base58 address can encode the same hash, so recipients // are deduplicated by the script they pay rather than by the decoded destination. @@ -40,6 +41,12 @@ static void ParseRecipients(const UniValue& address_amounts, const UniValue& sub throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("Invalid Dash address: %s (%s)", address, error_msg)); } + if (!chain.isV24Active()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("Invalid param for %s, paying a Platform address requires an asset lock " + "transaction of version 2, which is only valid after v24 activation", + address)); + } script_pub_key = GetScriptForPlatformDestination(platform_dest); } else { script_pub_key = GetScriptForDestination(dest); @@ -316,7 +323,7 @@ RPCHelpMan sendtoaddress() } std::vector recipients; - ParseRecipients(address_amounts, subtractFeeFromAmount, recipients); + ParseRecipients(pwallet->chain(), address_amounts, subtractFeeFromAmount, recipients); const bool verbose{request.params[11].isNull() ? false : request.params[11].get_bool()}; return SendMoney(*pwallet, coin_control, recipients, mapValue, verbose); @@ -412,7 +419,7 @@ RPCHelpMan sendmany() SetFeeEstimateMode(*pwallet, coin_control, /*conf_target=*/request.params[8], /*estimate_mode=*/request.params[9], /*fee_rate=*/request.params[10], /*override_min_fee=*/false); std::vector recipients; - ParseRecipients(sendTo, subtractFeeFromAmount, recipients); + ParseRecipients(pwallet->chain(), sendTo, subtractFeeFromAmount, recipients); const bool verbose{request.params[11].isNull() ? false : request.params[11].get_bool()}; return SendMoney(*pwallet, coin_control, recipients, std::move(mapValue), verbose); diff --git a/test/functional/feature_asset_locks.py b/test/functional/feature_asset_locks.py index b1b0aa980fbe..f2241a333d49 100755 --- a/test/functional/feature_asset_locks.py +++ b/test/functional/feature_asset_locks.py @@ -774,6 +774,12 @@ def test_asset_locks_v2_pre_v24(self, node_wallet, node, pubkey): result_expected={'allowed': False, 'reject-reason': 'bad-assetlocktx-version-2'}) self.log.info("v2 asset lock correctly rejected pre-v24") + self.log.info("Spending RPCs refuse to pay a Platform address before the fork") + assert_raises_rpc_error(-8, "only valid after v24 activation", node_wallet.sendtoaddress, + encode_platform_p2pkh('tdash', hash160(pubkey)), 1) + assert_raises_rpc_error(-8, "only valid after v24 activation", node_wallet.sendmany, "", + {encode_platform_p2pkh('tdash', hash160(pubkey)): 1}) + def test_asset_locks_v2(self, node_wallet, node, pubkey): self.log.info("Testing asset lock v2 after v24 activation...") assert softfork_active(node_wallet, 'v24')