Skip to content

Commit 0a40716

Browse files
UdjinM6claude
authored andcommitted
test: cover the v24 MN payment multiplicity boundary end-to-end
Add evo_dip3_activation_tests/mn_payment_multiplicity_v24_boundary, exercising the full FillBlockPayments -> ConnectBlock -> IsBlockPayeeValid round-trip across the v24 activation boundary with a single masternode. A plain v2 (BasicBLS) masternode -- deliberately NOT multi-payout -- is registered with a single owner payout and the operator payout pointing at the same script, with a 50% operator reward, so whenever the masternode reward is even the owner share and the operator share resolve to two identical coinbase outputs. The test builds a "merge cheat" coinbase that folds those two identical outputs into one, redirecting the freed value to the miner so the total coinbase value is unchanged (IsBlockValueValid still passes) while the masternode is underpaid, and asserts both sides of the boundary: - pre-v24, legacy existence-only matching ACCEPTS the merge cheat; - post-v24, strict multiplicity matching REJECTS it, and the faithful two-output block connects. The duplicate is an owner-payout/operator-payout script collision, not a multi-payout property: multi-payout owner shares are forced to distinct scripts (bad-protx-payee-dup) and so can never collide. Keeping the same v2 MN across the boundary makes explicit that strict multiplicity matching is gated purely on v24 activation, independent of the registrant's ProTx version. Also adds a TestChainV24PendingSetup fixture that activates v19/v20/mn_rr at height 500 and stops with v24 signalled but still pending, so the test can register a basic-BLS MN and then mine across v24 activation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 84a6ba1 commit 0a40716

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

‎src/test/evo_deterministicmns_tests.cpp‎

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
#include <chainparams.h>
88
#include <clientversion.h>
9+
#include <consensus/merkle.h>
910
#include <consensus/validation.h>
1011
#include <deploymentstatus.h>
1112
#include <evo/deterministicmns.h>
@@ -19,6 +20,7 @@
1920
#include <netbase.h>
2021
#include <node/transaction.h>
2122
#include <policy/policy.h>
23+
#include <pow.h>
2224
#include <script/interpreter.h>
2325
#include <script/sign.h>
2426
#include <script/signingprovider.h>
@@ -30,6 +32,7 @@
3032

3133
#include <boost/test/unit_test.hpp>
3234

35+
#include <optional>
3336
#include <vector>
3437

3538
using node::GetTransaction;
@@ -669,6 +672,211 @@ void FuncProUpRegTxV2CannotBypassV4PayoutCollateralReuse(TestChainSetup& setup)
669672
BOOST_CHECK_EQUAL(val_state.GetRejectReason(), "bad-protx-payee-reuse");
670673
}
671674

675+
// Coinbase validation matches each expected masternode payment against a DISTINCT coinbase output
676+
// only after v24. This exercises the exact activation boundary with a SINGLE masternode whose
677+
// owner payout and operator payout resolve to the same script AND amount (an even 50% operator
678+
// reward), so the coinbase carries two identical outputs. The "merge cheat" folds those two
679+
// identical outputs into one, redirecting the freed value to a miner output so the total coinbase
680+
// value is unchanged; this underpays the masternode.
681+
//
682+
// Pre-v24: existence-only matching ACCEPTS the merge cheat.
683+
// Post-v24: strict multiplicity matching REJECTS it (and the faithful block connects).
684+
//
685+
// The duplicate is NOT a multi-payout (v4) phenomenon: multi-payout owner shares are forced to
686+
// distinct scripts (bad-protx-payee-dup) and can never collide. It is a plain owner-payout ==
687+
// operator-payout script collision, which every ProTx version supports -- here a v2 (BasicBLS)
688+
// MN, the max version eligible while v24 is still pending, kept across the boundary.
689+
void FuncMNPaymentMultiplicityV24Boundary(TestChainSetup& setup)
690+
{
691+
auto& chainman = *Assert(setup.m_node.chainman.get());
692+
auto& dmnman = *Assert(setup.m_node.dmnman);
693+
const CScript coinbase_pk = GetScriptForRawPubKey(setup.coinbaseKey.GetPubKey());
694+
const auto& consensus = chainman.GetConsensus();
695+
696+
// Start in the pre-v24 window: v19 active (basic BLS scheme) so we can register a v2 MN, but
697+
// v24 -- and thus strict multiplicity matching -- not yet active.
698+
BOOST_REQUIRE(DeploymentActiveAfter(chainman.ActiveChain().Tip(), consensus, Consensus::DEPLOYMENT_V19));
699+
BOOST_REQUIRE(!DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_V24));
700+
BOOST_REQUIRE(!bls::bls_legacy_scheme.load());
701+
702+
auto utxos = BuildSimpleUtxoMap(setup.m_coinbase_txns);
703+
704+
// A single script that BOTH an owner payout and the operator payout will point at.
705+
CKey payout_key;
706+
payout_key.MakeNewKey(true);
707+
const CScript shared_script = GetScriptForDestination(PKHash(payout_key.GetPubKey()));
708+
709+
CKey owner_key;
710+
CKey collateral_key;
711+
CBLSSecretKey operator_key;
712+
owner_key.MakeNewKey(true);
713+
collateral_key.MakeNewKey(true);
714+
operator_key.MakeNewKey();
715+
const auto script_collateral = GetScriptForDestination(PKHash(collateral_key.GetPubKey()));
716+
717+
// Fund the collateral.
718+
CMutableTransaction tx_collateral;
719+
FundTransaction(chainman.ActiveChain(), tx_collateral, utxos, script_collateral, dmn_types::Regular.collat_amount,
720+
setup.coinbaseKey);
721+
SignTransaction(*(setup.m_node.mempool), tx_collateral, setup.coinbaseKey);
722+
setup.CreateAndProcessBlock({tx_collateral}, coinbase_pk);
723+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
724+
725+
// Register a plain v2 (BasicBLS) MN -- deliberately NOT multi-payout, and the max version
726+
// eligible while v24 is pending -- with a single legacy owner payout -> shared_script and a
727+
// 50% operator reward, so the owner share and the operator share are equal whenever the
728+
// masternode reward is even.
729+
CProRegTx pro_reg;
730+
pro_reg.nVersion = ProTxVersion::BasicBLS;
731+
pro_reg.netInfo = NetInfoInterface::MakeNetInfo(pro_reg.nVersion);
732+
// Legacy MnNetInfo (v2) on regtest must not use the mainnet port; a small port works.
733+
BOOST_CHECK_EQUAL(pro_reg.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, "1.1.1.1:1"), NetInfoStatus::Success);
734+
pro_reg.keyIDOwner = owner_key.GetPubKey().GetID();
735+
pro_reg.pubKeyOperator.Set(operator_key.GetPublicKey(), bls::bls_legacy_scheme.load());
736+
pro_reg.keyIDVoting = owner_key.GetPubKey().GetID();
737+
pro_reg.nOperatorReward = 5000; // 50%
738+
pro_reg.scriptPayout = shared_script;
739+
for (size_t i = 0; i < tx_collateral.vout.size(); ++i) {
740+
if (tx_collateral.vout[i].nValue == dmn_types::Regular.collat_amount) {
741+
pro_reg.collateralOutpoint = COutPoint(tx_collateral.GetHash(), i);
742+
break;
743+
}
744+
}
745+
746+
CMutableTransaction tx_reg;
747+
tx_reg.nVersion = 3;
748+
tx_reg.nType = TRANSACTION_PROVIDER_REGISTER;
749+
FundTransaction(chainman.ActiveChain(), tx_reg, utxos, GetScriptForDestination(PKHash(setup.coinbaseKey.GetPubKey())),
750+
1 * COIN, setup.coinbaseKey);
751+
pro_reg.inputsHash = CalcTxInputsHash(CTransaction(tx_reg));
752+
CMessageSigner::SignMessage(pro_reg.MakeSignString(), pro_reg.vchSig, collateral_key);
753+
SetTxPayload(tx_reg, pro_reg);
754+
SignTransaction(*(setup.m_node.mempool), tx_reg, setup.coinbaseKey);
755+
const auto proTxHash = tx_reg.GetHash();
756+
setup.CreateAndProcessBlock({tx_reg}, coinbase_pk);
757+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
758+
759+
auto dmn = dmnman.GetListAtChainTip().GetMN(proTxHash);
760+
BOOST_REQUIRE(dmn);
761+
BOOST_CHECK_EQUAL(dmn->pdmnState->nVersion, ProTxVersion::BasicBLS);
762+
763+
// Point the operator payout at the same script as the owner payout. Nothing checks the
764+
// operator payout script against the owner payout script, in any version, so this collision
765+
// is accepted.
766+
CProUpServTx pro_ups;
767+
pro_ups.nVersion = ProTxVersion::BasicBLS;
768+
pro_ups.proTxHash = proTxHash;
769+
pro_ups.netInfo = NetInfoInterface::MakeNetInfo(pro_ups.nVersion);
770+
BOOST_CHECK_EQUAL(pro_ups.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, "1.1.1.1:1"), NetInfoStatus::Success);
771+
pro_ups.scriptOperatorPayout = shared_script;
772+
773+
CMutableTransaction tx_ups;
774+
tx_ups.nVersion = 3;
775+
tx_ups.nType = TRANSACTION_PROVIDER_UPDATE_SERVICE;
776+
FundTransaction(chainman.ActiveChain(), tx_ups, utxos, GetScriptForDestination(PKHash(setup.coinbaseKey.GetPubKey())),
777+
1 * COIN, setup.coinbaseKey);
778+
pro_ups.inputsHash = CalcTxInputsHash(CTransaction(tx_ups));
779+
pro_ups.sig = operator_key.Sign(::SerializeHash(pro_ups), bls::bls_legacy_scheme);
780+
SetTxPayload(tx_ups, pro_ups);
781+
SignTransaction(*(setup.m_node.mempool), tx_ups, setup.coinbaseKey);
782+
setup.CreateAndProcessBlock({tx_ups}, coinbase_pk);
783+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
784+
BOOST_REQUIRE(dmnman.GetListAtChainTip().GetMN(proTxHash)->pdmnState->scriptOperatorPayout == shared_script);
785+
786+
// Two identical coinbase outputs paying shared_script (the owner/operator collision).
787+
auto find_duplicate = [&](const CBlock& b) -> std::optional<CTxOut> {
788+
const auto& vout = b.vtx[0]->vout;
789+
for (size_t i = 0; i < vout.size(); ++i) {
790+
if (vout[i].scriptPubKey != shared_script) continue;
791+
for (size_t j = i + 1; j < vout.size(); ++j) {
792+
if (vout[j] == vout[i]) return vout[i];
793+
}
794+
}
795+
return std::nullopt;
796+
};
797+
798+
// Mine until a block's (pre-operator) masternode reward is even: only then does the 50%
799+
// operator share equal the single owner share, so the owner and operator outputs -- both
800+
// paying shared_script -- collide into two identical coinbase outputs. The reward is constant
801+
// within a subsidy/reallocation epoch, so this can take up to a full epoch; intermediate
802+
// blocks are processed to advance the chain.
803+
auto mine_until_duplicate = [&]() -> std::pair<CBlock, CTxOut> {
804+
for (int i = 0; i < 2000; ++i) {
805+
CBlock good = setup.CreateBlock({}, coinbase_pk, chainman.ActiveChainstate());
806+
if (auto dup = find_duplicate(good)) return {good, *dup};
807+
BOOST_REQUIRE(chainman.ProcessNewBlock(std::make_shared<CBlock>(good), /*force_processing=*/true, nullptr));
808+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
809+
}
810+
BOOST_REQUIRE_MESSAGE(false, "expected owner/operator collision to yield a duplicate coinbase output");
811+
return {}; // unreachable, BOOST_REQUIRE above aborts the test
812+
};
813+
814+
// Build the "merge cheat" sibling of `good`: drop one of the two identical outputs and fold
815+
// its value into a non-masternode (miner) output. Total coinbase value is unchanged (so
816+
// IsBlockValueValid still passes), but only ONE distinct output now pays shared_script.
817+
auto build_merge_cheat = [&](const CBlock& good, const CTxOut& dup) -> CBlock {
818+
CBlock merged = good;
819+
CMutableTransaction cb(*merged.vtx[0]);
820+
bool kept_first = false;
821+
for (auto it = cb.vout.begin(); it != cb.vout.end();) {
822+
if (*it == dup && kept_first) {
823+
it = cb.vout.erase(it);
824+
} else {
825+
if (*it == dup) kept_first = true;
826+
++it;
827+
}
828+
}
829+
bool redirected = false;
830+
for (auto& o : cb.vout) {
831+
if (o.scriptPubKey != shared_script && !o.scriptPubKey.IsUnspendable()) {
832+
o.nValue += dup.nValue;
833+
redirected = true;
834+
break;
835+
}
836+
}
837+
BOOST_REQUIRE(redirected);
838+
merged.vtx[0] = MakeTransactionRef(std::move(cb));
839+
merged.hashMerkleRoot = BlockMerkleRoot(merged);
840+
merged.nNonce = 0;
841+
while (!CheckProofOfWork(merged.GetHash(), merged.nBits, consensus)) ++merged.nNonce;
842+
return merged;
843+
};
844+
845+
// ---- Pre-v24: legacy existence-only matching ACCEPTS the merge cheat (masternode underpaid).
846+
{
847+
const auto [good, dup] = mine_until_duplicate();
848+
// The collision must be reached while v24 is still pending, otherwise this phase would be
849+
// testing post-v24 behaviour by accident.
850+
BOOST_REQUIRE(!DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_V24));
851+
const CBlock merged = build_merge_cheat(good, dup);
852+
BOOST_REQUIRE(chainman.ProcessNewBlock(std::make_shared<CBlock>(merged), /*force_processing=*/true, nullptr));
853+
BOOST_CHECK_EQUAL(chainman.ActiveChain().Tip()->GetBlockHash(), merged.GetHash());
854+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
855+
}
856+
857+
// ---- Mine across v24 activation (the same v2 MN is kept; its collision is version-independent).
858+
for (int i = 0; i < 2000 && !DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_V24); ++i) {
859+
setup.CreateAndProcessBlock({}, coinbase_pk);
860+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
861+
}
862+
BOOST_REQUIRE(DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_V24));
863+
864+
// ---- Post-v24: strict multiplicity matching REJECTS the same merge (two expected outputs, one
865+
// distinct actual). Rejection is observed as the tip not advancing.
866+
{
867+
const auto [good, dup] = mine_until_duplicate();
868+
const CBlock merged = build_merge_cheat(good, dup);
869+
const uint256 tip_before = chainman.ActiveChain().Tip()->GetBlockHash();
870+
chainman.ProcessNewBlock(std::make_shared<CBlock>(merged), /*force_processing=*/true, nullptr);
871+
BOOST_CHECK(chainman.ActiveChain().Tip()->GetBlockHash() == tip_before);
872+
BOOST_CHECK(chainman.ActiveChain().Tip()->GetBlockHash() != merged.GetHash());
873+
874+
// The faithful block (both identical outputs present) connects.
875+
BOOST_REQUIRE(chainman.ProcessNewBlock(std::make_shared<CBlock>(good), /*force_processing=*/true, nullptr));
876+
BOOST_CHECK_EQUAL(chainman.ActiveChain().Tip()->GetBlockHash(), good.GetHash());
877+
}
878+
}
879+
672880
static std::shared_ptr<NetInfoInterface> DeserializeCoreP2PExtNetInfo(const std::vector<NetInfoEntry>& entries)
673881
{
674882
// Hand-roll ExtNetInfo's serialization (version byte followed by a map of purpose to entries)
@@ -1327,6 +1535,31 @@ struct TestChainV24SignalBeforeV19Setup : public TestChainSetup {
13271535
}
13281536
};
13291537

1538+
// v19/v20/mn_rr active at height 500, with v24 signalled but still PENDING (not yet locked in).
1539+
// The 500-block window means v24 locks in well after v19, so this fixture stops in the window
1540+
// where basic-BLS (v2) registrations are valid but strict multiplicity matching is off, letting
1541+
// a single test cross the v24 activation boundary.
1542+
struct TestChainV24PendingSetup : public TestChainSetup {
1543+
TestChainV24PendingSetup() :
1544+
TestChainSetup(494, CBaseChainParams::REGTEST,
1545+
{"-testactivationheight=v19@500", "-testactivationheight=v20@500",
1546+
"-testactivationheight=mn_rr@500",
1547+
"-vbparams=v24:0:9999999999:0:500:400:300:5:0"})
1548+
{
1549+
const CScript coinbase_pk = GetScriptForRawPubKey(coinbaseKey.GetPubKey());
1550+
auto& chainman = *Assert(m_node.chainman);
1551+
auto& dmnman = *Assert(m_node.dmnman);
1552+
// Mine just enough to activate v19/v20/mn_rr (height 500) while keeping v24 pending.
1553+
for (int i = 0; i < 20 && !DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman.GetConsensus(), Consensus::DEPLOYMENT_V19); ++i) {
1554+
CreateAndProcessBlock({}, coinbase_pk);
1555+
dmnman.UpdatedBlockTip(chainman.ActiveChain().Tip());
1556+
}
1557+
assert(DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman.GetConsensus(), Consensus::DEPLOYMENT_V19));
1558+
assert(!DeploymentActiveAfter(chainman.ActiveChain().Tip(), chainman, Consensus::DEPLOYMENT_V24));
1559+
assert(!bls::bls_legacy_scheme.load());
1560+
}
1561+
};
1562+
13301563
// DIP3 can only be activated with legacy scheme (v19 is activated later)
13311564
BOOST_AUTO_TEST_CASE(dip3_activation_legacy)
13321565
{
@@ -1365,6 +1598,12 @@ BOOST_AUTO_TEST_CASE(proregtx_rejects_invalid_deserialized_extnetinfo)
13651598
FuncProRegTxRejectsInvalidDeserializedExtNetInfo(setup);
13661599
}
13671600

1601+
BOOST_AUTO_TEST_CASE(mn_payment_multiplicity_v24_boundary)
1602+
{
1603+
TestChainV24PendingSetup setup;
1604+
FuncMNPaymentMultiplicityV24Boundary(setup);
1605+
}
1606+
13681607
BOOST_AUTO_TEST_CASE(dip3_protx_legacy)
13691608
{
13701609
TestChainDIP3Setup setup;

0 commit comments

Comments
 (0)