DEV Community

Daniel
Daniel

Posted on

How a Second NCP Exit Orphaned Unclaimed Withdrawal Reserves in WEMIX

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

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

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

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

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

or:

Reject a new exit for the same NCP while an earlier cycle remains unsettled
Enter fullscreen mode Exit fullscreen mode

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

There was no:

exitId

cycleId

round

epoch
Enter fullscreen mode Exit fullscreen mode

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

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

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

Before the second exit, the first cycle still had:

50 WEMIX
Enter fullscreen mode Exit fullscreen mode

reserved for users.

After the overwrite, the mapping contained only:

10 WEMIX
Enter fullscreen mode Exit fullscreen mode

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

Before the overwrite:

Total represented
70 WEMIX

User reserve
50 WEMIX

Administrator reserve
20 WEMIX
Enter fullscreen mode Exit fullscreen mode

After the second exit:

Total represented
10 WEMIX

User reserve
10 WEMIX

Administrator available
0 WEMIX
Enter fullscreen mode Exit fullscreen mode

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

The mappings represented only:

10 WEMIX
Enter fullscreen mode Exit fullscreen mode

The difference was:

70 WEMIX
Enter fullscreen mode Exit fullscreen mode

That difference matched the old unsettled claims:

50 WEMIX user reserve

20 WEMIX administrator reserve
Enter fullscreen mode Exit fullscreen mode

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

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

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

Boundary mocks were used only to satisfy:

onlyGovStaking

onlyNcpStaking
Enter fullscreen mode Exit fullscreen mode

They did not implement:

Exit accounting

Reserve storage

Withdrawal calculations

The overwrite

The failed withdrawal
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

Each deposit should create a new record:

NCP address

Exit ID

Cycle total

Cycle user reserve
Enter fullscreen mode Exit fullscreen mode

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

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

Recovery must preserve the original beneficiaries rather than treating the excess native balance as generic protocol surplus.

Regression tests

A complete correction should verify:

  1. A first exit records its full reserve

  2. Partial user withdrawals reduce only that cycle

  3. A second exit cannot overwrite an unsettled first cycle

  4. Two exit IDs for the same NCP remain independent

  5. Earlier users can withdraw after a later cycle is created

  6. Administrator reserves remain attached to the correct cycle

  7. Contract balance reconciles with represented unsettled reserves

  8. Different NCP addresses remain isolated

  9. Unauthorized EOA deposits remain rejected

  10. Unauthorized EOA withdrawals remain rejected

  11. Fully settled cycles can close safely

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

Assignment is dangerous in reserve accounting

Writing:

reserve[
    key
] =
    newValue;
Enter fullscreen mode Exit fullscreen mode

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

A second 10 WEMIX exit for the same NCP replaced both mappings.

The real contract then held:

80 WEMIX
Enter fullscreen mode Exit fullscreen mode

while representing only:

10 WEMIX
Enter fullscreen mode Exit fullscreen mode

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)