DEV Community

Daniel
Daniel

Posted on

How a MinStaking Increase Could Make an Understaked WEMIX NCP Unremovable

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

The deeper failure appeared when valid members tried to remove it.

The finalization path reached:

uint256 ext =
    locked
        -
    getMinStaking();
Enter fullscreen mode Exit fullscreen mode

When:

locked < current minStaking
Enter fullscreen mode Exit fullscreen mode

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

The target locked:

1,500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

At admission time:

locked balance >= current minStaking
Enter fullscreen mode Exit fullscreen mode

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

The target remained locked at:

1,500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

The resulting state was:

Target locked balance
1,500,000 WEMIX

Current minStaking
2,000,000 WEMIX

Shortfall
500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

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"
    );

    _;
}
Enter fullscreen mode Exit fullscreen mode

After the increase, the target failed:

lockedBalanceOf(target) >= getMinStaking()
Enter fullscreen mode Exit fullscreen mode

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

The PoC used:

unlockAmount
1,500,000 WEMIX

slashing
0 WEMIX
Enter fullscreen mode Exit fullscreen mode

The removal helper later required:

require(
    unlockAmount
        +
    slashing
        <=
    getMinStaking(),
    "minStaking value must be greater than or equal to the sum of unlockAmount, slashing"
);
Enter fullscreen mode Exit fullscreen mode

The values were valid:

1,500,000 + 0 <= 2,000,000
Enter fullscreen mode Exit fullscreen mode

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

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

The implementation assumed:

locked >= current minStaking
Enter fullscreen mode Exit fullscreen mode

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

The helper attempted:

1,500,000
-
2,000,000
Enter fullscreen mode Exit fullscreen mode

The subtraction reverted.

The function never reached:

if (
    locked
        >
    unlockAmount
)
Enter fullscreen mode Exit fullscreen mode

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

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

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

The subtraction no longer underflowed.

Removal succeeded.

Lower minimum control

Governance lowered minStaking back to the target’s locked amount.

Again:

locked >= current minStaking
Enter fullscreen mode Exit fullscreen mode

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

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

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

The command was:

go test ./wemix/governance-contract/test \
    -run TestUnder \
    -count=1 \
    -v
Enter fullscreen mode Exit fullscreen mode

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

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

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

Removal completed, but the delegated reserve was not transferred to NCPExit.

This report uses:

locked < current minStaking
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

The rule becomes:

If locked > minStaking
ext = locked - minStaking

Otherwise
ext = 0
Enter fullscreen mode Exit fullscreen mode

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

The existing excess calculation remains unchanged.

For a member exactly at the minimum:

ext = 0
Enter fullscreen mode Exit fullscreen mode

That is the correct mathematical excess.

For a member below the minimum:

ext = 0
Enter fullscreen mode Exit fullscreen mode

The cleanup no longer attempts an impossible unsigned subtraction.

The proposal bound remains intact:

unlockAmount + slashing <= current minStaking
Enter fullscreen mode Exit fullscreen mode

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:

  1. A target below the current minimum can be removed

  2. A target exactly at the current minimum preserves existing behavior

  3. A target above the current minimum preserves the excess path

  4. Valid members can still create and finalize removal proposals

  5. The target no longer remains in membership after successful cleanup

  6. Top up and lower-minimum controls continue to succeed

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

Conclusion

The target entered governance with:

Locked balance
1,500,000 WEMIX

Initial minStaking
1,500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

Governance later raised the minimum to:

2,000,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

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

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)