Summary
The governance system uses proposal.descriptionHash
as a salt when generating timelock operation IDs. Two different proposals with the same description and parameters will generate identical operation IDs, preventing the second proposal from being queued in the timelock.
Vulnerability Details
When a proposal is queued in the timelock, it generates an operation ID using:
[](https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/proposals/Governance.sol#L490C1-L497C11)
@> bytes32 salt = proposal.descriptionHash;
bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
salt
);
If two proposals share the same description and parameters, they will generate identical operation IDs. When the second proposal tries to queue, it fails with OperationAlreadyScheduled
because TimelockController::scheduleBatch
checks:
[](https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/proposals/TimelockController.sol#L140)
if (_operations[id].timestamp != 0) revert OperationAlreadyScheduled(id);
The issue is that using just the description hash as salt doesn't provide sufficient uniqueness for operation IDs.
Proof of Code:
Add this test to Governance.test.js
describe("Governance Hash Collision", () => {
it.only("should handle hash collisions correctly", async () => {
await veToken.mock_setInitialVotingPower(
await user1.getAddress(),
ethers.parseEther("100000")
);
const targets = [await testTarget.getAddress()];
const values = [0];
const calldatas = [testTarget.interface.encodeFunctionData("setValue", [0])];
const description = "Test Proposal";
await governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0
);
await time.increase(await governance.votingDelay() + 1n);
await governance.connect(user1).castVote(0, true);
await time.increase(await governance.votingPeriod() + 1n);
await governance.connect(owner).execute(0);
await time.increase(1);
await veToken.mock_setInitialVotingPower(
await user1.getAddress(),
ethers.parseEther("100000")
);
await governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0
);
await time.increase(await governance.votingDelay() + 1n);
await governance.connect(user1).castVote(1, true);
await time.increase(await governance.votingPeriod() + 1n);
await governance.connect(owner).execute(0);
await expect(
governance.connect(owner).execute(1)
).to.be.revertedWithCustomError(
timelock,
"OperationAlreadyScheduled"
);
});
Impact
Tools Used
Foundry
Recommendations
Use unique salts for each proposal by incorporating the proposal ID:
bytes32 salt = keccak256(abi.encode(proposalId, block.timestamp));
This ensures each proposal gets a unique operation ID in the timelock, even if other parameters match.