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.
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.
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 {
vm.deal(address(multiSigTimelock), 1 ether);
vm.prank(OWNER);
uint256 txId = multiSigTimelock.proposeTransaction(RECIPIENT, 100 ether, "");
MultiSigTimelock.Transaction memory txn = multiSigTimelock.getTransaction(txId);
assertEq(txn.value, 100 ether);
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId);
vm.warp(block.timestamp + 7 days + 1);
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);
vm.prank(OWNER);
uint256 txId1 = multiSigTimelock.proposeTransaction(RECIPIENT, 5 ether, "");
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId1);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId1);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId1);
vm.prank(OWNER);
uint256 txId2 = multiSigTimelock.proposeTransaction(RECIPIENT, 8 ether, "");
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId2);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId2);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId2);
vm.warp(block.timestamp + 1 days + 1);
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txId1);
vm.warp(block.timestamp + 1 days);
vm.prank(OWNER);
vm.expectRevert(
abi.encodeWithSelector(
MultiSigTimelock.MultiSigTimelock__InsufficientBalance.selector,
5 ether
)
);
multiSigTimelock.executeTransaction(txId2);
}
}
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;
}