Reproduced Exploit
BMX — a zero-rate incentive upsert pays a staker for the day before the schedule exists
_upsertIncentive updates the pool only inside if (info.rewardRate > 0). A new incentive whose previous rate is 0 therefore starts while poolLast is still yesterday. The next _updatePool sets activeSeconds = endTs - poolLast and pays that whole window.
Chain
Other
Category
logic
Date
Sep 2025
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 62814-h-7-in-the-incentivegauge-upsertincentive-function-updatepo. Standalone Foundry PoC and full write-up: 62814-h-7-in-the-incentivegauge-upsertincentive-function-updatepo_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/incorrect-order-of-operations
Reproduction: a self-contained Foundry PoC that compiles and runs offline with only
forge-std— no fork and no RPC. Trace: output.txt. PoC: test/62814-h-7-in-the-incentivegauge-upsertincentive-function-updatepo_exp.sol.
Key info#
| Impact | HIGH — the staker is paid 86,400 RWD. The fair accrual for a schedule that starts in this block is 0 |
| Protocol | BMX Deli Swap, IncentiveGauge |
| Vulnerable code | _upsertIncentive calls _updatePool only when rewardRate > 0, then _updatePool streams endTs - poolLast |
| Bug class | Pool checkpoint skipped, so the next stream includes time before the incentive |
| Finding | Sherlock (x15 and others) — BMX Deli Swap, September 2025 · id 62814, H-7 |
| Report | Judging issue 240 |
| Source | AuditVault finding |
| Status | Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
_upsertIncentive updates the pool only inside if (info.rewardRate > 0). A new incentive whose previous rate is 0 therefore starts while poolLast is still yesterday. The next _updatePool sets activeSeconds = endTs - poolLast and pays that whole window.
With one staker, a rate of 1 RWD per second, and a checkpoint 86,400 seconds old:
- Paid: 86,400 RWD, transferred in the same block the schedule is created.
- Fair: 0 RWD. Checkpointing before the rate is stored makes
poolLast == now, so the same claim streams nothing. - The gauge's remaining budget is short by 86,400 RWD.
The vulnerable code#
function _upsertIncentive(uint256 amount) internal returns (uint128 rate) {
uint256 total = amount;
if (info.rewardRate > 0) {
_updatePool(); // skipped when the stored rate is 0
}
rate = uint128(total / WEEK);
info.rewardRate = rate;
info.periodFinish = uint64(block.timestamp + WEEK);
info.lastUpdate = uint64(block.timestamp);
info.remaining = uint128(total);
}
function _updatePool() internal {
if (info.rewardRate > 0) {
uint256 endTs = nowTs < info.periodFinish ? nowTs : info.periodFinish;
if (endTs > poolLast) {
uint256 activeSeconds = endTs - poolLast; // @> VULN: stale poolLast
amt = uint256(info.rewardRate) * activeSeconds;
}
}
}
The fix calls _updatePool before the new rate is written, including when rewardRate is 0. That moves poolLast to now while the rate is still 0, so the new schedule does not pay for the gap.
Root cause#
The pool checkpoint and the incentive clock are updated in the wrong order. The incentive's lastUpdate becomes block.timestamp, but poolLast stays at the previous checkpoint whenever the stored rate is 0. The stream formula then treats every second since poolLast as time this incentive was active.
Preconditions#
poolLastis 86,400 seconds behindblock.timestamp(1,700,000,000 in this run). No timestamp change happens insiderun().- The incentive's
rewardRateis 0. - The gauge holds 604,800 RWD, which is one week at 1 RWD per second.
- One staker has the full share.
Attack walkthrough#
- Seed the staker and set
poolLastto yesterday.rewardRatestays 0. - Mint 604,800 RWD into the gauge and call
upsertIncentive. TherewardRate > 0branch does not call_updatePool. - The new rate,
periodFinish, andlastUpdateare stored from the current timestamp. claimruns in the same block.activeSecondsis 86,400. The staker is transferred 86,400 RWD.fairAmountis 0. The gauge balance is the week budget minus 86,400 RWD.
Control: the same seed, budget, and claim, but _updatePool runs before the rate is stored. paid is 0. The staker's balance stays 0. The gauge still holds the full 604,800 RWD.
Diagrams#
Impact#
The staker receives 86,400 RWD that the incentive had not yet streamed. The fair payment in this block is 0. Those tokens leave the gauge budget, so later claims against the same week are short by 86,400 RWD.
Remediation#
Call _updatePool before writing rewardRate, periodFinish, and remaining, whether or not a previous schedule is still active. The checkpoint then lands at the start of the new schedule and the following update cannot charge the stale gap.
Sources#
Taxonomy#
severity/highsector/dexplatform/sherlock
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 62814-h-7-in-the-incentivegauge-upsertincentive-function-updatepo_exp (evm-hack-registry mirror).
- AuditVault finding: 62814-h-7-in-the-incentivegauge-upsertincentive-function-updatepo.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.