diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..746ae31 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.sol] +indent_size = 4 + +[*.tree] +indent_size = 1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98c1028 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +export API_KEY_ALCHEMY="YOUR_API_KEY_ALCHEMY" +export API_KEY_ARBISCAN="YOUR_API_KEY_ARBISCAN" +export API_KEY_BSCSCAN="YOUR_API_KEY_BSCSCAN" +export API_KEY_ETHERSCAN="YOUR_API_KEY_ETHERSCAN" +export API_KEY_GNOSISSCAN="YOUR_API_KEY_GNOSISSCAN" +export API_KEY_INFURA="YOUR_API_KEY_INFURA" +export API_KEY_OPTIMISTIC_ETHERSCAN="YOUR_API_KEY_OPTIMISTIC_ETHERSCAN" +export API_KEY_POLYGONSCAN="YOUR_API_KEY_POLYGONSCAN" +export API_KEY_SNOWTRACE="YOUR_API_KEY_SNOWTRACE" +export MNEMONIC="YOUR_MNEMONIC" +export FOUNDRY_PROFILE="default" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7550749 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: "CI" + +env: + API_KEY_ALCHEMY: ${{ secrets.API_KEY_ALCHEMY }} + FOUNDRY_PROFILE: "ci" + +on: + workflow_dispatch: + pull_request: + push: + branches: + - "main" + +jobs: + lint: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Foundry" + uses: "foundry-rs/foundry-toolchain@v1" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the Node.js dependencies" + run: "bun install" + + - name: "Lint the code" + run: "bun run lint" + + - name: "Add lint summary" + run: | + echo "## Lint result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY + + build: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Foundry" + uses: "foundry-rs/foundry-toolchain@v1" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the Node.js dependencies" + run: "bun install" + + - name: "Build the contracts and print their size" + run: "forge build --sizes" + + - name: "Add build summary" + run: | + echo "## Build result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY + + test: + needs: ["lint", "build"] + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Foundry" + uses: "foundry-rs/foundry-toolchain@v1" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the Node.js dependencies" + run: "bun install" + + - name: "Show the Foundry config" + run: "forge config" + + - name: "Generate a fuzz seed that changes weekly to avoid burning through RPC allowance" + run: > + echo "FOUNDRY_FUZZ_SEED=$( + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) + )" >> $GITHUB_ENV + + - name: "Run the tests" + run: "forge test" + + - name: "Add test summary" + run: | + echo "## Tests result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e108b40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# directories +cache +coverage +node_modules +out + +# files +*.env +*.log +.DS_Store +.pnp.* +lcov.info +package-lock.json +pnpm-lock.yaml +yarn.lock + +# broadcasts +!broadcast +broadcast/* +broadcast/*/31337/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..da1251c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/Vectorized/solady +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..b9646d8 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,14 @@ +image: "gitpod/workspace-bun" + +tasks: + - name: "Install dependencies" + before: | + curl -L https://foundry.paradigm.xyz | bash + source ~/.bashrc + foundryup + init: "bun install" + +vscode: + extensions: + - "esbenp.prettier-vscode" + - "NomicFoundation.hardhat-solidity" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3996d20 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +# directories +broadcast +cache +coverage +node_modules +out + +# files +*.env +*.log +.DS_Store +.pnp.* +bun.lockb +lcov.info +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..a1ecdbb --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,7 @@ +bracketSpacing: true +printWidth: 120 +proseWrap: "always" +singleQuote: false +tabWidth: 2 +trailingComma: "all" +useTabs: false diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..7a15ca0 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,14 @@ +{ + "extends": "solhint:recommended", + "rules": { + "code-complexity": ["error", 8], + "compiler-version": ["error", ">=0.8.23"], + "func-name-mixedcase": "off", + "func-visibility": ["error", { "ignoreConstructors": true }], + "max-line-length": ["error", 120], + "named-parameters-mapping": "warn", + "no-console": "off", + "not-rely-on-time": "off", + "one-contract-per-file": "off" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..241108b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "[solidity]": { + "editor.defaultFormatter": "NomicFoundation.hardhat-solidity" + }, + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml" + }, + "solidity.formatter": "forge" +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..88a2b87 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,16 @@ +MIT License + +Copyright (c) 2023 Paul Razvan Berg + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d48ca2 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Single Keccak Create3 + +This is a different implementation of `CREATE3` that only requires a single keccak256 hash to calculate deployed address. + +## How it works + +To achieve this, before deploying a proxy, we `SSTORE2.write` the final deployed bytecode from the deployer and store a pointer to the storage contract in the deployer. The proxies deployed from the deployer will do a `SSTORE2.read` on `CALLER.pointer` to get the final deployed bytecode and return that in its constructor. + +We use a different SSTORE2 implementation here, using CREATE2 instead of CREATE under the hood. This bumps up costs slightly for the first deployment, but on the 2nd+ deployment of the same contract we get significant savings. + +Because of how this works, contract constructors are never run. It's highly recommended for constructor logic to be in an `initializer` function instead. See the section on safety below. + +## Gas + +Gas estimates with Solady's CREATE3 are in [GasComparison.t.sol](./test/GasComparison.t.sol). Disclaimer: Gas values are measured using foundry and will be inaccurate so this should only be used as a ballpark estimate. + +Cost of deploying an ERC20: +Single Keccak Create3, cached contract + no storage: 636k +Solady CREATE3: 732k +Single Keccak Create3, cached contract + storage: 810k +Single Keccak Create3, new contract + no storage: 1.26m +Single Keccak Create3, new contract + storage: 1.44m + +## Safety + +Deployments using this proxy bypass all constructor logic and is significantly less safe than regular deployments. The danger here can largely be mitigated via adding tests to foundry deploy scripts - see example in [SafeDeployPatternDemo](./script/SafeDeployPatternDemo.s.sol). + +## Acknowledgements + Future Extensions + +A significant amount of assembly logic was lifted from [Solady](https://github.com/Vectorized/solady). + +1. Utilizing SSTORE or TSTORE instead of another SSTORE2 for `storageArgs` +2. We currently cache deployed bytecode, this means deploying a second contract a different immutable variable wouldn't count as 2nd+ deployment. Can consider doing it like how solidity constructors set up immutable vars and have the proxy take in arrays of memory arguments and memory locations of immutable args \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..204be62 Binary files /dev/null and b/bun.lockb differ diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..edafcd5 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,53 @@ +# Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config + +[profile.default] + auto_detect_solc = false + block_timestamp = 1_680_220_800 # March 31, 2023 at 00:00 GMT + bytecode_hash = "none" + evm_version = "paris" # See https://www.evmdiff.com/features?name=PUSH0&kind=opcode + fuzz = { runs = 1_000 } + gas_reports = ["*"] + optimizer = true + optimizer_runs = 10_000 + out = "out" + script = "script" + solc = "0.8.23" + src = "src" + test = "test" + +[profile.ci] + fuzz = { runs = 10_000 } + verbosity = 4 + +[etherscan] + arbitrum = { key = "${API_KEY_ARBISCAN}" } + avalanche = { key = "${API_KEY_SNOWTRACE}" } + bnb_smart_chain = { key = "${API_KEY_BSCSCAN}" } + gnosis_chain = { key = "${API_KEY_GNOSISSCAN}" } + goerli = { key = "${API_KEY_ETHERSCAN}" } + mainnet = { key = "${API_KEY_ETHERSCAN}" } + optimism = { key = "${API_KEY_OPTIMISTIC_ETHERSCAN}" } + polygon = { key = "${API_KEY_POLYGONSCAN}" } + sepolia = { key = "${API_KEY_ETHERSCAN}" } + +[fmt] + bracket_spacing = true + int_types = "long" + line_length = 120 + multiline_func_header = "all" + number_underscore = "thousands" + quote_style = "double" + tab_width = 4 + wrap_comments = true + +[rpc_endpoints] + arbitrum = "https://arbitrum-mainnet.infura.io/v3/${API_KEY_INFURA}" + avalanche = "https://avalanche-mainnet.infura.io/v3/${API_KEY_INFURA}" + bnb_smart_chain = "https://bsc-dataseed.binance.org" + gnosis_chain = "https://rpc.gnosischain.com" + goerli = "https://goerli.infura.io/v3/${API_KEY_INFURA}" + localhost = "http://localhost:8545" + mainnet = "https://eth-mainnet.g.alchemy.com/v2/${API_KEY_ALCHEMY}" + optimism = "https://optimism-mainnet.infura.io/v3/${API_KEY_INFURA}" + polygon = "https://polygon-mainnet.infura.io/v3/${API_KEY_INFURA}" + sepolia = "https://sepolia.infura.io/v3/${API_KEY_INFURA}" diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..ae570fe --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit ae570fec082bfe1c1f45b0acca4a2b4f84d345ce diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..01ef448 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 01ef448981be9d20ca85f2faf6ebdf591ce409f3 diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..74e5718 --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit 74e5718ebee8baf66989e9f1b95f38ee94952f8d diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e81f54 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@howydev/skc3", + "description": "", + "version": "1.0.0", + "author": { + "name": "howydev", + "url": "https://github.com/howydev" + }, + "dependencies": { + "@openzeppelin/contracts": "^5.0.1" + }, + "devDependencies": { + "@prb/test": "^0.6.4", + "forge-std": "github:foundry-rs/forge-std#v1.7.5", + "prettier": "^3.0.0", + "solhint": "^3.6.2" + }, + "keywords": [ + "blockchain", + "ethereum", + "forge", + "foundry", + "smart-contracts", + "solidity", + "template" + ], + "private": true, + "scripts": { + "clean": "rm -rf cache out", + "build": "forge build", + "lint": "bun run lint:sol && bun run prettier:check", + "lint:sol": "forge fmt --check && bun solhint {script,src,test}/**/*.sol", + "prettier:check": "prettier --check **/*.{json,md,yml} --ignore-path=.prettierignore", + "prettier:write": "prettier --write **/*.{json,md,yml} --ignore-path=.prettierignore", + "test": "forge test", + "test:coverage": "forge coverage", + "test:coverage:report": "forge coverage --report lcov && genhtml lcov.info --branch-coverage --output-dir coverage" + } +} diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..0c85e18 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,3 @@ +@solady/=lib/solady/ +@openzeppelin-contracts/=lib/openzeppelin-contracts/ +@forge-std/=lib/forge-std/src \ No newline at end of file diff --git a/script/SafeDeployPatternDemo.s.sol b/script/SafeDeployPatternDemo.s.sol new file mode 100644 index 0000000..ca93476 --- /dev/null +++ b/script/SafeDeployPatternDemo.s.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23 <0.9.0; + +import { MockSingleKeccakCreate3 } from "../src/mocks/MockSingleKeccakCreate3.sol"; +import { SingleKeccakCreate3 } from "../src/SingleKeccakCreate3.sol"; +import { MockERC20 } from "../src/mocks/MockERC20.sol"; +import { Script, console2 } from "forge-std/Script.sol"; +import { Test } from "forge-std/Test.sol"; + +/** + * @title Safe Deploy Demo. Steps: + * @dev 1. if salt is used + * @dev 2. deploys an instance locally (this sets up immutable arguments), then copies that bytecode + * @dev 3. does checks on expected constructor logic (total supply, balances) + */ +contract SafeDeployPatternDemo is Script, SingleKeccakCreate3, Test { + function run() public { + // Replace these vars before actually deploying + bytes32 salt = 0x0; + address deployerAddr = address(0); + MockERC20 expected = new MockERC20(5); + uint256 mintAmt = 5; + address owner = address(0); + + bytes memory runtimeCode = address(expected).code; + + // gasless salt test + if (_getDeployedAddress(salt, deployerAddr).code.length > 0) { + console2.log("Salt already used"); + return; + } + + MockSingleKeccakCreate3 skc3 = MockSingleKeccakCreate3(deployerAddr); + + vm.startBroadcast(); + + bytes32[] memory storageSlots = new bytes32[](2); + storageSlots[0] = bytes32(keccak256(abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(uint256(0))))); + storageSlots[1] = bytes32(uint256(2)); + + bytes32[] memory storageVals = new bytes32[](2); + storageVals[0] = bytes32(mintAmt); + storageVals[1] = bytes32(mintAmt); + + MockERC20 deployed = MockERC20(skc3.deployAndSetupStorage(salt, runtimeCode, storageVals, storageSlots, 0)); + + vm.stopBroadcast(); + + // Add tests on the deployed contract + if (address(deployed).code.length == 0) { + revert("Deployment failed"); + } + + if (deployed.balanceOf(owner) != 5) { + revert("Mint failed"); + } + + if (deployed.totalSupply() != 5) { + revert("Total supply failed"); + } + } +} diff --git a/src/SingleKeccakCreate3.sol b/src/SingleKeccakCreate3.sol new file mode 100644 index 0000000..dc267d0 --- /dev/null +++ b/src/SingleKeccakCreate3.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +import { CREATE3 } from "@solady/src/utils/CREATE3.sol"; +import { Create2 } from "@openzeppelin-contracts/contracts/utils/Create2.sol"; +import { SingleKeccakCreate3Proxy } from "./SingleKeccakCreate3Proxy.sol"; +import { ISingleKeccakCreate3 } from "./interfaces/ISingleKeccakCreate3.sol"; + +/** + * @title SingleKeccakCreate3 + * @notice CREATE3 that requires a single keccak256 hash operation to mine + * @dev 4 deploy modes - no storage, storage, no storage with init calls, storage with init calls + */ +abstract contract SingleKeccakCreate3 is ISingleKeccakCreate3 { + /** + * @dev 1st byte: bool hasStorage + * @dev last 20 bytes: address pointer + */ + bytes32 public setupInfo; + bytes32 public storageArgs; + + uint256 internal constant _DATA_OFFSET = 1; + + error DeploymentFailed(); + error AlreadyDeployed(); + error CallFailed(uint256 idx); + error LengthMismatch(); + + /// Private helper methods + + /** + * @notice Private helper function to set up an SSTORE2 pointer + * @dev some logic lifted from solady SSTORE2. Uses create2 instead of create + * @param data contract creation code + */ + function _setupPointer(bytes memory data) private returns (bytes32 addr) { + assembly { + let originalDataLength := mload(data) + let dataSize := add(originalDataLength, _DATA_OFFSET) + mstore( + // Do a out-of-gas revert if `dataSize` is more than 2 bytes. + // The actual EVM limit may be smaller and may change over time. + add(data, gt(dataSize, 0xffff)), + // Left shift `dataSize` by 64 so that it lines up with the 0000 after PUSH2. + or(0xfd61000080600a3d393df300, shl(0x40, dataSize)) + ) + + dataSize := add(dataSize, 0xa) + let dataStart := add(data, 0x15) + + // FMP: 00000000_00000000_000000FF_20BYTESADDRESS + // FMP+32: salt = 0 + // FMP+64: initBytecodeHash + let fmp := mload(0x40) + mstore(add(fmp, 0x40), keccak256(dataStart, dataSize)) + mstore(fmp, or(shl(160, 0xff), address())) + addr := shr(96, shl(96, keccak256(add(fmp, 0x0b), 85))) + + // if no code at address, deploy + if iszero(extcodesize(addr)) { + let deployedAddr := create2(0, dataStart, dataSize, returndatasize()) + if iszero(deployedAddr) { + mstore(0x00, 0x30116425) + revert(0x1c, 0x04) + } + } + + // Restore original length of the variable size `data`. + mstore(data, originalDataLength) + } + } + + /** + * Private helper function to make calls to deployed contracts + * @param target contract to call + * @param calldatas array of calldata for calls + * @param values array of values for calls + */ + function _call(address target, bytes[] calldata calldatas, uint256[] calldata values) private { + if (calldatas.length != values.length) { + revert LengthMismatch(); + } + + uint256 len = calldatas.length; + for (uint256 i = 0; i < len; i++) { + (bool success,) = target.call{ value: values[i] }(calldatas[i]); + if (!success) { + revert CallFailed(i); + } + } + } + + function _getDeployedAddress(bytes32 salt, address deployer) internal pure returns (address addr) { + bytes memory proxy = type(SingleKeccakCreate3Proxy).creationCode; + assembly { + // layout : + // ptr: 00000000_00000000_000000FF_20BYTESADDRESS + // ptr+32: salt = 0 + // ptr+64: initBytecodeHash + let ptr := mload(0x40) + mstore(add(ptr, 0x40), keccak256(add(proxy, 0x20), mload(proxy))) + mstore(add(ptr, 0x20), salt) + mstore(ptr, or(shl(160, 0xff), deployer)) + addr := keccak256(add(ptr, 0x0b), 85) + } + } + + /// Internal Functions + + /** + * @notice Deploy a contract using single keccak create3 + * @param salt salt for create3 + * @param creationCode contract creation code + * @param value value to send + */ + function _deploy(bytes32 salt, bytes calldata creationCode, uint256 value) internal returns (address) { + // hasStorage = false + setupInfo = _setupPointer(creationCode); + + return address(new SingleKeccakCreate3Proxy{ salt: salt, value: value }()); + } + + /** + * @notice Deploy a contract using single keccak create3 then perform specified calls + * @param salt salt for create3 + * @param creationCode contract creation code + * @param calldatas array of calldata for calls + * @param values array of values for calls + * @param value value to send + */ + function _deployAndCall( + bytes32 salt, + bytes calldata creationCode, + bytes[] calldata calldatas, + uint256[] calldata values, + uint256 value + ) + internal + returns (address deployed) + { + deployed = _deploy(salt, creationCode, value); + _call(deployed, calldatas, values); + } + + /** + * @notice Deploy a contract using single keccak create3, setup storage + * @param salt salt for create3 + * @param creationCode contract creation code + * @param storageArgsVals array of bytes32 storage arguments + * @param storageArgsLoc array of storage locations to save data into + * @param value value to send + */ + function _deployAndSetupStorage( + bytes32 salt, + bytes calldata creationCode, + bytes32[] calldata storageArgsVals, + bytes32[] calldata storageArgsLoc, + uint256 value + ) + internal + returns (address) + { + if (storageArgsVals.length != storageArgsLoc.length) { + revert LengthMismatch(); + } + + // hasStorage = true + setupInfo = _setupPointer(creationCode) | bytes32(uint256(1) << 255); + storageArgs = _setupPointer(abi.encode(storageArgsVals, storageArgsLoc)); + + return address(new SingleKeccakCreate3Proxy{ salt: salt, value: value }()); + } + + /** + * @notice Deploy a contract using single keccak create3, setup storage, then perform specified calls + * @param salt salt for create3 + * @param creationCode contract creation code + * @param storageArgsVals array of bytes32 storage arguments + * @param storageArgsLoc array of storage locations to save data into + * @param calldatas array of calldata for calls + * @param values array of values for calls + * @param value value to send + */ + function _deployAndSetupStorageAndCall( + bytes32 salt, + bytes calldata creationCode, + bytes32[] calldata storageArgsVals, + bytes32[] calldata storageArgsLoc, + bytes[] calldata calldatas, + uint256[] calldata values, + uint256 value + ) + internal + returns (address deployed) + { + deployed = _deployAndSetupStorage(salt, creationCode, storageArgsVals, storageArgsLoc, value); + _call(deployed, calldatas, values); + } +} diff --git a/src/SingleKeccakCreate3Proxy.sol b/src/SingleKeccakCreate3Proxy.sol new file mode 100644 index 0000000..382aa25 --- /dev/null +++ b/src/SingleKeccakCreate3Proxy.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +import { ISingleKeccakCreate3 } from "./interfaces/ISingleKeccakCreate3.sol"; + +contract SingleKeccakCreate3Proxy { + uint256 private constant _DATA_OFFSET = 1; + + error InvalidPointer(); + + /** + * // Equivalent ~solidity code + * + * constructor() payable { + * bytes32 p = ISingleKeccakCreate3(msg.sender).setupInfo(); + * bytes memory creationCode = SSTORE2.read(address(uint160(uint256(p)))); + * + * p = p >> 255; + * if (uint256(p) % 2 == 1) { + * (bytes32[] memory storageArgsVals, bytes32[] memory storageArgsLoc) = abi.decode( + * SSTORE2.read(address(uint160(uint256(ISingleKeccakCreate3(msg.sender).storageArgs())))), + * (bytes32[], bytes32[]) + * ); + * + * for (uint256 i = 0; i < storageArgsVals.length; i++) { + * assembly { + * sstore( + * mload(add(storageArgsLoc, add(0x20, mul(i, 0x20)))), + * mload(add(storageArgsVals, add(0x20, mul(i, 0x20)))) + * ) + * } + * } + * } + * + * assembly { + * return(add(creationCode, 0x20), mload(creationCode)) + * } + * } + */ + + constructor() payable { + assembly { + // "setupInfo()" + mstore(0x00, 0xc0c8e36f) + pop(call(gas(), caller(), 0, 28, 0x04, 0, 0x20)) + let ptr := mload(0) + let pointerCodeSize := extcodesize(ptr) + if iszero(pointerCodeSize) { + // Store the function selector of `InvalidPointer()`. + mstore(0x00, 0x11052bb4) + // Revert with (offset, size). + revert(0x1c, 0x04) + } + + let size := sub(pointerCodeSize, _DATA_OFFSET) + + let bytecode := mload(0x40) + mstore(0x40, add(bytecode, and(add(size, 0x3f), 0xffe0))) + mstore(bytecode, size) + mstore(add(add(bytecode, 0x20), size), 0) // Zeroize the last slot. + extcodecopy(ptr, add(bytecode, 0x20), _DATA_OFFSET, size) + + // Set up storage if hasStorage flag is set + if iszero(iszero(shr(255, ptr))) { + // "storageArgs()" + mstore(0x00, 0x5601376b) + pop(call(gas(), caller(), 0, 28, 0x04, 0, 0x20)) + ptr := mload(0) + pointerCodeSize := extcodesize(ptr) + if iszero(pointerCodeSize) { + // Store the function selector of `InvalidPointer()`. + mstore(0x00, 0x11052bb4) + // Revert with (offset, size). + revert(0x1c, 0x04) + } + + size := sub(pointerCodeSize, _DATA_OFFSET) + + let storageArgs := mload(0x40) + mstore(0x40, add(storageArgs, and(add(size, 0x3f), 0xffe0))) + mstore(storageArgs, size) + mstore(add(add(storageArgs, 0x20), size), 0) // Zeroize the last slot. + extcodecopy(ptr, add(storageArgs, 0x20), _DATA_OFFSET, size) + + let valsOffset := mload(add(storageArgs, 0x20)) + let locOffset := mload(add(storageArgs, 0x40)) + + let valsArrStart := add(storageArgs, 0x60) + let len := mload(valsArrStart) + let locArrStart := add(add(valsArrStart, mul(len, 0x20)), 0x20) + + for { ptr := mul(len, 0x20) } ptr { ptr := sub(ptr, 0x20) } { + sstore(mload(add(locArrStart, ptr)), mload(add(valsArrStart, ptr))) + } + } + return(add(bytecode, 0x20), mload(bytecode)) + } + } +} diff --git a/src/interfaces/ISingleKeccakCreate3.sol b/src/interfaces/ISingleKeccakCreate3.sol new file mode 100644 index 0000000..9ee7ff0 --- /dev/null +++ b/src/interfaces/ISingleKeccakCreate3.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +interface ISingleKeccakCreate3 { + function setupInfo() external view returns (bytes32); + function storageArgs() external view returns (bytes32); +} diff --git a/src/mocks/MockERC20.sol b/src/mocks/MockERC20.sol new file mode 100644 index 0000000..f8f961a --- /dev/null +++ b/src/mocks/MockERC20.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +import { ERC20 } from "@openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import { ShortString, ShortStrings } from "@openzeppelin-contracts/contracts/utils/ShortStrings.sol"; +import { Ownable } from "@openzeppelin-contracts/contracts/access/Ownable.sol"; + +/** + * @title MockERC20 + * @dev is ownable, uses immutables for ERC20 name()/symbol(), and does mint on creation + */ +contract MockERC20 is ERC20, Ownable { + /** + * Storage Layout: + * balances in slot 0 + * totalSupply in slot 2 + * name in slot 3 + * symbol in slot 4 + * owner in slot 5 + */ + + using ShortStrings for string; + using ShortStrings for ShortString; + + ShortString internal immutable $name; + ShortString internal immutable $symbol; + + function name() public view override returns (string memory) { + return $name.toString(); + } + + function symbol() public view override returns (string memory) { + return $symbol.toString(); + } + + constructor(uint256 amt) ERC20("", "") Ownable(msg.sender) { + $name = string("testErc20").toShortString(); + $symbol = string("testErc20").toShortString(); + + _mint(msg.sender, amt); + } + + function mint(address to, uint256 amt) external onlyOwner { + _mint(to, amt); + } +} diff --git a/src/mocks/MockSingleKeccakCreate3.sol b/src/mocks/MockSingleKeccakCreate3.sol new file mode 100644 index 0000000..a34745c --- /dev/null +++ b/src/mocks/MockSingleKeccakCreate3.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +import { SingleKeccakCreate3 } from "../SingleKeccakCreate3.sol"; + +contract MockSingleKeccakCreate3 is SingleKeccakCreate3 { + function deploy(bytes32 salt, bytes calldata data, uint256 value) external returns (address addr) { + return _deploy(salt, data, value); + } + + function deployAndCall( + bytes32 salt, + bytes calldata data, + bytes[] calldata calldatas, + uint256[] calldata values, + uint256 value + ) + external + returns (address addr) + { + return _deployAndCall(salt, data, calldatas, values, value); + } + + function deployAndSetupStorage( + bytes32 salt, + bytes calldata creationCode, + bytes32[] calldata storageArgsVals, + bytes32[] calldata storageArgsLoc, + uint256 value + ) + external + returns (address) + { + return _deployAndSetupStorage(salt, creationCode, storageArgsVals, storageArgsLoc, value); + } + + function deployAndSetupStorageAndCall( + bytes32 salt, + bytes calldata creationCode, + bytes32[] calldata storageArgsVals, + bytes32[] calldata storageArgsLoc, + bytes[] calldata calldatas, + uint256[] calldata values, + uint256 value + ) + external + returns (address) + { + return + _deployAndSetupStorageAndCall(salt, creationCode, storageArgsVals, storageArgsLoc, calldatas, values, value); + } + + function getDeployedAddress(bytes32 salt) external view returns (address) { + return _getDeployedAddress(salt, address(this)); + } +} diff --git a/test/GasComparison.t.sol b/test/GasComparison.t.sol new file mode 100644 index 0000000..7d0e6f8 --- /dev/null +++ b/test/GasComparison.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { MockSingleKeccakCreate3 } from "../src/mocks/MockSingleKeccakCreate3.sol"; +import { MockERC20 } from "../src/mocks/MockERC20.sol"; +import { CREATE3 } from "@solady/src/utils/CREATE3.sol"; + +contract GasComparisonTest is Test { + uint256 mintAmt = 5; + MockERC20 public mockErc20; // has been deployed by deployer + MockSingleKeccakCreate3 public deployer; + + function setUp() public { + deployer = new MockSingleKeccakCreate3(); + + bytes32[] memory storageSlots = new bytes32[](1); + storageSlots[0] = bytes32(uint256(5)); + bytes32[] memory storageVals = new bytes32[](1); + storageVals[0] = bytes32(uint256(uint160(address(this)))); + + mockErc20 = MockERC20( + deployer.deployAndSetupStorage(0, address(new MockERC20(mintAmt)).code, storageVals, storageSlots, 0) + ); + } + + function test_soladyCreate3() public { + bytes memory creationCode = abi.encodePacked(type(MockERC20).creationCode, mintAmt); + CREATE3.deploy(0, creationCode, 0); + } + + function test_singleKeccakCreate3() public { + // mutate storage args a little to force a sstore2 instance + bytes32[] memory storageSlots = new bytes32[](3); + storageSlots[0] = + bytes32(keccak256(abi.encodePacked(bytes32(uint256(uint160(address(this)))), bytes32(uint256(0))))); + storageSlots[1] = bytes32(uint256(2)); + storageSlots[2] = bytes32(uint256(5)); + + bytes32[] memory storageVals = new bytes32[](3); + storageVals[0] = bytes32(uint256(mintAmt)); + storageVals[1] = bytes32(uint256(mintAmt)); + storageVals[2] = bytes32(uint256(uint160(address(this)))); + + // mutate the code a little to force a sstore2 instance + deployer.deployAndSetupStorage( + bytes32(uint256(1)), abi.encodePacked(address(mockErc20).code, uint256(1)), storageVals, storageSlots, 0 + ); + } + + function test_singleKeccakCreate3_NoStorage() public { + // mutate the code a little to force a sstore2 instance + deployer.deploy(bytes32(uint256(1)), abi.encodePacked(address(mockErc20).code, uint256(1)), 0); + } + + function test_singleKeccakCreate3_Repeat() public { + // mutate storage args a little to force a sstore2 instance + bytes32[] memory storageSlots = new bytes32[](3); + storageSlots[0] = + bytes32(keccak256(abi.encodePacked(bytes32(uint256(uint160(address(this)))), bytes32(uint256(0))))); + storageSlots[1] = bytes32(uint256(2)); + storageSlots[2] = bytes32(uint256(5)); + + bytes32[] memory storageVals = new bytes32[](3); + storageVals[0] = bytes32(uint256(mintAmt)); + storageVals[1] = bytes32(uint256(mintAmt)); + storageVals[2] = bytes32(uint256(uint160(address(this)))); + + // no sstore2 instance here + deployer.deployAndSetupStorage(bytes32(uint256(1)), address(mockErc20).code, storageVals, storageSlots, 0); + } + + function test_singleKeccakCreate3_NoStorage_Repeat() public { + // no sstore2 instance here + deployer.deploy(bytes32(uint256(1)), address(mockErc20).code, 0); + } +} diff --git a/test/SingleKeccakCreate3Test.t.sol b/test/SingleKeccakCreate3Test.t.sol new file mode 100644 index 0000000..40ee347 --- /dev/null +++ b/test/SingleKeccakCreate3Test.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { MockSingleKeccakCreate3 } from "../src/mocks/MockSingleKeccakCreate3.sol"; +import { MockERC20 } from "../src/mocks/MockERC20.sol"; + +contract SingleKeccakCreate3Test is Test { + MockERC20 public mockErc20; + + MockSingleKeccakCreate3 public deployer; + + function setUp() public { + deployer = new MockSingleKeccakCreate3(); + mockErc20 = new MockERC20(5); + } + + function test_flagsInPointer() public { + bytes32[] memory bytes32Arr = new bytes32[](1); + + // no storage, no immutable + deployer.deploy(0, address(mockErc20).code, 0); + assertEq((uint256(deployer.setupInfo() >> 255)), 0); + + // has storage + deployer.deployAndSetupStorage(bytes32(uint256(2)), address(mockErc20).code, bytes32Arr, bytes32Arr, 0); + assertEq((uint256(deployer.setupInfo() >> 255)), 1); + assertTrue(uint256(deployer.storageArgs()) >= 1); + } + + function test_deployedHasFunctionality() public { + MockERC20 a = MockERC20(deployer.deploy(0, address(mockErc20).code, 0)); + + assertEq(address(a).code.length, address(mockErc20).code.length); + assertEq(a.name(), "testErc20"); + assertEq(a.symbol(), "testErc20"); + assertEq(a.balanceOf(address(this)), 0); + } + + function test_matchDeployedBytecode() public { + // address.code gives runtime code with immutables already set + MockERC20 a = MockERC20(deployer.deploy(0, address(mockErc20).code, 0)); + + assertEq((address(mockErc20).code).length, (address(a).code).length); + assertEq(address(mockErc20).code, address(a).code); + } + + function test_getDeployedAddress(bytes32 r) public { + address a = deployer.getDeployedAddress(r); + address b = deployer.deploy(r, address(mockErc20).code, 0); + + assertEq(a, b); + } + + function test_noStorageOrImmutableVars() public { + // "creation code" with default values for immutables + MockERC20 a = new MockERC20(0); + + MockERC20 b = MockERC20(deployer.deploy(0, address(a).code, 0)); + + assertEq(b.totalSupply(), 0); + assertEq(b.balanceOf(address(this)), 0); + } + + function test_storageVars(uint128 mintAmt) public { + bytes32[] memory storageSlots = new bytes32[](3); + storageSlots[0] = + bytes32(keccak256(abi.encodePacked(bytes32(uint256(uint160(address(this)))), bytes32(uint256(0))))); + storageSlots[1] = bytes32(uint256(2)); + storageSlots[2] = bytes32(uint256(5)); + + bytes32[] memory storageVals = new bytes32[](3); + storageVals[0] = bytes32(uint256(mintAmt)); + storageVals[1] = bytes32(uint256(mintAmt)); + storageVals[2] = bytes32(uint256(uint160(address(this)))); + + MockERC20 b = + MockERC20(deployer.deployAndSetupStorage(0, address(mockErc20).code, storageVals, storageSlots, 0)); + + assertEq(b.totalSupply(), uint256(mintAmt)); + assertEq(b.balanceOf(address(this)), uint256(mintAmt)); + + b.mint(address(this), uint256(mintAmt)); // is owner, so can mint + assertEq(b.totalSupply(), uint256(mintAmt) * 2); + assertEq(b.balanceOf(address(this)), uint256(mintAmt) * 2); + } +}