Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ BITCOIN_TESTS =\
test/llmq_snapshot_tests.cpp \
test/llmq_utils_tests.cpp \
test/logging_tests.cpp \
test/masternode_payments_tests.cpp \
test/dbwrapper_tests.cpp \
test/validation_tests.cpp \
test/mempool_tests.cpp \
Expand Down
57 changes: 45 additions & 12 deletions src/masternode/payments.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,36 @@
#include <ranges>
#include <string>

int FindUnmatchedMasternodePayment(const std::vector<CTxOut>& expected,
const std::vector<CTxOut>& actual,
bool strict_multiplicity)
{
if (!strict_multiplicity) {
for (size_t i = 0; i < expected.size(); ++i) {
const auto& txout = expected[i];
if (!std::ranges::any_of(actual, [&txout](const auto& txout2) { return txout == txout2; })) {
return static_cast<int>(i);
}
}
return -1;
}

std::vector<bool> consumed(actual.size(), false);
for (size_t i = 0; i < expected.size(); ++i) {
const auto& txout = expected[i];
bool found = false;
for (size_t j = 0; j < actual.size(); ++j) {
if (!consumed[j] && actual[j] == txout) {
consumed[j] = true;
found = true;
break;
}
}
if (!found) return static_cast<int>(i);
}
return -1;
}

CAmount PlatformShare(const CAmount reward)
{
const CAmount platformReward = reward * 375 / 1000;
Expand Down Expand Up @@ -131,19 +161,22 @@ CAmount PlatformShare(const CAmount reward)
return true;
}

for (const auto& txout : voutMasternodePayments) {
bool found = std::ranges::any_of(txNew.vout, [&txout](const auto& txout2) { return txout == txout2; });
if (!found) {
std::string str_payout;
if (CTxDestination dest; ExtractDestination(txout.scriptPubKey, dest)) {
str_payout = "address=" + EncodeDestination(dest);
} else {
str_payout = "scriptPubKey=" + HexStr(txout.scriptPubKey);
}
LogPrintf("CMNPaymentsProcessor::%s -- ERROR! Failed to find expected payee %s amount=%lld height=%d\n",
__func__, str_payout, txout.nValue, nBlockHeight);
return false;
// After v24 activation each expected payment must be matched by a distinct coinbase output:
// duplicate expected outputs require duplicate coinbase outputs. Pre-v24 retains the legacy
// existence-only check to avoid tightening historical validation.
const bool strict_match{DeploymentActiveAfter(pindexPrev, m_chainman, Consensus::DEPLOYMENT_V24)};
const int unmatched_idx = FindUnmatchedMasternodePayment(voutMasternodePayments, txNew.vout, strict_match);
if (unmatched_idx >= 0) {
const auto& txout = voutMasternodePayments[unmatched_idx];
std::string str_payout;
if (CTxDestination dest; ExtractDestination(txout.scriptPubKey, dest)) {
str_payout = "address=" + EncodeDestination(dest);
} else {
str_payout = "scriptPubKey=" + HexStr(txout.scriptPubKey);
}
LogPrintf("CMNPaymentsProcessor::%s -- ERROR! Failed to find expected payee %s amount=%lld height=%d\n",
__func__, str_payout, txout.nValue, nBlockHeight);
return false;
}
return true;
}
Expand Down
19 changes: 19 additions & 0 deletions src/masternode/payments.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ class ChainstateManager;
class CTransaction;
class CTxOut;

/**
* Match the list of expected masternode payment outputs against the coinbase
* outputs.
*
* When @p strict_multiplicity is true, every expected output must be matched
* by a distinct actual output (multiplicity-correct matching): two identical
* expected outputs require two identical actual outputs.
*
* When false, the legacy behaviour is used where each expected output only
* has to appear at least once in the actual outputs. This is preserved for
* pre-v24 historical validation.
*
* @return -1 if every expected output is matched, otherwise the index in
* @p expected of the first output that could not be matched.
*/
int FindUnmatchedMasternodePayment(const std::vector<CTxOut>& expected,
const std::vector<CTxOut>& actual,
bool strict_multiplicity);

struct CMutableTransaction;

namespace governance {
Expand Down
88 changes: 88 additions & 0 deletions src/test/masternode_payments_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 The Dash Core developers
// Distributed under the MIT/X11 software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#include <masternode/payments.h>

#include <primitives/transaction.h>
#include <script/script.h>

#include <vector>

#include <boost/test/unit_test.hpp>

BOOST_AUTO_TEST_SUITE(masternode_payments_tests)

namespace {
CTxOut MakeOut(CAmount value, uint8_t script_byte)
{
CScript script;
script << OP_RETURN << std::vector<uint8_t>{script_byte};
return CTxOut{value, script};
}
} // namespace

BOOST_AUTO_TEST_CASE(strict_matches_simple)
{
const std::vector<CTxOut> expected{MakeOut(100, 0x01), MakeOut(200, 0x02)};
const std::vector<CTxOut> actual{MakeOut(100, 0x01), MakeOut(200, 0x02), MakeOut(50, 0x03)};

BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/true), -1);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/false), -1);
}

BOOST_AUTO_TEST_CASE(strict_missing_output_fails)
{
const std::vector<CTxOut> expected{MakeOut(100, 0x01), MakeOut(200, 0x02)};
const std::vector<CTxOut> actual{MakeOut(100, 0x01)};

BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/true), 1);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/false), 1);
}

// Regression: with two identical expected masternode payments, strict multiplicity
// matching must require two identical coinbase outputs. A single output should
// NOT satisfy both expected outputs (which was the bug pre-v24).
BOOST_AUTO_TEST_CASE(strict_duplicate_expected_requires_duplicate_actual)
{
const std::vector<CTxOut> expected{MakeOut(100, 0x01), MakeOut(100, 0x01)};

// Only one matching actual output: strict must reject, legacy must accept.
const std::vector<CTxOut> actual_one{MakeOut(100, 0x01), MakeOut(50, 0x02)};
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual_one, /*strict_multiplicity=*/true), 1);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual_one, /*strict_multiplicity=*/false), -1);

// Two matching actual outputs: both modes accept.
const std::vector<CTxOut> actual_two{MakeOut(100, 0x01), MakeOut(100, 0x01), MakeOut(50, 0x02)};
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual_two, /*strict_multiplicity=*/true), -1);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual_two, /*strict_multiplicity=*/false), -1);
}

// Pre-v24 path retains the old existence-only behaviour: a single actual output
// can satisfy any number of identical expected outputs.
BOOST_AUTO_TEST_CASE(legacy_existence_only_matches_duplicates)
{
const std::vector<CTxOut> expected{MakeOut(100, 0x01), MakeOut(100, 0x01), MakeOut(100, 0x01)};
const std::vector<CTxOut> actual{MakeOut(100, 0x01)};

BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/false), -1);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/true), 1);
}

BOOST_AUTO_TEST_CASE(empty_expected_is_trivially_matched)
{
const std::vector<CTxOut> expected{};
const std::vector<CTxOut> actual{MakeOut(100, 0x01)};
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/true), -1);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/false), -1);
}

BOOST_AUTO_TEST_CASE(strict_amount_must_match_exactly)
{
const std::vector<CTxOut> expected{MakeOut(100, 0x01)};
const std::vector<CTxOut> actual{MakeOut(99, 0x01)};
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/true), 0);
BOOST_CHECK_EQUAL(FindUnmatchedMasternodePayment(expected, actual, /*strict_multiplicity=*/false), 0);
}

BOOST_AUTO_TEST_SUITE_END()
Loading