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 @@ -120,6 +120,7 @@ BITCOIN_TESTS =\
test/flatfile_tests.cpp \
test/fs_tests.cpp \
test/getarg_tests.cpp \
test/governance_failed_trigger_rate_tests.cpp \
test/governance_inv_tests.cpp \
test/governance_superblock_tests.cpp \
test/governance_validators_tests.cpp \
Expand Down
33 changes: 24 additions & 9 deletions src/governance/governance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -344,18 +344,25 @@ void CGovernanceManager::AddGovernanceObjectInternal(CGovernanceObject& insert_o
LogPrint(BCLog::GOBJECT, "CGovernanceManager::AddGovernanceObject -- Before trigger block, GetDataAsPlainString = %s, nObjectType = %d\n",
Assert(govobj)->GetDataAsPlainString(), std23::to_underlying(govobj->GetObjectType()));

// Count the attempt against the per-masternode rate buffer before any early
// return. Failed AddTrigger paths used to skip this, so a single operator
// key could flood mapObjects with unparseable triggers.
MasternodeRateUpdate(*govobj);

if (govobj->GetObjectType() == GovernanceObject::TRIGGER && !m_superblocks.AddTrigger(govobj, nCachedBlockHeight)) {
LogPrint(BCLog::GOBJECT, "CGovernanceManager::AddGovernanceObject -- undo adding invalid trigger object: hash = %s\n", nHash.ToString());
govobj->PrepareDeletion(GetTime<std::chrono::seconds>().count());
return;
}

// Only objects we keep may be announced. Scheduling this before the AddTrigger
// check would make us re-announce, and serve on GETDATA, a trigger we just
// undid and marked for deletion.
ScheduleAdditionalRelay(*govobj);

LogPrint(BCLog::GOBJECT, "CGovernanceManager::AddGovernanceObject -- %s new, received from peer %s\n", strHash, peer_str);
RelayObject(*govobj);

// Update the rate buffer
MasternodeRateUpdate(*govobj);

m_mn_sync.BumpAssetLastTime("CGovernanceManager::AddGovernanceObject");

// WE MIGHT HAVE PENDING/ORPHAN VOTES FOR THIS OBJECT
Expand Down Expand Up @@ -701,15 +708,23 @@ void CGovernanceManager::MasternodeRateUpdate(const CGovernanceObject& govobj)
it = mapLastMasternodeObject.insert(txout_m_t::value_type(masternodeOutpoint, last_object_rec(true))).first;
}

int64_t nTimestamp = govobj.GetCreationTime();
it->second.triggerBuffer.AddTimestamp(nTimestamp);
it->second.triggerBuffer.AddTimestamp(govobj.GetCreationTime());
it->second.fStatusOK = true;
}

void CGovernanceManager::ScheduleAdditionalRelay(const CGovernanceObject& govobj)
{
AssertLockHeld(cs_store);

if (nTimestamp > GetTime() + count_seconds(MAX_TIME_FUTURE_DEVIATION) - count_seconds(RELIABLE_PROPAGATION_TIME)) {
// schedule additional relay for the object
if (govobj.GetObjectType() != GovernanceObject::TRIGGER) return;

// An object created this close to the future-deviation limit is still too new for
// peers with a lagging clock to accept, so re-announce it once it has aged past
// RELIABLE_PROPAGATION_TIME (see CheckPostponedObjects).
if (govobj.GetCreationTime() >
GetTime() + count_seconds(MAX_TIME_FUTURE_DEVIATION) - count_seconds(RELIABLE_PROPAGATION_TIME)) {
setAdditionalRelayObjects.insert(govobj.GetHash());
}

it->second.fStatusOK = true;
}

bool CGovernanceManager::MasternodeRateCheck(const CGovernanceObject& govobj, bool fUpdateFailStatus)
Expand Down
5 changes: 5 additions & 0 deletions src/governance/governance.h
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,11 @@ class CGovernanceManager : public GovernanceStore
void MasternodeRateUpdate(const CGovernanceObject& govobj)
EXCLUSIVE_LOCKS_REQUIRED(cs_store);

/** Queue a deferred re-announcement for a trigger that is too new to propagate
* reliably yet. Only call this for objects we are keeping. */
void ScheduleAdditionalRelay(const CGovernanceObject& govobj)
EXCLUSIVE_LOCKS_REQUIRED(cs_store);

bool MasternodeRateCheck(const CGovernanceObject& govobj, bool fUpdateFailStatus, bool fForce, bool& fRateCheckBypassed)
EXCLUSIVE_LOCKS_REQUIRED(cs_store);

Expand Down
299 changes: 299 additions & 0 deletions src/test/governance_failed_trigger_rate_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
// 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.

#include <test/util/setup_common.h>

#include <bls/bls.h>
#include <chainparams.h>
#include <deploymentstatus.h>
#include <evo/chainhelper.h>
#include <evo/deterministicmns.h>
#include <evo/dmn_types.h>
#include <evo/providertx.h>
#include <evo/specialtx.h>
#include <governance/governance.h>
#include <governance/object.h>
#include <masternode/sync.h>
#include <netfulfilledman.h>
#include <script/sign.h>
#include <script/signingprovider.h>
#include <script/standard.h>
#include <util/strencodings.h>
#include <util/time.h>
#include <validation.h>

#include <boost/test/unit_test.hpp>

#include <algorithm>
#include <map>
#include <string>
#include <vector>

using SimpleUTXOMap = std::map<COutPoint, Coin>;

static SimpleUTXOMap BuildSimpleUtxoMap(const std::vector<CTransactionRef>& txs)
{
SimpleUTXOMap utxos;
for (size_t i = 0; i < txs.size(); ++i) {
const auto& tx = txs[i];
for (size_t j = 0; j < tx->vout.size(); ++j) {
utxos.emplace(COutPoint(tx->GetHash(), j), Coin(tx->vout[j], static_cast<int>(i) + 1, /*fCoinBaseIn=*/false));
}
}
return utxos;
}

static SimpleUTXOMap SelectUTXOs(const CChain& active_chain, SimpleUTXOMap& utxos, CAmount amount, CAmount& changeRet)
{
changeRet = 0;
SimpleUTXOMap selectedUtxos;
CAmount selectedAmount = 0;
while (!utxos.empty()) {
bool found = false;
for (auto it = utxos.begin(); it != utxos.end(); ++it) {
if (active_chain.Height() - it->second.nHeight < 101) {
continue;
}
found = true;
selectedAmount += it->second.out.nValue;
selectedUtxos.emplace(it->first, it->second);
utxos.erase(it);
break;
}
BOOST_REQUIRE(found);
if (selectedAmount >= amount) {
changeRet = selectedAmount - amount;
break;
}
}
return selectedUtxos;
}

static SimpleUTXOMap FundTransaction(const ChainstateManager& chainman, CMutableTransaction& tx, SimpleUTXOMap& utxos,
const CScript& scriptPayout, CAmount amount)
{
CAmount change;
auto inputs = WITH_LOCK(::cs_main, return SelectUTXOs(chainman.ActiveChain(), utxos, amount, change));
for (const auto& input : inputs) {
tx.vin.emplace_back(input.first);
}
tx.vout.emplace_back(amount, scriptPayout);
if (change != 0) {
tx.vout.emplace_back(change, scriptPayout);
}
return inputs;
}

static void SignTransaction(CMutableTransaction& tx, const SimpleUTXOMap& coins, const CKey& coinbaseKey)
{
FillableSigningProvider tempKeystore;
tempKeystore.AddKeyPubKey(coinbaseKey, coinbaseKey.GetPubKey());
std::map<int, bilingual_str> input_errors;
BOOST_REQUIRE(::SignTransaction(tx, &tempKeystore, coins, SIGHASH_ALL, input_errors));
}

static CMutableTransaction CreateProRegTx(const ChainstateManager& chainman, SimpleUTXOMap& utxos, int port,
const CScript& scriptPayout, const CKey& coinbaseKey, CKey& ownerKeyRet,
CBLSSecretKey& operatorKeyRet)
{
ownerKeyRet.MakeNewKey(true);
operatorKeyRet.MakeNewKey();

CProRegTx proTx;
proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false);
proTx.netInfo = NetInfoInterface::MakeNetInfo(proTx.nVersion);
proTx.collateralOutpoint.n = 0;
BOOST_REQUIRE_EQUAL(proTx.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, strprintf("1.1.1.1:%d", port)),
NetInfoStatus::Success);
proTx.keyIDOwner = ownerKeyRet.GetPubKey().GetID();
proTx.pubKeyOperator.Set(operatorKeyRet.GetPublicKey(), bls::bls_legacy_scheme.load());
proTx.keyIDVoting = ownerKeyRet.GetPubKey().GetID();
proTx.scriptPayout = scriptPayout;

CMutableTransaction tx;
tx.nVersion = 3;
tx.nType = TRANSACTION_PROVIDER_REGISTER;
const auto spent = FundTransaction(chainman, tx, utxos, scriptPayout, dmn_types::Regular.collat_amount);
proTx.inputsHash = CalcTxInputsHash(CTransaction(tx));
SetTxPayload(tx, proTx);
SignTransaction(tx, spent, coinbaseKey);
return tx;
}

static CScript GenerateRandomAddress()
{
CKey key;
key.MakeNewKey(false);
return GetScriptForDestination(PKHash(key.GetPubKey()));
}

// Fixture: a chain with DIP3 active, one registered masternode whose operator BLS
// key we hold, a governance manager wired the way production init does, and
// CMasternodeSync driven to FINISHED (MasternodeRateCheck is a no-op until then).
//
// The DIP3 activation height is pushed to 109 so TestChainSetup's fixed-checkpoint
// assert still succeeds while the early coinbases are mature.
struct FailedTriggerRateSetup : public TestChainSetup {
COutPoint mn_outpoint;
CBLSSecretKey operator_key;

FailedTriggerRateSetup() :
TestChainSetup(/*num_blocks=*/107, CBaseChainParams::REGTEST, {"-dip3params=109:500"})
{
auto& chainman = *Assert(m_node.chainman.get());
auto& dmnman = *Assert(m_node.dmnman);
const CScript coinbase_pk = GetScriptForRawPubKey(coinbaseKey.GetPubKey());

// Activate DIP3 (fixture tip is one block before activation height 109).
// Enforcement is independent of activation; ProRegTx only needs activation.
CreateAndProcessBlock({}, coinbase_pk);
BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainman.ActiveChain().Height()), 108);

auto utxos = BuildSimpleUtxoMap(m_coinbase_txns);
CKey owner_key;
auto proreg_tx = CreateProRegTx(chainman, utxos, /*port=*/1, GenerateRandomAddress(), coinbaseKey, owner_key,
operator_key);
CreateAndProcessBlock({proreg_tx}, coinbase_pk);
dmnman.UpdatedBlockTip(WITH_LOCK(::cs_main, return chainman.ActiveChain().Tip()));

mn_outpoint = COutPoint{proreg_tx.GetHash(), 0};
const auto dmn = dmnman.GetListAtChainTip().GetMNByCollateral(mn_outpoint);
BOOST_REQUIRE(dmn);
BOOST_REQUIRE(dmn->pdmnState->pubKeyOperator.Get() == operator_key.GetPublicKey());

BOOST_REQUIRE(m_node.mn_metaman);
BOOST_REQUIRE(m_node.mn_sync);
BOOST_REQUIRE(m_node.chain_helper);
BOOST_REQUIRE(m_node.chain_helper->superblocks);
m_node.govman = std::make_unique<CGovernanceManager>(*m_node.mn_metaman, *m_node.chainman,
*m_node.chain_helper->superblocks, *m_node.dmnman,
*m_node.mn_sync);
BOOST_REQUIRE(m_node.govman->LoadCache(/*load_cache=*/false));

// Advance BLOCKCHAIN -> GOVERNANCE -> FINISHED. SyncFinished requires a
// loaded netfulfilledman.
BOOST_REQUIRE(m_node.netfulfilledman);
BOOST_REQUIRE(m_node.netfulfilledman->LoadCache(/*load_cache=*/false));
m_node.mn_sync->SwitchToNextAsset();
m_node.mn_sync->SwitchToNextAsset();
BOOST_REQUIRE(m_node.mn_sync->IsSynced());
}

~FailedTriggerRateSetup()
{
// chain_helper (owner of the SuperblockManager govman clears in its dtor)
// must outlive govman -- see PrepareShutdown in init.cpp.
m_node.govman.reset();
}

// Malformed trigger JSON: type=TRIGGER so LoadData/IsValidLocally accept it, but
// SuperblockManager::AddTrigger fails while constructing CSuperblock (missing
// event_block_height / payment fields). That is the failed-trigger path.
CGovernanceObject MakeFailedTrigger(int64_t creation_time, int salt) const
{
const std::string data = strprintf(R"({"type":2,"salt":%d})", salt);
CGovernanceObject govobj{uint256{}, /*revision=*/1, creation_time, uint256{}, HexStr(data)};
BOOST_REQUIRE_EQUAL(std23::to_underlying(govobj.GetObjectType()),
std23::to_underlying(GovernanceObject::TRIGGER));
govobj.SetMasternodeOutpoint(mn_outpoint);
const CBLSSignature sig = operator_key.Sign(govobj.GetSignatureHash(), /*specificLegacyScheme=*/false);
BOOST_REQUIRE(sig.IsValid());
govobj.SetSignature(sig.ToByteVector(/*specificLegacyScheme=*/false));
return govobj;
}
};

BOOST_FIXTURE_TEST_SUITE(governance_failed_trigger_rate_tests, FailedTriggerRateSetup)

// Regression: AddGovernanceObjectInternal used to return early on
// AddTrigger failure without calling MasternodeRateUpdate. Because
// MasternodeRateCheck short-circuits when mapLastMasternodeObject has no entry,
// a single operator key could flood mapObjects with unparseable triggers.
// After the fix, failed triggers still advance the per-masternode rate buffer
// so the flood is throttled.
BOOST_AUTO_TEST_CASE(failed_trigger_path_advances_masternode_rate_limit)
{
// Deterministic clock inside the allowed rate-check timestamp window.
SetMockTime(1'700'000'000s);
const int64_t base_time = GetTime<std::chrono::seconds>().count();

// Submit more failed triggers than RATE_BUFFER_SIZE from the same MN.
// Pre-fix the rate buffer never fills, so every object lands in mapObjects.
// Post-fix the buffer fills and further submissions are rate-rejected.
constexpr int flood_count = 12;
int objects_seen = 0;
for (int i = 0; i < flood_count; ++i) {
CGovernanceObject govobj = MakeFailedTrigger(base_time + i, /*salt=*/i);
const uint256 hash = govobj.GetHash();
// Unique per-object: salt + time vary both the data and the creation time.
BOOST_REQUIRE(!m_node.govman->HaveObjectForHash(hash));

LOCK(::cs_main);
// ProcessObject returns true for both accepted and rate-rejected objects
// (rate rejection is not a peer-misbehaviour event).
BOOST_CHECK(m_node.govman->ProcessObject(/*peer_str=*/"test-peer", hash, govobj));

if (m_node.govman->HaveObjectForHash(hash)) {
++objects_seen;
// Failed triggers are accepted into mapObjects then marked for deletion.
const auto stored = m_node.govman->FindGovernanceObject(hash);
BOOST_REQUIRE(stored);
BOOST_CHECK(stored->IsSetCachedDelete());
}
}

// With the rate buffer size of 5, a fixed limiter records the first five
// failed attempts and rejects the rest before insertion. Without the fix
// every object is inserted (objects_seen == flood_count).
BOOST_CHECK_LT(objects_seen, flood_count);
BOOST_CHECK_LE(objects_seen, RATE_BUFFER_SIZE + 1);

const UniValue stats = m_node.govman->ToJson();
BOOST_CHECK_LT(stats["objects_total"].getInt<int>(), flood_count);
BOOST_CHECK_EQUAL(stats["objects_total"].getInt<int>(), objects_seen);

// A trigger we undid must never be announced to peers.
BOOST_CHECK(m_node.govman->FetchRelayInventory().empty());
}

// A failed trigger must not be scheduled for the deferred "additional relay"
// pass. That bookkeeping (setAdditionalRelayObjects) fires for creation times
// close to MAX_TIME_FUTURE_DEVIATION, and used to live inside
// MasternodeRateUpdate() -- so counting the failed attempt against the rate
// buffer would otherwise make the node re-announce, and serve on GETDATA, an
// object it had just rejected and marked for deletion. One signature-valid
// message would then fan out to every peer, which in turn re-announce it.
BOOST_AUTO_TEST_CASE(failed_trigger_is_not_scheduled_for_additional_relay)
{
SetMockTime(1'700'000'000s);
const int64_t now = GetTime<std::chrono::seconds>().count();

// Inside (now + MAX_TIME_FUTURE_DEVIATION - RELIABLE_PROPAGATION_TIME,
// now + MAX_TIME_FUTURE_DEVIATION], i.e. accepted by MasternodeRateCheck but
// "too new to propagate reliably", which is what arms the additional relay.
const int64_t near_future = now + 3550;
CGovernanceObject govobj = MakeFailedTrigger(near_future, /*salt=*/0);
const uint256 hash = govobj.GetHash();

WITH_LOCK(::cs_main, BOOST_CHECK(m_node.govman->ProcessObject(/*peer_str=*/"test-peer", hash, govobj)));

// The object is stored (AddTrigger failure happens after insertion) but is
// immediately marked for deletion, so it is not syncable...
BOOST_REQUIRE(m_node.govman->HaveObjectForHash(hash));
BOOST_CHECK(!m_node.govman->HaveSyncableObjectForHash(hash));
// ...and nothing was queued for relay on the accept path either.
BOOST_CHECK(m_node.govman->FetchRelayInventory().empty());

// Move past RELIABLE_PROPAGATION_TIME so the deferred relay would be "ready",
// then run the pass. UpdatedBlockTip -> CheckPostponedObjects drains
// setAdditionalRelayObjects.
SetMockTime(std::chrono::seconds{now + 120});
m_node.govman->UpdatedBlockTip(WITH_LOCK(::cs_main, return m_node.chainman->ActiveChain().Tip()));

const auto invs = m_node.govman->FetchRelayInventory();
BOOST_CHECK(std::ranges::none_of(invs, [&](const CInv& inv) { return inv.hash == hash; }));
BOOST_CHECK(invs.empty());
}

BOOST_AUTO_TEST_SUITE_END()
Loading