From 353bb497fed0a566d8e81d0e55020951d7eb5072 Mon Sep 17 00:00:00 2001 From: Jaden Date: Wed, 25 Mar 2026 10:15:35 -0400 Subject: [PATCH 1/2] feat: adding onlyOwnerMulticaller contract --- deployments/common/instructions.md | 41 ++++ .../common/OnlyOwnerMulticallerDeployer.s.sol | 87 +++++++ src/common/OnlyOwnerMulticaller.sol | 223 ++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 deployments/common/instructions.md create mode 100644 script/common/OnlyOwnerMulticallerDeployer.s.sol create mode 100644 src/common/OnlyOwnerMulticaller.sol diff --git a/deployments/common/instructions.md b/deployments/common/instructions.md new file mode 100644 index 0000000..a421bce --- /dev/null +++ b/deployments/common/instructions.md @@ -0,0 +1,41 @@ +There is a single deployment script that needs to be triggered for every new chain, `./script/common/OnlyOwnerMulticallerDeployer.s.sol` + +The script requires the following environment variables: + +- `DEPLOYER_PK`: the private key of the deployer wallet +- `CHAIN`: the chain to deploy on (the available options can be found in `./foundry.toml`) +- `CREATE2_FACTORY`: the address of the `CREATE2` factory to be used for deterministic deployments - the default factory should be deployed at `0x4e59b44847b379578588920ca78fbf26c0b4956c`, in case it's not available on a given chain we should deploy it there or otherwise use a different factory +- `OWNER`: the address that will own the multicaller (must be the same across all chains to produce a canonical deployment address) +- `ETHERSCAN_API_KEY`: the API key needed to verify the contracts on Etherscan-powered explorers + +> **Note on canonical addresses:** The deployed address is derived from the CREATE2 factory address, the salt, and the contract creation bytecode (which includes the `OWNER` constructor argument). To get the same address on every chain, ensure `CREATE2_FACTORY`, `SALT`, and `OWNER` are identical. The project's `foundry.toml` sets `bytecode_hash = "none"` and `cbor_metadata = false` to strip non-deterministic compiler metadata from the bytecode. + +### Deployment + +The deployment can be triggered via the following command: + +```bash +forge script ./script/common/OnlyOwnerMulticallerDeployer.s.sol:OnlyOwnerMulticallerDeployer \ + --slow \ + --multi \ + --broadcast \ + --verify \ + --private-key $DEPLOYER_PK \ + --create2-deployer $CREATE2_FACTORY +``` + +The script will automatically skip deployment if the contract already exists at the predicted address. + +### Verification + +The above script should do the deployment and verification altogether. However, in cases when the verification failed for some reason, it can be triggered individually via the following command: + +```bash +forge verify-contract \ + --chain $CHAIN \ + --constructor-args $(cast abi-encode "constructor(address)" $OWNER) \ + $ONLY_OWNER_MULTICALLER \ + ./src/common/OnlyOwnerMulticaller.sol:OnlyOwnerMulticaller +``` + +In case `forge` doesn't have any default explorer for a given chain, make sure to pass the following extra arguments to the `forge verify-contract` command: `--verifier-url $VERIFIER_URL --etherscan-api-key $VERIFIER_API_KEY`. diff --git a/script/common/OnlyOwnerMulticallerDeployer.s.sol b/script/common/OnlyOwnerMulticallerDeployer.s.sol new file mode 100644 index 0000000..9161846 --- /dev/null +++ b/script/common/OnlyOwnerMulticallerDeployer.s.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; + +import {OnlyOwnerMulticaller} from "../../src/common/OnlyOwnerMulticaller.sol"; + +contract OnlyOwnerMulticallerDeployer is Script { + // Thrown when the predicted address doesn't match the deployed address + error IncorrectContractAddress(address predicted, address actual); + + // Modify for vanity address generation + bytes32 public SALT = bytes32(uint256(1)); + + function setUp() public {} + + function run() public { + vm.createSelectFork(vm.envString("CHAIN")); + + vm.startBroadcast(); + + deployOnlyOwnerMulticaller(vm.envAddress("OWNER")); + + vm.stopBroadcast(); + } + + function deployOnlyOwnerMulticaller(address owner) public returns (address) { + console2.log("Deploying OnlyOwnerMulticaller"); + + address create2Factory = vm.envAddress("CREATE2_FACTORY"); + + // Compute predicted address + address predictedAddress = address( + uint160( + uint( + keccak256( + abi.encodePacked( + bytes1(0xff), + create2Factory, + SALT, + keccak256( + abi.encodePacked( + type(OnlyOwnerMulticaller).creationCode, + abi.encode(owner) + ) + ) + ) + ) + ) + ) + ); + + console2.log("Predicted address for OnlyOwnerMulticaller", predictedAddress); + + // Verify if the contract has already been deployed + if (_hasBeenDeployed(predictedAddress)) { + console2.log("OnlyOwnerMulticaller was already deployed"); + return predictedAddress; + } + + // Deploy + OnlyOwnerMulticaller multicaller = new OnlyOwnerMulticaller{salt: SALT}(owner); + + // Ensure the predicted and actual addresses match + if (predictedAddress != address(multicaller)) { + revert IncorrectContractAddress( + predictedAddress, + address(multicaller) + ); + } + + console2.log("OnlyOwnerMulticaller deployed"); + + return address(multicaller); + } + + function _hasBeenDeployed( + address addressToCheck + ) internal view returns (bool) { + uint256 size; + assembly { + size := extcodesize(addressToCheck) + } + return (size > 0); + } +} diff --git a/src/common/OnlyOwnerMulticaller.sol b/src/common/OnlyOwnerMulticaller.sol new file mode 100644 index 0000000..73c543a --- /dev/null +++ b/src/common/OnlyOwnerMulticaller.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {Ownable} from "solady/src/auth/Ownable.sol"; + +/** + * @title OnlyOwnerMulticaller + * @author vectorized.eth + * @notice A fork of vectorized's Multicaller that can only be called by the owner. + * Contract that allows for efficient aggregation + * of multiple calls in a single transaction. + */ +contract OnlyOwnerMulticaller is Ownable { + constructor(address owner) { + _initializeOwner(owner); + } + + // ============================================================= + // ERRORS + // ============================================================= + + /** + * @dev The lengths of the input arrays are not the same. + */ + error ArrayLengthsMismatch(); + + // ============================================================= + // AGGREGATION OPERATIONS + // ============================================================= + + /** + * @dev Aggregates multiple calls in a single transaction. + * @param targets An array of addresses to call. + * @param data An array of calldata to forward to the targets. + * @param values How much ETH to forward to each target. + * @param refundTo The address to transfer any remaining ETH in the contract after the calls. + * If `address(0)`, remaining ETH will NOT be refunded. + * If `address(1)`, remaining ETH will be refunded to `msg.sender`. + * If anything else, remaining ETH will be refunded to `refundTo`. + * @return An array of the returndata from each call. + */ + function aggregate( + address[] calldata targets, + bytes[] calldata data, + uint256[] calldata values, + address refundTo + ) external payable onlyOwner returns (bytes[] memory) { + assembly { + if iszero( + and( + eq(targets.length, data.length), + eq(data.length, values.length) + ) + ) { + // Store the function selector of `ArrayLengthsMismatch()`. + mstore(returndatasize(), 0x3b800a46) + // Revert with (offset, size). + revert(0x1c, 0x04) + } + + let resultsSize := 0x40 + + if data.length { + let results := 0x40 + // Left shift by 5 is equivalent to multiplying by 0x20. + data.length := shl(5, data.length) + // Copy the offsets from calldata into memory. + calldatacopy(results, data.offset, data.length) + // Offset into `results`. + let resultsOffset := data.length + // Pointer to the end of `results`. + let end := add(results, data.length) + // For deriving the calldata offsets from the `results` pointer. + let valuesOffsetDiff := sub(values.offset, results) + let targetsOffsetDiff := sub(targets.offset, results) + + for {} 1 {} { + // The offset of the current bytes in the calldata. + let o := add(data.offset, mload(results)) + let memPtr := add(resultsOffset, 0x40) + // Copy the current bytes from calldata to the memory. + calldatacopy( + memPtr, + add(o, 0x20), // The offset of the current bytes' bytes. + calldataload(o) // The length of the current bytes. + ) + if iszero( + call( + gas(), // Remaining gas. + calldataload(add(targetsOffsetDiff, results)), // Address to call. + calldataload(add(valuesOffsetDiff, results)), // ETH to send. + memPtr, // Start of input calldata in memory. + calldataload(o), // Size of input calldata. + 0x00, // We will use returndatacopy instead. + 0x00 // We will use returndatacopy instead. + ) + ) { + // Bubble up the revert if the call reverts. + returndatacopy(0x00, 0x00, returndatasize()) + revert(0x00, returndatasize()) + } + // Append the current `resultsOffset` into `results`. + mstore(results, resultsOffset) + // Append the returndatasize, and the returndata. + mstore(memPtr, returndatasize()) + returndatacopy(add(memPtr, 0x20), 0x00, returndatasize()) + // Advance the `resultsOffset` by `returndatasize() + 0x20`, + // rounded up to the next multiple of 0x20. + resultsOffset := and( + add(add(resultsOffset, returndatasize()), 0x3f), + not(0x1f) + ) + // Advance the `results` pointer. + results := add(results, 0x20) + if eq(results, end) { + break + } + } + resultsSize := add(resultsOffset, 0x40) + } + + if refundTo { + // Force transfers all the remaining ETH in the contract to `refundTo`, + // with a gas stipend of 100000, which should be enough for most use cases. + // If sending via a regular call fails, force sends the ETH by + // creating a temporary contract which uses `SELFDESTRUCT` to force send the ETH. + if selfbalance() { + // If `refundTo` is `address(1)`, replace it with the `msg.sender`. + refundTo := xor( + refundTo, + mul(eq(refundTo, 1), xor(refundTo, caller())) + ) + // Transfer the ETH and check if it succeeded or not. + if iszero( + call( + 100000, + refundTo, + selfbalance(), + codesize(), + 0x00, + codesize(), + 0x00 + ) + ) { + mstore(0x00, refundTo) // Store the address in scratch space. + mstore8(0x0b, 0x73) // Opcode `PUSH20`. + mstore8(0x20, 0xff) // Opcode `SELFDESTRUCT`. + // We can directly use `SELFDESTRUCT` in the contract creation. + // Compatible with `SENDALL`: https://eips.ethereum.org/EIPS/eip-4758 + if iszero(create(selfbalance(), 0x0b, 0x16)) { + // Coerce gas estimation to provide enough gas for the `create` above. + revert(codesize(), codesize()) + } + } + } + } + + mstore(0x00, 0x20) // Store the memory offset of the `results`. + mstore(0x20, targets.length) // Store `targets.length` into `results`. + // Direct return. + return(0x00, resultsSize) + } + } + + /** + * @dev For receiving ETH. + * Does nothing and returns nothing. + */ + receive() external payable {} + + /** + * @dev Decompresses the calldata and performs a delegatecall + * with the decompressed calldata to itself. + * + * Accompanying JavaScript library to compress the calldata: + * https://github.com/vectorized/solady/blob/main/js/solady.js + * (See: `LibZip.cdCompress`) + */ + fallback() external payable { + assembly { + // If the calldata starts with the bitwise negation of + // `bytes4(keccak256("aggregate(address[],bytes[],uint256[],address)"))`. + let s := calldataload(returndatasize()) + if eq(shr(224, s), 0x66e0daa0) { + mstore(returndatasize(), not(s)) + let o := 4 + for { + let i := o + } lt(i, calldatasize()) {} { + let c := byte(returndatasize(), calldataload(i)) + i := add(i, 1) + if iszero(c) { + let d := byte(returndatasize(), calldataload(i)) + i := add(i, 1) + // Fill with either 0xff or 0x00. + mstore(o, not(returndatasize())) + if iszero(gt(d, 0x7f)) { + codecopy(o, codesize(), add(d, 1)) + } + o := add(o, add(and(d, 0x7f), 1)) + continue + } + mstore8(o, c) + o := add(o, 1) + } + let success := delegatecall( + gas(), + address(), + 0x00, + o, + 0x00, + 0x00 + ) + returndatacopy(0x00, 0x00, returndatasize()) + if iszero(success) { + revert(0x00, returndatasize()) + } + return(0x00, returndatasize()) + } + revert(returndatasize(), returndatasize()) + } + } +} From c505618fe31e2c817d379331ef76b5a2bd2bc213 Mon Sep 17 00:00:00 2001 From: Jaden Date: Wed, 25 Mar 2026 10:18:21 -0400 Subject: [PATCH 2/2] feat: adding deploymentsg --- deployments/common/addresses.json | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 deployments/common/addresses.json diff --git a/deployments/common/addresses.json b/deployments/common/addresses.json new file mode 100644 index 0000000..ca4815a --- /dev/null +++ b/deployments/common/addresses.json @@ -0,0 +1,47 @@ +[ + { + "name": "ethereum", + "chainId": 1, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "optimism", + "chainId": 10, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "unichain", + "chainId": 130, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "polygon", + "chainId": 137, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "worldchain", + "chainId": 480, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "hyperevm", + "chainId": 999, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "base", + "chainId": 8453, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "arbitrum", + "chainId": 42161, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + }, + { + "name": "avalanche", + "chainId": 43114, + "onlyOwnerMulticaller": "0xfc4ef5dff5a5e2c0d381ae073ad7ebb20ac60be2" + } +]