diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..841dc7e --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +######################################## +## Keys and Addresses ## +######################################## +PRIVATE_KEY= +DEPLOYER_ADDRESS= + +######################################## +## RPC URLs ## +######################################## +ETH_RPC_URL= \ No newline at end of file diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..b21f54b --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,14 @@ +name: "Setup" +description: "Install Foundry" + +runs: + using: "composite" + steps: + - name: "Install Foundry" + uses: "foundry-rs/foundry-toolchain@c7450ba673e133f5ee30098b3b54f444d3a2ca2d" + with: + version: "stable" + + - name: "Show the Foundry config" + shell: "bash" + run: "forge --version && echo '\n' && forge config" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f67383 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: "CI" + +permissions: {} + +# Uncomment to use secrets +# env: +# ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} +# ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + +on: + workflow_dispatch: + pull_request: + push: + branches: + - "main" + +jobs: + build: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd" + with: + submodules: recursive + + - name: "Setup" + uses: "./.github/actions/setup" + + - name: "Build the contracts and print their size" + run: "forge build --sizes --deny warnings" + + format: + runs-on: "ubuntu-latest" + needs: build + steps: + - name: "Check out the repo" + uses: "actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd" + with: + submodules: recursive + + - name: "Setup" + uses: "./.github/actions/setup" + + - name: "Install solhint" + run: "npm i -g solhint" + + - name: "Print solhint version" + run: "solhint --version" + + - name: "Lint the code" + run: "make lint" + + slither: + runs-on: ubuntu-latest + needs: build + steps: + - name: "Check out the repo" + uses: "actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd" + with: + submodules: recursive + + - name: "Setup" + uses: "./.github/actions/setup" + + - name: "Build contracts in src/ folder" + run: forge build --build-info --skip test script + + - name: "Run slither analyzer" + uses: crytic/slither-action@f197989dea5b53e986d0f88c60a034ddd77ec9a8 + with: + ignore-compile: false + slither-version: "0.11.0" + fail-on: "low" + + test: + runs-on: "ubuntu-latest" + needs: build + steps: + - name: "Check out the repo" + uses: "actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd" + with: + submodules: recursive + + - name: "Setup" + uses: "./.github/actions/setup" + + - 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: "FOUNDRY_PROFILE=ci forge test --force --isolate -vvv --show-progress --gas-snapshot-check true" + + - name: "Check test coverage" + env: + COVERAGE_MIN: 100 + run: make coverage-check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index b79c8d4..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CI - -permissions: {} - -on: - push: - pull_request: - workflow_dispatch: - -env: - FOUNDRY_PROFILE: ci - -jobs: - check: - name: Foundry project - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Show Forge version - run: forge --version - - - name: Run Forge fmt - run: forge fmt --check - - - name: Run Forge build - run: forge build --sizes - - - name: Run Forge tests - run: forge test -vvv diff --git a/.gitignore b/.gitignore index 85198aa..974d4fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Compiler files cache/ out/ +coverage # Ignores development broadcast logs !/broadcast @@ -12,3 +13,16 @@ docs/ # Dotenv file .env + +# Agents +.claude +.agents + +# Miscellaneous +*.log +.DS_Store +.pnp.* +lcov.info +.gas-snapshot +.cursorignore +coverage.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ac9ef14 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: mixed-line-ending + name: Mixed Line Ending (convert to LF) + args: ["--fix=lf"] + description: Forces to replace line ending by the UNIX 'lf' character. + exclude: "^docs/autogen" + + - repo: local + hooks: + - id: format + name: Run lint + description: Run lint with `make lint` + language: system + entry: make lint + exclude: "^lib/" + types: [solidity] + pass_filenames: false + + - id: slither + name: Run Slither + description: Run Slither with `make slither` + language: system + entry: make slither + types: [solidity] + pass_filenames: false + + - id: snapshot + name: Generate gas snapshot (might take a while) + description: Generate gas snapshot with `make snapshot` (might take a while) + language: system + entry: bash -c 'make snapshot-pre-commit 2>&1 | tee /dev/tty' + types: [solidity] + pass_filenames: true + require_serial: true diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..220cd26 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,55 @@ +{ + "extends": "solhint:recommended", + "rules": { + "code-complexity": ["error", 8], + "compiler-version": ["error", ">=0.8.34"], + "func-visibility": [ + "error", + { + "ignoreConstructors": true + } + ], + "max-line-length": ["error", 125], + "one-contract-per-file": "error", + "named-parameters-mapping": "error", + "func-param-name-mixedcase": "error", + "modifier-name-mixedcase": "error", + "ordering": "error", + "func-name-mixedcase": "error", + "private-vars-leading-underscore": "off", + "no-console": "error", + "not-rely-on-time": "off", + "gas-custom-errors": "error", + "no-unused-vars": "error", + "no-complex-fallback": "off", + "payable-fallback": "off", + "avoid-tx-origin": "error", + "no-inline-assembly": "off", + "avoid-low-level-calls": "off", + "import-path-check": "off", + "func-named-parameters": "warn", + "interface-starts-with-i": "error", + "use-natspec": [ + "warn", + { + "notice": { + "enabled": true + }, + "param": { + "enabled": true + }, + "return": { + "enabled": true + }, + "author": { "enabled": false } + } + ], + "gas-strict-inequalities": "off", + "no-empty-blocks": "off", + "gas-indexed-events": "off", + "gas-increment-by-one": "off", + "gas-struct-packing": "off", + "function-max-lines": "off", + "max-states-count": "off" + } +} diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..753c424 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "solidity.compileUsingRemoteVersion": "v0.8.34+commit.80d5c536", + "[solidity]": { + "editor.defaultFormatter": "JuanBlanco.solidity" + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4cc41e2 --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +ifneq (,$(wildcard ./.env)) + include .env + export +endif + +.PHONY: all clean build lint slither test fmt coverage-check snapshot + +help: ## Print all targets and descriptions + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[.a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } END { printf "\n" }' $(MAKEFILE_LIST) + +all: ## Build, fmt, slither, check coverage, and snapshot gas + make clean && \ + make build && \ + make fmt && \ + make slither && \ + make coverage-check && \ + make snapshot + +clean: ## Clean the project + forge clean + +build: ## Build the contracts + forge build --force + +lint: ## Run lint + forge fmt --check && \ + solhint -c .solhint.json --max-warnings 0 "src/**/*.sol" && \ + solhint -c script/.solhint.json --max-warnings 0 "script/**/*.sol" && \ + solhint -c test/.solhint.json --max-warnings 0 "test/**/*.t.sol" + +fmt: ## Format the contracts + forge fmt && \ + solhint -c .solhint.json --max-warnings 0 "src/**/*.sol" && \ + solhint -c script/.solhint.json --max-warnings 0 "script/**/*.sol" && \ + solhint -c test/.solhint.json --max-warnings 0 "test/**/*.t.sol" + +slither: ## Run slither (requires 0.11.0) + slither . --include-paths "(src)" --fail-low --config-file slither.config.json + +test: ## Run tests + forge test --force --isolate -vvv --show-progress --gas-snapshot-check true + +coverage-summary: ## Run tests and generate coverage summary + forge coverage --no-match-coverage "(test|script)" --force --report summary + +coverage-lcov: ## Run tests and generate coverage lcov report + forge coverage --no-match-coverage "(test|script)" --force --report lcov + +COVERAGE_MIN := 100 +coverage-check: ## Check if test coverage is above the minimum + make coverage-summary | tee coverage.txt + @coverage=$$(grep "| Total" coverage.txt | awk '{print $$4}' | sed 's/%//'); \ + if [ -z "$$coverage" ]; then \ + echo "\n❌ Failed to extract coverage percentage.\n"; \ + exit 1; \ + elif [ $$(echo "$$coverage < $(COVERAGE_MIN)" | bc -l) -eq 1 ]; then \ + echo "\n❌ Current coverage of $$coverage% below the minimum of $(COVERAGE_MIN)%.\n"; \ + exit 1; \ + else \ + echo "\n✅ Current coverage of $$coverage% meets the minimum of $(COVERAGE_MIN)%.\n"; \ + fi + @rm coverage.txt + +snapshot: ## Create a snapshot + forge snapshot --force --isolate --desc --show-progress diff --git a/README.md b/README.md index 72ce9fd..1b67b6d 100644 --- a/README.md +++ b/README.md @@ -6,35 +6,37 @@ This project is meant to be used as a templated during the creation of new Githu It will contain some useful configuration files and scripts, that can be used also with existing projects (manually copied). - ## Usage -### Build +### Make targets -```shell -forge build -``` - -### Test +To see all available make targets, run: ```shell -forge test +make help ``` -### Format +### Add dependencies ```shell -forge fmt +forge install ``` -### Gas Snapshots +### Set up remappings -```shell -forge snapshot +In `remappings.txt`, add the remappings for the dependencies, e.g.: + +``` +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ ``` +This will allow you to write shorter import paths in the contracts. + ### Deploy +Add `--broadcast` to send the transaction. Add `--verify` to verify the contract using the Etherscan settings in `foundry.toml`. + ```shell forge script script/Counter.s.sol:CounterScript --rpc-url --private-key ``` @@ -44,18 +46,25 @@ forge script script/Counter.s.sol:CounterScript --rpc-url --priva The following operations need to be performed after this repository has been created. - [ ] In GitHub repo settings: - - [ ] Add a new ruleset called "Protected branches" and include the following changes: - - Enforcement status: active - - Target branches: Include default branch - - Require linear history - - Require a pull request before merging - - Required approvals: 1 - - Allowed merge methods: Squash - - Block force pushes - - [ ] In General → Features → Pull requests: - - Select "Pull request title and description" in "Default commit message" option - - Unckeck "Allow merge commits" option - - Check "Allow auto-merge" option -- [ ] Run `forge install` to install the dependencies. This will create a new `foundry.lock` file which you should commit to the project + - [ ] Add a new ruleset called "Protected branches" and include the following changes: + - Enforcement status: active + - Target branches: Include default branch + - Require linear history + - Require a pull request before merging + - Required approvals: 1 + - Allowed merge methods: Squash + - Block force pushes + - [ ] In General → Features → Pull requests: + - Select "Pull request title and description" in "Default commit message" option + - Unckeck "Allow merge commits" option + - Check "Allow auto-merge" option + - [ ] Configure secrets in the repository settings (e.g. `ETH_RPC_URL`): + - In Settings → Secrets and variables → Actions → New repository secret + - Uncomment the `env` section in `.github/workflows/ci.yml` to use secrets in the CI workflow +- [ ] Initialize the submodules with `git submodule update --init` +- [ ] Install the dependencies with `forge install`. This will create a new `foundry.lock` file which you should commit to the project - [ ] Make sure you use the [latest version of Solidity](https://github.com/argotorg/solidity/releases) by updating the `solc` version in `foundry.toml` -- [ ] Once all entries in this list are checked, delete this section from the readme \ No newline at end of file +- [ ] Install `solhint` globally with `npm i -g solhint` +- [ ] Install `slither` globally with `pipx install slither-analyzer==0.11.0`. This will create a new `slither.config.json` file which you should commit to the project +- [ ] Install the pre-commit hooks with `pre-commit install` +- [ ] Once all entries in this list are checked, delete this section from the readme diff --git a/deployments.json b/deployments.json new file mode 100644 index 0000000..9e62aba --- /dev/null +++ b/deployments.json @@ -0,0 +1,14 @@ +{ + "ContractName_Proxy": { + "1": { + "address": "", + "transactionHash": "" + } + }, + "ContractName_Implementation": { + "1": { + "address": "", + "transactionHash": "" + } + } +} diff --git a/foundry.toml b/foundry.toml index 751f7be..34832e9 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,11 +2,50 @@ src = "src" out = "out" libs = ["lib"] -solc = "0.8.33" # See latest release at: https://github.com/argotorg/solidity/releases +fs_permissions = [ + { access = "read-write", path = "./" }, +] + +# Compiler +auto_detect_solc = false +solc = "0.8.34" # See latest release at: https://github.com/argotorg/solidity/releases +via_ir = true +optimizer = true +optimizer_runs = 100_000 +evm_version = "cancun" + +# Keep deployment bytecode stable. This helps deterministic deployments because +# metadata changes (like compiler version) will not change the final bytecode hash. +# See: https://docs.soliditylang.org/en/latest/metadata.html#encoding-of-the-metadata-hash-in-the-bytecode +bytecode_hash = "none" +cbor_metadata = false + +# Fuzz +fuzz = { runs = 1_000 } +invariant = { runs = 100, depth = 20, fail_on_revert = true } + +[profile.ci] + fuzz = { runs = 10_000 } + invariant = { runs = 100, depth = 100, fail_on_revert = true } + verbosity = 3 # outputs stack trace for failed tests only (https://book.getfoundry.sh/reference/config/testing) [fmt] +bracket_spacing = true +int_types = "long" +line_length = 125 +multiline_func_header = "attributes_first" +number_underscore = "thousands" +quote_style = "double" +tab_width = 4 +wrap_comments = true sort_imports = true -[profile.ci] -deny = "warnings" # Why not always: sometimes you just want to code and see what comes out -fuzz.seed = '0' # It makes CI reproducible, but still on a local machine it tries different parameters and so we can see edge cases if needed. +[lint] +exclude_lints = [] + +[rpc_endpoints] +mainnet = "{ETH_RPC_URL}" + +# Note: as of Etherscan API v2, the API key is shared across all chains +[etherscan] +mainnet = { key = "${ETHERSCAN_API_KEY}", chain = 1 } diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..e69de29 diff --git a/script/.solhint.json b/script/.solhint.json new file mode 100644 index 0000000..d8a3dd8 --- /dev/null +++ b/script/.solhint.json @@ -0,0 +1,37 @@ +{ + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error", ">=0.8.34"], + "func-visibility": [ + "error", + { + "ignoreConstructors": true + } + ], + "max-line-length": ["error", 125], + "one-contract-per-file": "warn", + "named-parameters-mapping": "warn", + "func-param-name-mixedcase": "error", + "modifier-name-mixedcase": "error", + "ordering": "error", + "func-name-mixedcase": "error", + "private-vars-leading-underscore": [ + "off", + { + "strict": false + } + ], + "not-rely-on-time": "off", + "no-console": "off", + "no-unused-vars": "error", + "payable-fallback": "error", + "avoid-tx-origin": "error", + "no-empty-blocks": "off", + "use-natspec": "off", + "no-inline-assembly": "off", + "import-path-check": "off", + "gas-increment-by-one": "off", + "gas-small-strings": "off", + "function-max-lines": "off" + } +} diff --git a/script/Counter.s.sol b/script/Counter.s.sol index 3983ed4..793ce52 100644 --- a/script/Counter.s.sol +++ b/script/Counter.s.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +pragma solidity 0.8.34; -import {Counter} from "../src/Counter.sol"; -import {Script} from "forge-std/Script.sol"; +import { Counter } from "../src/Counter.sol"; +import { Script } from "forge-std/Script.sol"; contract CounterScript is Script { Counter public counter; - function setUp() public {} + function setUp() public { } function run() public { vm.startBroadcast(); diff --git a/script/shell/mine-salt.sh b/script/shell/mine-salt.sh new file mode 100755 index 0000000..ec23f86 --- /dev/null +++ b/script/shell/mine-salt.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Utility for mining CREATE2 salts for a compiled contract. +# Supports optional address prefix/suffix filters and arbitrary constructor arguments. +# +# It would ideally be used with https://github.com/Arachnid/deterministic-deployment-proxy +# +# Examples: +# +# # Mine an address starting with 0x0000 for a no-arg contract +# ./script/shell/mine-salt.sh \ +# --deployer 0x4e59b44847b379578588920cA78FbF26c0B4956C \ +# --contract src/MyContract.sol:MyContract \ +# --starts-with 0000 +# +# # Mine an address ending in 0xdead with a constructor that takes (address, uint256) +# ./script/shell/mine-salt.sh \ +# --deployer 0x4e59b44847b379578588920cA78FbF26c0B4956C \ +# --contract MyContract \ +# --ends-with dead \ +# --ctor-signature "constructor(address,uint256)" \ +# --ctor-arg 0x1111111111111111111111111111111111111111 \ +# --ctor-arg 42 +# +# # Same as above, but pass all constructor args after --ctor-args (must be last) +# ./script/shell/mine-salt.sh \ +# --deployer 0x4e59b44847b379578588920cA78FbF26c0B4956C \ +# --contract MyContract \ +# --starts-with beef --ends-with cafe \ +# --ctor-signature "constructor(address,uint256)" \ +# --ctor-args 0x1111111111111111111111111111111111111111 42 + +DEPLOYER="" +CONTRACT="" +STARTS_WITH="" +ENDS_WITH="" +CTOR_SIG="" +CTOR_ARGS=() + +usage() { + cat <>> Running CREATE2 address miner..." +echo "Deployer: $DEPLOYER" +echo "Contract: $CONTRACT" +echo "Starts with: ${STARTS_WITH:-}" +echo "Ends with: ${ENDS_WITH:-}" +if [[ -n "$CTOR_SIG" ]]; then + echo "Constructor: '$CTOR_SIG'" + echo "Arguments: ${CTOR_ARGS[*]:-}" +fi +echo "Init code hash: $INIT_CODE_HASH" +echo + +CMD=(cast create2 --deployer "$DEPLOYER" --init-code-hash "$INIT_CODE_HASH") + +[[ -n "$STARTS_WITH" ]] && CMD+=(--starts-with "$STARTS_WITH") +[[ -n "$ENDS_WITH" ]] && CMD+=(--ends-with "$ENDS_WITH") + +"${CMD[@]}" | awk '/Successfully found contract address/,0' +echo \ No newline at end of file diff --git a/slither.config.json b/slither.config.json new file mode 100644 index 0000000..344ca63 --- /dev/null +++ b/slither.config.json @@ -0,0 +1,5 @@ +{ + "detectors_to_exclude": "solc-version,timestamp,unused-return,naming-convention,low-level-calls,calls-loop,costly-loop,assembly-usage,immutable-states,different-pragma-directives-are-used,reentrancy-events,uninitialized-state-variables,incorrect-equality,too-many-digits,uninitialized-local", + "filter_paths": "(lib/|test/|script/)", + "compile_libraries": false +} diff --git a/src/Counter.sol b/src/Counter.sol index aded799..c233e33 100644 --- a/src/Counter.sol +++ b/src/Counter.sol @@ -1,13 +1,21 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +pragma solidity 0.8.34; +/// @title Counter +/// @author Nomev Labs +/// @notice This contract is a simple counter +/// @dev This contract is a simple counter contract Counter { + /// @notice The current number uint256 public number; + /// @notice Set the number + /// @param newNumber The new number function setNumber(uint256 newNumber) public { number = newNumber; } + /// @notice Increment the number function increment() public { number++; } diff --git a/test/.solhint.json b/test/.solhint.json new file mode 100644 index 0000000..6b2180e --- /dev/null +++ b/test/.solhint.json @@ -0,0 +1,47 @@ +{ + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error", ">=0.8.34"], + "func-visibility": [ + "error", + { + "ignoreConstructors": true + } + ], + "max-line-length": "off", + "var-name-mixedcase": "off", + "one-contract-per-file": "off", + "named-parameters-mapping": "warn", + "func-param-name-mixedcase": "error", + "modifier-name-mixedcase": "error", + "func-name-mixedcase": "off", + "foundry-test-functions": "warn", + "gas-custom-errors": "off", + "private-vars-leading-underscore": [ + "off", + { + "strict": false + } + ], + "no-console": "off", + "not-rely-on-time": "off", + "no-inline-assembly": "off", + "no-unused-vars": "error", + "payable-fallback": "off", + "no-complex-fallback": "off", + "avoid-tx-origin": "error", + "avoid-low-level-calls": "off", + "ordering": "off", + "reentrancy": "off", + "import-path-check": "off", + "use-natspec": "off", + "gas-strict-inequalities": "off", + "no-empty-blocks": "off", + "gas-indexed-events": "off", + "gas-increment-by-one": "off", + "gas-struct-packing": "off", + "function-max-lines": "off", + "gas-small-strings": "off", + "max-states-count": "off" + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol index 8e2817e..66f7552 100644 --- a/test/Counter.t.sol +++ b/test/Counter.t.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +pragma solidity 0.8.34; -import {Counter} from "../src/Counter.sol"; -import {Test} from "forge-std/Test.sol"; +import { Counter } from "../src/Counter.sol"; +import { Test } from "forge-std/Test.sol"; contract CounterTest is Test { Counter public counter;