Skip to content

Commit 8db4b76

Browse files
Merge #7521: fix: update masternode rate limit on failed governance trigger path
a551177 fix: update masternode rate limit on failed governance trigger path (pasta) Pull request description: ## Issue being fixed or feature implemented In `AddGovernanceObjectInternal()`, a trigger object is emplaced into `mapObjects`, then `AddTrigger()` is attempted. On failure the function calls `PrepareDeletion()` and returns early, before `MasternodeRateUpdate()`. `MasternodeRateCheck()` allows any masternode that has no entry in `mapLastMasternodeObject`, and only `MasternodeRateUpdate()` creates that entry. A masternode that never lands a *successful* trigger therefore never gets a rate-buffer entry, and every malformed but signed trigger it sends passes the rate check. That defeats the limiter meant to stop exactly this. Each rejected trigger still costs a BLS verification, a `mapObjects` entry held for the deletion delay, and, after erasure, a `mapErasedGovernanceObjects` entry retained for about two superblock cycles (roughly 60 days on mainnet). Sending one needs a valid operator key for a masternode in the tip list, and the victim must have requested the hash after an INV. Rejected triggers are not relayed, so there is no fan-out; the attacker has to connect to each victim directly. ## What was done? - Call `MasternodeRateUpdate()` before the `AddTrigger` check, so failed attempts advance the masternode's rate buffer too. - `MasternodeRateUpdate()` also queued near-future triggers for the deferred re-announcement in `CheckPostponedObjects`. That part is split out into `ScheduleTriggerRelay()` and still runs only after `AddTrigger` succeeds. Otherwise moving the rate update would make the node re-announce, and serve on GETDATA, a trigger it had just undone and marked for deletion. ### Scope note: creation-time rate limiting on short-cycle networks The rate buffer stores the object's signer-chosen creation time, and `MasternodeRateCheck` accepts creation times from `now - 2 * cycle` to `now + 1h`, with a maximum rate of `2.2 / cycle` over five entries. On mainnet (cycle = 16616 blocks, about 28.8 days) the widest possible span is about `2 * cycle`, so a full buffer is always rejected (`5 / (2 * cycle)` = `2.5 / cycle` > `2.2 / cycle`). On testnet, devnet (1 h cycle) and regtest the accepted window is wider than `5 / 2.2` cycles, so an operator who alternates creation times between the window ends can stay under the rate. That weakness is in the existing limiter and applies equally to well-formed triggers today. This PR only brings failed triggers under the same limiter. Moving the limiter to receipt time would change how valid triggers are throttled and is left for a separate change. ## How Has This Been Tested? Two new cases in `src/test/governance_vote_processing_tests.cpp`, using its existing chain-backed fixture, which already registers a masternode with a known operator key. No new file or fixture, and `governance_inv_tests` is unchanged. - `failed_triggers_advance_the_masternode_rate_limit`: submits 12 signed triggers that fail `AddTrigger` and checks that exactly `RATE_BUFFER_SIZE - 1` (4) are stored. It fails with `12 != 4` on develop's `governance.cpp`/`governance.h`. - `failed_trigger_is_not_scheduled_for_deferred_relay`: a failed trigger created inside the deferred-relay window is never announced, even after it ages past `RELIABLE_PROPAGATION_TIME`. It fails when `ScheduleTriggerRelay()` is called before `AddTrigger`, which is the regression the split prevents. Run locally on macOS arm64 with `--enable-debug --enable-werror`: - `src/test/test_dash --run_test=governance_vote_processing_tests,governance_inv_tests,governance_superblock_tests,governance_validators_tests` passes. - `test/functional/test_runner.py feature_governance.py feature_governance_objects.py feature_governance_cl.py` passes. - `test/lint/lint-whitespace.py` passes. ## Breaking Changes None. ## Checklist: - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone Top commit has no ACKs. Tree-SHA512: 2892edb480e2c3c63c1be7d231d745ecea2d2dcf6161f48ec0cd3f3e6484a120af77de540db06e3e7892840dc6a1fb234df98025686fb27a121731a7996338ac
2 parents 3817625 + a551177 commit 8db4b76

3 files changed

Lines changed: 90 additions & 9 deletions

File tree

‎src/governance/governance.cpp‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -345,18 +345,25 @@ void CGovernanceManager::AddGovernanceObjectInternal(CGovernanceObject& insert_o
345345
LogPrint(BCLog::GOBJECT, "CGovernanceManager::AddGovernanceObject -- Before trigger block, GetDataAsPlainString = %s, nObjectType = %d\n",
346346
Assert(govobj)->GetDataAsPlainString(), std23::to_underlying(govobj->GetObjectType()));
347347

348+
// Count the attempt before AddTrigger can reject it: MasternodeRateCheck allows any
349+
// masternode without a rate-buffer entry, so a key whose triggers all fail AddTrigger
350+
// would otherwise never be throttled.
351+
MasternodeRateUpdate(*govobj);
352+
348353
if (govobj->GetObjectType() == GovernanceObject::TRIGGER && !m_superblocks.AddTrigger(govobj, nCachedBlockHeight)) {
349354
LogPrint(BCLog::GOBJECT, "CGovernanceManager::AddGovernanceObject -- undo adding invalid trigger object: hash = %s\n", nHash.ToString());
350355
govobj->PrepareDeletion(GetTime<std::chrono::seconds>().count());
351356
return;
352357
}
353358

359+
// Only objects we keep may be announced. Scheduling this before the AddTrigger
360+
// check would make us re-announce, and serve on GETDATA, a trigger we just
361+
// undid and marked for deletion.
362+
ScheduleTriggerRelay(*govobj);
363+
354364
LogPrint(BCLog::GOBJECT, "CGovernanceManager::AddGovernanceObject -- %s new, received from peer %s\n", strHash, peer_str);
355365
RelayObject(*govobj);
356366

357-
// Update the rate buffer
358-
MasternodeRateUpdate(*govobj);
359-
360367
m_mn_sync.BumpAssetLastTime("CGovernanceManager::AddGovernanceObject");
361368

362369
// WE MIGHT HAVE PENDING/ORPHAN VOTES FOR THIS OBJECT
@@ -705,15 +712,23 @@ void CGovernanceManager::MasternodeRateUpdate(const CGovernanceObject& govobj)
705712
const COutPoint& masternodeOutpoint = govobj.GetMasternodeOutpoint();
706713
auto it = mapLastMasternodeObject.try_emplace(masternodeOutpoint, /*fStatusOKIn=*/true).first;
707714

708-
int64_t nTimestamp = govobj.GetCreationTime();
709-
it->second.triggerBuffer.AddTimestamp(nTimestamp);
715+
it->second.triggerBuffer.AddTimestamp(govobj.GetCreationTime());
716+
it->second.fStatusOK = true;
717+
}
718+
719+
void CGovernanceManager::ScheduleTriggerRelay(const CGovernanceObject& govobj)
720+
{
721+
AssertLockHeld(cs_store);
710722

711-
if (nTimestamp > GetTime() + count_seconds(MAX_TIME_FUTURE_DEVIATION) - count_seconds(RELIABLE_PROPAGATION_TIME)) {
712-
// schedule additional relay for the object
723+
if (govobj.GetObjectType() != GovernanceObject::TRIGGER) return;
724+
725+
// A trigger created this close to the future-deviation limit is still too new for
726+
// peers with a lagging clock to accept, so re-announce it once it has aged past
727+
// RELIABLE_PROPAGATION_TIME (see CheckPostponedObjects).
728+
if (govobj.GetCreationTime() >
729+
GetTime() + count_seconds(MAX_TIME_FUTURE_DEVIATION) - count_seconds(RELIABLE_PROPAGATION_TIME)) {
713730
setAdditionalRelayObjects.insert(govobj.GetHash());
714731
}
715-
716-
it->second.fStatusOK = true;
717732
}
718733

719734
bool CGovernanceManager::MasternodeRateCheck(const CGovernanceObject& govobj, bool fUpdateFailStatus)

‎src/governance/governance.h‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,11 @@ class CGovernanceManager : public GovernanceStore
398398
void MasternodeRateUpdate(const CGovernanceObject& govobj)
399399
EXCLUSIVE_LOCKS_REQUIRED(cs_store);
400400

401+
/** Queue a deferred re-announcement for a trigger that is too new to propagate
402+
* reliably yet. Only call this for triggers we are keeping. */
403+
void ScheduleTriggerRelay(const CGovernanceObject& govobj)
404+
EXCLUSIVE_LOCKS_REQUIRED(cs_store);
405+
401406
bool MasternodeRateCheck(const CGovernanceObject& govobj, bool fUpdateFailStatus, bool fForce, bool& fRateCheckBypassed)
402407
EXCLUSIVE_LOCKS_REQUIRED(cs_store);
403408

‎src/test/governance_vote_processing_tests.cpp‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Distributed under the MIT software license, see the accompanying
33
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
44

5+
#include <bls/bls.h>
56
#include <consensus/amount.h>
67
#include <evo/deterministicmns.h>
78
#include <governance/governance.h>
@@ -59,6 +60,20 @@ void SignWithOperatorKey(CGovernanceVote& vote, const CBLSSecretKey& key)
5960
key.Sign(vote.GetSignatureHash(), /*specificLegacyScheme=*/false).ToByteVector(/*specificLegacyScheme=*/false));
6061
}
6162

63+
//! A trigger that is signed by the fixture masternode and passes IsValidLocally, but whose payload
64+
//! is not a superblock, so SuperblockManager::AddTrigger rejects it after it was inserted.
65+
CGovernanceObject MakeFailedTrigger(const COutPoint& mn_collateral, const CBLSSecretKey& operator_key,
66+
int64_t creation_time, int salt)
67+
{
68+
const std::string data{strprintf(R"({"type":2,"salt":%d})", salt)};
69+
CGovernanceObject govobj{uint256{}, /*revision=*/1, creation_time, uint256{}, HexStr(data)};
70+
BOOST_REQUIRE(govobj.GetObjectType() == GovernanceObject::TRIGGER);
71+
govobj.SetMasternodeOutpoint(mn_collateral);
72+
govobj.SetSignature(operator_key.Sign(govobj.GetSignatureHash(), /*specificLegacyScheme=*/false)
73+
.ToByteVector(/*specificLegacyScheme=*/false));
74+
return govobj;
75+
}
76+
6277
// A chain with one registered masternode whose voting (ECDSA) and operator (BLS) keys are known
6378
// to the test, so votes can be signed for real and CGovernanceVote::CheckSignature is actually
6479
// reached. Without a populated masternode list every CGovernanceVote::IsValid() call would
@@ -485,4 +500,50 @@ BOOST_AUTO_TEST_CASE(legacy_governance_cache_is_discarded)
485500
BOOST_CHECK(saved.empty());
486501
}
487502

503+
// MasternodeRateCheck allows a masternode that has no rate-buffer entry yet, and only accepted
504+
// triggers used to create one. A key whose triggers all fail AddTrigger was therefore never
505+
// throttled: every one cost a BLS verification and a mapErasedGovernanceObjects entry.
506+
BOOST_AUTO_TEST_CASE(failed_triggers_advance_the_masternode_rate_limit)
507+
{
508+
const int64_t now{GetTime()};
509+
constexpr int attempts{12};
510+
int stored{0};
511+
for (int i = 0; i < attempts; ++i) {
512+
CGovernanceObject govobj{MakeFailedTrigger(mn_collateral, mn_operator_key, now + i, /*salt=*/i)};
513+
const uint256 hash{govobj.GetHash()};
514+
// Rate rejection is not misbehaviour, so ProcessObject reports success either way.
515+
WITH_LOCK(::cs_main, BOOST_CHECK(m_node.govman->ProcessObject(/*peer_str=*/"test-peer", hash, govobj)));
516+
if (m_node.govman->HaveObjectForHash(hash)) {
517+
++stored;
518+
BOOST_CHECK(Assert(m_node.govman->FindGovernanceObject(hash))->IsSetCachedDelete());
519+
}
520+
}
521+
522+
// The rate is only computed over a full buffer, so the attempt that would fill it is the
523+
// first one rated, and these timestamps are far too close together to pass.
524+
BOOST_CHECK_EQUAL(stored, RATE_BUFFER_SIZE - 1);
525+
BOOST_CHECK(m_node.govman->FetchRelayInventory().empty());
526+
}
527+
528+
// Rate accounting now runs before AddTrigger, but scheduling the deferred re-announcement of a
529+
// trigger created close to the future-deviation limit must not: a trigger that AddTrigger rejected
530+
// would be announced, and served on GETDATA, once it aged past RELIABLE_PROPAGATION_TIME.
531+
BOOST_AUTO_TEST_CASE(failed_trigger_is_not_scheduled_for_deferred_relay)
532+
{
533+
const int64_t now{GetTime()};
534+
// Inside (now + MAX_TIME_FUTURE_DEVIATION - RELIABLE_PROPAGATION_TIME, now + MAX_TIME_FUTURE_DEVIATION],
535+
// i.e. 1h - 60s < offset <= 1h: accepted by MasternodeRateCheck, too new to propagate reliably.
536+
CGovernanceObject govobj{MakeFailedTrigger(mn_collateral, mn_operator_key, now + 3550, /*salt=*/0)};
537+
const uint256 hash{govobj.GetHash()};
538+
WITH_LOCK(::cs_main, BOOST_CHECK(m_node.govman->ProcessObject(/*peer_str=*/"test-peer", hash, govobj)));
539+
BOOST_REQUIRE(m_node.govman->HaveObjectForHash(hash));
540+
BOOST_CHECK(!m_node.govman->HaveSyncableObjectForHash(hash));
541+
BOOST_CHECK(m_node.govman->FetchRelayInventory().empty());
542+
543+
// Age it past RELIABLE_PROPAGATION_TIME and run the deferred-relay pass.
544+
SetMockTime(std::chrono::seconds{now + 120});
545+
m_node.govman->UpdatedBlockTip(WITH_LOCK(::cs_main, return m_node.chainman->ActiveChain().Tip()));
546+
BOOST_CHECK(m_node.govman->FetchRelayInventory().empty());
547+
}
548+
488549
BOOST_AUTO_TEST_SUITE_END()

0 commit comments

Comments
 (0)