|
6 | 6 |
|
7 | 7 | #include <chainparams.h> |
8 | 8 | #include <clientversion.h> |
| 9 | +#include <consensus/merkle.h> |
9 | 10 | #include <consensus/validation.h> |
10 | 11 | #include <deploymentstatus.h> |
11 | 12 | #include <evo/deterministicmns.h> |
|
19 | 20 | #include <netbase.h> |
20 | 21 | #include <node/transaction.h> |
21 | 22 | #include <policy/policy.h> |
| 23 | +#include <pow.h> |
22 | 24 | #include <script/interpreter.h> |
23 | 25 | #include <script/sign.h> |
24 | 26 | #include <script/signingprovider.h> |
|
30 | 32 |
|
31 | 33 | #include <boost/test/unit_test.hpp> |
32 | 34 |
|
| 35 | +#include <optional> |
33 | 36 | #include <vector> |
34 | 37 |
|
35 | 38 | using node::GetTransaction; |
@@ -669,6 +672,211 @@ void FuncProUpRegTxV2CannotBypassV4PayoutCollateralReuse(TestChainSetup& setup) |
669 | 672 | BOOST_CHECK_EQUAL(val_state.GetRejectReason(), "bad-protx-payee-reuse"); |
670 | 673 | } |
671 | 674 |
|
| 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 | + |
672 | 880 | static std::shared_ptr<NetInfoInterface> DeserializeCoreP2PExtNetInfo(const std::vector<NetInfoEntry>& entries) |
673 | 881 | { |
674 | 882 | // Hand-roll ExtNetInfo's serialization (version byte followed by a map of purpose to entries) |
@@ -1327,6 +1535,31 @@ struct TestChainV24SignalBeforeV19Setup : public TestChainSetup { |
1327 | 1535 | } |
1328 | 1536 | }; |
1329 | 1537 |
|
| 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 | + |
1330 | 1563 | // DIP3 can only be activated with legacy scheme (v19 is activated later) |
1331 | 1564 | BOOST_AUTO_TEST_CASE(dip3_activation_legacy) |
1332 | 1565 | { |
@@ -1365,6 +1598,12 @@ BOOST_AUTO_TEST_CASE(proregtx_rejects_invalid_deserialized_extnetinfo) |
1365 | 1598 | FuncProRegTxRejectsInvalidDeserializedExtNetInfo(setup); |
1366 | 1599 | } |
1367 | 1600 |
|
| 1601 | +BOOST_AUTO_TEST_CASE(mn_payment_multiplicity_v24_boundary) |
| 1602 | +{ |
| 1603 | + TestChainV24PendingSetup setup; |
| 1604 | + FuncMNPaymentMultiplicityV24Boundary(setup); |
| 1605 | +} |
| 1606 | + |
1368 | 1607 | BOOST_AUTO_TEST_CASE(dip3_protx_legacy) |
1369 | 1608 | { |
1370 | 1609 | TestChainDIP3Setup setup; |
|
0 commit comments