diff --git a/.czrc b/.czrc new file mode 100644 index 00000000..d1bcc209 --- /dev/null +++ b/.czrc @@ -0,0 +1,3 @@ +{ + "path": "cz-conventional-changelog" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6fd96c1e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# 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 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..a57b767a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,22 @@ +# directories +.coverage_artifacts +.coverage_cache +.coverage_contracts +artifacts +build +cache +coverage +dist +node_modules +types + +# files +*.env +*.log +.DS_Store +.pnp.* +bun.lockb +coverage.json +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..27672a35 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,21 @@ +extends: + - "eslint:recommended" + - "plugin:@typescript-eslint/eslint-recommended" + - "plugin:@typescript-eslint/recommended" + - "prettier" +parser: "@typescript-eslint/parser" +parserOptions: + project: "tsconfig.json" +plugins: + - "@typescript-eslint" +root: true +rules: + "@typescript-eslint/no-floating-promises": + - error + - ignoreIIFE: true + ignoreVoid: true + "@typescript-eslint/no-inferrable-types": "off" + "@typescript-eslint/no-unused-vars": + - error + - argsIgnorePattern: "_" + varsIgnorePattern: "_" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..b763d0f3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +custom: "https://omo.so/prberg" +github: "PaulRBerg" diff --git a/.github/scripts/rename.sh b/.github/scripts/rename.sh new file mode 100755 index 00000000..62e37dda --- /dev/null +++ b/.github/scripts/rename.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# https://gist.github.com/vncsna/64825d5609c146e80de8b1fd623011ca +set -euo pipefail + +# Define the input vars +GITHUB_REPOSITORY=${1?Error: Please pass username/repo, e.g. prb/foundry-template} +GITHUB_REPOSITORY_OWNER=${2?Error: Please pass username, e.g. prb} +GITHUB_REPOSITORY_DESCRIPTION=${3:-""} # If null then replace with empty string + +echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY" +echo "GITHUB_REPOSITORY_OWNER: $GITHUB_REPOSITORY_OWNER" +echo "GITHUB_REPOSITORY_DESCRIPTION: $GITHUB_REPOSITORY_DESCRIPTION" + +# jq is like sed for JSON data +JQ_OUTPUT=`jq \ + --arg NAME "@$GITHUB_REPOSITORY" \ + --arg AUTHOR_NAME "$GITHUB_REPOSITORY_OWNER" \ + --arg URL "https://github.com/$GITHUB_REPOSITORY_OWNER" \ + --arg DESCRIPTION "$GITHUB_REPOSITORY_DESCRIPTION" \ + '.name = $NAME | .description = $DESCRIPTION | .author |= ( .name = $AUTHOR_NAME | .url = $URL )' \ + package.json +` + +# Overwrite package.json +echo "$JQ_OUTPUT" > package.json + +# Make sed command compatible in both Mac and Linux environments +# Reference: https://stackoverflow.com/a/38595160/8696958 +sedi () { + sed --version >/dev/null 2>&1 && sed -i -- "$@" || sed -i "" "$@" +} + +# Rename instances of "PaulRBerg/foundry-template" to the new repo name in README.md for badges only +sedi "/gitpod/ s|PaulRBerg/foundry-template|"${GITHUB_REPOSITORY}"|;" "README.md" +sedi "/gitpod-badge/ s|PaulRBerg/foundry-template|"${GITHUB_REPOSITORY}"|;" "README.md" +sedi "/gha/ s|PaulRBerg/foundry-template|"${GITHUB_REPOSITORY}"|;" "README.md" +sedi "/gha-badge/ s|PaulRBerg/foundry-template|"${GITHUB_REPOSITORY}"|;" "README.md" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ec79e636 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: "CI" + +env: + HARDHAT_VAR_MNEMONIC: "test test test test test test test test test test test junk" + HARDHAT_VAR_INFURA_API_KEY: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + # Uncomment the following lines to set your configuration variables using + # GitHub secrets (https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) + # + # HARDHAT_VAR_MNEMONIC: ${{ secrets.Mnemonic }} + # HARDHAT_VAR_INFURA_API_KEY: ${{ secrets.InfuraApiKey }} + # HARDHAT_VAR_ARBISCAN_API_KEY: ${{ secrets.ArbiscanApiKey }} + # HARDHAT_VAR_BSCSCAN_API_KEY: ${{ secrets.BscscanApiKey }} + # HARDHAT_VAR_ETHERSCAN_API_KEY: ${{ secrets.EtherscanApiKey }} + # HARDHAT_VAR_OPTIMISM_API_KEY: ${{ secrets.OptimismApiKey }} + # HARDHAT_VAR_POLYGONSCAN_API_KEY: ${{ secrets.PolygonscanApiKey }} + # HARDHAT_VAR_SNOWTRACE_API_KEY: ${{ secrets.SnowtraceApiKey }} + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + ci: + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Install Bun" + uses: "oven-sh/setup-bun@v1" + + - name: "Install the dependencies" + run: "bun install" + + - name: "Lint the code" + run: "bun run lint" + + - name: "Add lint summary" + run: | + echo "## Lint results" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY + + - name: "Compile the contracts and generate the TypeChain bindings" + run: "bun run typechain" + + - name: "Test the contracts and generate the coverage report" + run: "bun run coverage" + + - name: "Add test summary" + run: | + echo "## Test results" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/create.yml b/.github/workflows/create.yml new file mode 100644 index 00000000..e0e93696 --- /dev/null +++ b/.github/workflows/create.yml @@ -0,0 +1,52 @@ +name: "Create" + +# The workflow will run only when the "Use this template" button is used +on: + create: + +jobs: + create: + # We only run this action when the repository isn't the template repository. References: + # - https://docs.github.com/en/actions/learn-github-actions/contexts + # - https://docs.github.com/en/actions/learn-github-actions/expressions + if: ${{ !github.event.repository.is_template }} + permissions: "write-all" + runs-on: "ubuntu-latest" + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + + - name: "Update package.json" + env: + GITHUB_REPOSITORY_DESCRIPTION: ${{ github.event.repository.description }} + run: + ./.github/scripts/rename.sh "$GITHUB_REPOSITORY" "$GITHUB_REPOSITORY_OWNER" "$GITHUB_REPOSITORY_DESCRIPTION" + + - name: "Add rename summary" + run: | + echo "## Commit result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY + + - name: "Remove files not needed in the user's copy of the template" + run: | + rm -f "./.github/FUNDING.yml" + rm -f "./.github/scripts/rename.sh" + rm -f "./.github/workflows/create.yml" + + - name: "Add remove summary" + run: | + echo "## Remove result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY + + - name: "Update commit" + uses: "stefanzweifel/git-auto-commit-action@v4" + with: + commit_message: "feat: initial commit" + commit_options: "--amend" + push_options: "--force" + skip_fetch: true + + - name: "Add commit summary" + run: | + echo "## Commit result" >> $GITHUB_STEP_SUMMARY + echo "✅ Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c3995b0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# directories +.coverage_artifacts +.coverage_cache +.coverage_contracts +artifacts +build +cache +coverage +dist +node_modules +types +deployments + +# files +*.env +*.log +.DS_Store +.pnp.* +coverage.json +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..a57b767a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,22 @@ +# directories +.coverage_artifacts +.coverage_cache +.coverage_contracts +artifacts +build +cache +coverage +dist +node_modules +types + +# files +*.env +*.log +.DS_Store +.pnp.* +bun.lockb +coverage.json +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 00000000..3b6e0857 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,23 @@ +bracketSpacing: true +plugins: + - "@trivago/prettier-plugin-sort-imports" + - "prettier-plugin-solidity" +printWidth: 120 +proseWrap: "always" +singleQuote: false +tabWidth: 2 +trailingComma: "all" + +overrides: + - files: "*.sol" + options: + compiler: "0.8.17" + parser: "solidity-parse" + tabWidth: 4 + - files: "*.ts" + options: + importOrder: ["", "^[./]"] + importOrderParserPlugins: ["typescript"] + importOrderSeparation: true + importOrderSortSpecifiers: true + parser: "typescript" diff --git a/.solcover.js b/.solcover.js new file mode 100644 index 00000000..c61d16c7 --- /dev/null +++ b/.solcover.js @@ -0,0 +1,4 @@ +module.exports = { + istanbulReporter: ["html", "lcov"], + skipFiles: ["test"], +}; diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 00000000..122f1f69 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,19 @@ +{ + "extends": "solhint:recommended", + "plugins": ["prettier"], + "rules": { + "code-complexity": ["error", 8], + "compiler-version": ["error", ">=0.8.4"], + "func-visibility": ["error", { "ignoreConstructors": true }], + "max-line-length": ["error", 120], + "named-parameters-mapping": "warn", + "no-console": "off", + "not-rely-on-time": "off", + "prettier/prettier": [ + "error", + { + "endOfLine": "auto" + } + ] + } +} diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 00000000..16dc0c0d --- /dev/null +++ b/.solhintignore @@ -0,0 +1,3 @@ +# directories +**/artifacts +**/node_modules diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..9a51b727 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "NomicFoundation.hardhat-solidity"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b7be98ac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "prettier.documentSelectors": ["**/*.sol"], + "solidity.formatter": "prettier", + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..88a2b87b --- /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 00000000..04dd3eac --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# Hardhat Template [![Open in Gitpod][gitpod-badge]][gitpod] [![Github Actions][gha-badge]][gha] [![Hardhat][hardhat-badge]][hardhat] [![License: MIT][license-badge]][license] + +[gitpod]: https://gitpod.io/#https://github.com/paulrberg/hardhat-template +[gitpod-badge]: https://img.shields.io/badge/Gitpod-Open%20in%20Gitpod-FFB45B?logo=gitpod +[gha]: https://github.com/paulrberg/hardhat-template/actions +[gha-badge]: https://github.com/paulrberg/hardhat-template/actions/workflows/ci.yml/badge.svg +[hardhat]: https://hardhat.org/ +[hardhat-badge]: https://img.shields.io/badge/Built%20with-Hardhat-FFDB1C.svg +[license]: https://opensource.org/licenses/MIT +[license-badge]: https://img.shields.io/badge/License-MIT-blue.svg + +A Hardhat-based template for developing Solidity smart contracts, with sensible defaults. + +- [Hardhat](https://github.com/nomiclabs/hardhat): compile, run and test smart contracts +- [TypeChain](https://github.com/ethereum-ts/TypeChain): generate TypeScript bindings for smart contracts +- [Ethers](https://github.com/ethers-io/ethers.js/): renowned Ethereum library and wallet implementation +- [Solhint](https://github.com/protofire/solhint): code linter +- [Solcover](https://github.com/sc-forks/solidity-coverage): code coverage +- [Prettier Plugin Solidity](https://github.com/prettier-solidity/prettier-plugin-solidity): code formatter + +## Getting Started + +Click the [`Use this template`](https://github.com/paulrberg/hardhat-template/generate) button at the top of the page to +create a new repository with this repo as the initial state. + +## Features + +This template builds upon the frameworks and libraries mentioned above, so for details about their specific features, +please consult their respective documentations. + +For example, for Hardhat, you can refer to the [Hardhat Tutorial](https://hardhat.org/tutorial) and the +[Hardhat Docs](https://hardhat.org/docs). You might be in particular interested in reading the +[Testing Contracts](https://hardhat.org/tutorial/testing-contracts) section. + +### Sensible Defaults + +This template comes with sensible default configurations in the following files: + +```text +├── .editorconfig +├── .eslintignore +├── .eslintrc.yml +├── .gitignore +├── .prettierignore +├── .prettierrc.yml +├── .solcover.js +├── .solhint.json +└── hardhat.config.ts +``` + +### VSCode Integration + +This template is IDE agnostic, but for the best user experience, you may want to use it in VSCode alongside Nomic +Foundation's [Solidity extension](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity). + +### GitHub Actions + +This template comes with GitHub Actions pre-configured. Your contracts will be linted and tested on every push and pull +request made to the `main` branch. + +Note though that to make this work, you must use your `INFURA_API_KEY` and your `MNEMONIC` as GitHub secrets. + +For more information on how to set up GitHub secrets, check out the +[docs](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions). + +You can edit the CI script in [.github/workflows/ci.yml](./.github/workflows/ci.yml). + +## Usage + +### Pre Requisites + +First, you need to install the dependencies: + +```sh +bun install +``` + +Then, you need to set up all the required +[Hardhat Configuration Variables](https://hardhat.org/hardhat-runner/docs/guides/configuration-variables). You might +also want to install some that are optional. + +To assist with the setup process, run `bunx hardhat vars setup`. To set a particular value, such as a BIP-39 mnemonic +variable, execute this: + +```sh +bunx hardhat vars set MNEMONIC +? Enter value: ‣ here is where your twelve words mnemonic should be put my friend +``` + +If you do not already have a mnemonic, you can generate one using this [website](https://iancoleman.io/bip39/). + +### Compile + +Compile the smart contracts with Hardhat: + +```sh +bun run compile +``` + +### TypeChain + +Compile the smart contracts and generate TypeChain bindings: + +```sh +bun run typechain +``` + +### Test + +Run the tests with Hardhat: + +```sh +bun run test +``` + +### Lint Solidity + +Lint the Solidity code: + +```sh +bun run lint:sol +``` + +### Lint TypeScript + +Lint the TypeScript code: + +```sh +bun run lint:ts +``` + +### Coverage + +Generate the code coverage report: + +```sh +bun run coverage +``` + +### Report Gas + +See the gas usage per unit test and average gas per method call: + +```sh +REPORT_GAS=true bun run test +``` + +### Clean + +Delete the smart contract artifacts, the coverage reports and the Hardhat cache: + +```sh +bun run clean +``` + +### Deploy + +Deploy the contracts to Hardhat Network: + +```sh +bun run deploy:contracts +``` + +### Tasks + +#### Deploy Lock + +Deploy a new instance of the Lock contract via a task: + +```sh +bun run task:deployLock --unlock 100 --value 0.1 +``` + +### Syntax Highlighting + +If you use VSCode, you can get Solidity syntax highlighting with the +[hardhat-solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension. + +## Using GitPod + +[GitPod](https://www.gitpod.io/) is an open-source developer platform for remote development. + +To view the coverage report generated by `bun run coverage`, just click `Go Live` from the status bar to turn the server +on/off. + +## Local development with Ganache + +### Install Ganache + +```sh +npm i -g ganache +``` + +### Run a Development Blockchain + +```sh +ganache -s test +``` + +> The `-s test` passes a seed to the local chain and makes it deterministic + +Make sure to set the mnemonic in your `.env` file to that of the instance running with Ganache. + +## License + +This project is licensed under MIT. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..484db9be Binary files /dev/null and b/bun.lockb differ diff --git a/contracts/Lock.sol b/contracts/Lock.sol new file mode 100644 index 00000000..c236c27b --- /dev/null +++ b/contracts/Lock.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.9; + +error InvalidUnlockTime(uint256 unlockTime); +error NotOwner(address owner); +error UnlockTimeNotReached(uint256 unlockTime); + +contract Lock { + uint256 public unlockTime; + address payable public owner; + + event Withdrawal(uint256 amount, uint256 when); + + constructor(uint256 _unlockTime) payable { + if (block.timestamp >= _unlockTime) { + revert InvalidUnlockTime(_unlockTime); + } + + unlockTime = _unlockTime; + owner = payable(msg.sender); + } + + function withdraw() public { + if (block.timestamp < unlockTime) { + revert UnlockTimeNotReached(unlockTime); + } + + if (msg.sender != owner) { + revert NotOwner(owner); + } + + emit Withdrawal(address(this).balance, block.timestamp); + + owner.transfer(address(this).balance); + } +} diff --git a/deploy/deploy.ts b/deploy/deploy.ts new file mode 100644 index 00000000..f09cf438 --- /dev/null +++ b/deploy/deploy.ts @@ -0,0 +1,24 @@ +import { DeployFunction } from "hardhat-deploy/types"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; + +const DAY_IN_SECONDS = 60 * 60 * 24; +const NOW_IN_SECONDS = Math.round(Date.now() / 1000); +const UNLOCK_IN_X_DAYS = NOW_IN_SECONDS + DAY_IN_SECONDS * 1; // 1 DAY + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { deployer } = await hre.getNamedAccounts(); + const { deploy } = hre.deployments; + const lockedAmount = hre.ethers.parseEther("0.01").toString(); + + const lock = await deploy("Lock", { + from: deployer, + args: [UNLOCK_IN_X_DAYS], + log: true, + value: lockedAmount, + }); + + console.log(`Lock contract: `, lock.address); +}; +export default func; +func.id = "deploy_lock"; // id required to prevent reexecution +func.tags = ["Lock"]; diff --git a/hardhat.config.ts b/hardhat.config.ts new file mode 100644 index 00000000..1e910162 --- /dev/null +++ b/hardhat.config.ts @@ -0,0 +1,125 @@ +import "@nomicfoundation/hardhat-toolbox"; +import "hardhat-deploy"; +import type { HardhatUserConfig } from "hardhat/config"; +import { vars } from "hardhat/config"; +import type { NetworkUserConfig } from "hardhat/types"; + +import "./tasks/accounts"; +import "./tasks/lock"; + +// Run 'npx hardhat vars setup' to see the list of variables that need to be set + +const mnemonic: string = vars.get("MNEMONIC"); +const infuraApiKey: string = vars.get("INFURA_API_KEY"); + +const chainIds = { + "arbitrum-mainnet": 42161, + avalanche: 43114, + bsc: 56, + ganache: 1337, + hardhat: 31337, + mainnet: 1, + "optimism-mainnet": 10, + "polygon-mainnet": 137, + "polygon-mumbai": 80001, + sepolia: 11155111, +}; + +function getChainConfig(chain: keyof typeof chainIds): NetworkUserConfig { + let jsonRpcUrl: string; + switch (chain) { + case "avalanche": + jsonRpcUrl = "https://api.avax.network/ext/bc/C/rpc"; + break; + case "bsc": + jsonRpcUrl = "https://bsc-dataseed1.binance.org"; + break; + default: + jsonRpcUrl = "https://" + chain + ".infura.io/v3/" + infuraApiKey; + } + return { + accounts: { + count: 10, + mnemonic, + path: "m/44'/60'/0'/0", + }, + chainId: chainIds[chain], + url: jsonRpcUrl, + }; +} + +const config: HardhatUserConfig = { + defaultNetwork: "hardhat", + namedAccounts: { + deployer: 0, + }, + etherscan: { + apiKey: { + arbitrumOne: vars.get("ARBISCAN_API_KEY", ""), + avalanche: vars.get("SNOWTRACE_API_KEY", ""), + bsc: vars.get("BSCSCAN_API_KEY", ""), + mainnet: vars.get("ETHERSCAN_API_KEY", ""), + optimisticEthereum: vars.get("OPTIMISM_API_KEY", ""), + polygon: vars.get("POLYGONSCAN_API_KEY", ""), + polygonMumbai: vars.get("POLYGONSCAN_API_KEY", ""), + sepolia: vars.get("ETHERSCAN_API_KEY", ""), + }, + }, + gasReporter: { + currency: "USD", + enabled: process.env.REPORT_GAS ? true : false, + excludeContracts: [], + src: "./contracts", + }, + networks: { + hardhat: { + accounts: { + mnemonic, + }, + chainId: chainIds.hardhat, + }, + ganache: { + accounts: { + mnemonic, + }, + chainId: chainIds.ganache, + url: "http://localhost:8545", + }, + arbitrum: getChainConfig("arbitrum-mainnet"), + avalanche: getChainConfig("avalanche"), + bsc: getChainConfig("bsc"), + mainnet: getChainConfig("mainnet"), + optimism: getChainConfig("optimism-mainnet"), + "polygon-mainnet": getChainConfig("polygon-mainnet"), + "polygon-mumbai": getChainConfig("polygon-mumbai"), + sepolia: getChainConfig("sepolia"), + }, + paths: { + artifacts: "./artifacts", + cache: "./cache", + sources: "./contracts", + tests: "./test", + }, + solidity: { + version: "0.8.19", + settings: { + metadata: { + // Not including the metadata hash + // https://github.com/paulrberg/hardhat-template/issues/31 + bytecodeHash: "none", + }, + // Disable the optimizer when debugging + // https://hardhat.org/hardhat-network/#solidity-optimizer-support + optimizer: { + enabled: true, + runs: 800, + }, + }, + }, + typechain: { + outDir: "types", + target: "ethers-v6", + }, +}; + +export default config; diff --git a/package.json b/package.json new file mode 100644 index 00000000..f4605af7 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "@prb/hardhat-template", + "description": "Hardhat-based template for developing Solidity smart contracts", + "version": "1.0.0", + "author": { + "name": "Paul Razvan Berg", + "url": "https://github.com/PaulRBerg" + }, + "devDependencies": { + "@nomicfoundation/hardhat-chai-matchers": "^2.0.2", + "@nomicfoundation/hardhat-ethers": "^3.0.5", + "@nomicfoundation/hardhat-network-helpers": "^1.0.10", + "@nomicfoundation/hardhat-toolbox": "^4.0.0", + "@nomicfoundation/hardhat-verify": "^2.0.2", + "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@typechain/ethers-v6": "^0.5.1", + "@typechain/hardhat": "^9.1.0", + "@types/chai": "^4.3.11", + "@types/fs-extra": "^11.0.4", + "@types/mocha": "^10.0.6", + "@types/node": "^20.10.4", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "chai": "^4.3.10", + "cross-env": "^7.0.3", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "ethers": "^6.9.0", + "fs-extra": "^11.2.0", + "hardhat": "^2.19.2", + "hardhat-deploy": "^0.12.1", + "hardhat-gas-reporter": "^1.0.9", + "lodash": "^4.17.21", + "mocha": "^10.2.0", + "prettier": "^3.1.1", + "prettier-plugin-solidity": "^1.2.0", + "rimraf": "^5.0.5", + "solhint": "^4.0.0", + "solhint-plugin-prettier": "^0.1.0", + "solidity-coverage": "^0.8.5", + "ts-generator": "^0.1.1", + "ts-node": "^10.9.2", + "typechain": "^8.3.2", + "typescript": "^5.3.3" + }, + "files": [ + "contracts" + ], + "keywords": [ + "blockchain", + "ethers", + "ethereum", + "hardhat", + "smart-contracts", + "solidity", + "template", + "typescript", + "typechain" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "rimraf ./artifacts ./cache ./coverage ./types ./coverage.json && bun run typechain", + "compile": "cross-env TS_NODE_TRANSPILE_ONLY=true hardhat compile", + "coverage": "hardhat coverage --solcoverjs ./.solcover.js --temp artifacts --testfiles \"test/**/*.ts\" && bun run typechain", + "deploy:contracts": "hardhat deploy", + "lint": "bun run lint:sol && bun run lint:ts && bun run prettier:check", + "lint:sol": "solhint --max-warnings 0 \"contracts/**/*.sol\"", + "lint:ts": "eslint --ignore-path ./.eslintignore --ext .js,.ts .", + "postcompile": "bun run typechain", + "prettier:check": "prettier --check \"**/*.{js,json,md,sol,ts,yml}\"", + "prettier:write": "prettier --write \"**/*.{js,json,md,sol,ts,yml}\"", + "task:deployLock": "hardhat task:deployLock", + "task:withdraw": "hardhat task:withdraw", + "test": "hardhat test", + "typechain": "cross-env TS_NODE_TRANSPILE_ONLY=true hardhat typechain" + } +} diff --git a/tasks/accounts.ts b/tasks/accounts.ts new file mode 100644 index 00000000..9174751e --- /dev/null +++ b/tasks/accounts.ts @@ -0,0 +1,9 @@ +import { task } from "hardhat/config"; + +task("accounts", "Prints the list of accounts", async (_taskArgs, hre) => { + const accounts = await hre.ethers.getSigners(); + + for (const account of accounts) { + console.log(account.address); + } +}); diff --git a/tasks/lock.ts b/tasks/lock.ts new file mode 100644 index 00000000..47aa8c2a --- /dev/null +++ b/tasks/lock.ts @@ -0,0 +1,63 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +function distance(past: number, future: number): string { + // get total seconds between the times + let delta = future - past; + + // calculate (and subtract) whole days + const days = Math.floor(delta / 86400); + delta -= days * 86400; + + // calculate (and subtract) whole hours + const hours = Math.floor(delta / 3600) % 24; + delta -= hours * 3600; + + // calculate (and subtract) whole minutes + const minutes = Math.floor(delta / 60) % 60; + delta -= minutes * 60; + + // what's left is seconds + const seconds = delta % 60; // in theory the modulus is not required + + return `${days} day(s), ${hours} hour(s), ${minutes} minute(s) and ${seconds} second(s)`; +} + +task("task:withdraw", "Calls the withdraw function of Lock Contract") + .addOptionalParam("address", "Optionally specify the Lock address to withdraw") + .addParam("account", "Specify which account [0, 9]") + .setAction(async function (taskArguments: TaskArguments, hre) { + const { ethers, deployments } = hre; + + const Lock = taskArguments.address ? { address: taskArguments.address } : await deployments.get("Lock"); + + const signers = await ethers.getSigners(); + console.log(taskArguments.address); + + const lock = await ethers.getContractAt("Lock", Lock.address); + + const initialBalance = await ethers.provider.getBalance(Lock.address); + await lock.connect(signers[taskArguments.account]).withdraw(); + const finalBalance = await ethers.provider.getBalance(Lock.address); + + console.log("Contract balance before withdraw", ethers.formatEther(initialBalance)); + console.log("Contract balance after withdraw", ethers.formatEther(finalBalance)); + + console.log("Lock Withdraw Success"); + }); + +task("task:deployLock", "Deploys Lock Contract") + .addParam("unlock", "When to unlock funds in seconds (number of seconds into the futrue)") + .addParam("value", "How much ether you intend locking (in ether not wei, e.g., 0.1)") + .setAction(async function (taskArguments: TaskArguments, { ethers }) { + const NOW_IN_SECONDS = Math.round(Date.now() / 1000); + + const signers = await ethers.getSigners(); + const lockedAmount = ethers.parseEther(taskArguments.value); + const unlockTime = NOW_IN_SECONDS + parseInt(taskArguments.unlock); + const lockFactory = await ethers.getContractFactory("Lock"); + console.log(`Deploying Lock and locking ${taskArguments.value} ETH for ${distance(NOW_IN_SECONDS, unlockTime)}`); + const lock = await lockFactory.connect(signers[0]).deploy(unlockTime, { value: lockedAmount }); + await lock.waitForDeployment(); + console.log("Lock deployed to: ", await lock.getAddress()); + }); diff --git a/test/lock/Lock.fixture.ts b/test/lock/Lock.fixture.ts new file mode 100644 index 00000000..4ffe253e --- /dev/null +++ b/test/lock/Lock.fixture.ts @@ -0,0 +1,22 @@ +import { time } from "@nomicfoundation/hardhat-network-helpers"; +import { ethers } from "hardhat"; + +import type { Lock } from "../../types/Lock"; +import type { Lock__factory } from "../../types/factories/Lock__factory"; + +export async function deployLockFixture() { + const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; + const ONE_GWEI = 1_000_000_000; + + const lockedAmount = ONE_GWEI; + const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS; + + // Contracts are deployed using the first signer/account by default + const [owner, otherAccount] = await ethers.getSigners(); + + const Lock = (await ethers.getContractFactory("Lock")) as Lock__factory; + const lock = (await Lock.deploy(unlockTime, { value: lockedAmount })) as Lock; + const lock_address = await lock.getAddress(); + + return { lock, lock_address, unlockTime, lockedAmount, owner, otherAccount }; +} diff --git a/test/lock/Lock.ts b/test/lock/Lock.ts new file mode 100644 index 00000000..932df747 --- /dev/null +++ b/test/lock/Lock.ts @@ -0,0 +1,102 @@ +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import type { Signers } from "../types"; +import { deployLockFixture } from "./Lock.fixture"; + +describe("Lock", function () { + before(async function () { + this.signers = {} as Signers; + + const signers = await ethers.getSigners(); + this.signers.admin = signers[0]; + + this.loadFixture = loadFixture; + }); + + describe("Deployment", function () { + beforeEach(async function () { + const { lock, lock_address, unlockTime, owner, lockedAmount } = await this.loadFixture(deployLockFixture); + this.lock = lock; + this.lock_address = lock_address; + this.unlockTime = unlockTime; + this.owner = owner; + this.lockedAmount = lockedAmount; + }); + + it("Should fail if the unlockTime is not in the future", async function () { + // We don't use the fixture here because we want a different deployment + const latestTime = await time.latest(); + const Lock = await ethers.getContractFactory("Lock"); + await expect(Lock.deploy(latestTime, { value: 1 })).to.be.revertedWithCustomError(Lock, "InvalidUnlockTime"); + }); + + it("Should set the right unlockTime", async function () { + expect(await this.lock.unlockTime()).to.equal(this.unlockTime); + }); + + it("Should set the right owner", async function () { + expect(await this.lock.owner()).to.equal(this.owner.address); + }); + + it("Should receive and store the funds to lock", async function () { + expect(await ethers.provider.getBalance(this.lock_address)).to.equal(this.lockedAmount); + }); + }); + + describe("Withdrawals", function () { + beforeEach(async function () { + const { lock, unlockTime, owner, lockedAmount, otherAccount } = await this.loadFixture(deployLockFixture); + this.lock = lock; + this.unlockTime = unlockTime; + this.owner = owner; + this.lockedAmount = lockedAmount; + this.otherAccount = otherAccount; + }); + + describe("Validations", function () { + it("Should revert with the right error if called too soon", async function () { + await expect(this.lock.withdraw()).to.be.revertedWithCustomError(this.lock, "UnlockTimeNotReached"); + }); + + it("Should revert with the right error if called from another account", async function () { + // We can increase the time in Hardhat Network + await time.increaseTo(this.unlockTime); + + // We use lock.connect() to send a transaction from another account + await expect(this.lock.connect(this.otherAccount).withdraw()).to.be.revertedWithCustomError( + this.lock, + "NotOwner", + ); + }); + + it("Shouldn't fail if the unlockTime has arrived and the owner calls it", async function () { + // Transactions are sent using the first signer by default + await time.increaseTo(this.unlockTime); + + await expect(this.lock.withdraw()).not.to.be.reverted; + }); + }); + + describe("Events", function () { + it("Should emit an event on withdrawals", async function () { + await time.increaseTo(this.unlockTime); + + await expect(this.lock.withdraw()).to.emit(this.lock, "Withdrawal").withArgs(this.lockedAmount, anyValue); // We accept any value as `when` arg + }); + }); + + describe("Transfers", function () { + it("Should transfer the funds to the owner", async function () { + await time.increaseTo(this.unlockTime); + + await expect(this.lock.withdraw()).to.changeEtherBalances( + [this.owner, this.lock], + [this.lockedAmount, -this.lockedAmount], + ); + }); + }); + }); +}); diff --git a/test/types.ts b/test/types.ts new file mode 100644 index 00000000..645314ee --- /dev/null +++ b/test/types.ts @@ -0,0 +1,17 @@ +import type { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/dist/src/signer-with-address"; + +import type { Lock } from "../types/Lock"; + +type Fixture = () => Promise; + +declare module "mocha" { + export interface Context { + lock: Lock; + loadFixture: (fixture: Fixture) => Promise; + signers: Signers; + } +} + +export interface Signers { + admin: SignerWithAddress; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..734e21a2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "lib": ["es2020"], + "module": "commonjs", + "moduleResolution": "node", + "noImplicitAny": true, + "removeComments": true, + "resolveJsonModule": true, + "sourceMap": true, + "strict": true, + "target": "es2020" + }, + "exclude": ["node_modules"], + "files": ["./hardhat.config.ts"], + "include": ["src/**/*", "tasks/**/*", "test/**/*", "deploy/**/*", "types/"] +}