Signers can delay confirmations indefinitely to bypass timelock then execute proposals immediately, leading to governance attack
Description
-
Signers can strategically withhold confirmations until AFTER the timelock period expires, then confirm and execute immediately. This completely defeats the purpose of the timelock as a cooling-off period.
-
The timelock clock should start after sufficient confirmations, not after proposal.
Currently: \
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
uint256 requiredDelay = _getTimelockDelay(txn.value);
@> uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
if (txn.value > address(this).balance) {
revert MultiSigTimelock__InsufficientBalance(address(this).balance);
}
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) {
revert MultiSigTimelock__ExecutionFailed();
}
emit TransactionExecuted(txnId, txn.to, txn.value);
}
Risk
Likelihood:
Impact:
Proof of Concept
-
Deploy the contract with multiple signers.
-
Propose a transaction and do not confirm it.
-
Advance time beyond the timelock period.
-
Have the required signers confirm the transaction.
-
Execute the transaction immediately after confirmations.
-
The transaction executes without any delay, demonstrating that the timelock was effectively bypassed.
Paste the following test case into test/MultiSigTimelock.t.sol to reproduce:
function testBypassTimelockByDelayingConfirmationsTillExpiry() external {
vm.startPrank(OWNER);
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
multiSigTimelock.grantSigningRole(SIGNER_FIVE);
vm.stopPrank();
uint256 amountToSend = 100 ether;
vm.deal(address(multiSigTimelock), amountToSend);
vm.deal(OWNER, amountToSend);
vm.prank(OWNER);
uint256 txnId = multiSigTimelock.proposeTransaction(SPENDER_ONE, amountToSend, hex"");
vm.expectRevert();
multiSigTimelock.executeTransaction(txnId);
uint256 timelockDelay = 7 days;
vm.warp(block.timestamp + timelockDelay);
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
multiSigTimelock.executeTransaction(txnId);
}
Recommended Mitigation
Track Confirmed At timestamp for each transaction by adding this field to the Transaction struct.
Start the timelock countdown only after the transaction reaches the required confirmations.
Modify executeTransaction to check if the current time is greater than Confirmed At + TIMELOCK_DURATION.
This ensures that even if signers delay their confirmations, the timelock period is always enforced after quorum is achieved.
struct Transaction {
address to;
uint256 value;
bytes data;
uint256 confirmations;
uint256 proposedAt;
+ uint256 confirmedAt; // NEW: When quorum was reached
bool executed;
}
function _confirmTransaction(uint256 txnId) internal {
if (s_signatures[txnId][msg.sender]) {
revert MultiSigTimeLock__UserAlreadySigned();
}
s_signatures[txnId][msg.sender] = true;
s_transactions[txnId].confirmations++;
+ if (s_transactions[txnId].confirmations == REQUIRED_CONFIRMATIONS) {
+ s_transactions[txnId].confirmedAt = block.timestamp;
}
emit TransactionConfirmed(txnId, msg.sender);
}
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
+ // NEW: Timelock starts from confirmation, not proposal
uint256 requiredDelay = _getTimelockDelay(txn.value);
- uint256 executionTime = txn.proposedAt + requiredDelay;
+ uint256 executionTime = txn.confirmedAt + requiredDelay; // NOT txn.proposedAt
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
+ // ... rest of execution logic
}