Summary
The execute
function in the governance contract is responsible for executing proposals by calling the _executeProposal
function, which in turn calls executeBatch
on the TimeLockController
contract. However, the execute
function is not marked as payable
, preventing it from sending Ether required by executeBatch
. Since both the governance and TimeLockController
contracts lack any alternative mechanism to receive Ether, execution will always fail when proposals require native token transfers.
Vulnerability Details
Affected Function:
function execute(uint256 proposalId) external override nonReentrant {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.executed) revert ProposalAlreadyExecuted(proposalId, block.timestamp);
ProposalState currentState = state(proposalId);
if (currentState == ProposalState.Succeeded) {
_queueProposal(proposalId);
} else if (currentState == ProposalState.Queued) {
_executeProposal(proposalId);
} else {
revert InvalidProposalState(
proposalId,
currentState,
currentState == ProposalState.Active ? ProposalState.Succeeded : ProposalState.Queued,
"Invalid state for execution"
);
}
}
Root Cause Analysis
The executeBatch
function in TimeLockController
is marked as payable
, meaning it expects Ether when executing transactions that require native token transfers.
The governance contract’s execute
function is not marked as payable
, preventing users from sending Ether along with the execution transaction.
Neither the Governance
nor TimeLockController
contracts have a receive
or fallback
function to accept Ether, making it impossible to fund executeBatch
indirectly.
As a result, proposals that involve transferring Ether will always revert due to insufficient balance in the TimeLockController
contract.
Affected executeBatch
Function:
function executeBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
bytes32 predecessor,
bytes32 salt
) external override payable nonReentrant onlyRole(EXECUTOR_ROLE) {
bytes32 id = hashOperationBatch(targets, values, calldatas, predecessor, salt);
Operation storage op = _operations[id];
if (op.timestamp == 0) revert OperationNotFound(id);
if (op.executed) revert OperationAlreadyExecuted(id);
if (block.timestamp < op.timestamp) revert OperationNotReady(id);
if (block.timestamp > op.timestamp + GRACE_PERIOD) revert OperationExpired(id);
if (predecessor != bytes32(0)) {
if (!isOperationDone(predecessor)) {
revert PredecessorNotExecuted(predecessor);
}
}
op.executed = true;
for (uint256 i = 0; i < targets.length; i++) {
(bool success, bytes memory returndata) = targets[i].call{value: values[i]}(calldatas[i]);
if (!success) {
revert CallReverted(id, i);
}
}
emit OperationExecuted(id, targets, values, calldatas, predecessor, salt);
}
Impact
Proposals requiring Ether transfers cannot be executed because the execute
function does not allow users to send Ether, and the TimeLockController
contract cannot receive Ether by other means. This will cause the execute function to always fail and proposal actions will not be completed.
Tools Used
Recommendations
To allow Ether transfers when executing proposals, mark the execute
function as payable
, enabling it to send Ether to executeBatch
as needed.
Recommended Fix:
function execute(uint256 proposalId) external payable override nonReentrant {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.executed) revert ProposalAlreadyExecuted(proposalId, block.timestamp);
ProposalState currentState = state(proposalId);
if (currentState == ProposalState.Succeeded) {
_queueProposal(proposalId);
} else if (currentState == ProposalState.Queued) {
_executeProposal(proposalId);
} else {
revert InvalidProposalState(
proposalId,
currentState,
currentState == ProposalState.Active ? ProposalState.Succeeded : ProposalState.Queued,
"Invalid state for execution"
);
}
}
Adding payable
allows the function to send Ether along with execution, resolving the issue.