DEV Community

Daniel
Daniel

Posted on

How Integer-Truncated Vote Weights Could Lock WEMIX Governance for Seven Days

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();
Enter fullscreen mode Exit fullscreen mode

With six members:

10000 / 6
1666 per voter
Enter fullscreen mode Exit fullscreen mode

The division discarded the remainder.

Even after all six members voted, the maximum recorded power was:

1666 × 6
9996
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

A three-to-three split therefore produced:

Accept power
4998

Reject power
4998

Total power
9996
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That proxy works only when:

10000 % memberCount == 0
Enter fullscreen mode Exit fullscreen mode

For six members:

10000 % 6
4
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

With six members:

Exact mathematical weight
1666.666...

Stored weight
1666
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

The three finalization conditions were:

Accept reaches 5001

Reject reaches 5001

Combined power reaches exactly 10000
Enter fullscreen mode Exit fullscreen mode

For a balanced six-member vote:

3 × 1666
4998 accept

3 × 1666
4998 reject
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

It also prevented duplicate votes:

require(
    !hasVotedMap[
        _ballotId
    ][
        _voter
    ],
    "already voted"
);
Enter fullscreen mode Exit fullscreen mode

After the sixth vote:

totalVoters
6

eligible members
6

all members voted
Yes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
);
Enter fullscreen mode Exit fullscreen mode

It then assigned:

ballotInVoting =
    ballotIdx;
Enter fullscreen mode Exit fullscreen mode

If a ballot was already InProgress, voting was restricted to that same ballot:

require(
    ballotIdx
        ==
    ballotInVoting,
    "Now in voting with different ballot"
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The emergency vote still reverted.

Only after moving beyond the end time could a member call:

finalizeEndedVote();
Enter fullscreen mode Exit fullscreen mode

The timeout cleanup rejected the expired ballot and reset:

ballotInVoting =
    0;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This moved the impact beyond incorrect accounting and into governance liveness.

The lock used the real maximum duration

The PoC read:

getBallotDurationMax()
604800
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The clean controls also showed:

Emergency proposal without a stuck ballot
Votable

Normal majority ballot
Finalized
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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"
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why the correction preserves existing behavior

The patch keeps:

Accept threshold finalization

Reject threshold finalization

Duplicate-vote prevention

Existing majority behavior

Existing tie outcome
Enter fullscreen mode Exit fullscreen mode

A full-participation tie still reaches:

accept > reject
Enter fullscreen mode Exit fullscreen mode

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:

  1. Six members with a three-to-three split finalize after the sixth vote

  2. Full participation finalizes for every supported member count even when 10000 is not evenly divisible

  3. Majority acceptance remains unchanged

  4. Majority rejection remains unchanged

  5. Duplicate votes remain rejected

  6. Finalization releases ballotInVoting

  7. Another valid proposal becomes votable immediately

  8. fromValidBallot uses the same full-participation rule

  9. Membership 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
Enter fullscreen mode Exit fullscreen mode

All six votes produced:

9996
Enter fullscreen mode Exit fullscreen mode

A three-to-three split recorded:

Accept
4998

Reject
4998
Enter fullscreen mode Exit fullscreen mode

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)