Reproduced Exploit
BMX — finalize sends WETH before the reward rate is set
setTokensPerInterval calls updateRewards, which calls distribute. distribute pays tokensPerInterval * time since last distribution, capped by the distributor's balance.
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: 62815-h-8-voterfinalize-incorrect-rewards-distribution-due-to-tran. Standalone Foundry PoC and full write-up: 62815-h-8-voterfinalize-incorrect-rewards-distribution-due-to-tran_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/reward-calculation
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/62815-h-8-voterfinalize-incorrect-rewards-distribution-due-to-tran_exp.sol.
Key info#
| Impact | HIGH — the staker is paid 604,800 WETH immediately and the distributor is left with 0 |
| Protocol | BMX Deli Swap Voter.finalize |
| Vulnerable code | WETH.transfer to the distributor runs before setTokensPerInterval |
| Bug class | Old reward rate is applied to WETH that was just deposited |
| Finding | Sherlock (0x73696d616f) — BMX Deli Swap, September 2025 · id 62815, H-8 |
| Report | Judging issue 242 |
| Source | AuditVault finding |
| Status | Fixed by notifying the rate before the transfer. Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
setTokensPerInterval calls updateRewards, which calls distribute. distribute pays tokensPerInterval * time since last distribution, capped by the distributor's balance.
finalize transfers the new WETH before that call. The old rate is 1 ether per second and the last distribution is one week old, so pending is 604,800 ether. That equals the pot just deposited, so distribute sends all of it to the staker. The distributor's balance is 0. Nothing is left to stream over the week.
run() does not warp. The block timestamp is already 1_700_000_000, and the stored lastDistributionTime is one week earlier.
The vulnerable code#
function finalize() external {
uint256 toRewards = WETH.balanceOf(address(this));
WETH.transfer(address(distributor), toRewards); // @> VULN: balance is visible to distribute
distributor.setTokensPerInterval(toRewards / WEEK);
}
distribute then does:
uint256 amount = pendingRewards();
lastDistributionTime = block.timestamp;
uint256 balance = rewardToken.balanceOf(address(this));
if (amount > balance) amount = balance;
rewardToken.transfer(msg.sender, amount);
The fixed order calls setTokensPerInterval first. pending is still a week of the old rate, but the balance is 0, so the transfer amount is 0. The WETH is moved onto the distributor only after that.
Root cause#
distribute pays the previously configured rate out of whatever balance it finds. Depositing the new epoch's WETH before updating the rate makes that balance include the new pot, and a week of the old rate consumes it in one call.
Preconditions#
lastDistributionTimeis one week before the block timestamp. No timestamp change happens insiderun().tokensPerIntervalis 1 ether per second, so pending is 604,800 ether.- The distributor holds no WETH until
finalizetransfers the pot. - One staker receives whatever the tracker is paid.
Attack walkthrough#
- Mint 604,800 ether of WETH to the voter.
- Seed the distributor: rate 1 ether per second, last distribution one week ago.
finalizetransfers the pot, then callssetTokensPerInterval.distributecomputes pending of 604,800 ether, sees that balance, and transfers it to the tracker.- The tracker pays the staker. The staker holds 604,800 ether. The distributor holds 0.
Control: setTokensPerInterval runs while the distributor is empty, so the staker is paid 0. The transfer then leaves 604,800 ether on the distributor.
Diagrams#
Impact#
The staker receives 604,800 WETH in the finalize call, which is the entire weekly pot. The distributor is left with 0, so later blocks have nothing to stream at the new rate.
Remediation#
Call setTokensPerInterval before transferring the WETH. distribute then caps the payout at the empty balance, and the deposit remains on the distributor for the week.
Sources#
- AuditVault finding 62815
- Sherlock judging issue 242
- Fixed order: Voter.sol calls
setTokensPerIntervalbeforeWETH.safeTransfer
Taxonomy#
severity/highvuln/logic/reward-calculation
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 62815-h-8-voterfinalize-incorrect-rewards-distribution-due-to-tran_exp (evm-hack-registry mirror).
- AuditVault finding: 62815-h-8-voterfinalize-incorrect-rewards-distribution-due-to-tran.
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.