[Medium] GhostGovernor: one global proposal lock lets a whale cancel or block every proposal

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

  1. Clone and pin:
git clone https://git.ghostchain.io/ghostchain/ghost-dao-contracts.git && git -C ghost-dao-contracts checkout b87dff4c7a6ac34b35698c42b4d1f34b5f930864
  1. Save the PoC gate simulation above as poc_governor_lock.py (it transcribes the exact condition order from GhostGovernor.propose, lines 123 to 148).
  2. Run:
python3 poc_governor_lock.py
  1. Observe Alice’s 40 sets the global lock, Bob (40) and Carol (39) revert ProposerNotEnoughVotes while 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.
  2. 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.

Thank you for your report!

In the end, governance usage on the EVM side has already been deprecated in favor of cross-chain governance. More precisely, governance is expected to happen via a signed message from the ghost chain through the EXODUS pallet.

In any case, the behavior you describe is expected: proposers are meant to outbid each other.