diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ccc913c2b..6c47305e5 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,7 @@ jobs: run: npm install - name: Check contract sizes - run: forge build --sizes --skip MultiCall.sol --skip CrossChainReceiverFactory.sol --skip 'test/*' + run: forge build --sizes --skip UniswapV4UnitTest.t.sol --skip MultiCall.sol --skip CrossChainReceiverFactory --skip SafeGuard.sol --skip 'test/*' - name: Run tests run: forge test --skip 'src/*' --skip 'test/0.8.28/*' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b3deecbc..2677c8e71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,21 +22,22 @@ jobs: - name: Install dependencies run: git submodule update --recursive --init - - name: Run EulerSwap math tests - run: forge test --skip 'src/*' --skip 'script/*' --skip 'test/unit/*' --skip 'test/integration/*' --skip 'test/0.8.25/*' --mp test/0.8.28/EulerSwapBUSL.t.sol + - name: Build Safe Guard + run: forge build src/deployer/SafeGuard.sol env: - FOUNDRY_SOLC_VERSION: 0.8.28 - - - name: Build contracts - run: forge build --skip MultiCall.sol --skip CrossChainReceiverFactory.sol --skip 'test/*' + FOUNDRY_EVM_VERSION: london + FOUNDRY_OPTIMIZER_RUNS: 200 - - name: Build UniswapV4 tests - run: forge build -- test/unit/core/UniswapV4UnitTest.t.sol + - name: Run SafeGuard tests + run: forge test --skip 'src/*' --skip 'test/unit/*' --skip 'test/integration/*' --skip 'test/0.8.28/*' --mp test/0.8.25/SafeGuard.t.sol + env: + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + BNB_MAINNET_RPC_URL: ${{ secrets.BNB_MAINNET_RPC_URL }} - - name: Build UniswapV4 - run: forge build -- lib/v4-core/src/PoolManager.sol + - name: Run EulerSwap math tests + run: forge test --skip 'src/*' --skip 'script/*' --skip 'test/unit/*' --skip 'test/integration/*' --skip 'test/0.8.25/*' --mp test/0.8.28/EulerSwapBUSL.t.sol env: - FOUNDRY_SOLC_VERSION: 0.8.26 + FOUNDRY_SOLC_VERSION: 0.8.28 - name: Build MultiCall run: forge build -- src/multicall/MultiCall.sol @@ -61,6 +62,17 @@ jobs: - name: Run CrossChainReceiverFactory tests run: forge test --skip 'src/*' --skip 'test/integration/*' --skip 'test/0.8.28/*' --skip 'test/unit/deployer/*' --mp test/unit/CrossChainReceiverFactory.t.sol + - name: Build contracts + run: forge build --skip UniswapV4UnitTest.t.sol --skip MultiCall.sol --skip CrossChainReceiverFactory.sol --skip SafeGuard.sol --skip 'test/*' + + - name: Build UniswapV4 tests + run: forge build --skip MultiCall.sol --skip SafeGuard.sol --skip 'test/*' -- test/unit/core/UniswapV4UnitTest.t.sol + + - name: Build UniswapV4 + run: forge build -- lib/v4-core/src/PoolManager.sol + env: + FOUNDRY_SOLC_VERSION: 0.8.26 + - name: Run all the other tests run: FOUNDRY_FUZZ_SEED="0x$(python3 -c 'import secrets, binascii; print(binascii.hexlify((secrets.randbits(256)).to_bytes(32, byteorder="big")).decode("ascii"))')" forge test --skip 'src/*' --skip 'test/0.8.28/*' --skip CrossChainReceiverFactory.t.sol --skip MultiCall.t.sol env: diff --git a/audits/Bailsec - 0x - SafeGuard - 2nd Report.pdf b/audits/Bailsec - 0x - SafeGuard - 2nd Report.pdf new file mode 100644 index 000000000..b5a795286 Binary files /dev/null and b/audits/Bailsec - 0x - SafeGuard - 2nd Report.pdf differ diff --git a/audits/Ox Timelock Gnosis Guard - March 13, 2025 - Dedaub.pdf b/audits/Ox Timelock Gnosis Guard - March 13, 2025 - Dedaub.pdf new file mode 100644 index 000000000..0cfce8980 Binary files /dev/null and b/audits/Ox Timelock Gnosis Guard - March 13, 2025 - Dedaub.pdf differ diff --git a/src/deployer/SafeGuard.sol b/src/deployer/SafeGuard.sol new file mode 100644 index 000000000..eb51b27cc --- /dev/null +++ b/src/deployer/SafeGuard.sol @@ -0,0 +1,648 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.25; + +// This enum is derived from the code deployed to 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA +enum Operation { + Call, + DelegateCall +} + +// This interface is excerpted from the contract at 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA +interface ISafeMinimal { + function checkNSignatures(bytes32 dataHash, bytes memory data, bytes memory signatures, uint256 requiredSignatures) + external + view; + + function checkSignatures(bytes32 dataHash, bytes memory data, bytes memory signatures) external view; + + function nonce() external view returns (uint256); + + function removeOwner(address prevOwner, address oldOwner, uint256 threshold) external; + + function isOwner(address) external view returns (bool); + + function getThreshold() external view returns (uint256); + + function getStorageAt(uint256 offset, uint256 length) external view returns (bytes memory); + + function approvedHashes(address owner, bytes32 txHash) external view returns (bool); + + function getModulesPaginated(address start, uint256 pageSize) + external + view + returns (address[] memory array, address next); + + // This function is not part of the interface at 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA + // . It's part of the implicit interface on the proxy contract(s) created by the factory at + // 0xc22834581ebc8527d974f8a1c97e1bea4ef910bc . + function masterCopy() external view returns (address); +} + +// This library is a reimplementation of the functionality of the functions by the same name in +// 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA +library SafeLib { + function encodeTransactionData( + ISafeMinimal safe, + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce + ) internal view returns (bytes memory) { + bytes32 safeTxHash = keccak256( + abi.encode( + SAFE_TX_TYPEHASH(safe), + to, + value, + keccak256(data), + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce + ) + ); + return abi.encodePacked(bytes2(0x1901), domainSeparator(safe), safeTxHash); + } + + function getTransactionHash( + ISafeMinimal safe, + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce + ) internal view returns (bytes32) { + return getTransactionHash( + safe, + encodeTransactionData( + safe, to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce + ) + ); + } + + function getTransactionHash(ISafeMinimal, bytes memory txHashData) internal pure returns (bytes32) { + return keccak256(txHashData); + } + + function OWNERS_SLOT(ISafeMinimal) internal pure returns (uint256) { + return 2; + } + + function getPrevOwner(ISafeMinimal safe, address owner) internal view returns (address) { + address cursor = address(1); + while (true) { + address nextOwner = + abi.decode(safe.getStorageAt(uint256(keccak256(abi.encode(cursor, OWNERS_SLOT(safe)))), 1), (address)); + if (nextOwner == owner) { + return cursor; + } + cursor = nextOwner; + } + revert(); // unreachable + } + + function OWNER_COUNT_SLOT(ISafeMinimal) internal pure returns (uint256) { + return 3; + } + + function ownerCount(ISafeMinimal safe) internal view returns (uint256) { + return abi.decode(safe.getStorageAt(OWNER_COUNT_SLOT(safe), 1), (uint256)); + } + + function DOMAIN_SEPARATOR_TYPEHASH(ISafeMinimal) internal pure returns (bytes32) { + // keccak256( + // "EIP712Domain(uint256 chainId,address verifyingContract)" + // ); + return 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218; + } + + function domainSeparator(ISafeMinimal safe) internal view returns (bytes32) { + return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH(safe), block.chainid, safe)); + } + + function SAFE_TX_TYPEHASH(ISafeMinimal) internal pure returns (bytes32) { + // keccak256( + // "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + // ); + return 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8; + } + + function GUARD_SLOT(ISafeMinimal) internal pure returns (uint256) { + // keccak256("guard_manager.guard.address") + return 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + } + + function getGuard(ISafeMinimal safe) internal view returns (address) { + return abi.decode(safe.getStorageAt(GUARD_SLOT(safe), 1), (address)); + } +} + +// This interface is excerpted from `GuardManager.sol` in 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA +interface IGuard { + function checkTransaction( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures, + address msgSender + ) external; + + function checkAfterExecution(bytes32 txHash, bool success) external; +} + +contract EvmVersionDummy { + fallback() external { + assembly ("memory-safe") { + mstore(0x00, 0x01) + return(0x00, 0x20) + } + } +} + +contract ZeroExSettlerDeployerSafeGuard is IGuard { + using SafeLib for ISafeMinimal; + + event TimelockUpdated(uint256 oldDelay, uint256 newDelay); + event SafeTransactionEnqueued( + bytes32 indexed txHash, + uint256 timelockEnd, + address indexed to, + uint256 value, + bytes data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 indexed nonce, + bytes signatures + ); + event SafeTransactionCanceled(bytes32 indexed txHash, address indexed canceledBy); + event ResignTxHash(bytes32 indexed txHash); + event LockDown(address indexed lockedDownBy, bytes32 indexed unlockTxHash); + event Unlocked(); + + error PermissionDenied(); + error NoDelegateCall(); + error GuardNotInstalled(); + error GuardIsOwner(); + error TimelockNotElapsed(bytes32 txHash, uint256 timelockEnd); + error TimelockElapsed(bytes32 txHash, uint256 timelockEnd); + error AlreadyQueued(bytes32 txHash); + error NotQueued(bytes32 txHash); + error LockedDown(address lockedDownBy); + error NotLockedDown(); + error UnexpectedUpgrade(address newSingleton); + error Reentrancy(); + error ModuleInstalled(address module); + error NotEnoughOwners(uint256 ownerCount); + error ThresholdTooLow(uint256 threshold); + error NotUnanimous(bytes32 txHash); + error TxHashNotApproved(bytes32 txHash); + + mapping(bytes32 => uint256) public timelockEnd; + address public lockedDownBy; + uint24 public delay; + bool private _reentrancyGuard; + bool private _guardRemoved; + + ISafeMinimal public immutable safe; + uint256 internal constant _MINIMUM_OWNERS = 3; + uint256 internal constant _MINIMUM_THRESHOLD = 2; + + bytes32 private constant _SINGLETON_INITHASH = 0x49f30800a6ac5996a48b80c47ff20f19f8728812498a2a7fe75a14864fab6438; + address private immutable _SINGLETON = address( + uint160( + uint256( + keccak256(bytes.concat(bytes1(0xff), bytes20(uint160(msg.sender)), bytes32(0), _SINGLETON_INITHASH)) + ) + ) + ); + address private constant _CREATE2_FACTORY = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + address private constant _SAFE_SINGLETON_FACTORY = 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7; + + // This is the correct hash only if this contract has been compiled for the London hardfork + bytes32 private constant _EVM_VERSION_DUMMY_INITHASH = + 0xe7bcbbfee5c3a9a42621a8cbb24d1eade8e9469bc40e23d16b5d0607ba27027a; + + constructor(ISafeMinimal safe_) { + safe = safe_; + assert(keccak256(type(EvmVersionDummy).creationCode) == _EVM_VERSION_DUMMY_INITHASH || block.chainid == 31337); + assert(msg.sender == _CREATE2_FACTORY || msg.sender == _SAFE_SINGLETON_FACTORY); + + // These checks ensure that the Guard is safely installed in the Safe at the time it is + // deployed, with the exception of the installation and subsequent concealment of a + // malicious Safe module. The author knows of no way to enforce that the Guard is installed + // atomic with its deployment. This introduces a TOCTOU vulnerability. Therefore, extensive + // simulation and excessive caution are imperative in this process. If the Guard is + // installed in a Safe where these checks fail, the Safe is bricked. Once the Guard is + // successfully deployed, the behavior ought to be sane, even in bizarre and outrageous + // circumstances. + _checkAfterExecution(safe); + assert(!_guardRemoved); + } + + function setDelay(uint24 newDelay) external onlySafe { + emit TimelockUpdated(delay, newDelay); + delay = newDelay; + } + + function _requireSafe() private view { + if (msg.sender != address(safe)) { + revert PermissionDenied(); + } + } + + modifier onlySafe() { + _requireSafe(); + _; + } + + function _requireOwner() private view { + if (!safe.isOwner(msg.sender)) { + revert PermissionDenied(); + } + } + + modifier onlyOwner() { + _requireOwner(); + _; + } + + function _requireNotLockedDown() private view { + address locker = lockedDownBy; + if (locker != address(0)) { + revert LockedDown(locker); + } + } + + function _requireLockedDown() private view { + if (lockedDownBy == address(0)) { + revert NotLockedDown(); + } + } + + function _requireNotRemoved() private view { + // If the guard has been removed, it's possible that the Safe may have been subsequently + // `SELFDESTRUCT`'d through a `DELEGATECALL` or any number of other, unsafe state + // modifications (including installation of Module). Consequently, we can perform no other + // checks or make other assumptions about the state of the Safe. + if (_guardRemoved) { + revert GuardNotInstalled(); + } + } + + modifier normalOperation() { + _requireNotRemoved(); + _requireNotLockedDown(); + _; + } + + function _requirePreApproved(bytes32 txHash) private view { + // By requiring that the Safe owner has preapproved the `txHash`, we prevent a single rogue + // signer from bricking the Safe. + if (!safe.approvedHashes(msg.sender, txHash)) { + revert TxHashNotApproved(txHash); + } + } + + function checkTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes calldata signatures, + address // msgSender + ) external override onlySafe { + if (_guardRemoved) { + // There are two ways for this branch to be reached. The first way is if the Guard is + // uninstalled and then reinstalled. Unfortunately, we can't distinguish this case from + // the second way. To avoid applying restrictions in circumstances we can't be + // completely certain about, we prefer to fail open rather than accidentally brick + // something. The second way is that the Guard has been uninstalled and now the Safe is + // calling `checkTransaction` through `execute`. Because `execute` provides complete + // freedom in the calls that may be performed both before and after this call, we cannot + // safely clear `_guardRemoved` because we don't know that the post-conditions in + // `checkAfterExecution` will be enforced. + return; + } + + if (_reentrancyGuard) { + revert Reentrancy(); + } + _reentrancyGuard = true; + + // At this point, we can be confident that we are executing inside of a call to + // `execTransaction`, but not inside `execute`. We can rely on `checkAfterExecution` to + // enforce its postconditions. + + // After extensive consideration, the ability to do `DELEGATECALL`'s to other contracts, + // including narrowly limiting that to the use of the Safe-approved `MultiCallSendOnly` + // contract (0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B), creates ways to brick/compromise + // the Safe in ways that cannot be detected by simple pre-/post-conditions applied in + // `checkTransaction` and `checkAfterExecution`. For example, allowing `MultiCallSendOnly` + // creates the possibility of the installation of a malicious Module through `addModule`, + // execution of a `DELEGATECALL` to an attacker-controlled contract through + // `execTransactionFromModule`, and then removing that malicious module from the ability of + // `getModulesPaginated` (or any other mechanism) to enumerate (i.e. setting slot + // 0xcc69885fda6bcc1a4ace058b4a62bf5e179ea78fd58a1ccd71c22cc9b688792f to 1) all in a single + // atomic transaction that will pass the postconditions. + // + // Therefore, due to a complete inability to secure the Safe against malicious/incompetent + // owners, `Operation.DelegateCall` is prohibited. + if (operation != Operation.Call) { + revert NoDelegateCall(); + } + + ISafeMinimal _safe = ISafeMinimal(msg.sender); + + // The nonce has already been incremented past the value used in the + // currently-executing transaction. We decrement it to get the value that was hashed + // to get the `txHash`. + uint256 nonce = _safe.nonce() - 1; + + // `txHashData` is used here for an outdated, nonstandard variant of nested ERC1271 + // signatures that passes the signing hash as `bytes` instead of as `bytes32`. This only + // matters for the `checkNSignatures` call when validating before `unlock()`. + bytes memory txHashData = _safe.encodeTransactionData( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce + ); + bytes32 txHash = _safe.getTransactionHash(txHashData); + + // Any transaction with unanimous signatures can bypass the timelock. This mechanism is also + // critical to the anti-griefing provisions. The pre-signed transaction(s) required when + // calling `lockDown()` or `cancel(...)` can be combined with signatures from well-behaved + // keyholders to un-brick the safe and remove the misbehaving actors. Unanimous transactions + // also cannot be `cancel(...)`'d. + try _safe.checkNSignatures(txHash, txHashData, signatures, _safe.ownerCount()) { + return; + } catch { + // The signatures are not unanimous; proceed to the timelock. If the call is to + // `unlock()`, we bail out because it *MUST* be unanimous. + if (to == address(this) && uint256(uint32(bytes4(data))) == uint256(uint32(this.unlock.selector))) { + revert NotUnanimous(txHash); + } + } + + // Fall through to the "normal" case. The checks that need to be performed here are 1) that + // the Safe is not locked down (checked in `checkAfterExecution`), 2) that the transaction + // was previously queued through `enqueue` and 3) that `delay` has elapsed since `enqueue` + // was called. + uint256 _timelockEnd = timelockEnd[txHash]; + if (_timelockEnd == 0) { + revert NotQueued(txHash); + } + if (block.timestamp <= _timelockEnd) { + revert TimelockNotElapsed(txHash, _timelockEnd); + } + } + + function checkAfterExecution(bytes32, bool) external override onlySafe { + if (_guardRemoved) { + // See comment in the same branch of `checkTransaction`. + return; + } + + if (!_reentrancyGuard) { + revert Reentrancy(); + } + _reentrancyGuard = false; + + // We have to check that we're not locked down here (instead of in `checkTransaction`) to + // ensure that the call to `unlock()` can't revert and burn the stored signatures + // (`safe.approvedHashes(...)`; i.e. increase the nonce), resulting in a bricked Safe via a + // griefing attack by a malicious owner. + // + // This is here instead of using the `notLockedDown` modifier so that we avoid bricking if + // the Guard is uninstalled. + _requireNotLockedDown(); + + ISafeMinimal _safe = ISafeMinimal(msg.sender); + + _checkAfterExecution(_safe); + } + + function _checkAfterExecution(ISafeMinimal _safe) private { + // The knowledge that the immutable `safe` address is computed using the `CREATE2` pattern + // from trusted initcode (and a factory likewise deployed by trusted initcode) gives us a + // pretty strong toehold of trust. We do not need to recheck this, ever. Furthermore, + // introspecting the proxy's implementation contract via the trustworthy `masterCopy()` + // accessor (which bypasses the implementation and only executes proxy bytecode) and + // constraining it to be another address deployed from trusted initcode gives us a full + // complement of function selectors that can be used for postcondition checks. + { + address singleton = _safe.masterCopy(); + if (singleton != _SINGLETON) { + revert UnexpectedUpgrade(singleton); + } + } + + // The presence of a Module means that the state of the Safe may be modified in + // unpredictable ways in between the enforcement of the pre-/post-conditions applied by + // `checkTransaction` and `checkAfterExecution`. Namely, it could cause a `DELEGATECALL` and + // consequently arbitrary modification of the state of the proxy (including + // `SELFDESTRUCT`). Therefore, we prohibit the installation of modules. + { + (address[] memory modules,) = _safe.getModulesPaginated(address(1), 1); + if (modules.length != 0) { + revert ModuleInstalled(modules[0]); + } + } + + // Due to a quirk of how `checkNSignatures` works (called as a guarded precondition to + // `unlock`; sometimes it validates `msg.sender` instead of a signature, for gas + // optimization), we could end up in a bizarre situation if `address(this)` is an + // owner. This would make our introspection checks wrong. Let's just prohibit that entirely. + if (_safe.isOwner(address(this))) { + revert GuardIsOwner(); + } + + // Some basic safety checks. If violated, the game theory of the `lockDown`/`unlock` game + // becomes degenerate. + { + uint256 ownerCount = _safe.ownerCount(); + if (ownerCount < _MINIMUM_OWNERS) { + revert NotEnoughOwners(ownerCount); + } + } + { + uint256 threshold = _safe.getThreshold(); + if (threshold < _MINIMUM_THRESHOLD) { + revert ThresholdTooLow(threshold); + } + } + + // We do not revert if `_safe.getGuard()` returns a value other than `address(this)`. This + // allows uninstallation of the guard (through the timelock, obviously) to later permit + // upgrades to other singleton implementation contracts. However, we do set the + // `_guardRemoved` flag, which disables all Guard functionality (failing open). It is not + // possible to un-set `_guardRemoved` once set. + if (_safe.getGuard() != address(this)) { + _guardRemoved = true; + } + } + + function enqueue( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce, + bytes calldata signatures + ) external normalOperation { + // See comment in `checkTransaction` + if (operation != Operation.Call) { + revert NoDelegateCall(); + } + + bytes memory txHashData = safe.encodeTransactionData( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce + ); + bytes32 txHash = safe.getTransactionHash(txHashData); + safe.checkSignatures(txHash, txHashData, signatures); + + uint256 _timelockEnd = block.timestamp + delay; + if (timelockEnd[txHash] != 0) { + revert AlreadyQueued(txHash); + } + timelockEnd[txHash] = _timelockEnd; + + emit SafeTransactionEnqueued( + txHash, + _timelockEnd, + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce, + signatures + ); + } + + function unlockTxHash() public view returns (bytes32) { + uint256 nonce = safe.nonce(); + return safe.getTransactionHash( + address(this), + 0 ether, + abi.encodeCall(this.unlock, ()), + Operation.Call, + 0, + 0, + 0, + address(0), + payable(address(0)), + nonce + ); + } + + function _removeOwnerTxHash(address prevOwner, address oldOwner, uint256 threshold, uint256 nonce) + private + view + returns (bytes32) + { + return safe.getTransactionHash( + address(safe), + 0 ether, + abi.encodeCall(safe.removeOwner, (prevOwner, oldOwner, threshold)), + Operation.Call, + 0, + 0, + 0, + address(0), + payable(address(0)), + nonce + ); + } + + function resignTxHash(address owner) external view returns (bytes32 txHash) { + address prevOwner = safe.getPrevOwner(owner); + uint256 threshold = safe.getThreshold(); + uint256 nonce = safe.nonce(); + if ( + lockedDownBy != address(0) + || safe.approvedHashes(owner, txHash = _removeOwnerTxHash(prevOwner, owner, threshold, nonce)) + ) { + nonce++; + txHash = _removeOwnerTxHash(prevOwner, owner, threshold, nonce); + } + } + + function cancel(bytes32 txHash) external onlyOwner { + uint256 nonce = safe.nonce(); + if (lockedDownBy != address(0)) { + nonce++; + } + bytes32 resignHash = _removeOwnerTxHash(safe.getPrevOwner(msg.sender), msg.sender, safe.getThreshold(), nonce); + _requirePreApproved(resignHash); + + uint256 _timelockEnd = timelockEnd[txHash]; + if (_timelockEnd == 0) { + revert NotQueued(txHash); + } + if (block.timestamp > _timelockEnd) { + revert TimelockElapsed(txHash, _timelockEnd); + } + timelockEnd[txHash] = type(uint256).max; + emit ResignTxHash(resignHash); + emit SafeTransactionCanceled(txHash, msg.sender); + } + + function lockDown() external normalOperation onlyOwner { + bytes32 txHash = unlockTxHash(); + _requirePreApproved(txHash); + + address prevOwner = safe.getPrevOwner(msg.sender); + uint256 threshold = safe.getThreshold(); + uint256 nonce = safe.nonce(); + if (safe.approvedHashes(msg.sender, _removeOwnerTxHash(prevOwner, msg.sender, threshold, nonce))) { + nonce++; + bytes32 resignHash = _removeOwnerTxHash(prevOwner, msg.sender, threshold, nonce); + _requirePreApproved(resignHash); + emit ResignTxHash(resignHash); + } + + lockedDownBy = msg.sender; + emit LockDown(msg.sender, txHash); + } + + function unlock() external onlySafe { + _requireLockedDown(); + delete lockedDownBy; + emit Unlocked(); + } +} diff --git a/test/0.8.25/SafeGuard.t.sol b/test/0.8.25/SafeGuard.t.sol new file mode 100644 index 000000000..86d45dcc1 --- /dev/null +++ b/test/0.8.25/SafeGuard.t.sol @@ -0,0 +1,918 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {Test} from "@forge-std/Test.sol"; +import {Vm} from "@forge-std/Vm.sol"; + +import {ItoA} from "src/utils/ItoA.sol"; +import {AddressDerivation} from "src/utils/AddressDerivation.sol"; + +interface ISafeSetup { + function addOwnerWithThreshold(address owner, uint256 _threshold) external; + + function removeOwner(address prevOwner, address owner, uint256 _threshold) external; + + function getOwners() external view returns (address[] memory); + + function setGuard(address guard) external; +} + +enum Operation { + Call, + DelegateCall +} + +interface ISafe { + function execTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures + ) external payable returns (bool); + + event ExecutionFailure(bytes32 txHash, uint256 payment); + + event ExecutionSuccess(bytes32 txHash, uint256 payment); + + function nonce() external view returns (uint256); + + function approveHash(bytes32 hashToApprove) external; + + event ApproveHash(bytes32 indexed approvedHash, address indexed owner); + + function isOwner(address) external view returns (bool); + + function enableModule(address) external; +} + +interface IGuard { + function checkTransaction( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures, + address msgSender + ) external; + + function checkAfterExecution(bytes32 txHash, bool success) external; +} + +interface IZeroExSettlerDeployerSafeGuard is IGuard { + event TimelockUpdated(uint256 oldDelay, uint256 newDelay); + event SafeTransactionEnqueued( + bytes32 indexed txHash, + uint256 timelockEnd, + address indexed to, + uint256 value, + bytes data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 indexed nonce, + bytes signatures + ); + event SafeTransactionCanceled(bytes32 indexed txHash, address indexed canceledBy); + event ResignTxHash(bytes32 indexed txHash); + event LockDown(address indexed lockedDownBy, bytes32 indexed unlockTxHash); + event Unlocked(); + + error PermissionDenied(); + error NoDelegateCall(); + error GuardNotInstalled(); + error GuardIsOwner(); + error TimelockNotElapsed(bytes32 txHash, uint256 timelockEnd); + error TimelockElapsed(bytes32 txHash, uint256 timelockEnd); + error AlreadyQueued(bytes32 txHash); + error NotQueued(bytes32 txHash); + error LockedDown(address lockedDownBy); + error NotLockedDown(); + error UnexpectedUpgrade(address newSingleton); + error Reentrancy(); + error ModuleInstalled(address module); + error NotEnoughOwners(uint256 ownerCount); + error ThresholdTooLow(uint256 threshold); + error NotUnanimous(bytes32 txHash); + error TxHashNotApproved(bytes32 txHash); + + function timelockEnd(bytes32) external view returns (uint256); + function lockedDownBy() external view returns (address); + function delay() external view returns (uint24); + function safe() external view returns (address); + + function enqueue( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce, + bytes calldata signatures + ) external; + + function setDelay(uint24) external; + + function resignTxHash(address owner) external view returns (bytes32); + + function cancel(bytes32 txHash) external; + + function unlockTxHash() external view returns (bytes32); + + function lockDown() external; + + function unlock() external; +} + +interface IMulticall { + function multiSend(bytes memory transactions) external payable; +} + +contract TestSafeGuard is Test { + using ItoA for uint256; + + address internal constant factory = 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7; + ISafe internal constant safe = ISafe(0xf36b9f50E59870A24F42F9Ba43b2aD0A4b8f2F51); + IZeroExSettlerDeployerSafeGuard internal guard; + uint256 internal pokeCounter; + + Vm.Wallet[] internal owners; + + function setUp() public { + ISafeSetup _safe = ISafeSetup(address(safe)); + + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 21015655); + vm.label(address(this), "FoundryTest"); + + string memory mnemonic = "test test test test test test test test test test test junk"; + address[] memory oldOwners = _safe.getOwners(); + + for (uint256 i; i < oldOwners.length; i++) { + owners.push(vm.createWallet(vm.deriveKey(mnemonic, uint32(i)), string.concat("Owner #", i.itoa()))); + } + + vm.startPrank(address(_safe)); + for (uint256 i; i < oldOwners.length; i++) { + _safe.addOwnerWithThreshold(owners[i].addr, 2); + } + for (uint256 i = 0; i < oldOwners.length; i++) { + _safe.removeOwner(owners[0].addr, oldOwners[i], 2); + } + vm.stopPrank(); + + bytes memory creationCode = bytes.concat( + vm.getCode("SafeGuard.sol:ZeroExSettlerDeployerSafeGuard"), + abi.encode(0xf36b9f50E59870A24F42F9Ba43b2aD0A4b8f2F51) + ); + guard = IZeroExSettlerDeployerSafeGuard( + AddressDerivation.deriveDeterministicContract(factory, bytes32(0), keccak256(creationCode)) + ); + + vm.prank(address(_safe)); + _safe.setGuard(address(guard)); + + (bool success, bytes memory returndata) = factory.call(bytes.concat(bytes32(0), creationCode)); + assertTrue(success); + assertEq(address(uint160(bytes20(returndata))), address(guard)); + + vm.prank(address(_safe)); + guard.setDelay(uint24(1 weeks)); + + // Heck yeah, bubble sort + { + Vm.Wallet memory tmp; + for (uint256 i = 1; i < owners.length; i++) { + for (uint256 j = i; j > 0; j--) { + if (owners[j - 1].addr > owners[j].addr) { + tmp = owners[j - 1]; + owners[j - 1] = owners[j]; + owners[j] = tmp; + } + } + } + for (uint256 i; i < owners.length - 1; i++) { + assertLt(uint160(owners[i].addr), uint160(owners[i + 1].addr)); + } + } + } + + function poke() external returns (uint256) { + require(msg.sender == address(safe)); + return ++pokeCounter; + } + + function _signSafeEncoded(Vm.Wallet storage signer, bytes32 hash) internal returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signer, hash); + return abi.encodePacked(r, s, v); + } + + function _enqueuePoke() + internal + returns ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce, + bytes32 txHash, + bytes memory signatures + ) + { + to = address(this); + value = 0 ether; + data = abi.encodeCall(this.poke, ()); + operation = Operation.Call; + safeTxGas = 0; + baseGas = 0; + gasPrice = 0; + gasToken = address(0); + refundReceiver = payable(address(0)); + nonce = safe.nonce(); + + txHash = keccak256( + bytes.concat( + hex"1901", + keccak256( + abi.encode( + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, safe + ) + ), + keccak256( + abi.encode( + keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ), + to, + value, + keccak256(data), + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce + ) + ) + ) + ); + + signatures = abi.encodePacked(_signSafeEncoded(owners[0], txHash), _signSafeEncoded(owners[1], txHash)); + + vm.expectEmit(true, true, true, true, address(guard)); + emit IZeroExSettlerDeployerSafeGuard.SafeTransactionEnqueued( + txHash, + guard.delay() + vm.getBlockTimestamp(), + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce, + signatures + ); + + guard.enqueue( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce, signatures + ); + } + + function testHappyPath() public { + ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + , + bytes32 txHash, + bytes memory signatures + ) = _enqueuePoke(); + + vm.warp(vm.getBlockTimestamp() + guard.delay() + 1 seconds); + + vm.expectEmit(true, true, true, true, address(safe)); + emit ISafe.ExecutionSuccess(txHash, 0); + safe.execTransaction( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures + ); + + assertEq(pokeCounter, 1); + } + + function testTimelockNonExpiry() external { + ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + , + bytes32 txHash, + bytes memory signatures + ) = _enqueuePoke(); + + vm.warp(vm.getBlockTimestamp() + guard.delay()); + + vm.expectRevert( + abi.encodeWithSelector( + IZeroExSettlerDeployerSafeGuard.TimelockNotElapsed.selector, txHash, vm.getBlockTimestamp() + ) + ); + safe.execTransaction( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures + ); + } + + function testCancelHappyPath() external { + ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + , + bytes32 txHash, + bytes memory signatures + ) = _enqueuePoke(); + + address owner = owners[owners.length - 1].addr; + + bytes32 resignTxHash = guard.resignTxHash(owner); + + vm.startPrank(owner); + + vm.expectEmit(true, true, true, true, address(safe)); + emit ISafe.ApproveHash(resignTxHash, owner); + safe.approveHash(resignTxHash); + + vm.expectEmit(true, true, true, true, address(guard)); + emit IZeroExSettlerDeployerSafeGuard.ResignTxHash(resignTxHash); + vm.expectEmit(true, true, true, true, address(guard)); + emit IZeroExSettlerDeployerSafeGuard.SafeTransactionCanceled(txHash, owner); + guard.cancel(txHash); + + vm.stopPrank(); + + vm.warp(vm.getBlockTimestamp() + guard.delay() + 1 seconds); + + vm.expectRevert( + abi.encodeWithSelector( + IZeroExSettlerDeployerSafeGuard.TimelockNotElapsed.selector, txHash, type(uint256).max + ) + ); + safe.execTransaction( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures + ); + } + + function testCancelNoApprove() external { + (,,,,,,,,,, bytes32 txHash,) = _enqueuePoke(); + + bytes32 resignTxHash = guard.resignTxHash(owners[4].addr); + + vm.prank(owners[4].addr); + vm.expectRevert( + abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.TxHashNotApproved.selector, resignTxHash) + ); + guard.cancel(txHash); + } + + function testCancelNotOwner() external { + (,,,,,,,,,, bytes32 txHash,) = _enqueuePoke(); + + vm.expectRevert(abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.PermissionDenied.selector)); + guard.cancel(txHash); + } + + function testLockDownHappyPath() + public + returns ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes32 txHash, + bytes memory signatures + ) + { + (to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver,, txHash, signatures) = + _enqueuePoke(); + + bytes32 unlockTxHash = guard.unlockTxHash(); + + vm.startPrank(owners[4].addr); + + vm.expectEmit(true, true, true, true, address(safe)); + emit ISafe.ApproveHash(unlockTxHash, owners[4].addr); + safe.approveHash(unlockTxHash); + + vm.expectEmit(true, true, true, true, address(guard)); + emit IZeroExSettlerDeployerSafeGuard.LockDown(owners[4].addr, unlockTxHash); + guard.lockDown(); + + vm.stopPrank(); + + vm.warp(vm.getBlockTimestamp() + guard.delay() + 1 seconds); + + vm.expectRevert(abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.LockedDown.selector, owners[4].addr)); + safe.execTransaction( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures + ); + } + + function testLockDownNoUnlock() external { + bytes32 unlockTxHash = guard.unlockTxHash(); + + vm.prank(owners[4].addr); + + vm.expectRevert( + abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.TxHashNotApproved.selector, unlockTxHash) + ); + guard.lockDown(); + } + + function testLockDownNoCancel() external { + (,,,,,,,,,, bytes32 txHash,) = _enqueuePoke(); + + address owner = owners[4].addr; + + bytes32 resignTxHash = guard.resignTxHash(owner); + bytes32 unlockTxHash = guard.unlockTxHash(); + + vm.startPrank(owner); + safe.approveHash(resignTxHash); + guard.cancel(txHash); + safe.approveHash(unlockTxHash); + vm.stopPrank(); + + bytes32 newResignTxHash = guard.resignTxHash(owner); + assertNotEq(resignTxHash, newResignTxHash); + + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.TxHashNotApproved.selector, newResignTxHash) + ); + guard.lockDown(); + } + + function testLockDownWithCancel() external { + (,,,,,,,,,, bytes32 txHash,) = _enqueuePoke(); + + address owner = owners[4].addr; + + bytes32 resignTxHash = guard.resignTxHash(owner); + bytes32 unlockTxHash = guard.unlockTxHash(); + + vm.startPrank(owner); + safe.approveHash(resignTxHash); + guard.cancel(txHash); + safe.approveHash(unlockTxHash); + vm.stopPrank(); + + bytes32 newResignTxHash = guard.resignTxHash(owner); + assertNotEq(resignTxHash, newResignTxHash); + + vm.startPrank(owner); + safe.approveHash(newResignTxHash); + guard.lockDown(); + vm.stopPrank(); + } + + function testResign() external { + (,,,,,,,,,, bytes32 txHash,) = _enqueuePoke(); + + address owner = owners[4].addr; + + bytes32 resignTxHash = guard.resignTxHash(owner); + + vm.startPrank(owner); + safe.approveHash(resignTxHash); + guard.cancel(txHash); + vm.stopPrank(); + + address prevOwner = owners[2].addr; + + bytes memory data = abi.encodeWithSignature("removeOwner(address,address,uint256)", prevOwner, owner, 2); + txHash = keccak256( + bytes.concat( + hex"1901", + keccak256( + abi.encode( + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, safe + ) + ), + keccak256( + abi.encode( + keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ), + safe, + 0, + keccak256(data), + Operation.Call, + 0, + 0, + 0, + address(0), + payable(address(0)), + safe.nonce() + ) + ) + ) + ); + assertEq(txHash, resignTxHash); + + bytes memory signatures = abi.encodePacked( + _signSafeEncoded(owners[0], txHash), bytes32(uint256(uint160(owner))), bytes32(0), uint8(1) + ); + guard.enqueue( + address(safe), 0, data, Operation.Call, 0, 0, 0, address(0), payable(address(0)), safe.nonce(), signatures + ); + + vm.warp(vm.getBlockTimestamp() + guard.delay() + 1 seconds); + + vm.expectEmit(true, true, true, true, address(safe)); + emit ISafe.ExecutionSuccess(txHash, 0); + safe.execTransaction( + address(safe), 0, data, Operation.Call, 0, 0, 0, address(0), payable(address(0)), signatures + ); + + assertFalse(safe.isOwner(owner)); + } + + function testInstallModule() external { + bytes memory data = abi.encodeCall(safe.enableModule, (address(this))); + bytes32 txHash = keccak256( + bytes.concat( + hex"1901", + keccak256( + abi.encode( + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, safe + ) + ), + keccak256( + abi.encode( + keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ), + safe, + 0 ether, + keccak256(data), + Operation.Call, + 0, + 0, + 0 gwei, + address(0), + payable(address(0)), + safe.nonce() + ) + ) + ) + ); + + bytes memory signatures = + abi.encodePacked(_signSafeEncoded(owners[0], txHash), _signSafeEncoded(owners[1], txHash)); + + guard.enqueue( + address(safe), + 0 ether, + data, + Operation.Call, + 0, + 0, + 0 gwei, + address(0), + payable(address(0)), + safe.nonce(), + signatures + ); + vm.warp(vm.getBlockTimestamp() + guard.delay() + 1 seconds); + + vm.expectRevert(abi.encodeWithSignature("ModuleInstalled(address)", address(this))); + safe.execTransaction( + address(safe), 0 ether, data, Operation.Call, 0, 0, 0 gwei, address(0), payable(address(0)), signatures + ); + } + + function testUnlockHappyPath() external { + ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + , + bytes memory signatures + ) = testLockDownHappyPath(); + + { + address unlockTo = address(guard); + uint256 unlockValue = 0 ether; + bytes memory unlockData = abi.encodeCall(guard.unlock, ()); + Operation unlockOperation = Operation.Call; + uint256 unlockSafeTxGas = 0; + uint256 unlockBaseGas = 0; + uint256 unlockGasPrice = 0; + address unlockGasToken = address(0); + address payable unlockRefundReceiver = payable(address(0)); + + bytes32 unlockTxHash = keccak256( + bytes.concat( + hex"1901", + keccak256( + abi.encode( + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, safe + ) + ), + keccak256( + abi.encode( + keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ), + unlockTo, + unlockValue, + keccak256(unlockData), + unlockOperation, + unlockSafeTxGas, + unlockBaseGas, + unlockGasPrice, + unlockGasToken, + unlockRefundReceiver, + safe.nonce() + ) + ) + ) + ); + + bytes memory unlockSignatures = abi.encodePacked( + _signSafeEncoded(owners[0], unlockTxHash), + _signSafeEncoded(owners[1], unlockTxHash), + _signSafeEncoded(owners[2], unlockTxHash), + _signSafeEncoded(owners[3], unlockTxHash), + uint256(uint160(owners[4].addr)), + bytes32(0), + uint8(1) + ); + + vm.expectEmit(true, true, true, true, address(safe)); + emit ISafe.ExecutionSuccess(unlockTxHash, 0); + safe.execTransaction( + unlockTo, + unlockValue, + unlockData, + unlockOperation, + unlockSafeTxGas, + unlockBaseGas, + unlockGasPrice, + unlockGasToken, + unlockRefundReceiver, + unlockSignatures + ); + } + + vm.expectRevert("GS026"); + safe.execTransaction( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures + ); + + testHappyPath(); + } + + function testUnlockNotUnanimous() external { + testLockDownHappyPath(); + + address unlockTo = address(guard); + uint256 unlockValue = 0 ether; + bytes memory unlockData = abi.encodeCall(guard.unlock, ()); + Operation unlockOperation = Operation.Call; + uint256 unlockSafeTxGas = 0; + uint256 unlockBaseGas = 0; + uint256 unlockGasPrice = 0; + address unlockGasToken = address(0); + address payable unlockRefundReceiver = payable(address(0)); + + bytes32 unlockTxHash = keccak256( + bytes.concat( + hex"1901", + keccak256( + abi.encode( + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, safe + ) + ), + keccak256( + abi.encode( + keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ), + unlockTo, + unlockValue, + keccak256(unlockData), + unlockOperation, + unlockSafeTxGas, + unlockBaseGas, + unlockGasPrice, + unlockGasToken, + unlockRefundReceiver, + safe.nonce() + ) + ) + ) + ); + + bytes memory unlockSignatures = abi.encodePacked( + _signSafeEncoded(owners[1], unlockTxHash), + _signSafeEncoded(owners[2], unlockTxHash), + _signSafeEncoded(owners[3], unlockTxHash), + uint256(uint160(owners[4].addr)), + bytes32(0), + uint8(1) + ); + + vm.expectRevert(abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.NotUnanimous.selector, unlockTxHash)); + safe.execTransaction( + unlockTo, + unlockValue, + unlockData, + unlockOperation, + unlockSafeTxGas, + unlockBaseGas, + unlockGasPrice, + unlockGasToken, + unlockRefundReceiver, + unlockSignatures + ); + + // This just validates that the signatures as encoded are otherwise + // valid in the absence of the guard's checks + vm.store(address(safe), 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8, bytes32(0)); + vm.expectEmit(true, true, true, true, address(safe)); + emit ISafe.ExecutionSuccess(unlockTxHash, 0); + safe.execTransaction( + unlockTo, + unlockValue, + unlockData, + unlockOperation, + unlockSafeTxGas, + unlockBaseGas, + unlockGasPrice, + unlockGasToken, + unlockRefundReceiver, + unlockSignatures + ); + } + + IMulticall internal constant _MULTICALL = IMulticall(0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B); + + function _encodeMulticallPoke() + internal + returns ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce, + bytes32 txHash, + bytes memory signatures + ) + { + to = address(_MULTICALL); + value = 0 ether; + data = abi.encodeCall(this.poke, ()); + data = abi.encodePacked(uint8(Operation.Call), address(this), uint256(0 ether), uint256(data.length), data); + data = abi.encodeCall(_MULTICALL.multiSend, (data)); + operation = Operation.DelegateCall; + safeTxGas = 0; + baseGas = 0; + gasPrice = 0; + gasToken = address(0); + refundReceiver = payable(address(0)); + nonce = safe.nonce(); + + txHash = keccak256( + bytes.concat( + hex"1901", + keccak256( + abi.encode( + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"), block.chainid, safe + ) + ), + keccak256( + abi.encode( + keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ), + to, + value, + keccak256(data), + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce + ) + ) + ) + ); + + signatures = abi.encodePacked(_signSafeEncoded(owners[0], txHash), _signSafeEncoded(owners[1], txHash)); + } + + function testMulticall0() external { + ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + uint256 nonce, + , + bytes memory signatures + ) = _encodeMulticallPoke(); + + vm.expectRevert(abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.NoDelegateCall.selector)); + guard.enqueue( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce, signatures + ); + } + + function testMulticall1() external { + ( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + , + , + bytes memory signatures + ) = _encodeMulticallPoke(); + + vm.expectRevert(abi.encodeWithSelector(IZeroExSettlerDeployerSafeGuard.NoDelegateCall.selector)); + safe.execTransaction( + to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures + ); + } +}