A withdrawal reserve must remain represented until every entitled party has claimed it.
WEMIX NCPExitImp did not preserve that invariant.
The contract stored exit accounting only by NCP address:
mapping(
address =>
uint256
) private _receivedTotalAmount;
mapping(
address =>
uint256
) private _lockedUserBalanceToNCPTotal;
That key identified the NCP, but not the exit cycle.
When the same NCP address exited again, depositExitAmount assigned new values to the same mappings:
_receivedTotalAmount[
exitNcp
] =
totalAmount;
_lockedUserBalanceToNCPTotal[
exitNcp
] =
lockedUserBalanceToNCPTotal;
The second exit did not create separate accounting.
It replaced the remaining totals from the first exit.
The proof of concept demonstrated the full state transition:
First exit total
100 WEMIX
First user reserve
80 WEMIX
Completed user withdrawal
30 WEMIX
Old user reserve still pending
50 WEMIX
Old administrator reserve still pending
20 WEMIX
Second exit for the same NCP
10 WEMIX
Contract balance after the second exit
80 WEMIX
Amount represented by the mappings
10 WEMIX
Unrepresented contract balance
70 WEMIX
Old user withdrawal for 50 WEMIX
Reverted
Old administrator withdrawal for 20 WEMIX
Reverted
The native funds remained inside the real NCPExit contract.
The accounting that authorized their withdrawal no longer represented them.
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 accounting discrepancy.
A later authorized NCP exit cycle erased the accounting for previously unclaimed withdrawal reserves for unclaimed withdrawal reserves, left 70 WEMIX stranded behind stale mappings, and caused a previously valid user withdrawal to fail.
That is a directly demonstrated withdrawal-reserve freeze.
The complete public report and proof of concept are available in the GitHub Gist.
The reserve invariant
NCPExit receives native value when an NCP leaves the governance or staking lifecycle.
The received amount can contain:
User withdrawal reserves
Administrator reserves
Once those claims are recorded, a later transition must not erase them before settlement.
The required invariant is:
Every exit reserve must remain represented and withdrawable until that exit cycle is fully settled.
A safe design needs one of two approaches:
Assign a unique identifier to every exit cycle
or:
Reject a new exit for the same NCP while an earlier cycle remains unsettled
NCPExitImp implemented neither.
An address was treated as a unique exit identity
The complete accounting key was the NCP address:
mapping(
address /* ncp address */
=>
uint256 /* user + administrator */
)
private
_receivedTotalAmount;
mapping(
address /* ncp address */
=>
uint256 /* user */
)
private
_lockedUserBalanceToNCPTotal;
There was no:
exitId
cycleId
round
epoch
An address identifies the participant.
It does not uniquely identify every lifecycle event involving that participant.
Two exits for the same address therefore collided in the same storage slots.
The second deposit overwrote the first cycle
The vulnerable function was:
function depositExitAmount(
address exitNcp,
uint256 totalAmount,
uint256 lockedUserBalanceToNCPTotal
)
external
payable
override
nonReentrant
onlyGovStaking
{
require(
totalAmount
==
msg.value
);
_receivedTotalAmount[
exitNcp
] =
totalAmount;
_lockedUserBalanceToNCPTotal[
exitNcp
] =
lockedUserBalanceToNCPTotal;
}
Both state updates used assignment.
The function did not verify that the same NCP had no:
Pending user reserve
Pending administrator reserve
Unfinished earlier exit
A second authorized deposit therefore replaced the old totals while the new native value was added to the existing contract balance.
The money accumulated.
The accounting did not.
User withdrawals relied on the overwritten mapping
The user path required the aggregate user reserve stored under the NCP address:
function withdrawForUser(
address exitNcp,
address exitUser,
uint256 amount
)
external
override
nonReentrant
onlyNcpStaking
{
require(
_lockedUserBalanceToNCPTotal[
exitNcp
]
>=
amount,
"_lockedUserBalanceToNCPTotal[exitNcp] >= amount"
);
_receivedTotalAmount[
exitNcp
] =
_receivedTotalAmount[
exitNcp
]
-
amount;
_lockedUserBalanceToNCPTotal[
exitNcp
] =
_lockedUserBalanceToNCPTotal[
exitNcp
]
-
amount;
payable(
exitUser
)
.sendValue(
amount
);
}
Before the second exit, the first cycle still had:
50 WEMIX
reserved for users.
After the overwrite, the mapping contained only:
10 WEMIX
The old withdrawal request for 50 WEMIX reverted at the reserve check.
The contract still held sufficient native value.
Its storage no longer recognized the old claim.
Administrator accounting was overwritten too
The administrator reserve was derived from the same two totals:
function withdrawForAdministrator(
address exitNcp,
uint256 amount,
address to
)
external
override
nonReentrant
onlyAdministrator
{
require(
_receivedTotalAmount[
exitNcp
]
-
_lockedUserBalanceToNCPTotal[
exitNcp
]
>=
amount
);
_receivedTotalAmount[
exitNcp
] =
_receivedTotalAmount[
exitNcp
]
-
amount;
payable(
to
)
.sendValue(
amount
);
}
Before the overwrite:
Total represented
70 WEMIX
User reserve
50 WEMIX
Administrator reserve
20 WEMIX
After the second exit:
Total represented
10 WEMIX
User reserve
10 WEMIX
Administrator available
0 WEMIX
The old 20 WEMIX administrator withdrawal also reverted.
The same overwrite therefore erased two different classes of outstanding claims.
The balance remained while the claims disappeared
This is what turns the issue into more than a view inconsistency.
The sequence was:
Contract balance after first exit
100 WEMIX
Contract balance after 30 WEMIX user withdrawal
70 WEMIX
Second exit deposit
10 WEMIX
Final contract balance
80 WEMIX
The mappings represented only:
10 WEMIX
The difference was:
70 WEMIX
That difference matched the old unsettled claims:
50 WEMIX user reserve
20 WEMIX administrator reserve
The contract still held the value.
The old accounting path could no longer release it.
The complete demonstrated lifecycle
The PoC executed this sequence:
1. NCP A completed a first exit
2. The real NCPExit proxy received 100 WEMIX
3. The real implementation recorded 80 WEMIX for users
4. The remaining 20 WEMIX was available to the administrator
5. One user withdrew 30 WEMIX successfully
6. The first cycle still represented 50 WEMIX for users and 20 WEMIX for the administrator
7. The same NCP address entered a later valid exit cycle
8. The second exit deposited 10 WEMIX
9. depositExitAmount replaced both mappings with the new 10 WEMIX values
10. The contract balance became 80 WEMIX
11. Only 10 WEMIX remained represented
12. The old 50 WEMIX user withdrawal reverted
13. The old 20 WEMIX administrator withdrawal reverted
A deposit for a different NCP remained isolated.
That control confirmed that the collision came from reusing the same address as the full accounting key.
The vulnerable function belonged to the real lifecycle
The deposit was not an unreachable helper.
StakingImp.transferLocked routed exit funds into 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 from address became the NCP accounting key.
Governance removal and change flows could reach this transfer path.
The report therefore did not rely on invoking an isolated function with no protocol caller.
Why the boundary mocks did not create the result
The proof used:
Real NCPExitImp
Real NCPExit proxy
Real Registry
Real native-value transfers
Boundary mocks were used only to satisfy:
onlyGovStaking
onlyNcpStaking
They did not implement:
Exit accounting
Reserve storage
Withdrawal calculations
The overwrite
The failed withdrawal
Those behaviors occurred inside the real NCPExitImp.
The PoC also tested direct EOA calls.
Direct EOA deposit was rejected.
Direct EOA user withdrawal was rejected.
The access controls worked as written.
The vulnerability was not unauthorized access.
It was accounting corruption inside an authorized repeated lifecycle.
The test command was:
go test ./wemix/governance-contract/test \
-run TestExit \
-count=1 \
-v
The test passed through the real contract and proxy path.
Why this is not a centralization report
The report did not claim that an arbitrary user could force NCP removal, readmission, and another removal.
The required condition was:
The same NCP address enters another valid exit cycle while earlier claims remain pending
The accounting contract accepted that transition.
If the protocol intended address reuse to be impossible, it needed to enforce that invariant.
depositExitAmount did not reject a new deposit while previous reserves remained unsettled.
Security cannot depend on an unstated lifecycle assumption when the receiving contract accepts the unsafe state transition.
What the PoC proves
The proof establishes:
The first exit reserve was recorded
A legitimate partial user withdrawal succeeded
A second exit for the same NCP was accepted
The new deposit overwrote the old accounting
The real contract retained the old native value
70 WEMIX became unrepresented by the mappings
The old 50 WEMIX user withdrawal reverted
The old 20 WEMIX administrator withdrawal reverted
A different NCP did not collide
Unauthorized EOA calls were rejected
The proof does not establish:
Direct theft by an arbitrary user
Protocol-wide insolvency
Irrecoverable loss that no upgrade could repair
An arbitrary attacker forcing the complete governance lifecycle
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 for any amount of time.
The PoC demonstrated that exact effect.
A real user reserve became unwithdrawable
Before the second exit, 50 WEMIX remained reserved for users.
After the overwrite, the mapping recognized only 10 WEMIX.
The old 50 WEMIX withdrawal reverted.
Native value became orphaned from its accounting
The real contract held 80 WEMIX.
Only 10 WEMIX remained represented.
The other 70 WEMIX could no longer be withdrawn through the accounting created by the first exit.
Both user and administrator claims were affected
The old user reserve disappeared from storage.
The old administrator reserve also disappeared.
Both withdrawals failed.
The unsafe transition was authorized and accepted
No permission bypass was needed.
The second deposit passed the contract’s caller checks.
The failure occurred because the storage model could not distinguish two exit cycles for the same NCP.
Recovery required protocol intervention
The old claims did not restore themselves.
Recovering them required an upgrade, migration, state reconstruction, or another privileged repair process.
That is a meaningful funds freeze.
Low does not fit the result
Low would fit:
A display inconsistency
Dust-only impact
A harmless overwrite
An unreachable path
A revert with no funds at risk
This report showed real native reserves, missing accounting, 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 demonstrated state:
Old represented reserve
70 WEMIX
Second exit deposit
10 WEMIX
Final contract balance
80 WEMIX
Final represented amount
10 WEMIX
Unrepresented amount
70 WEMIX
Old user withdrawal
Reverted
Old administrator withdrawal
Reverted
The strongest proven impact was the freezing and orphaning of withdrawal reserves during an accepted repeated exit 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 that an old user reserve became unrepresented and unwithdrawable while its native backing remained inside the real contract.
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:
50 WEMIX of user reserve orphaned
20 WEMIX of administrator reserve orphaned
70 WEMIX left unrepresented
Old user withdrawal reverting
Old administrator withdrawal reverting
A repeated lifecycle accepted by the contract
Without addressing those facts, the Low classification does not match the proof.
The safest correction: separate exit cycles
The robust fix is cycle-specific accounting.
Conceptually:
struct ExitReserve {
uint256 receivedTotalAmount;
uint256 lockedUserBalanceTotal;
bool active;
}
mapping(
address =>
uint256
)
public
latestExitId;
mapping(
address =>
mapping(
uint256 =>
ExitReserve
)
)
private
exitReserves;
Each deposit should create a new record:
NCP address
Exit ID
Cycle total
Cycle user reserve
User and administrator withdrawals should identify the cycle they settle.
A later exit can then coexist without replacing an earlier claim.
A minimal defensive alternative
A simpler design can reject a second deposit while the prior cycle remains unsettled:
require(
_receivedTotalAmount[
exitNcp
]
==
0,
"Previous exit remains unsettled"
);
require(
_lockedUserBalanceToNCPTotal[
exitNcp
]
==
0,
"User reserve remains unsettled"
);
This is less flexible than cycle-specific accounting.
It is still safer than silently replacing outstanding reserves.
Migration and recovery
A patch must also handle reserves that may already be orphaned.
Changing the mapping structure does not reconstruct earlier claims automatically.
A migration should:
Identify NCPs with unsettled exits
Reconcile native balance against represented totals
Reconstruct user and administrator claims from historical state and events
Move those claims into cycle-specific accounting
Block new deposits until reconciliation completes
Recovery must preserve the original beneficiaries rather than treating the excess native balance as generic protocol surplus.
Regression tests
A complete correction should verify:
A first exit records its full reserve
Partial user withdrawals reduce only that cycle
A second exit cannot overwrite an unsettled first cycle
Two exit IDs for the same NCP remain independent
Earlier users can withdraw after a later cycle is created
Administrator reserves remain attached to the correct cycle
Contract balance reconciles with represented unsettled reserves
Different NCP addresses remain isolated
Unauthorized EOA deposits remain rejected
Unauthorized EOA withdrawals remain rejected
Fully settled cycles can close safely
Historical reserves can be migrated without changing beneficiaries
The central invariant is:
Every native unit held for an exit must remain represented by exactly one unsettled claim until withdrawal or explicit migration.
Broader lessons
An address is not always a unique accounting identity
A participant can appear in multiple lifecycle events.
Accounting often needs both:
Who
Which event
Assignment is dangerous in reserve accounting
Writing:
reserve[
key
] =
newValue;
is safe only when the old value is guaranteed to be zero or fully settled.
That guarantee was missing.
Access control does not preserve accounting invariants
Only authorized contracts could reach the vulnerable functions.
The authorized flow still corrupted the reserve model.
Balance and represented liabilities must reconcile
A contract holding 80 WEMIX while representing only 10 WEMIX indicates a serious reconciliation failure even without immediate theft.
Lifecycle reuse must be explicit
If an address may exit more than once, each exit needs independent state.
If reuse is forbidden, the contract must enforce that rule.
Conclusion
NCPExitImp stored exit reserves only by NCP address.
The first exit deposited 100 WEMIX and reserved 80 WEMIX for users.
After a legitimate 30 WEMIX withdrawal, the first cycle still represented:
50 WEMIX user reserve
20 WEMIX administrator reserve
A second 10 WEMIX exit for the same NCP replaced both mappings.
The real contract then held:
80 WEMIX
while representing only:
10 WEMIX
The remaining 70 WEMIX was orphaned from the original exit accounting.
The old user withdrawal reverted.
The old administrator 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:
A valid later exit erased the accounting required to withdraw funds still owed from an earlier exit.
A contract that still holds the funds but has forgotten the claims against them does not have a Low-severity problem.
Top comments (0)