DEV Community

Daniel
Daniel

Posted on

How WEMIX NCP Removal Skipped the Exit Path and Froze Delegated Reserves

Removing an NCP should not strand the funds delegated to it.

WEMIX governance contained a branch where that could happen.

When the NCP’s total locked balance equaled the current minStaking and the removal unlockAmount, GovImp.transferLockedAndUnlock entered this path:

if (locked > unlockAmount) {
    unlock(addr, unlockAmount);
    staking.transferLocked(
        addr,
        slashing,
        ext
    );
} else {
    unlock(
        addr,
        locked
    );
}
Enter fullscreen mode Exit fullscreen mode

The else branch unlocked the aggregate NCP balance.

It did not call StakingImp.transferLocked.

As a result, it never reached NCPExit.depositExitAmount.

If the aggregate locked balance still contained delegated user stake, governance could remove the NCP while the delegated reserve remained recorded in StakingImp, no matching reserve was created in NCPExit, and the available withdrawal paths reverted.

The proof of concept demonstrated:

NCP own locked stake
1,500,000 WEMIX

Delegated user stake
500,000 WEMIX

Current minStaking
2,000,000 WEMIX

Locked balance before removal
2,000,000 WEMIX

Removal unlockAmount
2,000,000 WEMIX

Slashing
0 WEMIX

locked > unlockAmount
False

NCP member after removal
False

StakingImp delegated reserve after removal
500,000 WEMIX

NCPExit delegated reserve after removal
0 WEMIX

Delegated user withdrawal
Reverted

Direct NCP withdrawal
Reverted
Enter fullscreen mode Exit fullscreen mode

The delegated reserve was not stolen.

It was left between two lifecycle states:

Too late for the active-member withdrawal path

Never transferred into the post-removal NCPExit path
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.

The PoC used the real WEMIX governance contracts and showed 500,000 WEMIX of delegated reserve remaining in StakingImp, no corresponding reserve in NCPExit, and both tested withdrawal paths reverting after the NCP was removed.

That is a directly demonstrated freeze of delegated user funds.

The complete public report and proof of concept are available in the GitHub Gist.

The invariant NCP removal must preserve

Delegation changes the ownership composition of an NCP’s locked balance.

The balance can contain:

The NCP’s own stake

User-owned delegated stake
Enter fullscreen mode Exit fullscreen mode

A removal flow cannot safely treat the aggregate as though every unit belonged to the NCP.

The required invariant is:

After NCP removal, delegated user accounting must be zero in StakingImp or backed by an equal and withdrawable reserve in NCPExit.

A safe removal must therefore end in one of these states:

No delegated reserve remains
Enter fullscreen mode Exit fullscreen mode

or:

The complete delegated reserve has moved to NCPExit
Enter fullscreen mode Exit fullscreen mode

The vulnerable path produced neither.

Delegation was included in the NCP’s locked balance

The delegated deposit path increased the NCP balance, locked the deposited value, and recorded it as a user reserve:

_balance[
    ncp
] =
    _balance[
        ncp
    ]
        +
    userDepositValue;

_lock(
    ncp,
    userDepositValue
);

_lockedUserBalanceToNCP[
    ncp
][
    msg.sender
] =
    _lockedUserBalanceToNCP[
        ncp
    ][
        msg.sender
    ]
        +
    userDepositValue;

_lockedUserBalanceToNCPTotal[
    ncp
] =
    _lockedUserBalanceToNCPTotal[
        ncp
    ]
        +
    userDepositValue;
Enter fullscreen mode Exit fullscreen mode

The practical relationship became:

lockedBalanceOf(ncp)
=
NCP-owned locked stake
+
delegated user stake
Enter fullscreen mode Exit fullscreen mode

The PoC created:

NCP-owned stake
1,500,000 WEMIX

Delegated stake
500,000 WEMIX

lockedBalanceOf(ncp)
2,000,000 WEMIX

userTotalBalanceOf(ncp)
500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

The delegated amount was therefore part of the balance processed during removal.

The minStaking change created the dangerous equality

The initial minStaking was:

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

The protocol then raised it to:

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

That value matched the full locked balance, including the delegated reserve.

The removal ballot used:

unlockAmount
2,000,000 WEMIX

slashing
0 WEMIX
Enter fullscreen mode Exit fullscreen mode

Inside transferLockedAndUnlock:

locked
2,000,000 WEMIX

current minStaking
2,000,000 WEMIX

ext = locked - current minStaking
0 WEMIX

locked > unlockAmount
False
Enter fullscreen mode Exit fullscreen mode

The PoC therefore reached the exact equality case in the else branch.

The branch decision ignored the delegated liability

The relevant code was:

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 condition considered:

locked

unlockAmount
Enter fullscreen mode Exit fullscreen mode

It did not consider:

staking.userTotalBalanceOf(addr)
Enter fullscreen mode Exit fullscreen mode

The else branch assumed that unlocking the aggregate balance completed the exit.

That assumption was false while 500,000 WEMIX remained attributed to delegated users.

The safe branch reveals the intended exit flow

When locked > unlockAmount, governance called:

staking.transferLocked(
    addr,
    slashing,
    ext
);
Enter fullscreen mode Exit fullscreen mode

Inside StakingImp.transferLocked, the protocol preserved the remaining user reserve by sending it to NCPExit:

uint256 transferedBalance =
    lockedBalanceOf(
        from
    );

require(
    transferedBalance
        >=
    _lockedUserBalanceToNCPTotal[
        from
    ],
    "transferedBalance must be greater than or equal to _lockedUserBalanceToNCPTotal."
);

unlock(
    from,
    transferedBalance
);

_balance[
    from
] =
    _balance[
        from
    ]
        -
    transferedBalance;

ncpExit
    .depositExitAmount{
        value:
            transferedBalance
    }(
        from,
        transferedBalance,
        _lockedUserBalanceToNCPTotal[
            from
        ]
    );
Enter fullscreen mode Exit fullscreen mode

The clean control reached this branch.

It produced:

NCPExit user reserve
500,000 WEMIX

Delegated user withdrawal through NCPExit
Succeeded

User balance increase
500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

The protocol already had a functioning post-removal reserve path.

The equality case bypassed it.

Membership was removed before the reserve was preserved

GovImp.removeMember removed the NCP from the member set and then called:

transferLockedAndUnlock(
    ballotIdx,
    oldStaker
);
Enter fullscreen mode Exit fullscreen mode

After the vulnerable removal:

isMember(ncp)
False
Enter fullscreen mode Exit fullscreen mode

This mattered because the normal delegated withdrawal path required active membership.

The original delegated withdrawal path was closed

delegateUnlockAndWithdraw enforced:

require(
    IGov(
        getGovAddress()
    )
        .isMember(
            ncp
        ),
    "NCP should be a member"
);
Enter fullscreen mode Exit fullscreen mode

Once governance removed the NCP, the delegated withdrawal path reverted.

The PoC called the real path and observed no user balance increase.

This was not a frontend limitation.

The smart contract itself rejected the withdrawal.

The replacement exit path was never opened

After removal, users were supposed to withdraw through NCPExit.

That contract depended on a reserve created by:

depositExitAmount
Enter fullscreen mode Exit fullscreen mode

In the bug path:

NCPExit balance delta during removal
0 WEMIX

NCPExit user reserve
0 WEMIX

depositExitAmount reached
No
Enter fullscreen mode Exit fullscreen mode

The original route was closed by member removal.

The replacement route had no funds or accounting.

Direct NCP withdrawal reverted as well

After the vulnerable removal, the PoC observed:

lockedBalanceOf(ncp)
0 WEMIX

userTotalBalanceOf(ncp)
500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

The direct withdrawal function evaluated:

lockedBalanceOf(
    msg.sender
)
    -
_lockedUserBalanceToNCPTotal[
    msg.sender
]
Enter fullscreen mode Exit fullscreen mode

That became:

0
-
500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

Under Solidity 0.8 checked arithmetic, the subtraction reverted.

The PoC confirmed that direct NCP withdrawal also failed.

This did not prove that no conceivable privileged recovery existed.

It proved that the two tested withdrawal routes available through the normal contract lifecycle did not release the reserve.

The complete demonstrated sequence

The PoC executed:

1. An NCP became an active governance member

2. The NCP locked 1,500,000 WEMIX of its own stake

3. A user delegated 500,000 WEMIX

4. The total locked balance became 2,000,000 WEMIX

5. Governance raised minStaking to 2,000,000 WEMIX

6. Governance finalized removal with unlockAmount of 2,000,000 WEMIX and zero slashing

7. locked > unlockAmount evaluated to false

8. GovImp executed only unlock(addr, locked)

9. StakingImp.transferLocked was skipped

10. NCPExit.depositExitAmount was skipped

11. The NCP was removed from membership

12. StakingImp still recorded 500,000 WEMIX of delegated reserve

13. NCPExit recorded 0 WEMIX for that NCP

14. Delegated user withdrawal reverted

15. Direct NCP withdrawal reverted
Enter fullscreen mode Exit fullscreen mode

The reserve remained recorded but inaccessible through the demonstrated paths.

The PoC used the real contract flow

The Go proof used:

Real GovImp

Real StakingImp

Real NCPExitImp

Real governance proxy setup

Real delegated deposit path

Real member-removal finalization

Real native-value transfers

Real clean control path
Enter fullscreen mode Exit fullscreen mode

The vulnerable branch was not copied into a simplified contract.

A boundary mock represented only the NCPStaking caller surface required to invoke delegated operations.

It did not implement:

Governance removal

The locked-balance branch

Delegated reserve accounting

The NCPExit routing decision

The failed withdrawals
Enter fullscreen mode Exit fullscreen mode

The test command was:

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

The test passed.

The controls isolated the missing condition

The proof contained two strong controls.

Clean control

When:

locked > unlockAmount
True
Enter fullscreen mode Exit fullscreen mode

the real flow reached:

StakingImp.transferLocked

NCPExit.depositExitAmount
Enter fullscreen mode Exit fullscreen mode

The full 500,000 WEMIX reserve reached NCPExit, and the user withdrawal succeeded.

No-delegation control

The proof also reached the locked <= unlockAmount branch with:

userTotalBalanceOf(ncp)
0 WEMIX
Enter fullscreen mode Exit fullscreen mode

No delegated reserve was frozen.

Together, the controls show that the branch became unsafe specifically when it ignored a nonzero delegated liability.

Why this is a separate finding from the NCPExit overwrite

The earlier NCPExitImp finding involved a second exit deposit overwriting a reserve that had already been created.

This report has a different root cause.

Here:

NCPExit.depositExitAmount
Never called
Enter fullscreen mode Exit fullscreen mode

The failure originates in:

GovImp.transferLockedAndUnlock
Enter fullscreen mode Exit fullscreen mode

One issue destroys existing exit accounting.

This issue skips creation of the exit accounting entirely.

Why this is not a centralization report

The report does not claim that an arbitrary user can remove an NCP.

The demonstrated prerequisites were:

An NCP with own locked stake

A nonzero delegated user reserve

A valid minStaking increase

A valid governance removal

unlockAmount equal to the current locked balance

Zero slashing in the demonstrated path
Enter fullscreen mode Exit fullscreen mode

The security question is whether an authorized lifecycle preserves user funds.

If a permitted removal state strands delegated reserves, the contract must either handle the state safely or reject it atomically.

Operational assumptions cannot replace an enforced accounting invariant.

What the proof establishes

The proof demonstrates:

A real delegated reserve existed

Current minStaking was raised to the total locked balance

The real removal path reached the equality case

StakingImp.transferLocked was skipped

NCPExit.depositExitAmount was skipped

The NCP was removed from membership

500,000 WEMIX remained in delegated accounting

NCPExit contained no reserve for that NCP

Delegated user withdrawal reverted

Direct NCP withdrawal reverted

The clean branch routed and paid the reserve correctly

The no-delegation control froze no user reserve
Enter fullscreen mode Exit fullscreen mode

The proof does not demonstrate:

Direct theft by an arbitrary user

Irrecoverable loss that no upgrade can repair

Protocol-wide insolvency

An arbitrary attacker forcing governance removal

Every NCP removal entering the vulnerable branch
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 WEMIX program’s Major impact category included temporary freezing of funds.

That is the impact demonstrated here.

A real delegated reserve became inaccessible

The affected amount in the PoC was:

500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

It remained in StakingImp.userTotalBalanceOf.

The delegated withdrawal reverted.

The intended post-removal reserve was zero

NCPExit was the designed sink for user reserves after NCP removal.

The vulnerable branch skipped it.

The observed reserve was:

0 WEMIX
Enter fullscreen mode Exit fullscreen mode

Membership removal disabled the original route

The active-member withdrawal path required:

isMember(ncp) == true
Enter fullscreen mode Exit fullscreen mode

After removal, that condition no longer held.

Two normal withdrawal routes failed

The delegated user path reverted.

The direct NCP withdrawal path also reverted under the observed post-removal accounting.

The real governance lifecycle created the state

No direct storage writes or unauthorized contract calls were used.

The real removal finalization reached the vulnerable branch.

Recovery required protocol intervention

The reserve did not migrate or become withdrawable automatically.

Restoring access required a contract correction, migration, state repair, or another privileged recovery path.

That is materially different from Low impact.

Low does not match the proof

Low would fit:

A harmless event mismatch

A view-only inconsistency

Dust with no failed withdrawal

An unreachable state

A revert with no funds stranded
Enter fullscreen mode Exit fullscreen mode

This report showed a large delegated reserve, a skipped exit sink, and failed withdrawals.

Major is the proportionate classification.

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 state:

Delegated reserve before removal
500,000 WEMIX

Delegated reserve after removal
500,000 WEMIX

NCPExit reserve after removal
0 WEMIX

NCP membership after removal
False

Delegated withdrawal
Reverted

Direct NCP withdrawal
Reverted
Enter fullscreen mode Exit fullscreen mode

The strongest demonstrated impact was freezing delegated user reserves during an authorized NCP removal lifecycle.

That is a Major smart-contract accounting 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 proof showed 500,000 WEMIX remaining in delegated accounting, no matching reserve in NCPExit, and both tested withdrawal paths reverting after removal.

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:

500,000 WEMIX of delegated reserve frozen

NCPExit receiving zero reserve

The NCP already removed from membership

Delegated user withdrawal reverting

Direct NCP withdrawal reverting

The real governance removal path creating the state
Enter fullscreen mode Exit fullscreen mode

Without addressing those facts, the Low classification does not match the proof.

The proxy-compatible correction

The reference correction adds a dedicated delegated-exit function to StakingImp without introducing new storage variables.

GovImp.transferLockedAndUnlock checks whether the vulnerable branch still has delegated reserves:

if (
    locked
        >
    unlockAmount
) {
    unlock(
        addr,
        unlockAmount
    );

    staking
        .transferLocked(
            addr,
            slashing,
            ext
        );
} else {
    if (
        staking
            .userTotalBalanceOf(
                addr
            )
            >
        0
    ) {
        staking
            .transferDelegatedExit(
                addr,
                slashing
            );
    } else {
        unlock(
            addr,
            locked
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The dedicated function must:

Read the delegated total

Validate that locked value covers delegated reserves and slashing

Move the delegated amount to NCPExit

Clear delegated accounting

Handle slashing

Unlock any remaining NCP-owned stake
Enter fullscreen mode Exit fullscreen mode

The reason for a separate function is important.

In the proven bug state:

ext = locked - current minStaking
0 WEMIX
Enter fullscreen mode Exit fullscreen mode

while:

userTotalBalanceOf(ncp)
500,000 WEMIX
Enter fullscreen mode Exit fullscreen mode

The existing transferLocked path was not designed to be called with that combination.

Regression tests

A complete correction should verify:

  1. Removal with delegated reserve and locked == unlockAmount routes the complete reserve to NCPExit

  2. userTotalBalanceOf(ncp) is zero after successful migration

  3. Delegated withdrawal through NCPExit succeeds after removal

  4. The existing locked > unlockAmount path remains functional

  5. Removal with no delegated reserve preserves the existing behavior

  6. Direct NCP accounting does not underflow after the corrected removal

  7. Slashing cannot consume delegated user reserves

  8. Removal reverts atomically if delegated reserves cannot be preserved

The central postcondition is:

After removal, delegated accounting is zero in StakingImp or backed by an equal and working reserve in NCPExit.

Broader lessons

Aggregate stake can contain multiple owners

lockedBalanceOf combined NCP-owned and user-owned value.

A single aggregate unlock did not settle every underlying claim.

Branch decisions must include liabilities

The code selected its path using locked and unlockAmount.

It ignored userTotalBalanceOf.

That omitted value was the delegated liability.

Membership can be a financial precondition

Removing the NCP did more than change governance metadata.

It disabled the active-member withdrawal function.

A skipped call can freeze funds

The vulnerable path did not send the reserve to the wrong address.

It skipped the only call that created the post-removal withdrawal reserve.

Controls clarify the invariant

The clean control proved that NCPExit was the intended sink.

The no-delegation control proved that the frozen state depended on a delegated reserve.

Conclusion

The NCP began with:

1,500,000 WEMIX of its own locked stake

500,000 WEMIX of delegated user stake
Enter fullscreen mode Exit fullscreen mode

Governance raised minStaking until:

current minStaking
2,000,000 WEMIX

locked balance
2,000,000 WEMIX

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

Because:

locked > unlockAmount
False
Enter fullscreen mode Exit fullscreen mode

GovImp.transferLockedAndUnlock executed only:

unlock(
    addr,
    locked
);
Enter fullscreen mode Exit fullscreen mode

It skipped:

StakingImp.transferLocked

NCPExit.depositExitAmount
Enter fullscreen mode Exit fullscreen mode

After removal:

NCP membership
Removed

StakingImp delegated reserve
500,000 WEMIX

NCPExit delegated reserve
0 WEMIX

Delegated user withdrawal
Reverted

Direct NCP withdrawal
Reverted
Enter fullscreen mode Exit fullscreen mode

The program classified the report as Low and paid $100.

The demonstrated impact was Major.

The report did not claim arbitrary theft or protocol-wide insolvency.

It demonstrated a narrower and directly proven failure:

An authorized NCP removal completed while delegated user reserves remained in StakingImp without a working withdrawal path.

A governance removal that freezes delegated user funds is not a Low-severity issue.

Top comments (0)