From e4428b7d654fc4182b0f75db00cf5566f1bf8d91 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 10:33:32 -0500 Subject: [PATCH 1/7] refactor: pass special transaction deployment rules as a struct CheckSpecialTx and ProcessSpecialTxsInBlock take the deployment state that special transaction validation depends on from their callers. Bundle it in SpecialTxRules, filled by GetSpecialTxRules(), so another deployment can be added without touching every caller again. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/evo/providertx_service.cpp | 5 ++--- src/evo/specialtxman.cpp | 40 ++++++++++++++++------------------ src/evo/specialtxman.h | 19 +++++++++++----- src/instantsend/signing.cpp | 4 ++-- src/node/miner.cpp | 12 +++++----- src/validation.cpp | 37 ++++++++++++++++++++----------- src/validation.h | 4 ++++ 7 files changed, 69 insertions(+), 52 deletions(-) diff --git a/src/evo/providertx_service.cpp b/src/evo/providertx_service.cpp index 3d3f6ed5f439..c0a0da028788 100644 --- a/src/evo/providertx_service.cpp +++ b/src/evo/providertx_service.cpp @@ -360,9 +360,8 @@ std::optional Preflight(node::NodeContext& node, const CTransac } TxValidationState state; - const bool is_v24_active{DeploymentActiveAfter(tip, chainman, Consensus::DEPLOYMENT_V24)}; - if (!chain_helper.special_tx->CheckSpecialTx(tx, tip, is_v24_active, chainman.ActiveChainstate().CoinsTip(), true, - state)) { + if (!chain_helper.special_tx->CheckSpecialTx(tx, tip, GetSpecialTxRules(tip, chainman), + chainman.ActiveChainstate().CoinsTip(), true, state)) { return Error(ProviderTxErrorCode::CONSENSUS_REJECTED, state.ToString(), state.GetRejectReason()); } return std::nullopt; diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index be1f0f18175c..612584c16917 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -215,7 +215,7 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const } bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransaction& tx, - const CBlockIndex* pindexPrev, bool is_v24_active, + const CBlockIndex* pindexPrev, SpecialTxRules rules, const CCoinsViewCache& view, const std::optional& indexes, bool check_sigs, TxValidationState& state) { @@ -231,26 +231,25 @@ bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransa try { switch (tx.nType) { case TRANSACTION_PROVIDER_REGISTER: - return CheckProRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, is_v24_active, state, check_sigs); + return CheckProRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_SERVICE: - return CheckProUpServTx(tx, pindexPrev, m_dmnman, m_consensus_params, is_v24_active, state, check_sigs); + return CheckProUpServTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_REGISTRAR: - return CheckProUpRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, is_v24_active, state, check_sigs); + return CheckProUpRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_REVOKE: - return CheckProUpRevTx(tx, pindexPrev, m_dmnman, m_consensus_params, is_v24_active, state, check_sigs); + return CheckProUpRevTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_DISSOLVE: - return CheckProDisTx(tx, pindexPrev, m_dmnman, m_consensus_params, is_v24_active, state, check_sigs); + return CheckProDisTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_SHARE: - return CheckProUpShareTx(tx, pindexPrev, m_dmnman, m_consensus_params, is_v24_active, state, check_sigs); + return CheckProUpShareTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_SHARED_REGISTRAR: - return CheckProUpSharedRegTx(tx, pindexPrev, m_dmnman, m_consensus_params, is_v24_active, state, - check_sigs); + return CheckProUpSharedRegTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_COINBASE: { if (!tx.IsCoinBase()) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-cbtx-invalid"); } if (const auto opt_cbTx = GetTxPayload(tx)) { - return CheckCbTx(*opt_cbTx, pindexPrev, is_v24_active, state); + return CheckCbTx(*opt_cbTx, pindexPrev, rules.v24, state); } else { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-cbtx-payload"); } @@ -261,10 +260,10 @@ bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransa return chain ? CheckMNHFTx(m_blockman, m_qman, *chain, tx, pindexPrev, state) : CheckMNHFTx(m_blockman, m_qman, tx, pindexPrev, state); case TRANSACTION_ASSET_LOCK: - return CheckAssetLockTx(tx, state, is_v24_active); + return CheckAssetLockTx(tx, state, rules.v24); case TRANSACTION_ASSET_UNLOCK: - return chain ? CheckAssetUnlockTx(m_blockman, m_qman, *chain, tx, pindexPrev, indexes, is_v24_active, state) : - CheckAssetUnlockTx(m_blockman, m_qman, tx, pindexPrev, indexes, is_v24_active, state); + return chain ? CheckAssetUnlockTx(m_blockman, m_qman, *chain, tx, pindexPrev, indexes, rules.v24, state) + : CheckAssetUnlockTx(m_blockman, m_qman, tx, pindexPrev, indexes, rules.v24, state); } } catch (const std::exception& e) { LogPrintf("%s -- failed: %s\n", __func__, e.what()); @@ -274,11 +273,11 @@ bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransa return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-tx-type-check"); } -bool CSpecialTxProcessor::CheckSpecialTx(const CTransaction& tx, const CBlockIndex* pindexPrev, bool is_v24_active, +bool CSpecialTxProcessor::CheckSpecialTx(const CTransaction& tx, const CBlockIndex* pindexPrev, SpecialTxRules rules, const CCoinsViewCache& view, bool check_sigs, TxValidationState& state) { AssertLockHeld(::cs_main); - return CheckSpecialTxInner(nullptr, tx, pindexPrev, is_v24_active, view, std::nullopt, check_sigs, state); + return CheckSpecialTxInner(nullptr, tx, pindexPrev, rules, view, std::nullopt, check_sigs, state); } static void HandleQuorumCommitment(const llmq::CFinalCommitment& qc, const std::vector& members, @@ -796,7 +795,7 @@ bool CSpecialTxProcessor::RebuildListFromBlock(const CBlock& block, gsl::not_nul } bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const CChain& chain, const CBlock& block, - const CBlockIndex* pindex, bool is_v24_active, + const CBlockIndex* pindex, SpecialTxRules rules, const CCoinsViewCache& view, CAmount blockSubsidy, bool fJustCheck, bool fCheckCbTxMerkleRoots, BlockValidationState& state, MNListUpdates& updatesRet) @@ -821,7 +820,7 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const } if (opt_cbTx = GetTxPayload(*tx); opt_cbTx) { TxValidationState tx_state; - if (!CheckCbTx(*opt_cbTx, pindex->pprev, is_v24_active, tx_state)) { + if (!CheckCbTx(*opt_cbTx, pindex->pprev, rules.v24, tx_state)) { assert(tx_state.GetResult() == TxValidationResult::TX_CONSENSUS || tx_state.GetResult() == TxValidationResult::TX_BAD_SPECIAL); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), @@ -854,7 +853,7 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const for (size_t i = 0; i < block.vtx.size(); ++i) { // The shared-collateral creation rule applies to every transaction, including the // coinbase and non-special transactions, which CheckSpecialTxInner never sees - if (is_v24_active) { + if (rules.v24) { if (TxValidationState tx_state; !CheckSharedCollateralTemplateOutputs(*block.vtx[i], tx_state)) { return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), strprintf("Shared collateral check failed (tx hash %s) %s", @@ -869,8 +868,7 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const TxValidationState tx_state; // At this moment CheckSpecialTx() may fail by 2 possible ways: // consensus failures and "TX_BAD_SPECIAL" - if (!CheckSpecialTxInner(&chain, *ptr_tx, pindex->pprev, is_v24_active, view, indexes, fCheckCbTxMerkleRoots, - tx_state)) { + if (!CheckSpecialTxInner(&chain, *ptr_tx, pindex->pprev, rules, view, indexes, fCheckCbTxMerkleRoots, tx_state)) { assert(tx_state.GetResult() == TxValidationResult::TX_CONSENSUS || tx_state.GetResult() == TxValidationResult::TX_BAD_SPECIAL); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), strprintf("Special Transaction check failed (tx hash %s) %s", ptr_tx->GetHash().ToString(), tx_state.GetDebugMessage())); @@ -906,7 +904,7 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const CDeterministicMNList mn_list; if (DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_DIP0003)) { - if (!BuildNewListFromBlock(block, pindex->pprev, is_v24_active, view, true, state, mn_list)) { + if (!BuildNewListFromBlock(block, pindex->pprev, rules.v24, view, true, state, mn_list)) { // pass the state returned by the function above return false; } diff --git a/src/evo/specialtxman.h b/src/evo/specialtxman.h index cb900c749d89..c96241ac623b 100644 --- a/src/evo/specialtxman.h +++ b/src/evo/specialtxman.h @@ -44,6 +44,12 @@ namespace node { class BlockManager; } // namespace node +/** Activation state of the deployments that special transaction validation depends on, evaluated by the + * caller for the block after pindexPrev (see GetSpecialTxRules() in validation.h) */ +struct SpecialTxRules { + bool v24{false}; +}; + class CSpecialTxProcessor { private: @@ -77,13 +83,14 @@ class CSpecialTxProcessor { } - bool CheckSpecialTx(const CTransaction& tx, const CBlockIndex* pindexPrev, bool is_v24_active, + bool CheckSpecialTx(const CTransaction& tx, const CBlockIndex* pindexPrev, SpecialTxRules rules, const CCoinsViewCache& view, bool check_sigs, TxValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); - bool ProcessSpecialTxsInBlock(Chainstate& chainstate, const CChain& chain, const CBlock& block, const CBlockIndex* pindex, - bool is_v24_active, const CCoinsViewCache& view, CAmount blockSubsidy, bool fJustCheck, - bool fCheckCbTxMerkleRoots, BlockValidationState& state, - MNListUpdates& updatesRet) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + bool ProcessSpecialTxsInBlock(Chainstate& chainstate, const CChain& chain, const CBlock& block, + const CBlockIndex* pindex, SpecialTxRules rules, const CCoinsViewCache& view, + CAmount blockSubsidy, bool fJustCheck, bool fCheckCbTxMerkleRoots, + BlockValidationState& state, MNListUpdates& updatesRet) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); bool UndoSpecialTxsInBlock(const Chainstate& chainstate, const CBlock& block, const CBlockIndex* pindex, MNListUpdates& updatesRet) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); @@ -101,7 +108,7 @@ class CSpecialTxProcessor private: bool CheckSpecialTxInner(const CChain* chain, const CTransaction& tx, const CBlockIndex* pindexPrev, - bool is_v24_active, const CCoinsViewCache& view, const std::optional& indexes, + SpecialTxRules rules, const CCoinsViewCache& view, const std::optional& indexes, bool check_sigs, TxValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); bool CheckCreditPoolDiffForBlock(const CBlock& block, const CBlockIndex* pindex, const CCbTx& cbTx, CAmount blockSubsidy, BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); diff --git a/src/instantsend/signing.cpp b/src/instantsend/signing.cpp index 921b7291014e..bf5749a174a3 100644 --- a/src/instantsend/signing.cpp +++ b/src/instantsend/signing.cpp @@ -211,8 +211,8 @@ bool InstantSendSigner::CheckCanLockAssetUnlock(const CTransaction& tx, bool pri const CBlockIndex* tip = chainstate.m_chain.Tip(); // Minable in the next block: inside its height window and signed by a recent quorum TxValidationState state; - const bool is_v24_active{DeploymentActiveAfter(tip, m_chainman, Consensus::DEPLOYMENT_V24)}; - if (!chainstate.ChainHelper().special_tx->CheckSpecialTx(tx, tip, is_v24_active, chainstate.CoinsTip(), + if (!chainstate.ChainHelper().special_tx->CheckSpecialTx(tx, tip, GetSpecialTxRules(tip, m_chainman), + chainstate.CoinsTip(), /*check_sigs=*/true, state)) { return log_refusal(state.ToString()); } diff --git a/src/node/miner.cpp b/src/node/miner.cpp index 3a64cb5d942c..7009839ab8bc 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -378,8 +378,7 @@ bool BlockAssembler::TestPackage(uint64_t packageSize, unsigned int packageSigOp // - safe TXs in regard to ChainLocks bool BlockAssembler::TestPackageTransactions(const CTxMemPool::setEntries& package) const { - const bool is_v24_active{ - DeploymentActiveAfter(m_chainstate.m_chain.Tip(), m_chainstate.m_chainman, Consensus::DEPLOYMENT_V24)}; + const SpecialTxRules special_tx_rules{GetSpecialTxRules(m_chainstate.m_chain.Tip(), m_chainstate.m_chainman)}; for (CTxMemPool::txiter it : package) { if (!IsFinalTx(it->GetTx(), nHeight, m_lock_time_cutoff)) { return false; @@ -396,7 +395,7 @@ bool BlockAssembler::TestPackageTransactions(const CTxMemPool::setEntries& packa // signature regardless, which is cheap enough here given how rare MNHF signals are. if (it->GetTx().IsSpecialTxVersion()) { TxValidationState tx_state; - if (!m_chain_helper.special_tx->CheckSpecialTx(it->GetTx(), m_chainstate.m_chain.Tip(), is_v24_active, + if (!m_chain_helper.special_tx->CheckSpecialTx(it->GetTx(), m_chainstate.m_chain.Tip(), special_tx_rules, m_chainstate.CoinsTip(), /*check_sigs=*/false, tx_state)) { return false; } @@ -406,8 +405,7 @@ bool BlockAssembler::TestPackageTransactions(const CTxMemPool::setEntries& packa // normal transaction creating or spending a template output can sit in the mempool across // v24 activation on a node accepting nonstandard transactions, and would poison every // template through the same TestBlockValidity path described above. - if (DeploymentActiveAfter(m_chainstate.m_chain.Tip(), m_chainstate.m_chainman, - Consensus::DEPLOYMENT_V24)) { + if (special_tx_rules.v24) { TxValidationState tx_state; if (!CheckSharedCollateralSpends(it->GetTx(), m_chainstate.CoinsTip(), tx_state) || !CheckSharedCollateralTemplateOutputs(it->GetTx(), tx_state)) { @@ -511,7 +509,7 @@ void BlockAssembler::addPackageTxs(const CTxMemPool& mempool, int& nPackagesSele // This map with signals is used only to find duplicates auto signals = m_chain_helper.ehf_manager->GetSignalsStage(pindexPrev); - const bool is_v24_active{DeploymentActiveAfter(pindexPrev, m_chainstate.m_chainman, Consensus::DEPLOYMENT_V24)}; + const SpecialTxRules special_tx_rules{GetSpecialTxRules(pindexPrev, m_chainstate.m_chainman)}; // mapModifiedTx will store sorted packages after they are modified // because some of their txs are already in the block @@ -651,7 +649,7 @@ void BlockAssembler::addPackageTxs(const CTxMemPool& mempool, int& nPackagesSele // producing an invalid template. if (IsAssetUnlockWithStableTxid(tx)) { TxValidationState state; - if (!m_chain_helper.special_tx->CheckSpecialTx(tx, m_chainstate.m_chain.Tip(), is_v24_active, + if (!m_chain_helper.special_tx->CheckSpecialTx(tx, m_chainstate.m_chain.Tip(), special_tx_rules, m_chainstate.CoinsTip(), /*check_sigs=*/true, state)) { LogPrintf("%s: package tx %s skipped, asset unlock instance not currently minable: %s\n", __func__, tx.GetHash().ToString(), state.ToString()); diff --git a/src/validation.cpp b/src/validation.cpp index 397626256a13..f04f26785477 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -734,7 +734,7 @@ class MemPoolAccept // so it is validated against its window instead of the tip: nodes that did not hold it (after a // restart, or a new peer) admit it and its descendants too. It stays unminable and is not // InstantSend-locked until refreshed, as the miner and the lock signer validate against the tip. - bool CheckSpecialTxForMempool(const CTransaction& tx, bool is_v24_active, TxValidationState& state) + bool CheckSpecialTxForMempool(const CTransaction& tx, SpecialTxRules rules, TxValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(cs_main); // Enforce package mempool ancestor/descendant limits (distinct from individual @@ -799,12 +799,12 @@ class MemPoolAccept CTxMemPool::Limits m_limits; }; -bool MemPoolAccept::CheckSpecialTxForMempool(const CTransaction& tx, bool is_v24_active, TxValidationState& state) +bool MemPoolAccept::CheckSpecialTxForMempool(const CTransaction& tx, SpecialTxRules rules, TxValidationState& state) { const CChain& chain{m_active_chainstate.m_chain}; const auto check_at = [&](const CBlockIndex* pindex, TxValidationState& check_state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { - return m_chain_helper.special_tx->CheckSpecialTx(tx, pindex, is_v24_active, m_active_chainstate.CoinsTip(), + return m_chain_helper.special_tx->CheckSpecialTx(tx, pindex, rules, m_active_chainstate.CoinsTip(), /*check_sigs=*/true, check_state); }; const auto payload{IsAssetUnlockWithStableTxid(tx) ? GetTxPayload(tx) : std::nullopt}; @@ -1100,8 +1100,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) // mined // NOTE: we use UTXO here and do NOT allow mempool txes as masternode collaterals const CBlockIndex* tip{m_active_chainstate.m_chain.Tip()}; - const bool is_v24_active{DeploymentActiveAfter(tip, m_active_chainstate.m_chainman, Consensus::DEPLOYMENT_V24)}; - if (!CheckSpecialTxForMempool(tx, is_v24_active, state)) return false; + if (!CheckSpecialTxForMempool(tx, GetSpecialTxRules(tip, m_active_chainstate.m_chainman), state)) return false; if (m_pool.existsProviderTxConflict(tx)) { return state.Invalid(TxValidationResult::TX_CONFLICT, "protx-dup"); @@ -1398,8 +1397,8 @@ std::optional MemPoolAccept::TryAssetUnlockRefresh(const CT // and quorum signature. Everything the txid covers is identical to the held instance and // was validated when it was admitted. const CBlockIndex* tip{m_active_chainstate.m_chain.Tip()}; - const bool is_v24_active{DeploymentActiveAfter(tip, m_active_chainstate.m_chainman, Consensus::DEPLOYMENT_V24)}; - if (!m_chain_helper.special_tx->CheckSpecialTx(*ptx, tip, is_v24_active, m_active_chainstate.CoinsTip(), + if (!m_chain_helper.special_tx->CheckSpecialTx(*ptx, tip, GetSpecialTxRules(tip, m_active_chainstate.m_chainman), + m_active_chainstate.CoinsTip(), /*check_sigs=*/true, state)) { return MempoolAcceptResult::Failure(state); } @@ -2657,11 +2656,13 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, bool fDIP0001Active_context = DeploymentActiveAt(*pindex, params.GetConsensus(), Consensus::DEPLOYMENT_DIP0001); const CAmount blockSubsidy = GetBlockSubsidy(pindex, params.GetConsensus()); - const bool is_v24_active{DeploymentActiveAfter(pindex->pprev, m_chainman, Consensus::DEPLOYMENT_V24)}; + const SpecialTxRules special_tx_rules{GetSpecialTxRules(pindex->pprev, m_chainman)}; // MUST process special txes before updating UTXO to ensure consistency between mempool and block processing MNListUpdates mnlist_updates; - if (!m_chain_helper->special_tx->ProcessSpecialTxsInBlock(*this, m_chain, block, pindex, is_v24_active, view, blockSubsidy, fJustCheck, fScriptChecks, state, mnlist_updates)) { + if (!m_chain_helper->special_tx->ProcessSpecialTxsInBlock(*this, m_chain, block, pindex, special_tx_rules, view, + blockSubsidy, fJustCheck, fScriptChecks, state, + mnlist_updates)) { LogError("ConnectBlock(DASH): ProcessSpecialTxsInBlock for block %s failed with %s\n", pindex->GetBlockHash().ToString(), state.ToString()); return false; @@ -2839,7 +2840,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, const SuperBlockCheckType check_superblock = !m_chain_helper->IsSuperblockValidationRequired(pindex) ? SuperBlockCheckType::NoCheck - : is_v24_active ? SuperBlockCheckType::DisallowDuplicates : SuperBlockCheckType::AllowDuplicates; + : special_tx_rules.v24 ? SuperBlockCheckType::DisallowDuplicates : SuperBlockCheckType::AllowDuplicates; if (!m_chain_helper->mn_payments->IsBlockValueValid(m_chain, block, pindex->pprev, blockSubsidy + feeReward, strError, check_superblock)) { @@ -2856,7 +2857,8 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, Ticks(time_value_valid) / num_blocks_total); const MnRewardEra mn_reward_era{GetMnRewardEraAfter(pindex->pprev, m_chainman)}; - if (!m_chain_helper->mn_payments->IsBlockPayeeValid(m_chain, *block.vtx[0], pindex->pprev, blockSubsidy, feeReward, mn_reward_era, is_v24_active, check_superblock)) { + if (!m_chain_helper->mn_payments->IsBlockPayeeValid(m_chain, *block.vtx[0], pindex->pprev, blockSubsidy, feeReward, + mn_reward_era, special_tx_rules.v24, check_superblock)) { // NOTE: Do not punish, the node might be missing governance data LogPrintf("ERROR: ConnectBlock(DASH): couldn't find masternode or superblock payments\n"); return state.Invalid(BlockValidationResult::BLOCK_RESULT_UNSET, "bad-cb-payee"); @@ -5191,9 +5193,11 @@ bool Chainstate::RollforwardBlock(const CBlockIndex* pindex, CCoinsViewCache& in // MUST process special txes before updating UTXO to ensure consistency between mempool and block processing BlockValidationState state; const CAmount blockSubsidy = GetBlockSubsidy(pindex, m_chainman.GetConsensus()); - const bool is_v24_active{DeploymentActiveAfter(pindex->pprev, m_chainman, Consensus::DEPLOYMENT_V24)}; MNListUpdates mnlist_updates; - if (!m_chain_helper->special_tx->ProcessSpecialTxsInBlock(*this, m_chain, block, pindex, is_v24_active, inputs, blockSubsidy, /*fJustCheck=*/false, /*fCheckCbTxMerkleRoots=*/false, state, mnlist_updates)) { + if (!m_chain_helper->special_tx->ProcessSpecialTxsInBlock(*this, m_chain, block, pindex, + GetSpecialTxRules(pindex->pprev, m_chainman), inputs, + blockSubsidy, /*fJustCheck=*/false, + /*fCheckCbTxMerkleRoots=*/false, state, mnlist_updates)) { LogError("RollforwardBlock(DASH): ProcessSpecialTxsInBlock for block %s failed with %s\n", pindex->GetBlockHash().ToString(), state.ToString()); return false; @@ -6790,6 +6794,13 @@ bool IsBIP30Unspendable(const CBlockIndex& block_index) return false; } +SpecialTxRules GetSpecialTxRules(const CBlockIndex* pindexPrev, const ChainstateManager& chainman) +{ + return SpecialTxRules{ + .v24 = DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24), + }; +} + [[nodiscard]] uint16_t DeploymentToProtxVersion(gsl::not_null pindexPrev, const ChainstateManager& chainman, std::optional is_basic_override) { diff --git a/src/validation.h b/src/validation.h index e0c4087df5f0..2b6e8e6ce9f9 100644 --- a/src/validation.h +++ b/src/validation.h @@ -61,6 +61,7 @@ struct PrecomputedTransactionData; struct ChainTxData; class DisconnectedBlockTransactions; struct LockPoints; +struct SpecialTxRules; struct AssumeutxoData; namespace Consensus { struct Params; @@ -1273,6 +1274,9 @@ bool DeploymentEnabled(const ChainstateManager& chainman, DEP dep) return DeploymentEnabled(chainman.GetConsensus(), dep); } +/** The special transaction rules in force for the block after pindexPrev */ +SpecialTxRules GetSpecialTxRules(const CBlockIndex* pindexPrev, const ChainstateManager& chainman); + /** Get highest permissible ProTx version based on deployment status * Note: The override is needed because some RPCs need to use deployment status information for everything *except* * the BLS version upgrade since they are specializations for a specific BLS version. This is a one-off. From 68a9144d50c4000c9c7842342c6d2b46ae34291a Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 10:39:52 -0500 Subject: [PATCH 2/7] feat: add the evo_shares deployment, not signed in v24 DEPLOYMENT_EVO_SHARES (bit 14) is a masternode-activated deployment with the same window, thresholds and start times as DEPLOYMENT_V24. It will lift the v24 restrictions on EvoNode shared collateral and multiple owner payouts once Platform supports them, which is expected in v24.1. Its consensus parameters must be identical in v24 and v24.1: nStartTime is consensus (it gates versionbits STARTED, and an MNHF signal mined before it is treated as expired), so it cannot be used to hold the deployment back. A unit test keeps evo_shares' parameters equal to v24's on every network; in particular, mainnet's start time must be filled in for both. Instead, v24 masternodes do not sign the evo_shares MNHF signal. The deployment cannot start until masternodes running a later release sign it, and v24 nodes follow it from then on like any other deployment. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/chainparams.cpp | 36 +++++++++++++++++++++++++++++++ src/consensus/params.h | 1 + src/deploymentinfo.cpp | 4 ++++ src/llmq/ehf_signals.cpp | 7 +++++- src/rpc/blockchain.cpp | 1 + src/test/evo_snapshot_tests.cpp | 6 +++--- src/test/versionbits_tests.cpp | 22 +++++++++++++++++++ test/functional/rpc_blockchain.py | 13 +++++++++++ 8 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 901491876a69..c46bc0a738b8 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -218,6 +218,15 @@ class CMainParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].bit = 14; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; // TODO: same as DEPLOYMENT_V24 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; // TODO: same as DEPLOYMENT_V24 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nWindowSize = 4032; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdStart = 3226; // 80% of 4032 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdMin = 2420; // 60% of 4032 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].useEHF = true; + consensus.nMinimumChainWork = uint256S("0x00000000000000000000000000000000000000000000ba7225adc3153945d14d"); // 2487500 consensus.defaultAssumeValid = uint256S("0x00000000000000119fe42827219e0686d3f7b494ae65f823194c740c5dbab492"); // 2487500 @@ -418,6 +427,15 @@ class CTestNetParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].bit = 14; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nStartTime = 1790208000; // Thursday, September 24, 2026 0:00:00 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nWindowSize = 100; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdStart = 80; // 80% of 100 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdMin = 60; // 60% of 100 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].useEHF = true; + consensus.nMinimumChainWork = uint256S("0x000000000000000000000000000000000000000000000000036c8f738da818d2"); // 1400000 consensus.defaultAssumeValid = uint256S("0x000000541a23f9db7411cddbe50f9f1ebd4aa7108ebdcad62214753f648c0239"); // 1400000 @@ -591,6 +609,15 @@ class CDevNetParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].bit = 14; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nStartTime = 1751328000; // July 1, 2025 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nWindowSize = 120; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdStart = 96; // 80% of 120 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdMin = 72; // 60% of 120 + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].useEHF = true; + consensus.nMinimumChainWork = uint256{}; consensus.defaultAssumeValid = uint256{}; @@ -826,6 +853,15 @@ class CRegTestParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].bit = 14; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nStartTime = 0; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nWindowSize = 250; + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdStart = 250 / 5 * 4; // 80% of window size + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nThresholdMin = 250 / 5 * 3; // 60% of window size + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_EVO_SHARES].useEHF = true; + consensus.nMinimumChainWork = uint256{}; consensus.defaultAssumeValid = uint256{}; diff --git a/src/consensus/params.h b/src/consensus/params.h index 20c20531abcb..7a6f724b8e48 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -42,6 +42,7 @@ constexpr bool ValidDeployment(BuriedDeployment dep) { return dep <= DEPLOYMENT_ enum DeploymentPos : uint16_t { DEPLOYMENT_TESTDUMMY, DEPLOYMENT_V24, // Deployment of doubling withdrawal limit, extended addresses + DEPLOYMENT_EVO_SHARES, // Deployment of EvoNode shared collateral and multiple owner payouts // NOTE: Also add new deployments to VersionBitsDeploymentInfo in deploymentinfo.cpp MAX_VERSION_BITS_DEPLOYMENTS }; diff --git a/src/deploymentinfo.cpp b/src/deploymentinfo.cpp index 856403e32889..594bf6615a08 100644 --- a/src/deploymentinfo.cpp +++ b/src/deploymentinfo.cpp @@ -15,6 +15,10 @@ const struct VBDeploymentInfo VersionBitsDeploymentInfo[Consensus::MAX_VERSION_B /*.name =*/"v24", /*.gbt_force =*/true, }, + { + /*.name =*/"evo_shares", + /*.gbt_force =*/true, + }, }; std::string DeploymentName(Consensus::BuriedDeployment dep) diff --git a/src/llmq/ehf_signals.cpp b/src/llmq/ehf_signals.cpp index 6f0077455c98..fc8563fdc197 100644 --- a/src/llmq/ehf_signals.cpp +++ b/src/llmq/ehf_signals.cpp @@ -45,9 +45,14 @@ void CEHFSignalsHandler::UpdatedBlockTip(const CBlockIndex* const pindexNew) const auto ehfSignals = m_chainman.ActiveChainstate().ChainHelper().ehf_manager->GetSignalsStage(pindexNew); const int64_t time_past = pindexNew->GetMedianTimePast(); - for (const auto& deployment : Params().GetConsensus().vDeployments) { + for (int i = 0; i < Consensus::MAX_VERSION_BITS_DEPLOYMENTS; ++i) { + const auto pos{static_cast(i)}; + const auto& deployment{Params().GetConsensus().vDeployments[pos]}; // Skip deployments that do not use dip0023 if (!deployment.useEHF) continue; + // v24 follows evo_shares without signing it; a later release signs it once Platform supports + // EvoNode shares and payouts + if (pos == Consensus::DEPLOYMENT_EVO_SHARES) continue; // IsValidMNActivation() is permissive for validation (it accepts bits of NEVER_ACTIVE deployments // and bits outside of any window), so only sign inside the deployment's own start/timeout window if (!IsSignalWindowOpen(deployment, time_past)) continue; diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 2fa3cbac1c1d..e09dc384984c 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1687,6 +1687,7 @@ UniValue DeploymentInfo(const CBlockIndex* blockindex, const CMNHFManager::Signa } for (auto ehf_deploy : { /* sorted by activation block */ Consensus::DEPLOYMENT_V24, + Consensus::DEPLOYMENT_EVO_SHARES, Consensus::DEPLOYMENT_TESTDUMMY }) { SoftForkDescPushBack(blockindex, ehf_signals, softforks, chainman, ehf_deploy); } diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 2d3f97647c16..38d18473d454 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -1192,10 +1192,10 @@ BOOST_FIXTURE_TEST_CASE(mnhf_signal_wire_order_is_canonical, BasicTestingSetup) BOOST_FIXTURE_TEST_CASE(mnhf_signals_outnumbering_deployments_are_valid, BasicTestingSetup) { // CMNHFManager drops a signal only when a current deployment reuses its - // bit, so signals of buried EHF forks (MN_RR bit 10, WITHDRAWALS bit 11) - // stay in the map alongside V24's bit 12. + // bit, so signals of buried EHF forks (bits 10, 11 and 12) stay in the + // map alongside V24's bit 13. auto snapshot{SyntheticSnapshot()}; - snapshot.mnhf_signals = {{10, 100}, {11, 200}, {12, 300}}; + snapshot.mnhf_signals = {{10, 100}, {11, 200}, {12, 300}, {13, 400}}; BOOST_REQUIRE_GT(snapshot.mnhf_signals.size(), size_t{Consensus::MAX_VERSION_BITS_DEPLOYMENTS}); BOOST_CHECK_NO_THROW(snapshot.Validate()); diff --git a/src/test/versionbits_tests.cpp b/src/test/versionbits_tests.cpp index 1cd9db5fef07..6770f1d76a97 100644 --- a/src/test/versionbits_tests.cpp +++ b/src/test/versionbits_tests.cpp @@ -476,4 +476,26 @@ BOOST_AUTO_TEST_CASE(versionbits_computeblockversion) } } +BOOST_AUTO_TEST_CASE(evo_shares_follows_v24) +{ + // evo_shares lifts restrictions that v24 introduces. Its consensus parameters ship in v24 and must + // stay identical in the release that signals for it, so they mirror v24's on every network. + for (const auto& chain_name : {CBaseChainParams::MAIN, CBaseChainParams::TESTNET, CBaseChainParams::DEVNET, + CBaseChainParams::REGTEST}) { + BOOST_TEST_MESSAGE(chain_name); + const auto chainParams = CreateChainParams(*m_node.args, chain_name); + const auto& v24{chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_V24]}; + const auto& evo_shares{chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_EVO_SHARES]}; + BOOST_CHECK_EQUAL(evo_shares.bit, 14); + BOOST_CHECK_EQUAL(evo_shares.nStartTime, v24.nStartTime); + BOOST_CHECK_EQUAL(evo_shares.nTimeout, v24.nTimeout); + BOOST_CHECK_EQUAL(evo_shares.min_activation_height, v24.min_activation_height); + BOOST_CHECK_EQUAL(evo_shares.nWindowSize, v24.nWindowSize); + BOOST_CHECK_EQUAL(evo_shares.nThresholdStart, v24.nThresholdStart); + BOOST_CHECK_EQUAL(evo_shares.nThresholdMin, v24.nThresholdMin); + BOOST_CHECK_EQUAL(evo_shares.nFalloffCoeff, v24.nFalloffCoeff); + BOOST_CHECK(evo_shares.useEHF && v24.useEHF); + } +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/rpc_blockchain.py b/test/functional/rpc_blockchain.py index 4a7472db76a3..bf8bfa0500d2 100755 --- a/test/functional/rpc_blockchain.py +++ b/test/functional/rpc_blockchain.py @@ -227,6 +227,19 @@ def check_signalling_deploymentinfo_result(self, gdi_result, height, blockhash, }, 'active': False }, + 'evo_shares': { + 'type': 'bip9', + 'bip9': { + 'start_time': 0, + 'timeout': 9223372036854775807, # "evo_shares" does not have a timeout so is set to the max int64 value + 'min_activation_height': 0, + 'since': 0, + 'status': 'defined', + 'status_next': 'defined', + 'ehf': True, + }, + 'active': False + }, 'testdummy': { 'type': 'bip9', 'bip9': { From 9deae1f8f06ea4e59ce8446957afcbd123e5bbf3 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 10:53:46 -0500 Subject: [PATCH 3/7] feat: restrict EvoNode shares and multiple payouts until evo_shares Platform does not support EvoNodes with shared collateral or with more than one owner payout yet. Until DEPLOYMENT_EVO_SHARES is active: - an EvoNode ProRegTx with a share table is rejected with bad-protx-shares-evo. This rule moves from the stateless CProRegTx::IsTriviallyValid() into CheckProRegTx(), since it now depends on the deployment; - an EvoNode ProRegTx or ProUpRegTx with more than one owner payout is rejected with bad-protx-payouts-evo. The ProUpRegTx check covers the operator key rotation path too, as both paths store the payload's payouts. Neither rule needs a v24 gate: share tables and payout lists only exist in version 3 payloads, which need v24. The shared registration consent digest now also covers an EvoNode's platformNodeID, so share owners consent to the Platform identity too. This only changes the digest of EvoNode registrations, which were invalid until now; the digest of a regular masternode registration is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/evo/providertx.cpp | 6 +- src/evo/specialtxman.cpp | 32 ++-- src/evo/specialtxman.h | 6 +- src/test/evo_deterministicmns_tests.cpp | 207 ++++++++++++++++++++---- src/test/evo_sharedmn_tests.cpp | 20 ++- src/validation.cpp | 1 + 6 files changed, 225 insertions(+), 47 deletions(-) diff --git a/src/evo/providertx.cpp b/src/evo/providertx.cpp index 4037fa8bf302..c10b312a441a 100644 --- a/src/evo/providertx.cpp +++ b/src/evo/providertx.cpp @@ -278,9 +278,6 @@ bool CProRegTx::IsTriviallyValid(TxValidationState& state) const } if (IsShared()) { - if (nType != MnType::Regular) { - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-shares-evo"); - } // The collateral must be internal; the funding inputs and outputs are covered by the consent digest if (!collateralOutpoint.hash.IsNull()) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-shares-external"); @@ -359,6 +356,9 @@ uint256 CProRegTx::MakeSharedRegConsentHash(const CTransaction& tx) const hw << nMode; hw << NetInfoSerWrapper(const_cast&>(netInfo), nVersion >= ProTxVersion::ExtAddr); + if (nType == MnType::Evo) { + hw << platformNodeID; + } hw << keyIDVoting; hw << CBLSLazyPublicKeyVersionWrapper(const_cast(pubKeyOperator), nVersion == ProTxVersion::LegacyBLS); diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 612584c16917..28f59ec6ced5 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -231,11 +231,11 @@ bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransa try { switch (tx.nType) { case TRANSACTION_PROVIDER_REGISTER: - return CheckProRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, rules.v24, state, check_sigs); + return CheckProRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, rules, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_SERVICE: return CheckProUpServTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_REGISTRAR: - return CheckProUpRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, rules.v24, state, check_sigs); + return CheckProUpRegTx(tx, pindexPrev, m_dmnman, view, m_consensus_params, rules, state, check_sigs); case TRANSACTION_PROVIDER_UPDATE_REVOKE: return CheckProUpRevTx(tx, pindexPrev, m_dmnman, m_consensus_params, rules.v24, state, check_sigs); case TRANSACTION_PROVIDER_DISSOLVE: @@ -1146,20 +1146,29 @@ static bool IsVersionChangeValid(const uint16_t state_version, const uint16_t tx } bool CheckProRegTx(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, - const CCoinsViewCache& view, const Consensus::Params& consensus_params, bool is_v24_active, + const CCoinsViewCache& view, const Consensus::Params& consensus_params, SpecialTxRules rules, TxValidationState& state, bool check_sigs) { - const auto opt_ptx = GetValidatedPayload(tx, pindexPrev, consensus_params, is_v24_active, state); + const auto opt_ptx = GetValidatedPayload(tx, pindexPrev, consensus_params, rules.v24, state); if (!opt_ptx) { // pass the state returned by the function above return false; } // No longer allow legacy scheme masternode registration - if (is_v24_active && opt_ptx->nVersion < ProTxVersion::BasicBLS) { + if (rules.v24 && opt_ptx->nVersion < ProTxVersion::BasicBLS) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-version-disallowed"); } + if (opt_ptx->nType == MnType::Evo && !rules.evo_shares) { + if (opt_ptx->IsShared()) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-shares-evo"); + } + if (GetOwnerPayouts(*opt_ptx).size() > 1) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payouts-evo"); + } + } + // It's allowed to set addr to 0, which will put the MN into PoSe-banned state and require a ProUpServTx to be // issues later. If any of both is set, it must be valid however if (!CheckProviderNetworkFields(opt_ptx->netInfo, opt_ptx->nType, opt_ptx->nVersion, &opt_ptx->platformNodeID, @@ -1260,7 +1269,7 @@ bool CheckProRegTx(const CTransaction& tx, gsl::not_null pin // proves ownership of the operator key, so that gap lets anyone claim a masternode's key. // Nothing is excluded here: a duplicate key is never allowed, even for a ProTx replacing an // existing masternode. - if (is_v24_active && mnList.HasOperatorKeyUnderAnyScheme(opt_ptx->pubKeyOperator.Get(), /*self=*/uint256())) { + if (rules.v24 && mnList.HasOperatorKeyUnderAnyScheme(opt_ptx->pubKeyOperator.Get(), /*self=*/uint256())) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-dup-key"); } @@ -1417,10 +1426,10 @@ bool CheckProUpServTx(const CTransaction& tx, gsl::not_null } bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, - const CCoinsViewCache& view, const Consensus::Params& consensus_params, bool is_v24_active, + const CCoinsViewCache& view, const Consensus::Params& consensus_params, SpecialTxRules rules, TxValidationState& state, bool check_sigs) { - const auto opt_ptx = GetValidatedPayload(tx, pindexPrev, consensus_params, is_v24_active, state); + const auto opt_ptx = GetValidatedPayload(tx, pindexPrev, consensus_params, rules.v24, state); if (!opt_ptx) { // pass the state returned by the function above return false; @@ -1438,7 +1447,7 @@ bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null p return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-shared-mn"); } - if (!IsVersionChangeValid(dmn->pdmnState->nVersion, opt_ptx->nVersion, is_v24_active, state)) { + if (!IsVersionChangeValid(dmn->pdmnState->nVersion, opt_ptx->nVersion, rules.v24, state)) { // pass the state returned by the function above return false; } @@ -1448,7 +1457,7 @@ bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null p // if that target slot is already held by another masternode -- under either encoding -- so the // re-key in UpdateMN() cannot collide and throw out of block assembly. Scoped to those two cases // so a pre-existing cross-scheme pair's non-migrating routine update is not blocked. - if (is_v24_active) { + if (rules.v24) { const bool key_changed{!(opt_ptx->pubKeyOperator == dmn->pdmnState->pubKeyOperator)}; const bool migrating{IsSchemeMigration(dmn->pdmnState->nVersion, opt_ptx->nVersion)}; if ((key_changed || migrating) && @@ -1471,6 +1480,9 @@ bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null p const auto owner_payouts = GetOwnerPayouts(*opt_ptx); if (!IsPayoutListTriviallyValid(owner_payouts, dmn->pdmnState->keyIDOwner, opt_ptx->keyIDVoting, state)) return false; + if (dmn->nType == MnType::Evo && !rules.evo_shares && owner_payouts.size() > 1) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payouts-evo"); + } Coin coin; if (!view.GetCoin(dmn->collateralOutpoint, coin) || coin.IsSpent()) { diff --git a/src/evo/specialtxman.h b/src/evo/specialtxman.h index c96241ac623b..c90858d22062 100644 --- a/src/evo/specialtxman.h +++ b/src/evo/specialtxman.h @@ -48,6 +48,8 @@ class BlockManager; * caller for the block after pindexPrev (see GetSpecialTxRules() in validation.h) */ struct SpecialTxRules { bool v24{false}; + //! EvoNodes may register with shared collateral and have multiple owner payouts + bool evo_shares{false}; }; class CSpecialTxProcessor @@ -130,13 +132,13 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const const chainlock::Chainlocks& chainlocks, BlockValidationState& state); bool CheckProRegTx(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, - const CCoinsViewCache& view, const Consensus::Params& consensus_params, bool is_v24_active, + const CCoinsViewCache& view, const Consensus::Params& consensus_params, SpecialTxRules rules, TxValidationState& state, bool check_sigs); bool CheckProUpServTx(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, const Consensus::Params& consensus_params, bool is_v24_active, TxValidationState& state, bool check_sigs); bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, - const CCoinsViewCache& view, const Consensus::Params& consensus_params, bool is_v24_active, + const CCoinsViewCache& view, const Consensus::Params& consensus_params, SpecialTxRules rules, TxValidationState& state, bool check_sigs); bool CheckProUpRevTx(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, const Consensus::Params& consensus_params, bool is_v24_active, diff --git a/src/test/evo_deterministicmns_tests.cpp b/src/test/evo_deterministicmns_tests.cpp index f361bf5b46e5..8b2f1e888cd1 100644 --- a/src/test/evo_deterministicmns_tests.cpp +++ b/src/test/evo_deterministicmns_tests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -45,6 +46,11 @@ static bool IsV24Active(const ChainstateManager& chainman) EXCLUSIVE_LOCKS_REQUI return DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_V24); } +static SpecialTxRules TipRules(const ChainstateManager& chainman) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + return GetSpecialTxRules(chainman.ActiveChain().Tip(), chainman); +} + static CMutableTransaction CreateSpendTx(const ChainstateManager& chainman, SimpleUTXOMap& utxos, const CScript& scriptPayout, CAmount amount, const CKey& coinbaseKey) { CMutableTransaction tx; @@ -530,8 +536,8 @@ void FuncProUpRegTxV3OnLegacyValid(TestChainSetup& setup) { LOCK(cs_main); BOOST_CHECK(CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, - chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, /*check_sigs=*/true)); + chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), TipRules(chainman), + val_state, /*check_sigs=*/true)); } BOOST_CHECK(val_state.IsValid()); @@ -611,7 +617,7 @@ void FuncProUpRegTxV2CannotBypassV3PayoutCollateralReuse(TestChainSetup& setup) LOCK(cs_main); BOOST_CHECK(!CheckProUpRegTx(CTransaction(tx_upreg), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, /*check_sigs=*/true)); + TipRules(chainman), val_state, /*check_sigs=*/true)); } BOOST_CHECK_EQUAL(val_state.GetRejectReason(), "bad-protx-payee-reuse"); } @@ -887,7 +893,7 @@ void FuncProRegTxRejectsInvalidDeserializedExtNetInfo(TestChainSetup& setup) LOCK(cs_main); BOOST_CHECK(!CheckProRegTx(BuildExtNetInfoProRegTx(std::move(net_info)), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), state, + TipRules(chainman), state, /*check_sigs=*/false)); } BOOST_CHECK_EQUAL(state.GetResult(), TxValidationResult::TX_BAD_SPECIAL); @@ -954,10 +960,10 @@ void FuncDIP3Protx(TestChainSetup& setup) LOCK(cs_main); BOOST_REQUIRE(CheckProRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), dummy_state, true)); + TipRules(chainman), dummy_state, true)); BOOST_REQUIRE(CheckProRegTx(CTransaction(tx2), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), dummy_state, true)); + TipRules(chainman), dummy_state, true)); } // But the signature should not verify anymore BOOST_REQUIRE(CheckTransactionSignature(tx, coins)); @@ -1065,10 +1071,10 @@ void FuncDIP3Protx(TestChainSetup& setup) LOCK(cs_main); BOOST_REQUIRE(CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), dummy_state, true)); + TipRules(chainman), dummy_state, true)); BOOST_REQUIRE(!CheckProUpRegTx(CTransaction(tx2), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), dummy_state, true)); + TipRules(chainman), dummy_state, true)); } BOOST_REQUIRE(CheckTransactionSignature(tx, coins)); BOOST_REQUIRE(!CheckTransactionSignature(tx2, coins)); @@ -1945,7 +1951,7 @@ void FuncMigrationRejectedWhenKeySquatted(TestChainV24SignalBeforeV19Setup& setu LOCK(cs_main); BOOST_CHECK(!CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), st, true)); + TipRules(chainman), st, true)); BOOST_CHECK_EQUAL(st.GetRejectReason(), "bad-protx-dup-key"); } }; @@ -2196,14 +2202,16 @@ static uint256 RegisterBasicEvoNode(TestChainV24SignalBeforeV19Setup& setup, con } static CMutableTransaction CreateExtAddrProUpRegTx(TestChainV24SignalBeforeV19Setup& setup, const uint256& proTxHash, - const CKey& owner_key, const CBLSSecretKey& operator_key) + const CKey& owner_key, const CBLSSecretKey& operator_key, + MasternodePayoutShares payouts = { + {GenerateRandomAddress(), MasternodePayoutShare::MAX_REWARD}}) { CProUpRegTx proTx; proTx.nVersion = ProTxVersion::ExtAddr; proTx.proTxHash = proTxHash; proTx.pubKeyOperator.Set(operator_key.GetPublicKey(), /*specificLegacyScheme=*/false); proTx.keyIDVoting = owner_key.GetPubKey().GetID(); - proTx.payouts = {{GenerateRandomAddress(), MasternodePayoutShare::MAX_REWARD}}; + proTx.payouts = std::move(payouts); CMutableTransaction tx; tx.nVersion = 3; tx.nType = TRANSACTION_PROVIDER_UPDATE_REGISTRAR; @@ -2269,7 +2277,7 @@ void FuncEvoNodeRaisedToExtAddrByServiceUpdateOnly(TestChainV24SignalBeforeV19Se TxValidationState val_state; LOCK(cs_main); CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), - chainman.GetConsensus(), IsV24Active(chainman), val_state, /*check_sigs=*/true); + chainman.GetConsensus(), TipRules(chainman), val_state, /*check_sigs=*/true); return val_state.GetRejectReason(); }; auto check_proupserv = [&](const CMutableTransaction& tx) { @@ -2341,7 +2349,7 @@ void FuncEvoNodeRegistrarRaiseNotMined(TestChainV24SignalBeforeV19Setup& setup) LOCK(cs_main); BOOST_CHECK(!CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, /*check_sigs=*/true)); + TipRules(chainman), val_state, /*check_sigs=*/true)); BOOST_CHECK_EQUAL(val_state.GetRejectReason(), "bad-protx-netinfo-version"); } @@ -2367,6 +2375,151 @@ BOOST_AUTO_TEST_CASE(evonode_registrar_raise_not_mined) FuncEvoNodeRegistrarRaiseNotMined(setup); } +// An ExtAddr registration with internal collateral, shared when shares are given. Its funding input is a +// placeholder: CheckProRegTx() does not look the funding inputs up. +static CTransaction CreateExtAddrProRegTx(MnType type, const MasternodePayoutShares& payouts, const CollateralShares& shares) +{ + CBLSSecretKey operator_key; + operator_key.MakeNewKey(); + + CProRegTx pro_reg; + pro_reg.nVersion = ProTxVersion::ExtAddr; + pro_reg.nType = type; + pro_reg.netInfo = NetInfoInterface::MakeNetInfo(pro_reg.nVersion); + pro_reg.collateralOutpoint.n = 0; + pro_reg.pubKeyOperator.Set(operator_key.GetPublicKey(), /*specificLegacyScheme=*/false); + pro_reg.keyIDVoting = GenerateRandomKey().GetPubKey().GetID(); + if (type == MnType::Evo) { + pro_reg.platformNodeID = uint160{Span{GetRandHash()}.first(uint160::size())}; + } + if (shares.empty()) { + pro_reg.keyIDOwner = GenerateRandomKey().GetPubKey().GetID(); + pro_reg.payouts = payouts; + } else { + pro_reg.shares = shares; + pro_reg.vchJoinSigs.resize(shares.size()); + } + + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_PROVIDER_REGISTER; + tx.vin.emplace_back(COutPoint(GetRandHash(), 0)); + tx.vout.emplace_back(GetMnType(type).collat_amount, + shares.empty() ? GenerateRandomAddress() : SharedCollateralScript()); + pro_reg.inputsHash = CalcTxInputsHash(CTransaction(tx)); + SetTxPayload(tx, pro_reg); + return CTransaction(tx); +} + +// Until evo_shares activates, an EvoNode registers with a single owner payout and without shared +// collateral. Regular masternodes are not restricted. +void FuncEvoNodeRegistrationNeedsEvoShares(TestChainV24SignalBeforeV19Setup& setup) +{ + auto& chainman = setup.chainman; + auto& dmnman = setup.dmnman; + + setup.MineToV19(); + setup.MineToV24(); + + const MasternodePayoutShares one_payout{{GenerateRandomAddress(), MasternodePayoutShare::MAX_REWARD}}; + const MasternodePayoutShares two_payouts{{GenerateRandomAddress(), 6000}, {GenerateRandomAddress(), 4000}}; + const auto two_shares = [](MnType type) { + const CAmount collateral{GetMnType(type).collat_amount}; + return CollateralShares{{collateral - 100 * COIN, GenerateRandomAddress(), CScript{}, + GenerateRandomKey().GetPubKey().GetID()}, + {100 * COIN, GenerateRandomAddress(), CScript{}, GenerateRandomKey().GetPubKey().GetID()}}; + }; + + struct Case { + std::string name; + MnType type; + MasternodePayoutShares payouts; + CollateralShares shares; + bool evo_shares; + std::string expected; + }; + const std::vector cases{ + {"evo, one payout", MnType::Evo, one_payout, {}, false, ""}, + {"evo, two payouts", MnType::Evo, two_payouts, {}, false, "bad-protx-payouts-evo"}, + {"evo, two payouts, evo_shares", MnType::Evo, two_payouts, {}, true, ""}, + {"regular, two payouts", MnType::Regular, two_payouts, {}, false, ""}, + {"evo, shared", MnType::Evo, {}, two_shares(MnType::Evo), false, "bad-protx-shares-evo"}, + {"evo, shared, evo_shares", MnType::Evo, {}, two_shares(MnType::Evo), true, ""}, + {"regular, shared", MnType::Regular, {}, two_shares(MnType::Regular), false, ""}, + }; + for (const auto& c : cases) { + const auto tx{CreateExtAddrProRegTx(c.type, c.payouts, c.shares)}; + TxValidationState state; + LOCK(cs_main); + SpecialTxRules rules{TipRules(chainman)}; + BOOST_REQUIRE(rules.v24 && !rules.evo_shares); + rules.evo_shares = c.evo_shares; + CheckProRegTx(tx, chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), + chainman.GetConsensus(), rules, state, /*check_sigs=*/false); + BOOST_CHECK_MESSAGE(state.GetRejectReason() == c.expected, + strprintf("%s: got '%s'", c.name, state.GetRejectReason())); + } +} + +BOOST_AUTO_TEST_CASE(evonode_registration_needs_evo_shares) +{ + TestChainV24SignalBeforeV19Setup setup; + FuncEvoNodeRegistrationNeedsEvoShares(setup); +} + +// Until evo_shares activates, a registrar update keeps an EvoNode at a single owner payout, also when it +// rotates the operator key +void FuncEvoNodePayoutUpdateNeedsEvoShares(TestChainV24SignalBeforeV19Setup& setup) +{ + auto& chainman = setup.chainman; + auto& dmnman = setup.dmnman; + + setup.MineToV19(); + CKey owner_key; + owner_key.MakeNewKey(true); + CBLSSecretKey operator_key; + operator_key.MakeNewKey(); + CBLSSecretKey new_operator_key; + new_operator_key.MakeNewKey(); + const auto proTxHash = RegisterBasicEvoNode(setup, "1.1.1.6", 443, owner_key, operator_key); + setup.MineToV24(); + setup.ProcessBlock({CreateEvoProUpServTx(setup, proTxHash, ProTxVersion::ExtAddr, "1.1.1.6", operator_key)}); + + const MasternodePayoutShares two_payouts{{GenerateRandomAddress(), 6000}, {GenerateRandomAddress(), 4000}}; + struct Case { + std::string name; + const CBLSSecretKey& operator_key; + MasternodePayoutShares payouts; + bool evo_shares; + std::string expected; + }; + const std::vector cases{ + {"one payout", operator_key, {{GenerateRandomAddress(), MasternodePayoutShare::MAX_REWARD}}, false, ""}, + {"two payouts", operator_key, two_payouts, false, "bad-protx-payouts-evo"}, + {"two payouts, operator key rotation", new_operator_key, two_payouts, false, "bad-protx-payouts-evo"}, + {"two payouts, evo_shares", operator_key, two_payouts, true, ""}, + {"two payouts, operator key rotation, evo_shares", new_operator_key, two_payouts, true, ""}, + }; + for (const auto& c : cases) { + const auto tx{CreateExtAddrProUpRegTx(setup, proTxHash, owner_key, c.operator_key, c.payouts)}; + TxValidationState state; + LOCK(cs_main); + SpecialTxRules rules{TipRules(chainman)}; + BOOST_REQUIRE(rules.v24 && !rules.evo_shares); + rules.evo_shares = c.evo_shares; + CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), + chainman.GetConsensus(), rules, state, /*check_sigs=*/true); + BOOST_CHECK_MESSAGE(state.GetRejectReason() == c.expected, + strprintf("%s: got '%s'", c.name, state.GetRejectReason())); + } +} + +BOOST_AUTO_TEST_CASE(evonode_payout_update_needs_evo_shares) +{ + TestChainV24SignalBeforeV19Setup setup; + FuncEvoNodePayoutUpdateNeedsEvoShares(setup); +} + // The SAME masternode, two registrar updates in one block, version-crossing. tx1 rotates a // legacy MN to a new key at v2 (making it BasicBLS); tx2 then rotates it to another new key at v1. // tx2 passes CheckProUpRegTx against pindexPrev (the MN was legacy there), but in the rebuild the MN @@ -2406,11 +2559,11 @@ void FuncSameMnSameBlockVersionCrossingKeyRotation(TestChainV24SignalBeforeV19Se TxValidationState s1, s2; BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx1), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), s1, true), + TipRules(chainman), s1, true), "tx1 rejected standalone: " << s1.GetRejectReason()); BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx2), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), s2, true), + TipRules(chainman), s2, true), "tx2 rejected standalone: " << s2.GetRejectReason()); } @@ -2514,11 +2667,11 @@ void FuncSameMnSameBlockMigrationConsistent(TestChainV24SignalBeforeV19Setup& se TxValidationState s1, s2; BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx1), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), s1, true), + TipRules(chainman), s1, true), "tx1 rejected standalone: " << s1.GetRejectReason()); BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx2), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), s2, true), + TipRules(chainman), s2, true), "tx2 rejected standalone: " << s2.GetRejectReason()); } @@ -2718,11 +2871,11 @@ void FuncSameBlockCrossSchemeKeyPairRejected(TestChainV24SignalBeforeV19Setup& s TxValidationState s1, s2; BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx1), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), s1, true), + TipRules(chainman), s1, true), "tx1 rejected standalone: " << s1.GetRejectReason()); BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx2), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), s2, true), + TipRules(chainman), s2, true), "tx2 rejected standalone: " << s2.GetRejectReason()); } @@ -2829,7 +2982,7 @@ void FuncMempoolRejectsCrossSchemeKeyRace(TestChainV24SignalBeforeV19Setup& setu LOCK(cs_main); BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx_second), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), st, + TipRules(chainman), st, /*check_sigs=*/true), "second claim should still pass its consensus check: " << st.GetRejectReason()); } @@ -2903,8 +3056,8 @@ void FuncProUpRegTxRejectsCrossSchemeKeyReuse(TestChainV24SignalBeforeV19Setup& auto check_upreg = [&](const CMutableTransaction& tx, TxValidationState& st) { LOCK(cs_main); return CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, - chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), IsV24Active(chainman), - st, /*check_sigs=*/true); + chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), TipRules(chainman), st, + /*check_sigs=*/true); }; // MN-A tries to take MN-B's key under the OTHER encoding, via a v1 payload. This is the reverse @@ -2979,7 +3132,7 @@ void FuncProRegTxRejectsCrossSchemeKeyReuse(TestChainV24SignalBeforeV19Setup& se auto check_proreg = [&](const CMutableTransaction& tx, TxValidationState& st) { LOCK(cs_main); return CheckProRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, - chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), IsV24Active(chainman), st, + chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), TipRules(chainman), st, /*check_sigs=*/true); }; @@ -3131,7 +3284,7 @@ void FuncPreV24BehaviourUnchanged(TestChainSetup& setup) LOCK(cs_main); BOOST_CHECK_MESSAGE(CheckProUpRegTx(CTransaction(tx_upreg), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, + TipRules(chainman), val_state, /*check_sigs=*/true), "pre-v24 same-key ProUpRegTx wrongly rejected: " << val_state.GetRejectReason()); } @@ -3199,7 +3352,7 @@ void FuncStaleSpecialTxDoesNotPoisonTemplate(TestChainV24SignalBeforeV19Setup& s LOCK(cs_main); BOOST_REQUIRE(!CheckProRegTx(CTransaction(tx_stale), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, + TipRules(chainman), val_state, /*check_sigs=*/true)); BOOST_REQUIRE_EQUAL(val_state.GetRejectReason(), "bad-protx-dup-key"); } @@ -3255,7 +3408,7 @@ void FuncProUpRegTxMigratesLegacySameKey(TestChainV24SignalBeforeV19Setup& setup LOCK(cs_main); BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, + TipRules(chainman), val_state, /*check_sigs=*/true), "same-key migration rejected: " << val_state.GetRejectReason()); } @@ -3285,7 +3438,7 @@ void FuncProUpRegTxMigratesLegacySameKey(TestChainV24SignalBeforeV19Setup& setup LOCK(cs_main); BOOST_REQUIRE_MESSAGE(CheckProUpRegTx(CTransaction(tx), chainman.ActiveChain().Tip(), dmnman, chainman.ActiveChainstate().CoinsTip(), chainman.GetConsensus(), - IsV24Active(chainman), val_state, + TipRules(chainman), val_state, /*check_sigs=*/true), "new-key rotation rejected: " << val_state.GetRejectReason()); } diff --git a/src/test/evo_sharedmn_tests.cpp b/src/test/evo_sharedmn_tests.cpp index 5978d835ad85..cf6719b120c5 100644 --- a/src/test/evo_sharedmn_tests.cpp +++ b/src/test/evo_sharedmn_tests.cpp @@ -212,11 +212,6 @@ BOOST_AUTO_TEST_CASE(shared_proregtx_shape_validation) return proTx; }; - { - auto proTx = base(); - proTx.nType = MnType::Evo; - check(proTx, "bad-protx-shares-evo"); - } { auto proTx = base(); proTx.collateralOutpoint.hash = GetRandHash(); @@ -500,6 +495,21 @@ BOOST_AUTO_TEST_CASE(shared_reg_consent_hash) mutated.nType = MnType::Evo; BOOST_CHECK(mutated.MakeSharedRegConsentHash(CTransaction(mtx)) != base); } + const uint160 platform_node_id{std::vector(20, 0x01)}; + { + // An EvoNode's Platform node ID is covered + CProRegTx evo{proTx}; + evo.nType = MnType::Evo; + const uint256 evo_base = evo.MakeSharedRegConsentHash(CTransaction(mtx)); + evo.platformNodeID = platform_node_id; + BOOST_CHECK(evo.MakeSharedRegConsentHash(CTransaction(mtx)) != evo_base); + } + { + // A regular masternode has no Platform node ID, and its digest is unchanged by the field + CProRegTx mutated{proTx}; + mutated.platformNodeID = platform_node_id; + BOOST_CHECK(mutated.MakeSharedRegConsentHash(CTransaction(mtx)) == base); + } { CProRegTx mutated{proTx}; CBLSSecretKey sk; diff --git a/src/validation.cpp b/src/validation.cpp index f04f26785477..3e20f8617c8b 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -6798,6 +6798,7 @@ SpecialTxRules GetSpecialTxRules(const CBlockIndex* pindexPrev, const Chainstate { return SpecialTxRules{ .v24 = DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24), + .evo_shares = DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_EVO_SHARES), }; } From b3e6f5eed6e646bd4b431e3503a6485f3f24aebd Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 11:16:44 -0500 Subject: [PATCH 4/7] feat(rpc): prepare shared EvoNode registrations Add "protx shared_register_prepare_evo", which takes the arguments of "protx shared_register_prepare" followed by platformNodeID, platformP2PAddrs and platformHTTPSAddrs. PrepareSharedRegistration() gains the masternode type and Platform node ID, applies the Platform addresses for an EvoNode and checks the network fields the way a regular registration does. An EvoNode registration with shared collateral is only valid once evo_shares is active, so preparing one fails until then instead of after every participant has signed. Dash Qt keeps preparing regular shared registrations only; EvoNode support there is left for v24.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/evo/providertx_service.cpp | 37 ++++++- src/interfaces/providertx.h | 3 + src/rpc/client.cpp | 6 ++ src/rpc/evo.cpp | 99 ++++++++++++++---- test/functional/feature_masternode_shares.py | 104 +++++++++++++++++-- 5 files changed, 218 insertions(+), 31 deletions(-) diff --git a/src/evo/providertx_service.cpp b/src/evo/providertx_service.cpp index c0a0da028788..d12822ad0ba2 100644 --- a/src/evo/providertx_service.cpp +++ b/src/evo/providertx_service.cpp @@ -99,6 +99,14 @@ uint16_t CurrentProviderTxVersion(node::NodeContext& node, std::optional b return DeploymentToProtxVersion(chainman.ActiveChain().Tip(), chainman, basic_override); } +bool EvoSharesActive(node::NodeContext& node) +{ + AssertLockNotHeld(::cs_main); + auto& chainman{*Assert(node.chainman)}; + LOCK(::cs_main); + return DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_EVO_SHARES); +} + struct ChainSnapshot { uint16_t provider_tx_version{0}; CDeterministicMNCPtr dmn; @@ -255,6 +263,17 @@ std::optional ValidateNetworkFields(const ProTx& payload, bool return std::nullopt; } +template +std::optional ApplyPlatformNodeId(ProTx& payload, const std::optional& platform_node_id) +{ + if (payload.nType != MnType::Evo) return std::nullopt; + if (!platform_node_id) { + return Error(ProviderTxErrorCode::INVALID_PARAMETER, "platformNodeID must be specified for an EvoNode"); + } + payload.platformNodeID = *platform_node_id; + return std::nullopt; +} + using PayoutResult = std::variant; PayoutResult BuildPayouts(const std::vector& payouts) @@ -991,12 +1010,20 @@ ProviderTxResult Revoke(node::NodeContext& node, Wallet& w ProviderTxResult PrepareSharedRegistration(node::NodeContext& node, const SharedRegistrationRequest& request) { + if (!IsValidMnType(request.type)) { + return Error(ProviderTxErrorCode::INVALID_PARAMETER, "invalid masternode type"); + } + if (request.type == MnType::Evo && !EvoSharesActive(node)) { + return Error(ProviderTxErrorCode::INVALID_PARAMETER, + "EvoNode shared registration requires the evo_shares deployment to be active"); + } + CMutableTransaction tx{request.funding_tx}; tx.nVersion = 3; tx.nType = TRANSACTION_PROVIDER_REGISTER; CProRegTx payload; - payload.nType = MnType::Regular; + payload.nType = request.type; payload.nVersion = CurrentProviderTxVersion(node); if (payload.nVersion < ProTxVersion::ExtAddr) { return Error(ProviderTxErrorCode::INVALID_PARAMETER, @@ -1022,7 +1049,13 @@ ProviderTxResult PrepareSharedRegistration(node::Nod share.reward ? GetScriptForDestination(*share.reward) : CScript{}, share.owner); } - if (auto error{ApplyNetInfo(payload, request.net_info, /*platform=*/false, /*optional=*/true)}) return *error; + if (auto error{ApplyNetInfo(payload, request.net_info, /*platform=*/payload.nType == MnType::Evo, /*optional=*/true)}) { + return *error; + } + if (auto error{ApplyPlatformNodeId(payload, request.platform_node_id)}) return *error; + if (auto error{ValidateNetworkFields(payload, /*allow_empty=*/true, /*check_platform_node_id=*/true)}) { + return *error; + } if (!request.operator_key.IsValid()) { return Error(ProviderTxErrorCode::INVALID_PARAMETER, "operator BLS address must be a valid BLS public key"); diff --git a/src/interfaces/providertx.h b/src/interfaces/providertx.h index 63decede3568..d23c71dee4c2 100644 --- a/src/interfaces/providertx.h +++ b/src/interfaces/providertx.h @@ -153,9 +153,12 @@ struct SharedShareSpec { //! Turns a funding transaction that already holds every participant's inputs and change //! into an unsigned shared ProRegTx. Needs no wallet: nothing is funded or signed. struct SharedRegistrationRequest { + MnType type{MnType::Regular}; CMutableTransaction funding_tx; std::vector shares; ProviderNetInfo net_info; + //! Required for an EvoNode + std::optional platform_node_id; CBLSPublicKey operator_key; CKeyID voting_key; uint16_t operator_reward{0}; diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index e35183e739f1..b52709fc741b 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -326,6 +326,12 @@ static const CRPCConvertParam vRPCConvertParams[] = { "protx shared_register_prepare", 3, "coreP2PAddrs", true }, { "protx shared_register_prepare", 7, "earlyPeriodBlocks" }, { "protx shared_register_prepare", 8, "earlyPenalty" }, + { "protx shared_register_prepare_evo", 2, "shares" }, + { "protx shared_register_prepare_evo", 3, "coreP2PAddrs", true }, + { "protx shared_register_prepare_evo", 7, "earlyPeriodBlocks" }, + { "protx shared_register_prepare_evo", 8, "earlyPenalty" }, + { "protx shared_register_prepare_evo", 10, "platformP2PAddrs", true }, + { "protx shared_register_prepare_evo", 11, "platformHTTPSAddrs", true }, { "protx revoke", 3, "reason" }, { "protx revoke", 5, "submit" }, { "protx shared_combine", 2, "signatures" }, diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index ce043a8d9488..3c7088bf560e 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -563,6 +563,16 @@ static interfaces::ProviderPlatformEndpoints ParsePlatformNetInfo(const UniValue strprintf("Invalid param for %s, must be a valid port [1-65535]", field_name)); } +static uint160 ParsePlatformNodeID(const UniValue& value) +{ + if (!IsHex(value.get_str())) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "platformNodeID must be hexadecimal string"); + } + uint160 platform_node_id; + platform_node_id.SetHex(value.get_str()); + return platform_node_id; +} + // forward declaration namespace { enum class ProTxRegisterAction @@ -2156,26 +2166,10 @@ static RPCHelpMan evodb_repair() } #ifdef ENABLE_WALLET -static RPCHelpMan protx_shared_register_prepare() +static RPCHelpMan protx_shared_register_prepare_wrapper(const MnType mn_type) { - return RPCHelpMan{"protx shared_register_prepare", - "\nCreates an unsigned shared masternode registration (a version 3 ProRegTx with a collateral share\n" - "table) by appending the shared-collateral output and payload to a caller-supplied funding\n" - "transaction. The funding transaction must already contain every participant's contribution inputs\n" - "and any change outputs; the consent digest binds all of them. Every share owner must sign the\n" - "returned consent hash via \"protx shared_sign\"; combine with \"protx shared_combine\", then have the\n" - "funding inputs signed (signrawtransactionwithwallet) and broadcast with sendrawtransaction.\n" - "\nIMPORTANT: consent is required from each share in the table, not from each funding input; an input\n" - "is not tied to any share. Before signing its funding inputs, each participant should check the\n" - "share table (\"proRegTx\" in decoderawtransaction, or \"terms\" from shared_sign) for its own share,\n" - "with its own amount and addresses. Signing inputs into a table without that share gives the\n" - "contribution to the other shares.\n" - "\nIMPORTANT: once the registration confirms, every participant should create a zero-penalty\n" - "standby dissolution (\"protx shared_dissolve false false\") and store\n" - "the returned hex with their refund-key backup, separately from the share owner key. It becomes\n" - "valid when the early period ends (or immediately when no early period applies), then never expires;\n" - "broadcasting it after that point recovers the participant's principal without cooperation.\n", - { + const bool evo{mn_type == MnType::Evo}; + std::vector args{ {"fundingTx", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The serialized funding transaction with all participants' inputs and change outputs."}, {"shares", RPCArg::Type::ARR, RPCArg::Optional::NO, "The collateral share table, in consensus-significant order. Amounts must sum to the collateral.", { @@ -2195,7 +2189,46 @@ static RPCHelpMan protx_shared_register_prepare() RPCArgOptions{.skip_type_check = true}}, {"earlyPeriodBlocks", RPCArg::Type::NUM, RPCArg::Optional::NO, "Length in blocks of the early period during which unilateral dissolution is penalized (up to 420480)."}, {"earlyPenalty", RPCArg::Type::NUM, RPCArg::Optional::NO, "Penalty in duffs for unilateral dissolution during the early period (must be below the smallest share, and zero when earlyPeriodBlocks is zero)."}, - }, + }; + if (evo) { + args.push_back(GetRpcArg("platformNodeID")); + args.push_back({"platformP2PAddrs", RPCArg::Type::ARR, RPCArg::Optional::NO, + "Array of addresses in the form \"ADDR:PORT\" used by Platform for peer-to-peer connection.\n" + "Must be unique on the network. Can be set to an empty string, which will require a ProUpServTx afterwards.", + { + {"address", RPCArg::Type::STR, RPCArg::Optional::NO, ""}, + }, + RPCArgOptions{.skip_type_check = true}}); + args.push_back({"platformHTTPSAddrs", RPCArg::Type::ARR, RPCArg::Optional::NO, + "Array of addresses in the form \"ADDR:PORT\" used by Platform for their HTTPS API.\n" + "Must be unique on the network. Can be set to an empty string, which will require a ProUpServTx afterwards.", + { + {"address", RPCArg::Type::STR, RPCArg::Optional::NO, ""}, + }, + RPCArgOptions{.skip_type_check = true}}); + } + const std::string example_args{"\"fundingTx\" \"[...]\" \"1.2.3.4:1234\" \"operatorPubKey\" \"" + EXAMPLE_ADDRESS[1] + + "\" 0 10000 5000000000"}; + return RPCHelpMan{evo ? "protx shared_register_prepare_evo" : "protx shared_register_prepare", + std::string{evo ? "\nSame as \"protx shared_register_prepare\", but for an EvoNode. EvoNodes can only register with\n" + "shared collateral once the evo_shares deployment is active.\n" : ""} + + "\nCreates an unsigned shared masternode registration (a version 3 ProRegTx with a collateral share\n" + "table) by appending the shared-collateral output and payload to a caller-supplied funding\n" + "transaction. The funding transaction must already contain every participant's contribution inputs\n" + "and any change outputs; the consent digest binds all of them. Every share owner must sign the\n" + "returned consent hash via \"protx shared_sign\"; combine with \"protx shared_combine\", then have the\n" + "funding inputs signed (signrawtransactionwithwallet) and broadcast with sendrawtransaction.\n" + "\nIMPORTANT: consent is required from each share in the table, not from each funding input; an input\n" + "is not tied to any share. Before signing its funding inputs, each participant should check the\n" + "share table (\"proRegTx\" in decoderawtransaction, or \"terms\" from shared_sign) for its own share,\n" + "with its own amount and addresses. Signing inputs into a table without that share gives the\n" + "contribution to the other shares.\n" + "\nIMPORTANT: once the registration confirms, every participant should create a zero-penalty\n" + "standby dissolution (\"protx shared_dissolve false false\") and store\n" + "the returned hex with their refund-key backup, separately from the share owner key. It becomes\n" + "valid when the early period ends (or immediately when no early period applies), then never expires;\n" + "broadcasting it after that point recovers the participant's principal without cooperation.\n", + args, RPCResult{RPCResult::Type::OBJ, "", "", { {RPCResult::Type::STR_HEX, "tx", "The serialized unsigned shared ProRegTx"}, @@ -2204,12 +2237,14 @@ static RPCHelpMan protx_shared_register_prepare() CProRegTx::GetJsonHelp("terms", /*optional=*/false), {RPCResult::Type::STR, "warning", /*optional=*/true, "Present when earlyPenalty is zero: any participant can force an early exit at no cost beyond the transaction fee"}, }}, - RPCExamples{HelpExampleCli("protx", "shared_register_prepare \"fundingTx\" \"[...]\" \"1.2.3.4:1234\" \"operatorPubKey\" \"" + EXAMPLE_ADDRESS[1] + "\" 0 10000 5000000000")}, - [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue + RPCExamples{evo ? HelpExampleCli("protx", "shared_register_prepare_evo " + example_args + " \"f2dbd9b0a1f541a7c44d34a58674d0262f5feca5\" \"[\\\"1.2.3.4:22821\\\"]\" \"[\\\"1.2.3.4:22822\\\"]\"") + : HelpExampleCli("protx", "shared_register_prepare " + example_args)}, + [mn_type](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { NodeContext& node = EnsureAnyNodeContext(request.context); interfaces::SharedRegistrationRequest typed_request; + typed_request.type = mn_type; if (!DecodeHexTx(typed_request.funding_tx, request.params[0].get_str())) { throw JSONRPCError(RPC_INVALID_PARAMETER, "funding transaction not deserializable"); } @@ -2239,6 +2274,14 @@ static RPCHelpMan protx_shared_register_prepare() typed_request.early_period_blocks = static_cast(earlyPeriodBlocks); typed_request.early_penalty = request.params[7].getInt(); + if (mn_type == MnType::Evo) { + typed_request.platform_node_id = ParsePlatformNodeID(request.params[8]); + typed_request.net_info.platform_p2p = ParsePlatformNetInfo(request.params[9], "platformP2PAddrs", + /*extended_addresses=*/true); + typed_request.net_info.platform_https = ParsePlatformNetInfo(request.params[10], "platformHTTPSAddrs", + /*extended_addresses=*/true); + } + const auto prepared{UnwrapOrThrow(evo::provider::PrepareSharedRegistration(node, typed_request))}; UniValue ret(UniValue::VOBJ); ret.pushKV("tx", EncodeHexTx(*prepared.tx)); @@ -2253,6 +2296,16 @@ static RPCHelpMan protx_shared_register_prepare() }; } +static RPCHelpMan protx_shared_register_prepare() +{ + return protx_shared_register_prepare_wrapper(MnType::Regular); +} + +static RPCHelpMan protx_shared_register_prepare_evo() +{ + return protx_shared_register_prepare_wrapper(MnType::Evo); +} + static RPCHelpMan protx_shared_dissolve_prepare() { return RPCHelpMan{"protx shared_dissolve_prepare", @@ -2317,6 +2370,7 @@ static RPCHelpMan protx_help() " update_registrar_legacy - (DEPRECATED) Create ProUpRegTx by parsing BLS using the legacy scheme, then send it to network\n" " revoke - Create and send ProUpRevTx to network\n" " shared_register_prepare - Create an unsigned shared masternode ProTx\n" + " shared_register_prepare_evo - Create an unsigned shared masternode ProTx for an EvoNode\n" " shared_sign - Sign a shared masternode transaction with this wallet's share owner keys\n" " shared_combine - Combine share owner signatures into a shared masternode transaction\n" " shared_dissolve - Create, sign and send a unilateral ProDisTx\n" @@ -2448,6 +2502,7 @@ Span GetWalletEvoRPCCommands() {"evo", &protx_update_registrar}, {"evo", &protx_revoke}, {"evo", &protx_shared_register_prepare}, + {"evo", &protx_shared_register_prepare_evo}, {"evo", &protx_shared_sign}, {"evo", &protx_shared_combine}, {"evo", &protx_shared_dissolve}, diff --git a/test/functional/feature_masternode_shares.py b/test/functional/feature_masternode_shares.py index 4acfdd83c1fa..b4855a999899 100755 --- a/test/functional/feature_masternode_shares.py +++ b/test/functional/feature_masternode_shares.py @@ -18,6 +18,7 @@ assert_equal, assert_greater_than, assert_raises_rpc_error, + get_bip9_details, softfork_active, ) from test_framework.wallet_util import get_generate_key @@ -25,7 +26,11 @@ # Keep the earliest activation height above the ~119 blocks the framework setup mines, so # run_test starts with v24 locked in but not yet active and can exercise pre-activation rules V24_MIN_ACTIVATION_HEIGHT = 250 +# Above the ~1300 blocks the regular shared masternode checks mine, which run before evo_shares +# activates +EVO_SHARES_MIN_ACTIVATION_HEIGHT = 2000 COLLATERAL = 1000 * COIN +EVO_COLLATERAL = 4000 * COIN SHARED_COLLATERAL_SCRIPT = "04445348437551" # Must comfortably exceed the blocks mined between the first registration and its early-period # dissolution checks (~16 worst case), so the masternode is still inside the early period there @@ -42,25 +47,26 @@ def add_options(self, parser): self.add_wallet_options(parser) def set_test_params(self): + # evo_shares is activated by miners here; as shipped, masternodes do not sign it self.set_dash_test_params(2, 0, extra_args=[[ f"-vbparams=v24:{self.mocktime}:999999999999:{V24_MIN_ACTIVATION_HEIGHT}:10:8:6:5:0", + f"-vbparams=evo_shares:{self.mocktime}:999999999999:{EVO_SHARES_MIN_ACTIVATION_HEIGHT}:10:8:6:5:0", ]] * 2) - def activate_v24(self): - while not softfork_active(self.nodes[0], "v24"): + def activate(self, name): + while not softfork_active(self.nodes[0], name): self.bump_mocktime(50) self.generate(self.nodes[0], 50, sync_fun=self.no_op) - assert softfork_active(self.nodes[0], "v24") - def build_funding_tx(self, node): + def build_funding_tx(self, node, collateral=COLLATERAL): """Returns hex of a transaction with enough inputs to fund the collateral plus fee and a change output, but without the collateral output itself (shared_register_prepare appends it).""" dummy = node.getnewaddress() - raw = node.createrawtransaction([], {dummy: 1000}) + raw = node.createrawtransaction([], {dummy: collateral // COIN}) funded = node.fundrawtransaction(raw, {"feeRate": 0.00010000})["hex"] tx = tx_from_hex(funded) - vout = [out for out in tx.vout if out.nValue != COLLATERAL] + vout = [out for out in tx.vout if out.nValue != collateral] assert_equal(len(vout), len(tx.vout) - 1) tx.vout = vout return tx.serialize().hex() @@ -593,7 +599,7 @@ def run_test(self): pre_tmpl_txid = node.decoderawtransaction(pre_tmpl_signed["hex"])["txid"] self.generateblock(node, pre_miner, [pre_tmpl_signed["hex"]], sync_fun=self.no_op) - self.activate_v24() + self.activate("v24") self.log.info("A coinbase cannot create a shared collateral template output") assert_raises_rpc_error(-1, "bad-shared-collateral-create", self.generateblock, node, @@ -1222,6 +1228,90 @@ def submit_with_sigs(sigs): assert_greater_than(node.getreceivedbyaddress(addr, 1), Decimal(0)) self.test_separate_participant_wallets() + self.test_evonode_shared_registration() + + def test_evonode_shared_registration(self): + self.log.info("An EvoNode cannot be prepared for shared collateral before evo_shares activation") + node = self.nodes[0] + wallet = node.get_wallet_rpc(self.default_wallet_name) + assert not softfork_active(node, "evo_shares") + port = p2p_port(9) + platform_node_id = "%040x" % port + shares = [ + {"amount": 3000 * COIN, "refundAddress": wallet.getnewaddress(), "ownerAddress": wallet.getnewaddress()}, + {"amount": 1000 * COIN, "refundAddress": wallet.getnewaddress(), "ownerAddress": wallet.getnewaddress()}, + ] + # Funded before activation so that its inputs are mature below the activation height too + funding_hex = self.build_funding_tx(wallet, EVO_COLLATERAL) + operator = node.bls("generate") + args = [funding_hex, shares, f"127.0.0.1:{port}", operator["public"], wallet.getnewaddress(), 0, + EARLY_PERIOD_BLOCKS, EARLY_PENALTY, platform_node_id, [f"127.0.0.1:{port + 1000}"], + [f"127.0.0.1:{port + 2000}"]] + assert_raises_rpc_error(-8, "requires the evo_shares deployment to be active", wallet.protx, + "shared_register_prepare_evo", *args) + + self.activate("evo_shares") + prepared = wallet.protx("shared_register_prepare_evo", *args) + assert_equal(prepared["terms"]["platformNodeID"], platform_node_id) + signatures = wallet.protx("shared_sign", prepared["tx"])["signatures"] + signed = wallet.signrawtransactionwithwallet(wallet.protx("shared_combine", prepared["tx"], signatures)) + assert_equal(signed["complete"], True) + + self.log.info("A shared EvoNode registration is invalid in a block before evo_shares activation") + last_inactive = node.getblockhash(get_bip9_details(node, "evo_shares")["since"] - 1) + node.invalidateblock(last_inactive) + assert not softfork_active(node, "evo_shares") + self.assert_rejected_transaction(node, tx_from_hex(signed["hex"]), "bad-protx-shares-evo") + node.reconsiderblock(last_inactive) + + self.log.info("An EvoNode registers with shared collateral once evo_shares is active") + protx_hash = node.sendrawtransaction(signed["hex"]) + self.bump_mocktime(10 * 60 + 1) + self.generate(node, 1, sync_fun=self.no_op) + info = node.protx("info", protx_hash) + assert_equal(info["type"], "Evo") + assert_equal(info["state"]["platformNodeID"], platform_node_id) + assert_equal([share["amount"] for share in info["state"]["shares"]], [share["amount"] for share in shares]) + + self.log.info("A shared EvoNode's owner reward is split by share amounts") + payees = self.owner_gbt_payees(node) + assert_equal([p["payee"] for p in payees], [share["refundAddress"] for share in shares]) + owner_total = sum(p["amount"] for p in payees) + assert_equal(payees[0]["amount"], owner_total * shares[0]["amount"] // EVO_COLLATERAL) + assert_equal(payees[1]["amount"], owner_total - payees[0]["amount"]) + self.generate(node, 1, sync_fun=self.no_op) + + self.log.info("A shared EvoNode updates its Platform service, a share reward and its voting key") + fee_addresses = [wallet.getnewaddress() for _ in range(3)] + wallet.sendmany("", {address: 1 for address in fee_addresses}) + self.bump_mocktime(10 * 60 + 1) + self.generate(node, 1, sync_fun=self.no_op) + new_platform_node_id = "%040x" % (port + 1) + wallet.protx("update_service_evo", protx_hash, [f"127.0.0.1:{port}"], operator["secret"], new_platform_node_id, + [f"127.0.0.1:{port + 3000}"], [f"127.0.0.1:{port + 4000}"], "", fee_addresses[0]) + reward = wallet.getnewaddress() + wallet.protx("shared_update_share", protx_hash, 0, reward, fee_addresses[1]) + voting = wallet.getnewaddress() + prepared = wallet.protx("shared_update_registrar_prepare", protx_hash, "", voting, fee_addresses[2]) + wallet.protx("shared_combine", prepared["tx"], wallet.protx("shared_sign", prepared["tx"])["signatures"], True) + self.bump_mocktime(10 * 60 + 1) + self.generate(node, 1, sync_fun=self.no_op) + state = node.protx("info", protx_hash)["state"] + assert_equal(state["platformNodeID"], new_platform_node_id) + assert_equal(state["shares"][0]["rewardAddress"], reward) + assert_equal(state["votingAddress"], voting) + + self.log.info("A shared EvoNode dissolves and refunds its collateral to every share") + prepared = wallet.protx("shared_dissolve_prepare", protx_hash, 1, DISSOLVE_FEE) + dissolution = wallet.protx("shared_combine", prepared["tx"], wallet.protx("shared_sign", prepared["tx"])["signatures"]) + refunds = {out["scriptPubKey"]["address"]: out["value"] for out in node.decoderawtransaction(dissolution)["vout"]} + assert_equal(refunds, {shares[0]["refundAddress"]: Decimal(shares[0]["amount"]) / COIN, + shares[1]["refundAddress"]: Decimal(shares[1]["amount"] - DISSOLVE_FEE) / COIN}) + dissolve_txid = node.sendrawtransaction(dissolution) + self.bump_mocktime(10 * 60 + 1) + block_hash = self.generate(node, 1, sync_fun=self.no_op)[0] + assert dissolve_txid in node.getblock(block_hash)["tx"] + assert_equal(node.masternodelist(), {}) if __name__ == '__main__': From d42644f227dd8f5fefb63a82b4d84f4d49319db5 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 11:16:53 -0500 Subject: [PATCH 5/7] test: cover evo_shares signing and EvoNode owner payouts feature_evo_shares_activation.py checks that masternodes sign another masternode-activated deployment but not evo_shares, and that an EvoNode keeps a single owner payout until evo_shares is active: registration, registrar update, and an update that rotates the operator key are all rejected. It then lets miners activate evo_shares (-vbparams with EHF disabled) and checks that an EvoNode can update and register with several owner payouts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../feature_evo_shares_activation.py | 143 ++++++++++++++++++ test/functional/test_runner.py | 1 + 2 files changed, 144 insertions(+) create mode 100755 test/functional/feature_evo_shares_activation.py diff --git a/test/functional/feature_evo_shares_activation.py b/test/functional/feature_evo_shares_activation.py new file mode 100755 index 000000000000..c44ffa5e4702 --- /dev/null +++ b/test/functional/feature_evo_shares_activation.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test the evo_shares deployment. + +v24 follows evo_shares without signing it, so on a real network it stays inactive until a later +release signs it. Until then an EvoNode keeps a single owner payout. The test first runs the +deployment as shipped (masternode-activated), then lets miners activate it (-vbparams with EHF +disabled) to check the restriction lifts. +""" + +from test_framework.test_framework import DashTestFramework, MasternodeInfo, p2p_port +from test_framework.util import assert_equal, get_bip9_details, softfork_active + +EVO_SHARES_BIT = 14 +TESTDUMMY_BIT = 28 +V24_MIN_ACTIVATION_HEIGHT = 200 + + +def payout_address_rewards(payouts): + return [{"address": p["address"], "reward": p["reward"]} for p in payouts] + + +class EvoSharesActivationTest(DashTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + extra_args = [[ + f"-vbparams=v24:{self.mocktime}:999999999999:{V24_MIN_ACTIVATION_HEIGHT}:10:8:6:5:0", + # A deployment masternodes do sign, to show that the evo_shares check below is not vacuous + "-vbparams=testdummy:0:999999999999:0:4:4:4:5:1", + "-persistmempool=0", + ] for _ in range(2)] + # One masternode is enough: an MNHF signal only needs a recovered signature of any quorum + self.set_dash_test_params(2, 1, extra_args=extra_args) + self.set_dash_llmq_test_params(1, 1) + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def mnhf_signals_in_mempool(self, node, version_bit): + return [txid for txid in node.getrawmempool() + if node.getrawtransaction(txid, True).get("mnhfTx", {}).get("signal", {}).get("versionBit") == version_bit] + + def test_not_signed(self): + self.log.info("Masternodes sign other deployments but not evo_shares") + node = self.nodes[0] + mn_node = self.mninfo[0].get_node(self) + self.mine_quorum_single_member() + with mn_node.assert_debug_log(expected_msgs=[f"bit={TESTDUMMY_BIT} at height"], + unexpected_msgs=[f"bit={EVO_SHARES_BIT} at height"]): + self.generate(node, 1, sync_fun=self.sync_blocks) + self.wait_until(lambda: len(self.mnhf_signals_in_mempool(mn_node, TESTDUMMY_BIT)) == 1) + assert_equal(self.mnhf_signals_in_mempool(mn_node, EVO_SHARES_BIT), []) + self.generate(mn_node, 1, sync_fun=self.sync_blocks) + assert_equal(get_bip9_details(node, "evo_shares")["status"], "defined") + + @staticmethod + def evonode_platform_args(evo): + return { + "platform_node_id": "%040x" % evo.nodePort, + "addrs_platform_p2p": [f"127.0.0.1:{evo.nodePort + 1000}"], + "addrs_platform_https": [f"127.0.0.1:{evo.nodePort + 2000}"], + } + + def fund_evonode(self, node, port): + evo = MasternodeInfo(evo=True, legacy=False) + evo.generate_addresses(node) + evo.nodePort = port + collateral_txid = node.sendmany("", {evo.collateral_address: evo.get_collateral_value(), evo.fundsAddr: 1}) + self.bury_tx(node, collateral_txid) + evo.collateral_txid = collateral_txid + evo.collateral_vout = evo.get_collateral_vout(node, collateral_txid) + return evo + + @staticmethod + def two_payouts(node): + return [{"address": node.getnewaddress(), "reward": 6000}, {"address": node.getnewaddress(), "reward": 4000}] + + def test_single_payout(self): + self.log.info("An EvoNode keeps a single owner payout until evo_shares is active") + node = self.nodes[0] + while not softfork_active(node, "v24"): + self.generate(node, 10, sync_fun=self.sync_blocks) + assert not softfork_active(node, "evo_shares") + evo = self.fund_evonode(node, p2p_port(10)) + platform = self.evonode_platform_args(evo) + evo.register(node, submit=True, payouts=self.two_payouts(node), **platform, + expected_assert_code=-1, expected_assert_msg="bad-protx-payouts-evo") + protx_hash = evo.register(node, submit=True, **platform) + self.bury_tx(node, protx_hash) + evo.set_params(proTxHash=protx_hash) + + node.sendtoaddress(evo.fundsAddr, 1) + evo.update_registrar(node, submit=True, payouts=self.two_payouts(node), fundsAddr=evo.fundsAddr, + expected_assert_code=-1, expected_assert_msg="bad-protx-payouts-evo") + # Rotating the operator key does not open a way around it + evo.update_registrar(node, submit=True, pubKeyOperator=node.bls("generate")["public"], + payouts=self.two_payouts(node), fundsAddr=evo.fundsAddr, + expected_assert_code=-1, expected_assert_msg="bad-protx-payouts-evo") + assert_equal(len(node.protx("info", protx_hash)["state"]["payouts"]), 1) + return evo + + def activate_evo_shares_by_miners(self): + self.log.info("Let miners activate evo_shares") + for i in range(self.num_nodes): + self.extra_args[i].append("-vbparams=evo_shares:0:999999999999:0:10:8:6:5:0") + self.restart_node(0) + mn = self.mninfo[0] + self.stop_node(mn.nodeIdx) + self.start_masternode(mn) + self.connect_nodes(mn.nodeIdx, 0) + node = self.nodes[0] + while not softfork_active(node, "evo_shares"): + self.generate(node, 10, sync_fun=self.sync_blocks) + + def test_multiple_payouts(self, evo): + self.log.info("EvoNodes can have multiple owner payouts once evo_shares is active") + node = self.nodes[0] + payouts = self.two_payouts(node) + update_hash = evo.update_registrar(node, submit=True, payouts=payouts, fundsAddr=evo.fundsAddr) + self.bury_tx(node, update_hash) + assert_equal(payout_address_rewards(node.protx("info", evo.proTxHash)["state"]["payouts"]), payouts) + + evo2 = self.fund_evonode(node, p2p_port(11)) + payouts = self.two_payouts(node) + protx_hash = evo2.register(node, submit=True, payouts=payouts, **self.evonode_platform_args(evo2)) + self.bury_tx(node, protx_hash) + assert_equal(payout_address_rewards(node.protx("info", protx_hash)["state"]["payouts"]), payouts) + + def run_test(self): + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.wait_for_sporks_same() + self.test_not_signed() + evo = self.test_single_payout() + self.activate_evo_shares_by_miners() + self.test_multiple_payouts(evo) + + +if __name__ == '__main__': + EvoSharesActivationTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 0b5e675ae1c8..4d6bee430d26 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -131,6 +131,7 @@ 'feature_llmq_is_cl_conflicts.py', # NOTE: needs dash_hash to pass 'feature_dip4_coinbasemerkleroots.py', # NOTE: needs dash_hash to pass 'feature_mnehf.py', # NOTE: needs dash_hash to pass + 'feature_evo_shares_activation.py', # NOTE: needs dash_hash to pass 'feature_masternode_params.py', # NOTE: needs dash_hash to pass 'feature_masternode_params_devnet.py', # NOTE: needs dash_hash to pass 'feature_governance.py --descriptors', From 8edce1f3c52bc0a90dcd80897a78f28f7474d654 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 11:16:53 -0500 Subject: [PATCH 6/7] doc: add release notes for the evo_shares deployment Co-Authored-By: Claude Opus 5.5 (1M context) --- doc/release-notes-7807.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 doc/release-notes-7807.md diff --git a/doc/release-notes-7807.md b/doc/release-notes-7807.md new file mode 100644 index 000000000000..ce06a893ef4b --- /dev/null +++ b/doc/release-notes-7807.md @@ -0,0 +1,20 @@ +Consensus (v24, not yet activated on mainnet or testnet) +--------------------------------------------------------- + +- EvoNodes cannot register with shared collateral or have more than one owner payout until + the new `evo_shares` deployment activates. An EvoNode ProRegTx with a share table is + rejected with `bad-protx-shares-evo`, and an EvoNode ProRegTx or ProUpRegTx with more than + one owner payout is rejected with `bad-protx-payouts-evo`. Regular masternodes are not + affected. +- `evo_shares` (version bit 14) is a masternode-activated deployment with the same parameters + as `v24`. This release follows it but its masternodes do not sign it, so it stays inactive + until a later release signs it once Platform supports EvoNode shares and payouts. +- The consent digest that share owners sign for a shared EvoNode registration also covers the + EvoNode's `platformNodeID`. The digest of a regular shared registration is unchanged. + +New RPCs +-------- + +- `protx shared_register_prepare_evo` creates an unsigned shared EvoNode registration. It + takes the arguments of `protx shared_register_prepare` followed by `platformNodeID`, + `platformP2PAddrs` and `platformHTTPSAddrs`, and fails until `evo_shares` is active. From 787ad82452d8eea1ce461c825d784da583bded4f Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 6 Oct 2026 11:37:41 -0500 Subject: [PATCH 7/7] refactor: share platformNodeID handling between provider transactions Registration and service updates for EvoNodes parsed the platformNodeID argument and required it in the same way as the shared registration does. Use the ParsePlatformNodeID() and ApplyPlatformNodeId() helpers there too. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/evo/providertx_service.cpp | 14 ++------------ src/rpc/evo.cpp | 14 ++------------ 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/evo/providertx_service.cpp b/src/evo/providertx_service.cpp index d12822ad0ba2..aa9426567183 100644 --- a/src/evo/providertx_service.cpp +++ b/src/evo/providertx_service.cpp @@ -533,12 +533,7 @@ RegistrationResult BuildRegistration(node::NodeContext& node, Wallet& wallet, return Error(ProviderTxErrorCode::INVALID_PARAMETER, "payouts array requires provider transaction version 3"); } - if (request.type == MnType::Evo) { - if (!request.platform_node_id) { - return Error(ProviderTxErrorCode::INVALID_PARAMETER, "platformNodeID must be specified for an EvoNode"); - } - payload.platformNodeID = *request.platform_node_id; - } + if (auto error{ApplyPlatformNodeId(payload, request.platform_node_id)}) return *error; if (auto error{ValidateNetworkFields(payload, /*allow_empty=*/true, /*check_platform_node_id=*/true)}) { return *error; @@ -840,12 +835,7 @@ ProviderTxResult UpdateService(node::NodeContext& node, Wa if (auto error{ApplyNetInfo(payload, request.net_info, request.type == MnType::Evo, /*optional=*/false)}) { return *error; } - if (request.type == MnType::Evo) { - if (!request.platform_node_id) { - return Error(ProviderTxErrorCode::INVALID_PARAMETER, "platformNodeID must be specified for an EvoNode"); - } - payload.platformNodeID = *request.platform_node_id; - } + if (auto error{ApplyPlatformNodeId(payload, request.platform_node_id)}) return *error; if (auto error{ValidateNetworkFields(payload, /*allow_empty=*/false, /*check_platform_node_id=*/true)}) { return *error; diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index 3c7088bf560e..8db3f4f93e89 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -922,12 +922,7 @@ static UniValue protx_register_common_wrapper(const JSONRPCRequest& request, typed_request.payouts = ParsePayouts(request.params[paramIdx + 5], "payouts"); if (mnType == MnType::Evo) { - if (!IsHex(request.params[paramIdx + 6].get_str())) { - throw JSONRPCError(RPC_INVALID_PARAMETER, "platformNodeID must be hexadecimal string"); - } - uint160 platform_node_id; - platform_node_id.SetHex(request.params[paramIdx + 6].get_str()); - typed_request.platform_node_id = platform_node_id; + typed_request.platform_node_id = ParsePlatformNodeID(request.params[paramIdx + 6]); typed_request.net_info.platform_p2p = ParsePlatformNetInfo(request.params[paramIdx + 7], "platformP2PAddrs", capabilities.extended_addresses); typed_request.net_info.platform_https = ParsePlatformNetInfo(request.params[paramIdx + 8], "platformHTTPSAddrs", @@ -1072,12 +1067,7 @@ static UniValue protx_update_service_common_wrapper(const JSONRPCRequest& reques size_t paramIdx{3}; if (mnType == MnType::Evo) { const auto capabilities{evo::provider::GetCapabilities(node)}; - if (!IsHex(request.params[paramIdx].get_str())) { - throw JSONRPCError(RPC_INVALID_PARAMETER, "platformNodeID must be hexadecimal string"); - } - uint160 platform_node_id; - platform_node_id.SetHex(request.params[paramIdx].get_str()); - typed_request.platform_node_id = platform_node_id; + typed_request.platform_node_id = ParsePlatformNodeID(request.params[paramIdx]); typed_request.net_info.platform_p2p = ParsePlatformNetInfo(request.params[paramIdx + 1], "platformP2PAddrs", capabilities.extended_addresses); typed_request.net_info.platform_https = ParsePlatformNetInfo(request.params[paramIdx + 2], "platformHTTPSAddrs",