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
);
}
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
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
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
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
StakingImpor backed by an equal and withdrawable reserve inNCPExit.
A safe removal must therefore end in one of these states:
No delegated reserve remains
or:
The complete delegated reserve has moved to NCPExit
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;
The practical relationship became:
lockedBalanceOf(ncp)
=
NCP-owned locked stake
+
delegated user stake
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
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
The protocol then raised it to:
2,000,000 WEMIX
That value matched the full locked balance, including the delegated reserve.
The removal ballot used:
unlockAmount
2,000,000 WEMIX
slashing
0 WEMIX
Inside transferLockedAndUnlock:
locked
2,000,000 WEMIX
current minStaking
2,000,000 WEMIX
ext = locked - current minStaking
0 WEMIX
locked > unlockAmount
False
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
);
}
The condition considered:
locked
unlockAmount
It did not consider:
staking.userTotalBalanceOf(addr)
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
);
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
]
);
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
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
);
After the vulnerable removal:
isMember(ncp)
False
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"
);
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
In the bug path:
NCPExit balance delta during removal
0 WEMIX
NCPExit user reserve
0 WEMIX
depositExitAmount reached
No
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
The direct withdrawal function evaluated:
lockedBalanceOf(
msg.sender
)
-
_lockedUserBalanceToNCPTotal[
msg.sender
]
That became:
0
-
500,000 WEMIX
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
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
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
The test command was:
go test ./wemix/governance-contract/test \
-run TestRemoveSkip \
-count=1 \
-v
The test passed.
The controls isolated the missing condition
The proof contained two strong controls.
Clean control
When:
locked > unlockAmount
True
the real flow reached:
StakingImp.transferLocked
NCPExit.depositExitAmount
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
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
The failure originates in:
GovImp.transferLockedAndUnlock
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
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
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
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
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
Membership removal disabled the original route
The active-member withdrawal path required:
isMember(ncp) == true
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
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
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
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
);
}
}
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
The reason for a separate function is important.
In the proven bug state:
ext = locked - current minStaking
0 WEMIX
while:
userTotalBalanceOf(ncp)
500,000 WEMIX
The existing transferLocked path was not designed to be called with that combination.
Regression tests
A complete correction should verify:
Removal with delegated reserve and
locked == unlockAmountroutes the complete reserve toNCPExituserTotalBalanceOf(ncp)is zero after successful migrationDelegated withdrawal through
NCPExitsucceeds after removalThe existing
locked > unlockAmountpath remains functionalRemoval with no delegated reserve preserves the existing behavior
Direct NCP accounting does not underflow after the corrected removal
Slashing cannot consume delegated user reserves
Removal reverts atomically if delegated reserves cannot be preserved
The central postcondition is:
After removal, delegated accounting is zero in
StakingImpor backed by an equal and working reserve inNCPExit.
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
Governance raised minStaking until:
current minStaking
2,000,000 WEMIX
locked balance
2,000,000 WEMIX
unlockAmount
2,000,000 WEMIX
Because:
locked > unlockAmount
False
GovImp.transferLockedAndUnlock executed only:
unlock(
addr,
locked
);
It skipped:
StakingImp.transferLocked
NCPExit.depositExitAmount
After removal:
NCP membership
Removed
StakingImp delegated reserve
500,000 WEMIX
NCPExit delegated reserve
0 WEMIX
Delegated user withdrawal
Reverted
Direct NCP withdrawal
Reverted
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
StakingImpwithout a working withdrawal path.
A governance removal that freezes delegated user funds is not a Low-severity issue.
Top comments (0)