Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
Timelock's executeTransaction deletes transaction after executing it. However, if it fails it should keep it otherwise operations multisig will have to wait for a week to reexecute it.
Vulnerability Details
executeTransaction in Timelock.sol executes a transaction from the queue. However there's no revert on failure. Therefore if proposed transaction fails due to insufficient gas or PufferVault temporarily not having enough funds for a deposit community will have to wait for another delay_period to reexecute it.
Impact Details
Operations will have to wait for a week or ask community to execute a transaction if it's urgent.
Optionally add a reentrancy guard but I don't really think this is needed here.
Proof of Concept
As I said earlier a transaction should be kept when it fails. Otherwise operations will need to wait for another week to reexecute. PoC demonstrates a case when operations multisig runs out of gas when pausing targets (there could be other cases of when a transaction fails temporarily). Run with:
// Timelock.t.solcontractCustomDeployerisDeployPuffETH {// we need to extract a private key to populate accessManagerfunctiongetDeployerKey() publicviewreturns(uint256) {return _deployerPrivateKey; }}contractTimelockTestisTest {// ... CustomDeployer public deployPuff;functionsetUp() public { deployPuff =newCustomDeployer();// ... }functionsetPausers(uint gasLimit) publicreturns(bytes32 txHash,address[] memory targets) { vm.startBroadcast(deployPuff.getDeployerKey());bytes4[] memory selectors =newbytes4[](1); selectors[0] = UUPSUpgradeable.upgradeToAndCall.selector; targets =newaddress[](20);for (uint i =0; i < targets.length; i++) { targets[i] =address(uint160(i +1)); accessManager.setTargetFunctionRole(address(targets[i]), selectors,1);assertTrue(!accessManager.isTargetClosed(targets[i]),"target should be open"); } vm.stopBroadcast();bytesmemory callData = abi.encodeCall(Timelock.pause, (targets)); vm.startPrank(timelock.OPERATIONS_MULTISIG());uint256 operationId =1234; txHash = timelock.queueTransaction(address(timelock), callData, operationId);uint256 lockedUntil = block.timestamp + timelock.delay();assertTrue(timelock.queue(txHash) !=0,"should be queued"); vm.warp(lockedUntil +1); timelock.executeTransaction{gas: gasLimit}(address(timelock), callData, operationId); }functiontest_checkExecuteTransactionFail() public {// not enough gas for a multicall (bytes32 txHash,address[] memory targets) =setPausers(500_000);// not enough gas for transaction to go through therefore multicall failsfor (uint i =0; i < targets.length; i++) {assertTrue(!accessManager.isTargetClosed(targets[i]),"target should be open"); }// we shouldn't actually remove itassertTrue(timelock.queue(txHash) !=0,"should be queued"); // <---- fails }functiontest_checkExecuteTransactionOk() public { (bytes32 txHash,address[] memory targets) =setPausers(1_000_000);// transaction successfully went through and closed targetsfor (uint i =0; i < targets.length; i++) {assertTrue(accessManager.isTargetClosed(targets[i]),"target should be open"); }assertTrue(timelock.queue(txHash) ==0,"should be removed from queue"); }}