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;
+    }
+}