MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

Transaction Proposals Not Validated Against Contract Balance

Description:

The proposeTransaction() function allows signers to propose transactions with any value amount without checking if the contract has sufficient balance to fulfill the transaction. The balance check only occurs in _executeTransaction() when the transaction is being executed, which could be days or weeks after the proposal (especially for large transactions with 7-day timelocks).

This means users can waste gas confirming transactions that will ultimately fail due to insufficient funds, and the contract state becomes cluttered with unexecutable transactions.

Impact:

This creates poor user experience and wasted gas costs. Signers may spend time and gas confirming a transaction, wait through the timelock period, and only discover at execution time that the contract lacks sufficient balance.

For transactions with long timelocks (7 days for ≥100 ETH), this is particularly frustrating. Additionally, if the contract balance changes during the timelock period (through withdrawals of other transactions), a previously valid proposal might become invalid. While not a critical security vulnerability, it represents inefficient design and poor gas optimization.

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract MultiSigTimelockVulnerabilityTests is Test {
MultiSigTimelock multiSigTimelock;
address public OWNER = address(this);
address public SIGNER_TWO = makeAddr("signer_two");
address public SIGNER_THREE = makeAddr("signer_three");
address public SIGNER_FOUR = makeAddr("signer_four");
address public MALICIOUS_SIGNER = makeAddr("malicious_signer");
address public RECIPIENT = makeAddr("recipient");
function setUp() public {
multiSigTimelock = new MultiSigTimelock();
}
modifier grantSigningRoles() {
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
_;
}
function test_CanProposeTransactionExceedingBalance() public grantSigningRoles {
// Fund wallet with only 1 ETH
vm.deal(address(multiSigTimelock), 1 ether);
// Contract has 1 ETH but can propose 100 ETH transaction
vm.prank(OWNER);
uint256 txId = multiSigTimelock.proposeTransaction(RECIPIENT, 100 ether, "");
// Proposal succeeds despite insufficient balance
MultiSigTimelock.Transaction memory txn = multiSigTimelock.getTransaction(txId);
assertEq(txn.value, 100 ether);
// Signers waste gas confirming
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId);
// Wait for 7-day timelock
vm.warp(block.timestamp + 7 days + 1);
// Only now do we discover insufficient balance
vm.prank(OWNER);
vm.expectRevert(
abi.encodeWithSelector(
MultiSigTimelock.MultiSigTimelock__InsufficientBalance.selector,
1 ether
)
);
multiSigTimelock.executeTransaction(txId);
}
function test_BalanceChangesAfterProposal() public grantSigningRoles {
vm.deal(address(multiSigTimelock), 10 ether);
// Propose first transaction for 5 ETH - valid at proposal time
vm.prank(OWNER);
uint256 txId1 = multiSigTimelock.proposeTransaction(RECIPIENT, 5 ether, "");
// Confirm and queue first transaction
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId1);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId1);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId1);
// Propose second transaction for 8 ETH
vm.prank(OWNER);
uint256 txId2 = multiSigTimelock.proposeTransaction(RECIPIENT, 8 ether, "");
// Confirm second transaction
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId2);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId2);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId2);
// Execute first transaction
vm.warp(block.timestamp + 1 days + 1);
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txId1);
// Now balance is only 5 ETH, second transaction will fail
vm.warp(block.timestamp + 1 days);
vm.prank(OWNER);
vm.expectRevert(
abi.encodeWithSelector(
MultiSigTimelock.MultiSigTimelock__InsufficientBalance.selector,
5 ether
)
);
multiSigTimelock.executeTransaction(txId2);
}
}

Mitigation:

Add a balance check in the proposeTransaction() function to ensure the contract has sufficient funds:

function _proposeTransaction(address to, uint256 value, bytes memory data) internal returns (uint256) {
if (value > address(this).balance) {
revert MultiSigTimelock__InsufficientBalance(address(this).balance);
}
uint256 transactionId = s_transactionCount;
s_transactions[transactionId] = Transaction({
to: to,
value: value,
data: data,
confirmations: 0,
proposedAt: block.timestamp,
executed: false
});
s_transactionCount++;
emit TransactionProposed(transactionId, to, value);
return transactionId;
}
Updates

Lead Judging Commences

kelechikizito Lead Judge 4 days ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!