A governance system should always be able to remove a member that no longer satisfies its active staking requirements.
WEMIX had a state where the opposite happened.
An NCP could join governance while the current minStaking requirement was satisfied.
Governance could later raise that minimum above the NCP’s locked balance.
The NCP would remain in the member set, but checkLockedAmount would prevent it from voting or creating proposals.
That was already an inconsistent state:
Still a governance member
Yes
Still satisfies the current staking minimum
No
Can vote
No
Can create proposals
No
The deeper failure appeared when valid members tried to remove it.
The finalization path reached:
uint256 ext =
locked
-
getMinStaking();
When:
locked < current minStaking
Solidity 0.8 checked arithmetic reverted before either removal branch could run.
The entire transaction rolled back.
The understaked NCP remained in the governance member set.
The proof of concept demonstrated:
Initial minStaking
1,500,000 WEMIX
Target locked balance
1,500,000 WEMIX
Current minStaking after increase
2,000,000 WEMIX
Target remains a member
True
Target vote
Reverted
Target proposal
Reverted
Removal proposal from a valid member
Succeeded
Removal parameters
Valid
Removal finalization
Reverted
Target remains a member after failed removal
True
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 harmless arithmetic edge case.
The underflow disabled the standard cleanup path for exactly the member state governance needed to clean up.
The result was a governance lifecycle denial of service: an NCP that no longer met the current minimum could neither participate normally nor be removed through the ordinary finalization flow.
The complete public report and proof of concept are available in the GitHub Gist.
The invariant governance must preserve
A configurable staking minimum can change over time.
That means governance must safely handle members who were valid under an earlier value but fall below a later one.
The required invariant is:
An NCP that becomes understaked after a valid minStaking change must remain removable by valid governance members.
WEMIX violated that invariant.
The same parameter increase that disabled the target’s normal participation also created the arithmetic condition that blocked its removal.
Governance could therefore create a member state that its own cleanup logic could not resolve.
The target entered governance legitimately
The PoC did not begin with an invalid or manually corrupted member.
The initial minimum was:
1,500,000 WEMIX
The target locked:
1,500,000 WEMIX
At admission time:
locked balance >= current minStaking
The target satisfied the active requirement and entered through the real membership flow.
No admission check was bypassed.
No storage was modified directly.
The vulnerable state appeared only after a later governance parameter change.
Governance raised minStaking above the target balance
The proof used the real environment proposal flow to raise minStaking to:
2,000,000 WEMIX
The target remained locked at:
1,500,000 WEMIX
The resulting state was:
Target locked balance
1,500,000 WEMIX
Current minStaking
2,000,000 WEMIX
Shortfall
500,000 WEMIX
The target remained present in the actual member set.
The parameter change did not automatically remove it, migrate it, or place it into a separate cleanup state.
The target lost normal governance participation
The ordinary governance gate was:
modifier checkLockedAmount() {
address staker =
getStakerAddr(
_msgSender()
);
require(
lockedBalanceOf(
staker
)
<=
getMaxStaking()
&&
lockedBalanceOf(
staker
)
>=
getMinStaking(),
"Invalid staking balance"
);
_;
}
After the increase, the target failed:
lockedBalanceOf(target) >= getMinStaking()
The PoC confirmed that the target’s vote reverted.
It also confirmed that the target could not create a normal proposal protected by the same modifier.
The target therefore remained a governance member in storage while losing the ordinary powers associated with membership.
A valid member could still create the removal proposal
Another member remained above the new minimum.
That valid member created a proposal to remove the understaked target.
The relevant proposal checks included:
require(
isMember(
staker
),
"Non-member"
);
require(
lockedBalanceOf(
staker
)
>=
lockAmount,
"Insufficient balance that can be unlocked."
);
The PoC used:
unlockAmount
1,500,000 WEMIX
slashing
0 WEMIX
The removal helper later required:
require(
unlockAmount
+
slashing
<=
getMinStaking(),
"minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
);
The values were valid:
1,500,000 + 0 <= 2,000,000
The proposal was accepted.
The voting member was valid.
The target was still a member.
The failure occurred only when the approved cleanup reached finalization.
Finalization reached the vulnerable helper
The real removal flow called:
transferLockedAndUnlock(
ballotIdx,
oldStaker
);
The affected helper was:
function transferLockedAndUnlock(
uint256 ballotIdx,
address addr
)
private
{
(
uint256 unlockAmount,
uint256 slashing
) =
getBallotForExit(
ballotIdx
);
require(
unlockAmount
+
slashing
<=
getMinStaking(),
"minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
);
IStaking staking =
IStaking(
getStakingAddress()
);
uint256 locked =
staking.lockedBalanceOf(
addr
);
uint256 ext =
locked
-
getMinStaking();
if (
locked
>
unlockAmount
) {
unlock(
addr,
unlockAmount
);
staking.transferLocked(
addr,
slashing,
ext
);
} else {
unlock(
addr,
locked
);
}
}
The implementation assumed:
locked >= current minStaking
That assumption was valid for an active member before the parameter increase.
It was not valid for the member state created by the increase itself.
The underflow happened before branch selection
The concrete values were:
locked
1,500,000 WEMIX
current minStaking
2,000,000 WEMIX
The helper attempted:
1,500,000
-
2,000,000
The subtraction reverted.
The function never reached:
if (
locked
>
unlockAmount
)
This distinction is important.
The bug was not that the wrong branch executed.
No cleanup branch executed at all.
The arithmetic failed before the function could decide how to unlock or transfer the target’s stake.
Atomic rollback restored every removal change
removeMember modified membership structures before calling transferLockedAndUnlock.
It removed entries from the staker, voter, reward, and node structures, reduced the member count, and then reached the vulnerable helper.
But EVM transactions are atomic.
When the helper reverted, every earlier state update in that transaction reverted too.
The final state was:
Target was a member before finalization
True
Removal finalization
Reverted
Target is a member afterward
True
Target locked balance afterward
1,500,000 WEMIX
The source code may appear to remove the member first.
The committed state did not.
The target was trapped in a contradictory lifecycle state
After the minimum increase and failed cleanup:
Target remains a member
True
Target satisfies current minStaking
False
Target can vote
False
Target can create proposals
False
Valid members can propose removal
True
Approved removal can finalize
False
The member was too understaked to participate.
The same understaked condition caused the removal calculation to revert.
This is the core lifecycle failure.
The controls isolate the exact cause
The proof included three positive controls.
Top up control
The target added enough stake to satisfy the new minimum.
Afterward:
locked >= current minStaking
The subtraction no longer underflowed.
Removal succeeded.
Lower minimum control
Governance lowered minStaking back to the target’s locked amount.
Again:
locked >= current minStaking
Removal succeeded.
Healthy removal control
A target that never became understaked was removed successfully through the ordinary path.
Together, the controls demonstrate:
Proposal creation works
Voting works
Removal works for healthy members
Removal works after a top up
Removal works after lowering the minimum
Removal fails specifically while locked < current minStaking
That isolates the unchecked subtraction as the root cause.
The PoC used the real governance path
The Go proof used:
Real GovImp
Real StakingImp
Real EnvStorageImp
Real BallotStorageImp
Real addProposalToChangeEnv
Real addProposalToRemoveMember
Real vote finalization
Real transferLockedAndUnlock
The vulnerable calculation was not copied into a simplified contract.
The primary path used:
No delegated user reserve
No NCPExit deposit
No NCPExit mapping overwrite
No public RPC
No mainnet or testnet dependency
The command was:
go test ./wemix/governance-contract/test \
-run TestUnder \
-count=1 \
-v
The test passed and confirmed:
Target became below minStaking
Pass
Target vote rejected
Pass
Target proposal rejected
Pass
Removal proposal accepted
Pass
Removal finalization reverted
Pass
Target remained a member
Pass
Top up control removal succeeded
Pass
Lower minimum control removal succeeded
Pass
Healthy removal control succeeded
Pass
Why this is a separate finding
This issue is distinct from the previous WEMIX NCP exit reports.
It is not the NCPExit overwrite
That finding required multiple deposits for the same NCP and overwrote existing exit accounting.
This report:
Does not call NCPExit twice
Does not overwrite NCPExit mappings
Does not strand value already deposited into NCPExit
The root cause is an unchecked subtraction in GovImp.transferLockedAndUnlock.
It is not the delegated exit skip
The delegated reserve finding used:
locked == current minStaking
Removal completed, but the delegated reserve was not transferred to NCPExit.
This report uses:
locked < current minStaking
Removal does not complete.
The transaction reverts before either branch can run.
One issue completes removal with broken delegated accounting.
This issue blocks removal itself.
What the proof establishes
The proof directly demonstrates:
The target entered under a valid earlier minimum
Governance raised minStaking above the target balance
The target remained in the member set
The target could no longer vote
The target could no longer create proposals
A valid member created the removal proposal
The removal parameters passed their bounds
Finalization reached the real removal path
locked - getMinStaking underflowed
The transaction reverted
The target remained a member
Three control paths succeeded after the underflow condition was removed
The proof does not demonstrate:
Direct theft
Loss of user balances
Protocol-wide insolvency
Permanent network shutdown
An arbitrary external attacker changing governance parameters
A state that no upgrade or parameter change could recover
Those limits are why the report was submitted as Major rather than Critical.
Why this is Major, not Low
The report demonstrates a governance lifecycle denial of service.
The invalid member loses participation
After the parameter change, checkLockedAmount blocks the target from voting and proposing.
The target remains in governance without satisfying the current requirement.
The standard cleanup path is unavailable
Valid members can create and approve a removal proposal.
Finalization still reverts.
The ordinary mechanism intended to restore membership integrity cannot complete.
The actual member set remains inconsistent
This is not a view-only mismatch.
The target remains in the real member set after the failed transaction.
The failure is deterministic
Whenever the removal helper evaluates:
locked < current minStaking
the subtraction reverts before any branch runs.
No race, timing condition, or frontend behavior is required.
Recovery requires another state transition
The PoC demonstrated three recovery classes:
The target voluntarily tops up
Governance rolls minStaking back down
The implementation is corrected
Until one of those occurs, the standard removal finalization remains blocked.
Low does not fit the result
Low would fit:
A harmless arithmetic value
A reverted view function
A cosmetic member-status mismatch
An unreachable edge case
A failed path with another normal cleanup route
This report showed the real governance cleanup transaction reverting and the invalid member remaining in the actual member set.
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:
Initial minStaking
1,500,000 WEMIX
Current minStaking
2,000,000 WEMIX
Target locked
1,500,000 WEMIX
Target participation
Blocked
Removal proposal
Valid
Removal finalization
Reverted
Target remains a member
True
The demonstrated impact was not a theoretical unsigned subtraction.
It was governance cleanup denial of service.
A member that no longer satisfies the active requirement can remain stuck until a top up, parameter rollback, or implementation correction removes the condition.
That is not Low severity.
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 proof showed a valid governance parameter change producing an understaked member that could no longer participate and could not be removed because the real finalization path reverted.
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:
An invalid NCP remaining in the actual member set
The target unable to vote or propose
A valid removal proposal being accepted
Finalization reverting deterministically
The target remaining a member after failed cleanup
Removal succeeding only after the underflow condition is eliminated
Without addressing those facts, the Low classification does not match the proof.
The minimal safe correction
The subtraction must occur only when excess stake actually exists.
The report proposed:
function transferLockedAndUnlock(
uint256 ballotIdx,
address addr
)
private
{
(
uint256 unlockAmount,
uint256 slashing
) =
getBallotForExit(
ballotIdx
);
uint256 minStaking =
getMinStaking();
require(
unlockAmount
+
slashing
<=
minStaking,
"minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
);
IStaking staking =
IStaking(
getStakingAddress()
);
uint256 locked =
staking.lockedBalanceOf(
addr
);
uint256 ext =
0;
if (
locked
>
minStaking
) {
ext =
locked
-
minStaking;
}
if (
locked
>
unlockAmount
) {
unlock(
addr,
unlockAmount
);
staking.transferLocked(
addr,
slashing,
ext
);
} else {
unlock(
addr,
locked
);
}
}
The rule becomes:
If locked > minStaking
ext = locked - minStaking
Otherwise
ext = 0
There is no unsigned negative excess.
The function can reach the appropriate cleanup branch instead of reverting during arithmetic.
Why the correction preserves existing behavior
For a member above the minimum:
ext = locked - minStaking
The existing excess calculation remains unchanged.
For a member exactly at the minimum:
ext = 0
That is the correct mathematical excess.
For a member below the minimum:
ext = 0
The cleanup no longer attempts an impossible unsigned subtraction.
The proposal bound remains intact:
unlockAmount + slashing <= current minStaking
The patch does not weaken that rule.
Delegated reserve scenarios still require regression coverage together with the separate delegated exit correction because that report addresses a different accounting invariant.
Regression tests
A complete correction should verify:
A target below the current minimum can be removed
A target exactly at the current minimum preserves existing behavior
A target above the current minimum preserves the excess path
Valid members can still create and finalize removal proposals
The target no longer remains in membership after successful cleanup
Top up and lower-minimum controls continue to succeed
Delegated reserve scenarios remain safe with the separate delegated exit fix
The central invariant is:
A valid minStaking change must never create an NCP state that is both ineligible to participate and impossible to remove.
Broader lessons
Configuration changes create migration states
Raising a minimum does not automatically migrate existing members.
Cleanup logic must support members admitted under the previous value.
Removal code must handle invalid members
A removal path exists to process members that should no longer remain.
It cannot assume the target still satisfies every current admission requirement.
Arithmetic should follow the condition it represents
ext represented stake above the current minimum.
The code calculated it before checking whether any excess existed.
Atomicity can hide failed cleanup
The source removed membership entries before calling the helper.
The later revert restored all of them.
Only the committed state determines whether removal succeeded.
Controls make lifecycle findings stronger
The top up, lower-minimum, and healthy controls proved that the entire governance system was not generically broken.
The failure was the exact state:
locked < current minStaking
Conclusion
The target entered governance with:
Locked balance
1,500,000 WEMIX
Initial minStaking
1,500,000 WEMIX
Governance later raised the minimum to:
2,000,000 WEMIX
The target remained a member but could no longer vote or propose.
A valid member created a valid removal proposal.
During finalization, transferLockedAndUnlock attempted:
1,500,000
-
2,000,000
The subtraction reverted before either branch executed.
The whole removal transaction rolled back.
The understaked NCP remained in the member set.
The program classified the report as Low and paid $100.

The demonstrated impact was Major.
The report did not claim theft, insolvency, or permanent network shutdown.
It demonstrated a narrower and directly proven failure:
A valid minStaking increase could leave an NCP too understaked to participate and understaked in a way the normal removal calculation could not safely handle.
A governance cleanup path that cannot remove the invalid member it was invoked to remove is not a Low severity issue.
Top comments (0)