diff --git a/README.md b/README.md index d70226f..1485c59 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,32 @@ Batches delegatecall `MultiSendCallOnly`. On a chain the Safe transaction servic safe.withMultiSendCallOnly(0x...); ``` +#### Direct execution with several owners (no Transaction Service) + +On a chain without the Safe Transaction Service, owners can approve a transaction and anyone can execute it with `execTransaction`. Approvals come from offline signatures, on-chain `approveHash` calls, or a mix of both. + +```solidity +bytes32 safeTxHash = safe.getSafeTxHash(target, 0, data, Enum.Operation.Call, nonce); + +// Path 1: offline signatures over safeTxHash, e.g. from safe.sign(...) or `cast wallet sign`, in any order. +// eth_sign signatures must have v + 4 (31/32), as safe.sign produces for Trezor. +bytes[] memory signatures = new bytes[](2); +signatures[0] = signatureFromOwnerA; +signatures[1] = signatureFromOwnerB; + +// Path 2: an owner sends approveHash as an ordinary transaction, which works with --ledger or --trezor +// without the ffi signing step. Send this calldata to the Safe address: +bytes memory approveData = safe.getApproveHashData(safeTxHash); +// Then collect v = 1 signatures for every owner that approved, plus the executor if it is an owner: +bytes[] memory approved = safe.getApprovedHashSignatures(safeTxHash, executor); + +// Build execTransaction calldata: signatures are checked against the owners and the threshold, then sorted +bytes memory execData = safe.getExecTransactionDataWithSignatures(target, data, signatures, nonce); +bytes memory batchExecData = safe.getExecTransactionsDataWithSignatures(targets, datas, signatures, nonce); +``` + +Both paths can be mixed in one `bytes[]`. Batches must be signed with `Enum.Operation.DelegateCall`. `combineSignatures(safeTxHash, signatures)` returns the combined `signatures` bytes on their own. + #### Simulation (no hardware wallet required) Simulate transactions against a local fork before broadcasting. No signing device is needed — the library writes directly to the Safe's `approvedHashes` storage slot. diff --git a/src/ISafeSmartAccount.sol b/src/ISafeSmartAccount.sol index 3ef377f..fcb6a07 100644 --- a/src/ISafeSmartAccount.sol +++ b/src/ISafeSmartAccount.sol @@ -8,6 +8,14 @@ interface ISafeSmartAccount { function isOwner(address owner) external view returns (bool); + function getOwners() external view returns (address[] memory); + + function getThreshold() external view returns (uint256); + + function approvedHashes(address owner, bytes32 hash) external view returns (uint256); + + function approveHash(bytes32 hashToApprove) external; + function getTransactionHash( address to, uint256 value, diff --git a/src/Safe.sol b/src/Safe.sol index 035f72d..beb7e44 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -33,6 +33,11 @@ library Safe { error MultiSendCallOnlyNotFound(uint256 chainId); error ArrayLengthsMismatch(uint256 a, uint256 b); error ProposeTransactionFailed(uint256 statusCode, string response); + error InvalidSignatureLength(uint256 index, uint256 length); + error UnsupportedSignatureType(uint256 index, uint8 v); + error SignerNotOwner(address signer); + error DuplicateSigner(address signer); + error NotEnoughSignatures(uint256 provided, uint256 threshold); struct Instance { address safe; @@ -770,6 +775,209 @@ library Safe { ); } + // ========================================================================= + // Direct execution with several owners + // ========================================================================= + + /// @notice Build the calldata for an owner to approve a Safe transaction on-chain + /// @dev The owner sends this calldata to the Safe as an ordinary transaction (for example with + /// `--ledger`), so no raw-hash signature is needed. The approval is then picked up by + /// {getApprovedHashSignatures}. + /// + /// @param safeTxHash The Safe transaction hash to approve, e.g. from {getSafeTxHash} + /// @return The calldata for `approveHash(safeTxHash)` + function getApproveHashData(Client storage, bytes32 safeTxHash) internal pure returns (bytes memory) { + return abi.encodeCall(ISafeSmartAccount.approveHash, (safeTxHash)); + } + + /// @notice Build approved-hash signatures (v = 1, r = owner) for the owners that approved a Safe transaction + /// @dev Includes every owner with `approvedHashes(owner, safeTxHash) != 0`, plus `executor` when it is an + /// owner: the Safe accepts the caller of execTransaction as approved without an on-chain approveHash. + /// Pass address(0) as `executor` when the account that will call execTransaction is not an owner. + /// The result can be passed to {combineSignatures} or the `...WithSignatures` functions, alone or + /// together with offline signatures. + /// + /// @param self The Safe client + /// @param safeTxHash The Safe transaction hash + /// @param executor The account that will call execTransaction + /// @return signatures One 65-byte approved-hash signature per approving owner, in owner order + function getApprovedHashSignatures(Client storage self, bytes32 safeTxHash, address executor) + internal + view + returns (bytes[] memory signatures) + { + ISafeSmartAccount safeAccount = ISafeSmartAccount(instance(self).safe); + address[] memory owners = safeAccount.getOwners(); + bytes[] memory tmp = new bytes[](owners.length); + uint256 count; + for (uint256 i; i < owners.length; i++) { + if (owners[i] == executor || safeAccount.approvedHashes(owners[i], safeTxHash) != 0) { + // Approved-hash signature format: r=owner (padded), s=0, v=1 + tmp[count++] = abi.encodePacked(bytes32(uint256(uint160(owners[i]))), bytes32(0), uint8(1)); + } + } + signatures = new bytes[](count); + for (uint256 i; i < count; i++) { + signatures[i] = tmp[i]; + } + } + + /// @notice Combine owner signatures into the `signatures` bytes that execTransaction expects + /// @dev Each entry must be 65 bytes and one of: + /// - an ECDSA signature of the safeTxHash (v = 27 or 28), e.g. from {sign} without a hardware + /// wallet, from {sign} with Ledger (EIP-712), or `cast wallet sign --data`; + /// - an eth_sign signature of the safeTxHash with v + 4 (v = 31 or 32), as {sign} produces for Trezor; + /// - an approved-hash signature (v = 1, r = owner), as {getApprovedHashSignatures} produces. + /// Contract signatures (v = 0) are not supported. Each entry must belong to a current owner, owners + /// must not repeat, and at least `threshold` entries are required. The output is sorted by owner + /// address ascending, as Safe's checkNSignatures requires, so the input order does not matter. + /// + /// @param self The Safe client + /// @param safeTxHash The Safe transaction hash the owners signed or approved + /// @param signatures The owner signatures, in any order + /// @return combined The concatenated signatures, sorted by owner + function combineSignatures(Client storage self, bytes32 safeTxHash, bytes[] memory signatures) + internal + view + returns (bytes memory combined) + { + ISafeSmartAccount safeAccount = ISafeSmartAccount(instance(self).safe); + uint256 threshold = safeAccount.getThreshold(); + if (signatures.length < threshold) revert NotEnoughSignatures(signatures.length, threshold); + + uint256 n = signatures.length; + address[] memory signers = new address[](n); + bytes[] memory sorted = new bytes[](n); + for (uint256 i; i < n; i++) { + signers[i] = _recoverSigner(safeTxHash, signatures[i], i); + if (!safeAccount.isOwner(signers[i])) revert SignerNotOwner(signers[i]); + sorted[i] = signatures[i]; + } + // Sort ascending by owner: Safe's checkNSignatures requires strictly increasing owners. + for (uint256 i; i < n; i++) { + for (uint256 j = i + 1; j < n; j++) { + if (uint160(signers[i]) > uint160(signers[j])) { + (signers[i], signers[j]) = (signers[j], signers[i]); + (sorted[i], sorted[j]) = (sorted[j], sorted[i]); + } + } + } + for (uint256 i; i < n; i++) { + if (i > 0 && signers[i] == signers[i - 1]) revert DuplicateSigner(signers[i]); + combined = bytes.concat(combined, sorted[i]); + } + } + + /// @notice Build execTransaction calldata for a single call, signed by several owners + /// @dev Uses the Safe's current nonce. See the explicit-nonce overload for sequential transactions. + /// + /// @param self The Safe client + /// @param to The target address for the transaction + /// @param data The data payload for the transaction + /// @param signatures The owner signatures, in any order; see {combineSignatures} + /// @return The calldata for execTransaction, to be sent to the Safe + function getExecTransactionDataWithSignatures( + Client storage self, + address to, + bytes memory data, + bytes[] memory signatures + ) internal view returns (bytes memory) { + return getExecTransactionDataWithSignatures(self, to, data, signatures, getNonce(self)); + } + + /// @notice Build execTransaction calldata for a single call, signed by several owners, with an explicit nonce + /// @dev The nonce selects the safeTxHash the signatures are checked against. execTransaction always + /// uses the Safe's on-chain nonce, so the calldata only succeeds once the Safe's nonce equals `nonce`. + /// Use this to prepare several transactions in one script run before any of them executes. + function getExecTransactionDataWithSignatures( + Client storage self, + address to, + bytes memory data, + bytes[] memory signatures, + uint256 nonce + ) internal view returns (bytes memory) { + bytes32 safeTxHash = getSafeTxHash(self, to, 0, data, Enum.Operation.Call, nonce); + return getExecTransactionData( + self, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: Enum.Operation.Call, + sender: address(0), + signature: combineSignatures(self, safeTxHash, signatures), + nonce: nonce + }) + ); + } + + /// @notice Build execTransaction calldata for a MultiSend batch, signed by several owners + /// @dev The signatures must be over the batch safeTxHash with Enum.Operation.DelegateCall, as for + /// {proposeTransactionsWithSignature}. Uses the Safe's current nonce. + /// + /// @param self The Safe client + /// @param targets The list of target addresses for the transactions + /// @param datas The list of data payloads for the transactions + /// @param signatures The owner signatures, in any order; see {combineSignatures} + /// @return The calldata for execTransaction, to be sent to the Safe + function getExecTransactionsDataWithSignatures( + Client storage self, + address[] memory targets, + bytes[] memory datas, + bytes[] memory signatures + ) internal view returns (bytes memory) { + return getExecTransactionsDataWithSignatures(self, targets, datas, signatures, getNonce(self)); + } + + /// @notice Build execTransaction calldata for a MultiSend batch, signed by several owners, with an explicit nonce + /// @dev Same nonce rationale as getExecTransactionDataWithSignatures(..., nonce). + function getExecTransactionsDataWithSignatures( + Client storage self, + address[] memory targets, + bytes[] memory datas, + bytes[] memory signatures, + uint256 nonce + ) internal view returns (bytes memory) { + (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); + // using DelegateCall to preserve msg.sender across sub-calls + bytes32 safeTxHash = getSafeTxHash(self, to, 0, data, Enum.Operation.DelegateCall, nonce); + return getExecTransactionData( + self, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: Enum.Operation.DelegateCall, + sender: address(0), + signature: combineSignatures(self, safeTxHash, signatures), + nonce: nonce + }) + ); + } + + /// @dev Return the owner a 65-byte signature stands for, using the same rules as Safe's checkNSignatures. + function _recoverSigner(bytes32 safeTxHash, bytes memory signature, uint256 index) private pure returns (address) { + if (signature.length != 65) revert InvalidSignatureLength(index, signature.length); + bytes32 r; + bytes32 s; + uint8 v; + assembly ("memory-safe") { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + if (v == 1) return address(uint160(uint256(r))); + if (v == 31 || v == 32) { + // eth_sign: the owner signed "\x19Ethereum Signed Message:\n32" + safeTxHash, and v was raised by 4. + // Malleability does not matter here: this mirrors checkNSignatures, which uses plain ecrecover too. + /// forge-lint: disable-next-line(ecrecover) + return ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", safeTxHash)), v - 4, r, s); + } + /// forge-lint: disable-next-line(ecrecover) + if (v == 27 || v == 28) return ecrecover(safeTxHash, v, r, s); + revert UnsupportedSignatureType(index, v); + } + /// @notice Prepare the signature for a transaction, using a custom nonce /// /// @param self The Safe client diff --git a/test/SafeDirectExec.t.sol b/test/SafeDirectExec.t.sol new file mode 100644 index 0000000..201babe --- /dev/null +++ b/test/SafeDirectExec.t.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Safe} from "../src/Safe.sol"; +import {ISafeSmartAccount} from "../src/ISafeSmartAccount.sol"; +import {Enum} from "safe-smart-account/common/Enum.sol"; + +interface ISafeProxyFactory { + function createProxyWithNonce(address singleton, bytes memory initializer, uint256 saltNonce) + external + returns (address proxy); +} + +interface ISafeSetup { + function setup( + address[] calldata owners, + uint256 threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; +} + +interface IERC20Transfer { + function transfer(address to, uint256 amount) external returns (bool); + function balanceOf(address owner) external view returns (uint256); +} + +/// @notice Tests for executing a threshold-2 Safe directly, without the Safe Transaction Service. +/// +/// Each test deploys a fresh 2-of-3 Safe through the canonical v1.4.1 SafeProxyFactory on a Base fork, +/// so owners and keys are known and no storage writes are needed. +contract SafeDirectExecTest is Test { + using Safe for *; + + // https://github.com/safe-global/safe-deployments/blob/main/src/assets/v1.4.1/safe_proxy_factory.json + address constant SAFE_PROXY_FACTORY = 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67; + // https://github.com/safe-global/safe-deployments/blob/main/src/assets/v1.4.1/safe_l2.json + address constant SAFE_L2_SINGLETON = 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762; + address constant WETH = 0x4200000000000000000000000000000000000006; + + Safe.Client safe; + address safeAddress; + + address owner1; + uint256 owner1Key; + address owner2; + uint256 owner2Key; + address owner3; + uint256 owner3Key; + + address recipient = makeAddr("recipient"); + address nonOwnerExecutor = makeAddr("executor"); + + function setUp() public { + vm.createSelectFork("https://mainnet.base.org"); + + (owner1, owner1Key) = makeAddrAndKey("owner1"); + (owner2, owner2Key) = makeAddrAndKey("owner2"); + (owner3, owner3Key) = makeAddrAndKey("owner3"); + vm.rememberKey(owner1Key); + vm.rememberKey(owner2Key); + vm.rememberKey(owner3Key); + + address[] memory owners = new address[](3); + owners[0] = owner1; + owners[1] = owner2; + owners[2] = owner3; + bytes memory initializer = abi.encodeCall( + ISafeSetup.setup, (owners, 2, address(0), "", address(0), address(0), 0, payable(address(0))) + ); + safeAddress = ISafeProxyFactory(SAFE_PROXY_FACTORY).createProxyWithNonce(SAFE_L2_SINGLETON, initializer, 0); + safe.initialize(safeAddress); + + assertEq(ISafeSmartAccount(safeAddress).getThreshold(), 2); + deal(WETH, safeAddress, 1 ether); + } + + // ------------------------------------------------------------------------- + // Offline signatures + // ------------------------------------------------------------------------- + + function test_Safe_execWithSignatures_offlineSignaturesUnsorted_executes() public { + bytes memory data = _transferData(0.1 ether); + (address low, address high) = _sortedPair(owner1, owner2); + + // Pass the higher address first: combineSignatures must sort them. + bytes[] memory signatures = new bytes[](2); + signatures[0] = safe.sign(WETH, data, Enum.Operation.Call, high, ""); + signatures[1] = safe.sign(WETH, data, Enum.Operation.Call, low, ""); + + bytes memory execData = safe.getExecTransactionDataWithSignatures(WETH, data, signatures); + _exec(nonOwnerExecutor, execData); + + assertEq(IERC20Transfer(WETH).balanceOf(recipient), 0.1 ether); + assertEq(safe.getNonce(), 1); + } + + function test_Safe_execWithSignatures_ethSignSignature_executes() public { + // eth_sign signature with v + 4, the format {sign} produces for Trezor + bytes memory data = _transferData(0.1 ether); + bytes32 safeTxHash = safe.getSafeTxHash(WETH, 0, data, Enum.Operation.Call, safe.getNonce()); + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(owner3Key, keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", safeTxHash))); + + bytes[] memory signatures = new bytes[](2); + signatures[0] = abi.encodePacked(r, s, v + 4); + signatures[1] = safe.sign(WETH, data, Enum.Operation.Call, owner1, ""); + + _exec(nonOwnerExecutor, safe.getExecTransactionDataWithSignatures(WETH, data, signatures)); + assertEq(IERC20Transfer(WETH).balanceOf(recipient), 0.1 ether); + } + + // ------------------------------------------------------------------------- + // On-chain approvals + // ------------------------------------------------------------------------- + + function test_Safe_execWithSignatures_approveHashAndExecutor_executes() public { + bytes memory data = _transferData(0.1 ether); + bytes32 safeTxHash = safe.getSafeTxHash(WETH, 0, data, Enum.Operation.Call, safe.getNonce()); + + // owner1 approves on-chain with an ordinary transaction + _exec(owner1, safe.getApproveHashData(safeTxHash)); + assertEq(ISafeSmartAccount(safeAddress).approvedHashes(owner1, safeTxHash), 1); + + // owner2 executes: its approval is implicit, so no signature is needed from it + bytes[] memory signatures = safe.getApprovedHashSignatures(safeTxHash, owner2); + assertEq(signatures.length, 2); + + _exec(owner2, safe.getExecTransactionDataWithSignatures(WETH, data, signatures)); + assertEq(IERC20Transfer(WETH).balanceOf(recipient), 0.1 ether); + } + + function test_Safe_execWithSignatures_mixApproveHashAndOfflineSignature_executes() public { + bytes memory data = _transferData(0.1 ether); + bytes32 safeTxHash = safe.getSafeTxHash(WETH, 0, data, Enum.Operation.Call, safe.getNonce()); + + _exec(owner3, safe.getApproveHashData(safeTxHash)); + // A non-owner executes, so it adds no implicit approval + bytes[] memory approved = safe.getApprovedHashSignatures(safeTxHash, nonOwnerExecutor); + assertEq(approved.length, 1); + + bytes[] memory signatures = new bytes[](2); + signatures[0] = approved[0]; + signatures[1] = safe.sign(WETH, data, Enum.Operation.Call, owner1, ""); + + _exec(nonOwnerExecutor, safe.getExecTransactionDataWithSignatures(WETH, data, signatures)); + assertEq(IERC20Transfer(WETH).balanceOf(recipient), 0.1 ether); + } + + // ------------------------------------------------------------------------- + // Rejections + // ------------------------------------------------------------------------- + + function test_Safe_execWithSignatures_revertsWithTooFewSignatures() public { + bytes memory data = _transferData(0.1 ether); + bytes[] memory signatures = new bytes[](1); + signatures[0] = safe.sign(WETH, data, Enum.Operation.Call, owner1, ""); + + vm.expectRevert(abi.encodeWithSelector(Safe.NotEnoughSignatures.selector, 1, 2)); + this.exposedGetExecTransactionDataWithSignatures(WETH, data, signatures); + } + + function test_Safe_execWithSignatures_revertsWithNonOwnerSignature() public { + bytes memory data = _transferData(0.1 ether); + (address stranger, uint256 strangerKey) = makeAddrAndKey("stranger"); + vm.rememberKey(strangerKey); + + bytes[] memory signatures = new bytes[](2); + signatures[0] = safe.sign(WETH, data, Enum.Operation.Call, owner1, ""); + signatures[1] = safe.sign(WETH, data, Enum.Operation.Call, stranger, ""); + + vm.expectRevert(abi.encodeWithSelector(Safe.SignerNotOwner.selector, stranger)); + this.exposedGetExecTransactionDataWithSignatures(WETH, data, signatures); + } + + function test_Safe_execWithSignatures_revertsWithDuplicateOwner() public { + bytes memory data = _transferData(0.1 ether); + bytes[] memory signatures = new bytes[](2); + signatures[0] = safe.sign(WETH, data, Enum.Operation.Call, owner1, ""); + signatures[1] = signatures[0]; + + vm.expectRevert(abi.encodeWithSelector(Safe.DuplicateSigner.selector, owner1)); + this.exposedGetExecTransactionDataWithSignatures(WETH, data, signatures); + } + + function test_Safe_execWithSignatures_revertsWithSignatureForAnotherNonce() public { + // Signed for nonce 1 but checked against the current nonce 0: recovers to an unrelated address + bytes memory data = _transferData(0.1 ether); + bytes[] memory signatures = new bytes[](2); + signatures[0] = safe.sign(WETH, data, Enum.Operation.Call, owner1, 1, ""); + signatures[1] = safe.sign(WETH, data, Enum.Operation.Call, owner2, 1, ""); + + vm.expectPartialRevert(Safe.SignerNotOwner.selector); + this.exposedGetExecTransactionDataWithSignatures(WETH, data, signatures); + } + + // ------------------------------------------------------------------------- + // Batch and nonce + // ------------------------------------------------------------------------- + + function test_Safe_execTransactionsWithSignatures_multiSendBatch_executes() public { + address[] memory targets = new address[](2); + bytes[] memory datas = new bytes[](2); + targets[0] = WETH; + datas[0] = _transferData(0.1 ether); + targets[1] = WETH; + datas[1] = _transferData(0.2 ether); + + // Batches are signed with DelegateCall, as for proposeTransactionsWithSignature + (address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); + bytes[] memory signatures = new bytes[](2); + signatures[0] = safe.sign(to, data, Enum.Operation.DelegateCall, owner3, ""); + signatures[1] = safe.sign(to, data, Enum.Operation.DelegateCall, owner2, ""); + + _exec(nonOwnerExecutor, safe.getExecTransactionsDataWithSignatures(targets, datas, signatures)); + assertEq(IERC20Transfer(WETH).balanceOf(recipient), 0.3 ether); + assertEq(safe.getNonce(), 1); + } + + function test_Safe_execWithSignatures_explicitNonce_sequentialTransactions() public { + // Prepare two transactions before either executes; the second needs nonce 1. + bytes memory dataA = _transferData(0.1 ether); + bytes memory dataB = _transferData(0.2 ether); + + bytes[] memory sigsA = new bytes[](2); + sigsA[0] = safe.sign(WETH, dataA, Enum.Operation.Call, owner1, 0, ""); + sigsA[1] = safe.sign(WETH, dataA, Enum.Operation.Call, owner2, 0, ""); + bytes[] memory sigsB = new bytes[](2); + sigsB[0] = safe.sign(WETH, dataB, Enum.Operation.Call, owner1, 1, ""); + sigsB[1] = safe.sign(WETH, dataB, Enum.Operation.Call, owner3, 1, ""); + + bytes memory execA = safe.getExecTransactionDataWithSignatures(WETH, dataA, sigsA, 0); + bytes memory execB = safe.getExecTransactionDataWithSignatures(WETH, dataB, sigsB, 1); + + _exec(nonOwnerExecutor, execA); + _exec(nonOwnerExecutor, execB); + assertEq(IERC20Transfer(WETH).balanceOf(recipient), 0.3 ether); + assertEq(safe.getNonce(), 2); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + function exposedGetExecTransactionDataWithSignatures(address to, bytes memory data, bytes[] memory signatures) + external + view + returns (bytes memory) + { + return safe.getExecTransactionDataWithSignatures(to, data, signatures); + } + + function _transferData(uint256 amount) private view returns (bytes memory) { + return abi.encodeCall(IERC20Transfer.transfer, (recipient, amount)); + } + + function _sortedPair(address a, address b) private pure returns (address, address) { + return uint160(a) < uint160(b) ? (a, b) : (b, a); + } + + function _exec(address sender, bytes memory callData) private { + vm.prank(sender); + (bool ok, bytes memory ret) = safeAddress.call(callData); + assertTrue(ok, "call to Safe reverted"); + if (ret.length == 32) assertTrue(abi.decode(ret, (bool)), "execTransaction returned false"); + } +}