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.

Sep 2025•Other•logic•4 min read

Chain

Other

Category

logic

Date

Sep 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

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.

Loading fork state…

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-registry mirror.


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#

ImpactHIGH — the staker is paid 604,800 WETH immediately and the distributor is left with 0
ProtocolBMX Deli Swap Voter.finalize
Vulnerable codeWETH.transfer to the distributor runs before setTokensPerInterval
Bug classOld reward rate is applied to WETH that was just deposited
FindingSherlock (0x73696d616f) — BMX Deli Swap, September 2025 · id 62815, H-8
ReportJudging issue 242
SourceAuditVault finding
StatusFixed by notifying the rate before the transfer. Reproduced here as a local synthetic.
Compilersolc 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#

SOLIDITY
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:

SOLIDITY
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#

  • lastDistributionTime is one week before the block timestamp. No timestamp change happens inside run().
  • tokensPerInterval is 1 ether per second, so pending is 604,800 ether.
  • The distributor holds no WETH until finalize transfers the pot.
  • One staker receives whatever the tracker is paid.

Attack walkthrough#

  1. Mint 604,800 ether of WETH to the voter.
  2. Seed the distributor: rate 1 ether per second, last distribution one week ago.
  3. finalize transfers the pot, then calls setTokensPerInterval.
  4. distribute computes pending of 604,800 ether, sees that balance, and transfers it to the tracker.
  5. 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#

flowchart TD A["Old rate is 1 ether per second and a week has elapsed"] --> B["finalize transfers 604800 ether to the distributor"] B --> C["setTokensPerInterval calls distribute"] C --> D["Pending equals the new balance"] D --> E["Distributor sends 604800 ether to the tracker"] E --> F["Staker is paid the whole pot and the distributor holds 0"]

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#

Taxonomy#

  • severity/high
  • vuln/logic/reward-calculation

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

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.