From a4252076bfe5a28034a8e0e3c424542c867d7b35 Mon Sep 17 00:00:00 2001 From: Spencer Graham Date: Mon, 25 Dec 2023 12:09:37 -0600 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 100 +++++++++++++++++++++++++ .github/workflows/foundry-gas-diff.yml | 60 +++++++++++++++ .gitignore | 6 ++ .gitmodules | 6 ++ .markdownlintrc | 8 ++ README.md | 25 +++++++ example.env | 10 +++ foundry.toml | 72 ++++++++++++++++++ lib/forge-std | 1 + lib/hats-module | 1 + remappings.txt | 11 +++ script/Deploy.s.sol | 93 +++++++++++++++++++++++ src/Module.sol | 82 ++++++++++++++++++++ test/Module.t.sol | 90 ++++++++++++++++++++++ 14 files changed, 565 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/foundry-gas-diff.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .markdownlintrc create mode 100644 README.md create mode 100644 example.env create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 160000 lib/hats-module create mode 100644 remappings.txt create mode 100644 script/Deploy.s.sol create mode 100644 src/Module.sol create mode 100644 test/Module.t.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11d2e8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + +env: + FOUNDRY_PROFILE: ci + INFURA_KEY: ${{ secrets.INFURA_KEY }} + ALCHEMY_KEY: ${{ secrets.ALCHEMY_KEY }} + GC_RPC: ${{ secrets.GC_RPC }} + POLYGON_RPC: ${{ secrets.POLYGON_RPC }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + +jobs: + + lint: + name: "Markdown linting" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + name: Check out the code + - name: Lint Code Base + uses: docker://avtodev/markdown-lint:v1 + with: + args: "**/*.md" + test: + name: "Foundry tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Check formatting + run: forge fmt --check + + - name: Check contract sizes + run: forge build --sizes --skip script + # Replace the above line with the below if using pre-compiled ir-optimized contracts + # run: FOUNDRY_PROFILE=optimized forge build --sizes --skip script + + - name: Run tests + run: forge test -vvv + + # coverage: + # name: "Test coverage" + + # env: + # FOUNDRY_PROFILE: lite + + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v3 + + # - name: Install Foundry + # uses: foundry-rs/foundry-toolchain@v1 + # with: + # version: nightly + + # - name: Run coverage + # run: forge coverage --report summary --report lcov + + # # To ignore coverage for certain directories modify the paths in this step as needed. The + # # below default ignores coverage results for the test and script directories. Alternatively, + # # to include coverage in all directories, comment out this step. Note that because this + # # filtering applies to the lcov file, the summary table generated in the previous step will + # # still include all files and directories. + # # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov + # # defaults to removing branch info. + # - name: Filter directories + # run: | + # sudo apt update && sudo apt install -y lcov + # lcov --remove lcov.info 'src/mocks/*' 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1 + + # # This step posts a detailed coverage report as a comment and deletes previous comments on + # # each push. The below step is used to fail coverage if the specified coverage threshold is + # # not met. The below step can post a comment (when it's `github-token` is specified) but it's + # # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which + # # is why we use both in this way. + # - name: Post coverage report + # if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. + # uses: romeovs/lcov-reporter-action@v0.3.1 + # with: + # delete-old-comments: true + # lcov-file: ./lcov.info + # github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. + + # - name: Verify minimum coverage + # uses: zgosalvez/github-actions-report-lcov@v2 + # with: + # coverage-files: ./lcov.info + # minimum-coverage: 95 # Set coverage threshold. \ No newline at end of file diff --git a/.github/workflows/foundry-gas-diff.yml b/.github/workflows/foundry-gas-diff.yml new file mode 100644 index 0000000..b737e89 --- /dev/null +++ b/.github/workflows/foundry-gas-diff.yml @@ -0,0 +1,60 @@ +name: Report gas diff + +on: + push: + branches: + - main + pull_request: + # Optionally configure to run only for changes in specific files. For example: + paths: + - src/** + - test/** + - foundry.toml + - remappings.txt + - .github/workflows/foundry-gas-diff.yml + +env: + FOUNDRY_PROFILE: default + INFURA_KEY: ${{ secrets.INFURA_KEY }} + ALCHEMY_KEY: ${{ secrets.ALCHEMY_KEY }} + GC_RPC: ${{ secrets.GC_RPC }} + POLYGON_RPC: ${{ secrets.POLYGON_RPC }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + +jobs: + compare_gas_reports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + # Add any step generating a gas report to a temporary file named gasreport.ansi. For example: + - name: Run tests + run: forge test --gas-report > gasreport.ansi # <- this file name should be unique in your repository! + env: + # make fuzzing semi-deterministic to avoid noisy gas cost estimation + # due to non-deterministic fuzzing (but still use pseudo-random fuzzing seeds) + FOUNDRY_FUZZ_SEED: 0x${{ github.event.pull_request.base.sha || github.sha }} + + - name: Compare gas reports + uses: Rubilmax/foundry-gas-diff@v3.13.6 + with: + summaryQuantile: 0.9 # only display the 10% most significant gas diffs in the summary (defaults to 20%) + sortCriteria: avg,max # sort diff rows by criteria + sortOrders: desc,asc # and directions + ignore: test-foundry/**/* # filter out gas reports from specific paths (test/ is included by default) + id: gas_diff + + - name: Add gas diff to sticky comment + if: github.event_name == 'pull_request' || github.event_name == 'pull_request_target' + uses: marocchino/sticky-pull-request-comment@v2 + with: + # delete the comment in case changes no longer impact gas costs + delete: ${{ !steps.gas_diff.outputs.markdown }} + message: ${{ steps.gas_diff.outputs.markdown }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83f2723 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +cache/ +out/ +optimized-out/ +.vscode/ +broadcast/ +.env \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d3d1250 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/hats-module"] + path = lib/hats-module + url = https://github.com/Hats-Protocol/hats-module diff --git a/.markdownlintrc b/.markdownlintrc new file mode 100644 index 0000000..4fd2a6c --- /dev/null +++ b/.markdownlintrc @@ -0,0 +1,8 @@ +{ + "MD024": false, + "MD013": false, + "MD026": false, + "MD028": false, + "MD033": false, + "MD046": false +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3869f78 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# hats-module-template + +Template repo for Hats Module projects. + +## Overview and Usage + +## Development + +This repo uses Foundry for development and testing. To get started: + +1. Fork the project +2. Install [Foundry](https://book.getfoundry.sh/getting-started/installation) +3. To install dependencies, run `forge install` +4. To compile the contracts, run `forge build` +5. To test, run `forge test` + +### IR-Optimized Builds + +This repo also supports contracts compiled via IR. Since compiling all contracts via IR would slow down testing workflows, we only want to do this for our target contract(s), not anything in this `test` or `script` stack. We accomplish this by pre-compiled the target contract(s) and then loading the pre-compiled artifacts in the test suite. + +First, we compile the target contract(s) via IR by running`FOUNDRY_PROFILE=optimized forge build` (ensuring that FOUNDRY_PROFILE is not in our .env file) + +Next, ensure that tests are using the `DeployOptimized` script, and run `forge test` as normal. + +See the wonderful [Seaport repo](https://github.com/ProjectOpenSea/seaport/blob/main/README.md#foundry-tests) for more details and options for this approach. diff --git a/example.env b/example.env new file mode 100644 index 0000000..ee2fc7e --- /dev/null +++ b/example.env @@ -0,0 +1,10 @@ +GC_RPC= +POLYGON_RPC= +INFURA_KEY= +export PRIVATE_KEY= +ETHERSCAN_KEY= +GNOSISSCAN_KEY= +POLYGONSCAN_KEY= +OPTIMISM_KEY= +ARBISCAN_KEY= +FOUNDRY_PROFILE= \ No newline at end of file diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..0f0a747 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,72 @@ +[profile.default] +src = 'src' +out = 'out' +libs = ['lib'] +optimizer_runs = 1_000_000 +# the following setting ensures that deterministically deployed contracts will always be to the same address +bytecode_hash = "none" +gas_reports = [] +auto_detect_solc = false +solc = "0.8.19" +remappings = [ + "ds-test/=lib/forge-std/lib/ds-test/src/", + "forge-std/=lib/forge-std/src/", + "hats-module/=lib/hats-module/src/", +] +# Enable tests to read ir-optimized bytecode precompiled by profile.optimized +fs_permissions = [{ access = "read", path = "./optimized-out" }] + +# for pre-compiling ir-optimized bytecode that will be later deployed by tests +[profile.optimized] +via_ir = true +out = "optimized-out" +script = "src" +bytecode_hash = "none" +sizes = true +# no need to compile tests with via-ir since they load optimized bytecode directly by default +test = "src" + +# for running tests against pre-compiled ir-optimized deployments +[profile.test] +src = "test" + +[profile.ci] +fuzz = { runs = 5000 } +invariant = { runs = 1000 } + +[profile.lite] +fuzz = { runs = 32 } +invariant = { runs = 10 } +# Speed up compilation and tests during development. +optimizer = false + +[fmt] +bracket_spacing = true +int_types = "long" +line_length = 120 +multiline_func_header = "attributes_first" +number_underscore = "thousands" +quote_style = "double" +tab_width = 2 +wrap_comments = true + +[rpc_endpoints] +arbitrum = "https://arbitrum-mainnet.infura.io/v3/${INFURA_KEY}" +gnosis = "${GC_RPC}" +goerli = "https://goerli.infura.io/v3/${INFURA_KEY}" +local = "http://localhost:8545" +mainnet = "https://mainnet.infura.io/v3/${INFURA_KEY}" +optimism = "https://optimism-mainnet.infura.io/v3/${INFURA_KEY}" +polygon = "${POLYGON_RPC}" +sepolia = "https://sepolia.infura.io/v3/${INFURA_KEY}" + +[etherscan] +arbitrum = { key = "${ARBISCAN_KEY}", url = "https://api.arbiscan.io/api" } +goerli = { key = "${ETHERSCAN_KEY}", url = "https://api-goerli.etherscan.io/api" } +gnosis = { key = "${GNOSISSCAN_KEY}", url = "https://api.gnosisscan.io/api" } +mainnet = { key = "${ETHERSCAN_KEY}", url = "https://api.etherscan.io/api" } +optimism = { key = "${OPTIMISM_KEY}", url = "https://api-optimistic.etherscan.io/api" } +sepolia = { key = "${ETHERSCAN_KEY}", url = "https://api-sepolia.etherscan.io/api" } +polygon = { key = "${POLYGONSCAN_KEY}", url = "https://api.polygonscan.com/api" } + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..74cfb77 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 74cfb77e308dd188d2f58864aaf44963ae6b88b1 diff --git a/lib/hats-module b/lib/hats-module new file mode 160000 index 0000000..38f80ee --- /dev/null +++ b/lib/hats-module @@ -0,0 +1 @@ +Subproject commit 38f80eed6ce444f924f5982858daf75e724be6f9 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..a1579e1 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,11 @@ +ds-test/=lib/forge-std/lib/ds-test/src/ +forge-std/=lib/forge-std/src/ +hats-module/=lib/hats-module/src/ +@openzeppelin-contracts/=lib/hats-module/lib/openzeppelin-contracts/ +ERC1155/=lib/hats-module/lib/hats-protocol/lib/ERC1155/ +hats-protocol/=lib/hats-module/lib/hats-protocol/src +openzeppelin-contracts/=lib/hats-module/lib/openzeppelin-contracts/ +solady/=lib/hats-module/lib/solady/src/ +solbase/=lib/hats-module/lib/hats-protocol/lib/solbase/src/ +solmate/=lib/hats-module/lib/solady/lib/solmate/src/ +utils/=lib/hats-module/lib/hats-protocol/lib/utils/ diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol new file mode 100644 index 0000000..5ea4e8d --- /dev/null +++ b/script/Deploy.s.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import { Script, console2 } from "forge-std/Script.sol"; +import { Module } from "../src/Module.sol"; + +contract Deploy is Script { + Module public implementation; + bytes32 public SALT = bytes32(abi.encode("change this to the value of your choice")); + + // default values + bool internal _verbose = true; + string internal _version = "0.0.1"; // increment this with each new deployment + + /// @dev Override default values, if desired + function prepare(bool verbose, string memory version) public { + _verbose = verbose; + _version = version; + } + + /// @dev Set up the deployer via their private key from the environment + function deployer() public returns (address) { + uint256 privKey = vm.envUint("PRIVATE_KEY"); + return vm.rememberKey(privKey); + } + + function _log(string memory prefix) internal view { + if (_verbose) { + console2.log(string.concat(prefix, "Module:"), address(implementation)); + } + } + + /// @dev Deploy the contract to a deterministic address via forge's create2 deployer factory. + function run() public virtual { + vm.startBroadcast(deployer()); + + /** + * @dev Deploy the contract to a deterministic address via forge's create2 deployer factory, which is at this + * address on all chains: `0x4e59b44847b379578588920cA78FbF26c0B4956C`. + * The resulting deployment address is determined by only two factors: + * 1. The bytecode hash of the contract to deploy. Setting `bytecode_hash` to "none" in foundry.toml ensures that + * never differs regardless of where its being compiled + * 2. The provided salt, `SALT` + */ + implementation = new Module{ salt: SALT}(_version /* insert constructor args here */); + + vm.stopBroadcast(); + + _log(""); + } +} + +/// @dev Deploy pre-compiled ir-optimized bytecode to a non-deterministic address +contract DeployPrecompiled is Deploy { + /// @dev Update SALT and default values in Deploy contract + + function run() public override { + vm.startBroadcast(deployer()); + + bytes memory args = abi.encode( /* insert constructor args here */ ); + + /// @dev Load and deploy pre-compiled ir-optimized bytecode. + implementation = Module(deployCode("optimized-out/Module.sol/Module.json", args)); + + vm.stopBroadcast(); + + _log("Precompiled "); + } +} + +/* FORGE CLI COMMANDS + +## A. Simulate the deployment locally +forge script script/Deploy.s.sol -f mainnet + +## B. Deploy to real network and verify on etherscan +forge script script/Deploy.s.sol -f mainnet --broadcast --verify + +## C. Fix verification issues (replace values in curly braces with the actual values) +forge verify-contract --chain-id 1 --num-of-optimizations 1000000 --watch --constructor-args $(cast abi-encode \ + "constructor({args})" "{arg1}" "{arg2}" "{argN}" ) \ + --compiler-version v0.8.19 {deploymentAddress} \ + src/{Counter}.sol:{Counter} --etherscan-api-key $ETHERSCAN_KEY + +## D. To verify ir-optimized contracts on etherscan... + 1. Run (C) with the following additional flag: `--show-standard-json-input > etherscan.json` + 2. Patch `etherscan.json`: `"optimizer":{"enabled":true,"runs":100}` => +`"optimizer":{"enabled":true,"runs":100},"viaIR":true` + 3. Upload the patched `etherscan.json` to etherscan manually + + See this github issue for more: https://github.com/foundry-rs/foundry/issues/3507#issuecomment-1465382107 + +*/ diff --git a/src/Module.sol b/src/Module.sol new file mode 100644 index 0000000..40ac940 --- /dev/null +++ b/src/Module.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +// import { console2 } from "forge-std/Test.sol"; // remove before deploy +import { HatsModule } from "hats-module/HatsModule.sol"; + +contract Module is HatsModule { + /*////////////////////////////////////////////////////////////// + CUSTOM ERRORS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + DATA MODELS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /** + * This contract is a clone with immutable args, which means that it is deployed with a set of + * immutable storage variables (ie constants). Accessing these constants is cheaper than accessing + * regular storage variables (such as those set on initialization of a typical EIP-1167 clone), + * but requires a slightly different approach since they are read from calldata instead of storage. + * + * Below is a table of constants and their location. + * + * For more, see here: https://github.com/Saw-mon-and-Natalie/clones-with-immutable-args + * + * ----------------------------------------------------------------------+ + * CLONE IMMUTABLE "STORAGE" | + * ----------------------------------------------------------------------| + * Offset | Constant | Type | Length | Source | + * ----------------------------------------------------------------------| + * 0 | IMPLEMENTATION | address | 20 | HatsModule | + * 20 | HATS | address | 20 | HatsModule | + * 40 | hatId | uint256 | 32 | HatsModule | + * 72+ | {other constants} | address | - | {this} | + * ----------------------------------------------------------------------+ + */ + + /*////////////////////////////////////////////////////////////// + MUTABLE STATE + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /// @notice Deploy the implementation contract and set its version + /// @dev This is only used to deploy the implementation contract, and should not be used to deploy clones + constructor(string memory _version) HatsModule(_version) { } + + /*////////////////////////////////////////////////////////////// + INITIALIZOR + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc HatsModule + function _setUp(bytes calldata _initData) internal override { + // decode init data + } + + /*////////////////////////////////////////////////////////////// + PUBLIC FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + VIEW FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + MODIFERS + //////////////////////////////////////////////////////////////*/ +} diff --git a/test/Module.t.sol b/test/Module.t.sol new file mode 100644 index 0000000..286c1c1 --- /dev/null +++ b/test/Module.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import { Test, console2 } from "forge-std/Test.sol"; +import { Module } from "../src/Module.sol"; +import { Deploy, DeployPrecompiled } from "../script/Deploy.s.sol"; +import { + HatsModuleFactory, IHats, deployModuleInstance, deployModuleFactory +} from "hats-module/utils/DeployFunctions.sol"; +import { IHats } from "hats-protocol/Interfaces/IHats.sol"; + +contract ModuleTest is Deploy, Test { + /// @dev Inherit from DeployPrecompiled instead of Deploy if working with pre-compiled contracts + + /// @dev variables inhereted from Deploy script + // Module public implementation; + // bytes32 public SALT; + + uint256 public fork; + uint256 public BLOCK_NUMBER = 17_671_864; // deployment block for Hats.sol + IHats public HATS = IHats(0x3bc1A0Ad72417f2d411118085256fC53CBdDd137); // v1.hatsprotocol.eth + HatsModuleFactory public factory; + Module public instance; + bytes public otherImmutableArgs; + bytes public initArgs; + uint256 public hatId; + + string public MODULE_VERSION; + + function setUp() public virtual { + // create and activate a fork, at BLOCK_NUMBER + fork = vm.createSelectFork(vm.rpcUrl("mainnet"), BLOCK_NUMBER); + + // deploy implementation via the script + prepare(false, MODULE_VERSION); + run(); + + // deploy the hats module factory + factory = deployModuleFactory(HATS, SALT, "test factory"); + } +} + +contract WithInstanceTest is ModuleTest { + function setUp() public virtual override { + super.setUp(); + + // set up the hats + + // set up the other immutable args + otherImmutableArgs = abi.encodePacked(); + + // set up the init args + initArgs = abi.encode(); + + // deploy an instance of the module + instance = Module(deployModuleInstance(factory, address(implementation), hatId, otherImmutableArgs, initArgs)); + } +} + +contract Deployment is WithInstanceTest { + /// @dev ensure that both the implementation and instance are properly initialized + function test_initialization() public { + // implementation + vm.expectRevert("Initializable: contract is already initialized"); + implementation.setUp("setUp attempt"); + // instance + vm.expectRevert("Initializable: contract is already initialized"); + instance.setUp("setUp attempt"); + } + + function test_version() public { + assertEq(instance.version(), MODULE_VERSION); + } + + function test_implementation() public { + assertEq(address(instance.IMPLEMENTATION()), address(implementation)); + } + + function test_hats() public { + assertEq(address(instance.HATS()), address(HATS)); + } + + function test_hatId() public { + assertEq(instance.hatId(), hatId); + } + + // test other initial values +} + +contract UnitTests is WithInstanceTest { }