From 9e648d139db4e65a901b95f8ef458216832c88d3 Mon Sep 17 00:00:00 2001 From: ChefMist <133624774+ChefMist@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:22:54 +0800 Subject: [PATCH] [Step 1 of 2] feat: add create2Factory (#1) * forge install: openzeppelin-contracts v5.0.2 * feat: add create2 factory * feat: clean up and make create2Factory more friendly * test: add more test * feat: include deployment script * updated foundry_out * forge install: forge-gas-snapshot * feat: clean up unnecessary files and add gas snapshot * feat: adjust higher optimizer runs for better gas * feat: add interfce and clean up * feedback: add nonReentrant for deploy and update test comment * feat: add test on bsc/eth forks to verify * include env variable in workflow to test --- ...toryTest#test_Deploy_ContractWithArgs.snap | 1 + ...oryTest#test_SetWhitelistedUser_false.snap | 1 + ...toryTest#test_SetWhitelistedUser_true.snap | 1 + .github/workflows/test.yml | 3 + .gitmodules | 6 + foundry.toml | 9 +- lib/forge-gas-snapshot | 1 + lib/openzeppelin-contracts | 1 + remappings.txt | 4 + script/01_DeployCreate2Factory.s.sol | 28 ++++ script/Counter.s.sol | 19 --- src/Counter.sol | 14 -- src/Create2Factory.sol | 54 +++++++ src/interfaces/ICreate2Factory.sol | 18 +++ test/Counter.t.sol | 24 --- test/Create2Factory.t.sol | 148 ++++++++++++++++++ test/Create2FactoryFork.t.sol | 61 ++++++++ test/mocks/MockOwner.sol | 11 ++ test/mocks/MockWithConstructorArgs.sol | 10 ++ 19 files changed, 356 insertions(+), 58 deletions(-) create mode 100644 .forge-snapshots/Create2FactoryTest#test_Deploy_ContractWithArgs.snap create mode 100644 .forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_false.snap create mode 100644 .forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_true.snap create mode 160000 lib/forge-gas-snapshot create mode 160000 lib/openzeppelin-contracts create mode 100644 remappings.txt create mode 100644 script/01_DeployCreate2Factory.s.sol delete mode 100644 script/Counter.s.sol delete mode 100644 src/Counter.sol create mode 100644 src/Create2Factory.sol create mode 100644 src/interfaces/ICreate2Factory.sol delete mode 100644 test/Counter.t.sol create mode 100644 test/Create2Factory.t.sol create mode 100644 test/Create2FactoryFork.t.sol create mode 100644 test/mocks/MockOwner.sol create mode 100644 test/mocks/MockWithConstructorArgs.sol diff --git a/.forge-snapshots/Create2FactoryTest#test_Deploy_ContractWithArgs.snap b/.forge-snapshots/Create2FactoryTest#test_Deploy_ContractWithArgs.snap new file mode 100644 index 0000000..8166f2a --- /dev/null +++ b/.forge-snapshots/Create2FactoryTest#test_Deploy_ContractWithArgs.snap @@ -0,0 +1 @@ +106498 \ No newline at end of file diff --git a/.forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_false.snap b/.forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_false.snap new file mode 100644 index 0000000..185c198 --- /dev/null +++ b/.forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_false.snap @@ -0,0 +1 @@ +25860 \ No newline at end of file diff --git a/.forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_true.snap b/.forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_true.snap new file mode 100644 index 0000000..968f04d --- /dev/null +++ b/.forge-snapshots/Create2FactoryTest#test_SetWhitelistedUser_true.snap @@ -0,0 +1 @@ +47772 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb6f74e..d73ff27 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,3 +23,6 @@ jobs: - name: Run tests run: forge test -vvv --isolate + env: + TESTNET_FORK_URL_BSC: ${{ secrets.TESTNET_FORK_URL_BSC }} + TESTNET_FORK_URL_SEPOLIA: ${{ secrets.TESTNET_FORK_URL_SEPOLIA }} diff --git a/.gitmodules b/.gitmodules index 888d42d..19c36b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/forge-gas-snapshot"] + path = lib/forge-gas-snapshot + url = https://github.com/marktoda/forge-gas-snapshot diff --git a/foundry.toml b/foundry.toml index 25b918f..813079f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,13 @@ [profile.default] src = "src" -out = "out" +out = 'foundry-out' libs = ["lib"] +ffi = true +fs_permissions = [ + { access = "read-write", path = ".forge-snapshots/" }, + { access = "read", path = "./foundry-out" }, +] +evm_version = 'cancun' +optimizer_runs = 30_000 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-gas-snapshot b/lib/forge-gas-snapshot new file mode 160000 index 0000000..cf34ad1 --- /dev/null +++ b/lib/forge-gas-snapshot @@ -0,0 +1 @@ +Subproject commit cf34ad1ed0a1f323e77557b9bce420f3385f7400 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..dbb6104 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..57dcf10 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,4 @@ +ds-test/=lib/forge-std/lib/ds-test/src/ +forge-std/=lib/forge-std/ +forge-gas-snapshot/=lib/forge-gas-snapshot/src/ +@openzeppelin/=lib/openzeppelin-contracts/ diff --git a/script/01_DeployCreate2Factory.s.sol b/script/01_DeployCreate2Factory.s.sol new file mode 100644 index 0000000..d459c2f --- /dev/null +++ b/script/01_DeployCreate2Factory.s.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/src/Script.sol"; +import {Create2Factory} from "../src/Create2Factory.sol"; + +/** + * forge script script/01_DeployCreate2Factory.s.sol:DeployCreate2FactoryScript -vvv \ + * --rpc-url $RPC_URL \ + * --broadcast \ + * --slow \ + * --verify + */ +contract DeployCreate2FactoryScript is Script { + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + // Sanity check to use nonce 0, to ensure the contract is deployed at the same address on other chain + uint64 nonce = vm.getNonce(vm.addr(deployerPrivateKey)); + vm.assertEq(nonce, 0, "Must create contract with nonce 0"); + + Create2Factory create2Factory = new Create2Factory(); + console.log("Create2Factory contract deployed at ", address(create2Factory)); + + vm.stopBroadcast(); + } +} diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/Create2Factory.sol b/src/Create2Factory.sol new file mode 100644 index 0000000..f0dea31 --- /dev/null +++ b/src/Create2Factory.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import {ICreate2Factory} from "./interfaces/ICreate2Factory.sol"; +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @notice Deploy contracts in a deterministic way using CREATE2 +/// @dev ensure this contract is deployed on multiple chain with the same address +contract Create2Factory is ICreate2Factory, Ownable2Step, ReentrancyGuard { + event SetWhitelist(address indexed user, bool isWhitelist); + + // Only whitelisted user can interact with create2Factory + mapping(address user => bool isWhitelisted) public isUserWhitelisted; + + modifier onlyWhitelisted() { + require(isUserWhitelisted[msg.sender], "Create2Factory: caller is not whitelisted"); + _; + } + + constructor() Ownable(msg.sender) { + isUserWhitelisted[msg.sender] = true; + } + + /// @inheritdoc ICreate2Factory + function deploy(bytes32 salt, bytes memory creationCode) + external + payable + onlyWhitelisted + nonReentrant + returns (address deployed) + { + deployed = Create2.deploy(msg.value, salt, creationCode); + } + + /// @inheritdoc ICreate2Factory + function computeAddress(bytes32 salt, bytes32 bytecodeHash) public view returns (address) { + return Create2.computeAddress(salt, bytecodeHash); + } + + /// @inheritdoc ICreate2Factory + function execute(address target, bytes calldata data) external payable onlyWhitelisted nonReentrant { + (bool success,) = target.call{value: msg.value}(data); + require(success, "Create2Factory: failed execute call"); + } + + /// @inheritdoc ICreate2Factory + function setWhitelistUser(address user, bool isWhiteList) external onlyOwner { + isUserWhitelisted[user] = isWhiteList; + + emit SetWhitelist(user, isWhiteList); + } +} diff --git a/src/interfaces/ICreate2Factory.sol b/src/interfaces/ICreate2Factory.sol new file mode 100644 index 0000000..7b8e5fd --- /dev/null +++ b/src/interfaces/ICreate2Factory.sol @@ -0,0 +1,18 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ICreate2Factory { + /// @notice create2 deploy a contract + /// @dev So long the same salt, creationCode is used, the contract will be deployed at the same address on other chain + function deploy(bytes32 salt, bytes memory creationCode) external payable returns (address deployed); + + /// @notice compute the create2 address based on salt, bytecodeHash + function computeAddress(bytes32 salt, bytes32 bytecodeHash) external view returns (address); + + /// @notice execute a call on a deployed contract + /// @dev used in scenario where contract owner is create2Factory and we need to transfer ownership + function execute(address target, bytes calldata data) external payable; + + /// @notice set user as whitelisted + function setWhitelistUser(address user, bool isWhiteList) external; +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/Create2Factory.t.sol b/test/Create2Factory.t.sol new file mode 100644 index 0000000..e46f050 --- /dev/null +++ b/test/Create2Factory.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/src/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {Create2Factory} from "../src/Create2Factory.sol"; +import {MockOwner} from "./mocks/MockOwner.sol"; +import {MockWithConstructorArgs} from "./mocks/MockWithConstructorArgs.sol"; + +contract Create2FactoryTest is Test, GasSnapshot { + Create2Factory create2Factory; + + address pcsDeployer = makeAddr("pcsDeployer"); + address alice = makeAddr("alice"); + + function setUp() public { + create2Factory = new Create2Factory(); + create2Factory.setWhitelistUser(pcsDeployer, true); + } + + function test_Deploy_ContractWithArgs() public { + // deploy + bytes memory creationCode = abi.encodePacked(type(MockWithConstructorArgs).creationCode, abi.encode(42)); + bytes32 salt = bytes32(uint256(0x1234)); + address deployed = create2Factory.deploy(salt, creationCode); + snapLastCall("Create2FactoryTest#test_Deploy_ContractWithArgs"); + + // verify + address expectedDeployed = create2Factory.computeAddress(salt, keccak256(creationCode)); + assertEq(deployed, expectedDeployed); + + MockWithConstructorArgs deployedContract = MockWithConstructorArgs(deployed); + assertEq(deployedContract.args(), 42); + } + + /// @dev different nonce should still deploy at the same address + function test_Deploy_DifferentNonce(uint64 nonce) public { + vm.setNonce(pcsDeployer, nonce); + vm.startPrank(pcsDeployer); + + // deploy + bytes memory creationCode = type(MockOwner).creationCode; + bytes32 salt = bytes32(uint256(0x1234)); + address deployed = create2Factory.deploy(salt, creationCode); + + // verify + address expectedDeployed = create2Factory.computeAddress(salt, keccak256(creationCode)); + assertEq(deployed, expectedDeployed); + } + + /// @dev deployment address should match with different address + function test_Deploy_DifferentDeployer(address deployer) public { + vm.assume(deployer != address(0)); + create2Factory.setWhitelistUser(deployer, true); + + // deploy as deployer + bytes memory creationCode = type(MockOwner).creationCode; + bytes32 salt = bytes32(uint256(0x1234)); + vm.prank(deployer); + address deployed = create2Factory.deploy(salt, creationCode); + + // verify + address expectedDeployed = create2Factory.computeAddress(salt, keccak256(creationCode)); + assertEq(deployed, expectedDeployed); + } + + function test_Deploy_NotWhitelisted() public { + vm.startPrank(alice); + + // deploy + bytes memory creationCode = type(MockOwner).creationCode; + bytes32 salt = bytes32(uint256(0x1234)); + vm.expectRevert("Create2Factory: caller is not whitelisted"); + create2Factory.deploy(salt, creationCode); + } + + function test_Execute() public { + vm.startPrank(pcsDeployer); + + // deploy + bytes memory creationCode = type(MockOwner).creationCode; + bytes32 salt = bytes32(uint256(0x1234)); + address deployed = create2Factory.deploy(salt, creationCode); + + // verify + MockOwner owner = MockOwner(deployed); + assertEq(owner.owner(), address(create2Factory)); + + // execute + bytes memory data = abi.encodeWithSignature("transferOwnership(address)", pcsDeployer); + create2Factory.execute(deployed, data); + assertEq(owner.owner(), pcsDeployer); + } + + function test_Execute_Payable() public { + vm.deal(pcsDeployer, 1 ether); + vm.startPrank(pcsDeployer); + + // deploy + bytes memory creationCode = type(MockOwner).creationCode; + bytes32 salt = bytes32(uint256(0x1234)); + address deployed = create2Factory.deploy(salt, creationCode); + + // before + assertEq(deployed.balance, 0); + + // execute + bytes memory data = abi.encodeWithSignature("payableFunc()"); + create2Factory.execute{value: 1 ether}(deployed, data); + + // after + assertEq(deployed.balance, 1 ether); + } + + function test_Execute_NotWhitelisted() public { + vm.prank(alice); + vm.expectRevert("Create2Factory: caller is not whitelisted"); + bytes memory data = abi.encodeWithSignature("transferOwnership(address)", alice); + create2Factory.execute(makeAddr("random"), data); + } + + function test_SetWhitelistedUser() public { + // before + assertEq(create2Factory.isUserWhitelisted(alice), false); + + // set whitelisted + vm.expectEmit(); + emit Create2Factory.SetWhitelist(alice, true); + create2Factory.setWhitelistUser(alice, true); + snapLastCall("Create2FactoryTest#test_SetWhitelistedUser_true"); + assertEq(create2Factory.isUserWhitelisted(alice), true); + + // set not whitelisted + vm.expectEmit(); + emit Create2Factory.SetWhitelist(alice, false); + create2Factory.setWhitelistUser(alice, false); + snapLastCall("Create2FactoryTest#test_SetWhitelistedUser_false"); + assertEq(create2Factory.isUserWhitelisted(alice), false); + } + + function test_SetWhitelistUser_OnlyOwner() public { + vm.prank(alice); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + create2Factory.setWhitelistUser(alice, true); + } +} diff --git a/test/Create2FactoryFork.t.sol b/test/Create2FactoryFork.t.sol new file mode 100644 index 0000000..48d0ae7 --- /dev/null +++ b/test/Create2FactoryFork.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/src/Test.sol"; +import {Create2Factory} from "../src/Create2Factory.sol"; +import {MockOwner} from "./mocks/MockOwner.sol"; + +/// @dev run tests on testnet fork (bsc/sepolia) to verify same contract address across chain. To run the test, +/// ensure TESTNET_FORK_URL_BSC and TESTNET_FORK_URL_SEPOLIA environment variable are set +contract Create2FactoryForkTest is Test { + Create2Factory create2Factory; + + address create2Deployer = makeAddr("pcsDeployer"); + /// @dev address with difference nonce on bsc / eth + address pcsDeployer = 0x42571B8414c68B63A2729146CE93F23639d25399; + + function test_Deploy_OnTestnetFork() public { + if (!vm.envExists("TESTNET_FORK_URL_BSC") || !vm.envExists("TESTNET_FORK_URL_SEPOLIA")) { + return; + } + + // deploy on bsc + uint256 bscForkId = vm.createFork(vm.envString("TESTNET_FORK_URL_BSC")); + uint256 sepoliaForkId = vm.createFork(vm.envString("TESTNET_FORK_URL_SEPOLIA")); + + //////////////////////////////////////////////////////// + // Step 1: Deploy create2Factory on both chain + //////////////////////////////////////////////////////// + vm.selectFork(bscForkId); + vm.startPrank(create2Deployer); + Create2Factory bscCreate2 = new Create2Factory(); + bscCreate2.setWhitelistUser(pcsDeployer, true); + vm.stopPrank(); + + vm.selectFork(sepoliaForkId); + vm.startPrank(create2Deployer); + Create2Factory sepoliaCreate2 = new Create2Factory(); + bscCreate2.setWhitelistUser(pcsDeployer, true); + vm.stopPrank(); + + // assert step 1 + assertEq(address(bscCreate2), address(sepoliaCreate2)); + + //////////////////////////////////////////////////////// + // Step 2: Deploy contracts on both chain using pcsDeployer + //////////////////////////////////////////////////////// + bytes memory creationCode = type(MockOwner).creationCode; + bytes32 salt = bytes32(uint256(0x1234)); + + vm.selectFork(bscForkId); + vm.prank(pcsDeployer); + address bscMockOwner = bscCreate2.deploy(salt, creationCode); + + vm.selectFork(sepoliaForkId); + vm.prank(pcsDeployer); + address sepoliaMockOwner = sepoliaCreate2.deploy(salt, creationCode); + + // assert step 2 + assertEq(bscMockOwner, sepoliaMockOwner); + } +} diff --git a/test/mocks/MockOwner.sol b/test/mocks/MockOwner.sol new file mode 100644 index 0000000..44f503f --- /dev/null +++ b/test/mocks/MockOwner.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract MockOwner is Ownable { + constructor() Ownable(msg.sender) {} + + /// @notice test function to test payableFunc + function payableFunc() external payable {} +} diff --git a/test/mocks/MockWithConstructorArgs.sol b/test/mocks/MockWithConstructorArgs.sol new file mode 100644 index 0000000..f639166 --- /dev/null +++ b/test/mocks/MockWithConstructorArgs.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +contract MockWithConstructorArgs { + uint256 public args; + + constructor(uint256 _args) { + args = _args; + } +}