Severity: Medium
Component: ghost-dao-contracts @ b87dff4c7a6ac34b35698c42b4d1f34b5f930864 (governance/GhostGovernor.sol lines 123 to 153)
Source review only. No transactions. I simulated the propose gate offline, 5 of 5 runs identical.
Description
activeProposedLock is one global scalar set to the last proposer’s full vote balance, not to the proposal threshold. While a proposal is Pending or Active, any other proposer whose votes are less than or equal to that lock reverts ProposerNotEnoughVotes, and a proposer with more votes force cancels the live proposal through an internal _cancel call before filing their own. The result is a single proposal slot per cycle that the largest holder owns.
if (currentProposalState & activePendingMask != 0) {
if (proposerVotes <= activeProposedLock) {
revert ProposerNotEnoughVotes(proposerVotes, activeProposedLock);
}
...
_cancel(currentTargets, currentValues, currentCalldatas, descriptionHash);
}
...
activeProposedLock = proposerVotes;
Proof of concept (a runnable simulation of the exact propose() gate condition from GhostGovernor.sol lines 123 to 148; no network. 5 of 5 runs byte identical):
def propose(votes, state, lock, last_id):
if last_id != 0 and state in ("Pending", "Active"):
if votes <= lock: # line 131: <= against full balance
raise Revert(f"ProposerNotEnoughVotes({votes}, {lock})")
cancelled = True # line 143: _cancel of the live proposal
else:
cancelled = False
return votes, cancelled
Raw output of the PoC (identical on all 5 runs):
Alice(40) proposes -> activeProposedLock = 40
Bob(40) proposes -> REVERT ProposerNotEnoughVotes(40, 40)
Carol(39) proposes -> REVERT ProposerNotEnoughVotes(39, 40)
Dave(41) proposes -> ACCEPTED, cancels Alice's live proposal = True, new lock = 41
CONTROL no live proposal: Fresh40(40) proposes -> ACCEPTED, lock = 40
Supply 100. Alice’s 40 sets the lock; Bob with an equal 40 and Carol with 39 both revert while the proposal is live. The controls: 41 is not rejected and force cancels Alice’s proposal, and with no live proposal a 40 vote proposer is accepted.
Steps to reproduce
- Clone and pin:
git clone https://git.ghostchain.io/ghostchain/ghost-dao-contracts.git && git -C ghost-dao-contracts checkout b87dff4c7a6ac34b35698c42b4d1f34b5f930864
- Save the PoC gate simulation above as
poc_governor_lock.py(it transcribes the exact condition order fromGhostGovernor.propose, lines 123 to 148). - Run:
python3 poc_governor_lock.py
- Observe Alice’s 40 sets the global lock, Bob (40) and Carol (39) revert
ProposerNotEnoughVoteswhile the proposal is live, Dave (41) is accepted and force cancels Alice’s proposal, and the control shows a 40 vote proposer is accepted when no proposal is live. - Run 5 times; byte identical output.
Impact
Toy numbers: with a threshold of 100k votes, a whale holding 10M proposes at time zero. For the full voting delay plus voting period, roughly two weeks, every proposer below 10M votes reverts on every propose call, so governance throughput drops to one proposal per cycle and the whale controls the agenda and can cancel any competitor it outweighs. A passive token position, no attacker funds at risk.
Fix
Track the lock per proposer in a mapping instead of one global scalar, or bound it by the proposal threshold rather than the proposer’s full balance, and drop the automatic cancellation of other proposers’ live proposals.