A governance ballot should not remain open after every eligible member has already voted.
WEMIX could reach exactly that state.
Each member received equal voting power through integer division:
uint256 weight =
10000
/
getMemberLength();
With six members:
10000 / 6
1666 per voter
The division discarded the remainder.
Even after all six members voted, the maximum recorded power was:
1666 × 6
9996
The finalization logic required one side to reach the 5001 threshold or the combined power to equal exactly 10000:
if (
accept >= threshold
||
reject >= threshold
||
(
accept
+
reject
)
==
10000
) {
finalizeVote(
ballotIdx,
ballotType,
accept > reject,
false
);
}
A three-to-three split therefore produced:
Accept power
4998
Reject power
4998
Total power
9996
Neither side reached 5001.
The total could never reach 10000.
Every eligible member had voted.
Duplicate votes were correctly rejected.
The ballot still remained InProgress.
Because WEMIX used ballotInVoting as a singleton active-voting slot, that fully participated ballot prevented another valid governance proposal from being voted until the maximum duration expired.
The proof of concept used the configured maximum duration:
604800 seconds
7 days
It then created a valid operational emergency proposal and demonstrated:
Emergency vote before timeout
Reverted
Emergency vote at endTime minus one
Reverted
Timeout cleanup
Succeeded
Emergency vote after cleanup
Succeeded
I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.
The program classified it as Low and paid a $100 bounty.
That classification does not match the demonstrated impact.
This was not a cosmetic four-unit rounding discrepancy.
Those four unreachable units made fully recorded participation indistinguishable from incomplete voting power and allowed one ballot to block the governance voting pipeline for seven days.
The complete public report and proof of concept are available in the GitHub Gist.
The invariant that failed
Equal-weight governance does not require 10000 to divide perfectly by every possible member count.
It does require the protocol to recognize when every eligible voter has participated.
The invariant should be:
Once every eligible member has voted, arithmetic rounding must not keep the ballot active.
WEMIX inferred full participation from:
accept power + reject power == 10000
That proxy works only when:
10000 % memberCount == 0
For six members:
10000 % 6
4
Those four units were never assigned to any voter.
The contract interpreted the missing power as incomplete participation even though the authoritative voter count already showed that all six members had voted.
Where the voting power was truncated
createVote calculated the weight at the moment each vote was created:
function createVote(
uint256 ballotIdx,
bool approval
)
private
{
uint256 voteIdx =
voteLength
+
1;
address staker =
getStakerAddr(
msg.sender
);
uint256 weight =
10000
/
getMemberLength();
uint256 decision =
approval
?
uint256(
DecisionTypes.Accept
)
:
uint256(
DecisionTypes.Reject
);
IBallotStorage(
getBallotStorageAddress()
).createVote(
voteIdx,
ballotIdx,
staker,
decision,
weight
);
voteLength =
voteIdx;
}
With six members:
Exact mathematical weight
1666.666...
Stored weight
1666
The remainder was not distributed, stored, or reconciled later.
That alone would have been a minor precision issue if ballot finalization used the actual participation count.
It did not.
Finalization depended on an unreachable equality
After recording a vote, GovImp.vote read the accumulated power:
(
,
uint256 accept,
uint256 reject
) =
getBallotVotingInfo(
ballotIdx
);
uint256 threshold =
getThreshold();
if (
accept >= threshold
||
reject >= threshold
||
(
accept
+
reject
)
==
10000
) {
finalizeVote(
ballotIdx,
ballotType,
accept > reject,
false
);
}
The three finalization conditions were:
Accept reaches 5001
Reject reaches 5001
Combined power reaches exactly 10000
For a balanced six-member vote:
3 × 1666
4998 accept
3 × 1666
4998 reject
Every condition remained false.
The ballot was fully participated but numerically unable to prove it.
The contract already knew that everybody had voted
BallotStorageImp tracked the real number of voters:
_ballot.totalVoters =
_ballot.totalVoters
+
1;
It also prevented duplicate votes:
require(
!hasVotedMap[
_ballotId
][
_voter
],
"already voted"
);
After the sixth vote:
totalVoters
6
eligible members
6
all members voted
Yes
But the finalization path ignored totalVoters.
That created an impossible lifecycle state:
Every eligible member has voted
True
Any member can vote again
False
Either side reached threshold
False
Combined power equals 10000
False
Ballot finalized
False
The missing four units could not be supplied by any legitimate action.
Why another vote could not fix the ballot
Once all six members had voted, the duplicate-vote protection correctly rejected every further attempt.
Before timeout:
Additional eligible voters
0
Duplicate voting allowed
No
Power still missing
4
Normal finalization available
No
The ballot was not waiting for participation.
It was waiting for voting power that the formula had made unreachable.
BallotInVoting amplified one rounding bug
WEMIX allowed only one ballot to occupy the active voting slot.
When a ready ballot began, checkVotable required:
require(
ballotInVoting == 0,
"Now in voting with different ballot"
);
It then assigned:
ballotInVoting =
ballotIdx;
If a ballot was already InProgress, voting was restricted to that same ballot:
require(
ballotIdx
==
ballotInVoting,
"Now in voting with different ballot"
);
The affected ballot therefore did more than remain unresolved.
It occupied the shared voting slot and blocked every different proposal from entering the voting flow.
The PoC blocked a valid emergency proposal
After creating the stuck ballot, the proof created a separate valid operational environment proposal.
Proposal creation succeeded.
Voting on it reverted because ballotInVoting still referenced the fully voted split ballot.
The test then advanced time to:
endTime - 1
The emergency vote still reverted.
Only after moving beyond the end time could a member call:
finalizeEndedVote();
The timeout cleanup rejected the expired ballot and reset:
ballotInVoting =
0;
The same emergency proposal then became votable and finalized normally.
The demonstrated sequence was:
Fully participated split ballot
Remained InProgress
Emergency proposal
Created
Emergency vote before timeout
Blocked
Emergency vote one second before expiry
Blocked
Timeout cleanup
Succeeded
Emergency vote after cleanup
Succeeded
This moved the impact beyond incorrect accounting and into governance liveness.
The lock used the real maximum duration
The PoC read:
getBallotDurationMax()
604800
That equals seven days.
The affected ballot was created with this valid configured duration.
The test did not invent a delay outside protocol rules.
It proved that the contract could keep the active voting slot occupied for the maximum duration allowed by governance.
For an operational emergency proposal, seven days is a material delay.
The ballot and voters were valid
The proof removed several alternative explanations.
Every participant was:
A real governance member
A real staker
Assigned a nonzero staker index
Within the required staking range
The member count was read from the real contract and remained six during the affected ballot.
The proposal was a valid addProposalToChangeEnv ballot.
There was no voter-only target, invalid role configuration, NCP exit path, delegated reserve path, or understaked member.
The stuck state came directly from the voting-power formula and the finalization conditions.
What the proof used
The Go test exercised the real:
GovImp
GovImp.vote
GovImp.checkVotable
GovImp.finalizeEndedVote
BallotStorageImp
BallotStorageImp.createVote
BallotStorageImp.hasVotedMap
StakingImp
Current member count
Configured maximum ballot duration
The vulnerable arithmetic and lifecycle logic were not copied into a simplified contract.
The command was:
go test ./wemix/governance-contract/test \
-run TestVoteRounding \
-count=1 \
-v
The test passed.
Exact runtime result
The proof recorded:
Current member count
6
Vote weight
1666
Recorded full-participation power
9996
Lost remainder
4
Accept voters
3
Reject voters
3
Accept power
4998
Reject power
4998
Threshold
5001
All voters participated
Yes
Ballot state
InProgress
Maximum duration
604800 seconds
The liveness assertions recorded:
Further votes can clear the ballot
No
Emergency vote before timeout
Reverted
Emergency vote at endTime minus one
Reverted
Timeout cleanup
Succeeded
Emergency vote after cleanup
Succeeded
The clean controls also showed:
Emergency proposal without a stuck ballot
Votable
Normal majority ballot
Finalized
These controls isolated integer truncation as the cause of the lock.
Why this is a separate finding
This report is distinct from the other WEMIX governance findings.
It does not use a voter-only target
Every voter was a real staker with a nonzero staker index.
It does not involve NCPExit
No exit reserve, delegated balance, or exit mapping was involved.
It does not remove an NCP
The test never reached transferLockedAndUnlock.
It does not depend on minStaking underflow
Every voter remained within the required staking range.
It is entirely on chain
The root cause exists in smart contract vote arithmetic and ballot finalization, not in the client or P2P layer.
What the proof establishes
The proof directly demonstrates:
Six valid members
1666 power per vote
Four voting-power units lost
All six members voted
Three accepted and three rejected
4998 power on each side
9996 total power
No threshold reached
Ballot remained InProgress
No voter could vote again
Another valid proposal could not be voted
The block persisted at endTime minus one
Timeout cleanup restored governance
The emergency proposal succeeded afterward
The proof does not demonstrate:
Direct theft
Permanent governance takeover
Permanent fund freeze
Protocol insolvency
Unauthorized minting
Permanent network shutdown
A lock that survives the configured timeout
Those limits are why the report was submitted as Major rather than Critical.
Why this is Major, not Low
The issue caused a temporary denial of service against the governance voting pipeline.
The ballot had complete participation
This was not a proposal waiting for absent members.
Every eligible voter had acted.
The protocol still failed to finalize it.
The only active voting slot remained occupied
ballotInVoting prevented any different proposal from being voted.
The impact therefore extended beyond the original split ballot.
A valid emergency proposal was demonstrably blocked
The PoC created another valid proposal and showed that its vote reverted both before timeout and at endTime - 1.
The lock lasted seven days
The duration came from the real getBallotDurationMax() configuration.
Recovery was available only after timeout cleanup.
That makes the issue temporary, which rules out Critical, but it does not make the impact Low.
No invalid privilege was required
The state could arise from six legitimate members casting ordinary votes.
No outsider, invalid role, administrator key, or contract owner permission was needed to create the vulnerable ballot state.
Low does not fit the demonstrated effect
Low would fit:
A displayed percentage discrepancy
A harmless four-unit rounding loss
A ballot that still finalized from voter count
A state that did not affect other proposals
A delay immediately recoverable through another vote
This report showed a fully participated ballot preventing emergency governance voting for the maximum configured duration.
That is Major.
Why the $100 outcome understates the evidence
The final Low classification produced a $100 bounty.
That records the program decision.
It does not change the observed result:
Eligible voters
6
Votes cast
6
Recorded power
9996
Ballot finalized
No
Emergency vote before timeout
Reverted
Emergency vote one second before expiry
Reverted
Governance restored only after timeout
Yes
The vulnerability was not the numerical difference between 9996 and 10000.
The vulnerability was the lifecycle consequence:
Full participation was treated as incomplete participation, allowing one ballot to block every other governance vote for seven days.
That is a Major governance liveness failure.
Why I describe the WEMIX handling as systematic downgrading
Severity disagreements are normal in bug bounty programs.
My concern is the recurring outcome across my own valid WEMIX submissions.
Reports supported by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.
This report was submitted as Major.
The PoC showed every eligible member voting, the ballot remaining InProgress, duplicate votes being impossible, a valid emergency proposal being blocked at endTime - 1, and governance recovering only after timeout cleanup.
It was classified as Low.
I cannot infer intent from that decision.
I can document the repeated outcome and compare the final classification with the technical evidence.
Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.
A technically convincing Low justification would need to explain why these effects are minor:
Every eligible member already voted
The ballot still remained active
No additional vote could change the result
BallotInVoting blocked another valid proposal
The emergency vote remained blocked for seven days
Only timeout cleanup restored voting
Without addressing those facts, the Low classification does not match the proof.
The minimal correction
The contract already tracked totalVoters.
The minimal correction is to use the actual participation count instead of an exact rounded-power equality.
Inside vote:
(
uint256 totalVoters,
uint256 accept,
uint256 reject
) =
getBallotVotingInfo(
ballotIdx
);
uint256 threshold =
getThreshold();
if (
accept >= threshold
||
reject >= threshold
||
totalVoters
>=
getMemberLength()
) {
finalizeVote(
ballotIdx,
ballotType,
accept > reject,
false
);
}
The corresponding validation in fromValidBallot should use the same rule:
(
uint256 totalVoters,
uint256 accept,
uint256 reject
) =
getBallotVotingInfo(
ballotIdx
);
require(
accept >= getThreshold()
||
reject >= getThreshold()
||
totalVoters
>=
getMemberLength(),
"Not yet finalized"
);
This makes full participation depend on actual voters rather than a power total that may be mathematically unreachable.
Hardening the fix
Using the current member count is the smallest compatible correction for the demonstrated flow.
A more robust governance design should snapshot the number of eligible voters when the ballot starts and compare totalVoters against that snapshot.
That prevents later membership changes from redefining what full participation means for an already active ballot.
The invariant becomes:
totalVoters >= eligibleVotersAtBallotStart
Why the correction preserves existing behavior
The patch keeps:
Accept threshold finalization
Reject threshold finalization
Duplicate-vote prevention
Existing majority behavior
Existing tie outcome
A full-participation tie still reaches:
accept > reject
and finalizes as rejected under the existing decision rule.
The only behavior removed is the invalid state where every eligible voter has participated but integer truncation keeps the ballot open.
Regression tests
A complete correction should verify:
Six members with a three-to-three split finalize after the sixth vote
Full participation finalizes for every supported member count even when
10000is not evenly divisibleMajority acceptance remains unchanged
Majority rejection remains unchanged
Duplicate votes remain rejected
Finalization releases
ballotInVotingAnother valid proposal becomes votable immediately
fromValidBallotuses the same full-participation ruleMembership changes cannot redefine the eligible voter count for an active ballot
The central invariant is:
Once every voter eligible for a ballot has voted, integer rounding must not keep that ballot active.
Broader lessons
Percentages should not replace participation counts
A rounded power total is not authoritative evidence that everybody has voted.
The contract already stored the real count.
Exact equality turns small remainders into liveness failures
Losing four units appears minor.
Requiring exactly 10000 converted that small arithmetic loss into a seven-day governance lock.
Singleton state amplifies local bugs
One ballot failed to finalize.
Because ballotInVoting was global, every other proposal was affected.
Emergency governance must be tested behind existing state
Creating an emergency proposal is not enough.
The protocol must also prove that it can enter voting while previous ballots exist.
Full participation is a protocol event
It should be detected from eligible voters and recorded participation, not inferred from a percentage sum that may truncate.
Conclusion
WEMIX governance had six valid members.
Each vote received:
1666
All six votes produced:
9996
A three-to-three split recorded:
Accept
4998
Reject
4998
No side reached 5001.
The total never reached 10000.
Every eligible member had voted.
Nobody could vote again.
The ballot remained InProgress.
Because it occupied ballotInVoting, a valid emergency proposal could not be voted.
The lock remained at endTime - 1.
Only after the maximum duration of 604800 seconds could timeout cleanup restore governance voting.
The program classified the report as Low and paid $100.
![]()
The demonstrated impact was Major.
The report did not claim theft, permanent takeover, or permanent shutdown.
It demonstrated a narrower and directly proven failure:
Integer-truncated vote weights could keep a fully participated ballot active and block all other governance voting for seven days.
A governance ballot that has received every possible vote but still blocks emergency action until maximum timeout is not a Low-severity issue.
Top comments (0)