From 901db9f8de08b2473efc7431899af7ee360bd66f Mon Sep 17 00:00:00 2001 From: Xaleee <92092281+Xaleee@users.noreply.github.com> Date: Tue, 19 May 2026 11:16:46 +0300 Subject: [PATCH] Foundry Setup --- .env.example | 16 ++++ .gitattributes | 19 +++++ .gitignore | 23 ++++++ .gitmodules | 6 ++ README.md | 56 +++++++++++++ foundry.lock | 14 ++++ foundry.toml | 55 +++++++++++++ lib/forge-std | 1 + lib/openzeppelin-contracts | 1 + remappings.txt | 2 + script/Deploy.s.sol | 23 ++++++ src/Counter.sol | 83 +++++++++++++++++++ test/fuzz/Counter.fuzz.t.sol | 53 ++++++++++++ test/invariant/Counter.invariant.t.sol | 48 +++++++++++ test/invariant/handlers/CounterHandler.sol | 53 ++++++++++++ test/unit/Counter.t.sol | 94 ++++++++++++++++++++++ 16 files changed, 547 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 foundry.lock create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 160000 lib/openzeppelin-contracts create mode 100644 remappings.txt create mode 100644 script/Deploy.s.sol create mode 100644 src/Counter.sol create mode 100644 test/fuzz/Counter.fuzz.t.sol create mode 100644 test/invariant/Counter.invariant.t.sol create mode 100644 test/invariant/handlers/CounterHandler.sol create mode 100644 test/unit/Counter.t.sol diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4c08baa --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Deployment +PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 +OWNER_ADDRESS=0x0000000000000000000000000000000000000000 + +# RPC endpoints +MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY +SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY +ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY +OPTIMISM_RPC_URL=https://opt-mainnet.g.alchemy.com/v2/YOUR_KEY +BASE_RPC_URL=https://base-mainnet.g.alchemy.com/v2/YOUR_KEY + +# Block explorers (verification) +ETHERSCAN_API_KEY= +ARBISCAN_API_KEY= +OPTIMISTIC_ETHERSCAN_API_KEY= +BASESCAN_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..621891e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Force LF line endings for all text files, regardless of platform. +# Prevents Windows CRLF mess when collaborating with Linux/Mac contributors. +* text=auto eol=lf + +# Solidity & related +*.sol text eol=lf +*.toml text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf + +# Binary files - never touch +*.png binary +*.jpg binary +*.pdf binary +*.zip binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc16843 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Foundry build artifacts +cache/ +out/ +broadcast/ + +# Environment +.env +.env.local +.env.*.local + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Coverage +lcov.info +coverage/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..690924b --- /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/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/README.md b/README.md index c30c839..01f8130 100644 --- a/README.md +++ b/README.md @@ -1 +1,57 @@ # Augur Lituus oracle + +- Solidity `^0.8.35` +- Git submodules for dependencies +- Unit + fuzz + invariant tests included as a working reference + +## Setup + +```bash +git clone --recurse-submodules && cd +cp .env.example .env +forge build +forge test +``` + +If you forgot `--recurse-submodules` on clone: + +```bash +git submodule update --init --recursive +``` + +## Common commands + +```bash +forge build # compile +forge test # run all tests +forge test -vvv # verbose, with traces on failure +forge coverage # coverage report +forge fmt # format +forge snapshot # gas snapshot + +FOUNDRY_PROFILE=ci forge test # 10k fuzz runs +FOUNDRY_PROFILE=deep forge test # 100k fuzz runs, deep invariants +``` + +## Deploy + +```bash +# local +anvil # terminal 1 +forge script script/Deploy.s.sol --rpc-url http://localhost:8545 --broadcast + +# testnet / mainnet (RPC aliases defined in foundry.toml) +forge script script/Deploy.s.sol --rpc-url sepolia --broadcast --verify +``` + +## Layout + +``` +src/ contracts +test/ + unit/ deterministic scenarios + fuzz/ property-based + invariant/ stateful, via a handler +script/ deploy scripts +lib/ submodule dependencies +``` diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..b7186f8 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib\\forge-std": { + "tag": { + "name": "v1.14.0", + "rev": "1801b0541f4fda118a10798fd3486bb7051c5dd6" + } + }, + "lib\\openzeppelin-contracts": { + "tag": { + "name": "v5.6.1", + "rev": "5fd1781b1454fd1ef8e722282f86f9293cacf256" + } + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..051938a --- /dev/null +++ b/foundry.toml @@ -0,0 +1,55 @@ +# Full reference: https://github.com/foundry-rs/foundry/tree/master/config + +[profile.default] +solc = "0.8.35" +src = "src" +out = "out" +test = "test" +script = "script" +libs = ["lib"] +optimizer = true +optimizer_runs = 200 +via_ir = false +bytecode_hash = "none" # reproducible bytecode +cbor_metadata = false +gas_reports = ["*"] +fs_permissions = [{ access = "read-write", path = "./broadcast" }] +ignored_warnings_from = ["lib"] # ignoring warnings from external packages + +fuzz = { runs = 256 } +invariant = { runs = 256, depth = 15, fail_on_revert = false, call_override = false } + +# Used in CI, heavier testing, fail fast +[profile.ci] +fuzz = { runs = 10_000 } +invariant = { runs = 1_000, depth = 50, fail_on_revert = false } +verbosity = 3 + +# Run locally before a release, exhaustive +[profile.deep] +fuzz = { runs = 100_000 } +invariant = { runs = 5_000, depth = 100, fail_on_revert = false } + +[fmt] +line_length = 120 +tab_width = 4 +bracket_spacing = true +int_types = "long" +multiline_func_header = "all" +quote_style = "double" +number_underscore = "thousands" +wrap_comments = true + +[rpc_endpoints] +mainnet = "${MAINNET_RPC_URL}" +sepolia = "${SEPOLIA_RPC_URL}" +arbitrum = "${ARBITRUM_RPC_URL}" +optimism = "${OPTIMISM_RPC_URL}" +base = "${BASE_RPC_URL}" + +[etherscan] +mainnet = { key = "${ETHERSCAN_API_KEY}" } +sepolia = { key = "${ETHERSCAN_API_KEY}" } +arbitrum = { key = "${ARBISCAN_API_KEY}" } +optimism = { key = "${OPTIMISTIC_ETHERSCAN_API_KEY}" } +base = { key = "${BASESCAN_API_KEY}" } diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..1801b05 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 1801b0541f4fda118a10798fd3486bb7051c5dd6 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..5fd1781 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..094529f --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +forge-std/=lib/forge-std/src/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol new file mode 100644 index 0000000..e5bc854 --- /dev/null +++ b/script/Deploy.s.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Script, console } from "forge-std/Script.sol"; + +import { Counter } from "src/Counter.sol"; + +/// @notice Deployment script for Counter. +contract Deploy is Script { + function run() external returns (Counter counter) { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + address owner = vm.envOr("OWNER_ADDRESS", deployer); + + vm.startBroadcast(deployerKey); + counter = new Counter(owner); + vm.stopBroadcast(); + + console.log("Counter deployed at :", address(counter)); + console.log("Owner set to :", owner); + console.log("Deployer :", deployer); + } +} diff --git a/src/Counter.sol b/src/Counter.sol new file mode 100644 index 0000000..b5b7fec --- /dev/null +++ b/src/Counter.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @title Counter +/// @notice Minimal counter demonstrating template patterns: OZ integration, +/// custom errors, indexed events, NatSpec, and bounded state. +contract Counter is Ownable { + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + /// @notice Current counter value. + uint256 public number; + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Hard upper bound. Anything above reverts. + uint256 public constant MAX_NUMBER = type(uint128).max; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when `number` is incremented by one. + /// @param by Caller that triggered the increment. + /// @param newValue Value after the increment. + event Incremented(address indexed by, uint256 newValue); + + /// @notice Emitted when `number` is set to an arbitrary value. + /// @param by Caller that triggered the set. + /// @param newValue Value after the set. + event NumberSet(address indexed by, uint256 newValue); + + /// @notice Emitted when `number` is reset to zero. + /// @param by Owner that triggered the reset. + event Reset(address indexed by); + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when an operation would push `number` above MAX_NUMBER. + error CounterOverflow(); + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /// @param initialOwner Address granted exclusive reset rights. + constructor(address initialOwner) Ownable(initialOwner) { } + + /*////////////////////////////////////////////////////////////// + EXTERNAL WRITES + //////////////////////////////////////////////////////////////*/ + + /// @notice Increment `number` by one. + /// @dev Reverts with {CounterOverflow} if `number` already at MAX_NUMBER. + function increment() external { + if (number >= MAX_NUMBER) revert CounterOverflow(); + unchecked { + ++number; + } + emit Incremented(msg.sender, number); + } + + /// @notice Set `number` to a specific value. + /// @param newNumber Target value, must be <= MAX_NUMBER. + function setNumber(uint256 newNumber) external { + if (newNumber > MAX_NUMBER) revert CounterOverflow(); + number = newNumber; + emit NumberSet(msg.sender, newNumber); + } + + /// @notice Reset the counter to zero. Owner-only. + function reset() external onlyOwner { + number = 0; + emit Reset(msg.sender); + } +} diff --git a/test/fuzz/Counter.fuzz.t.sol b/test/fuzz/Counter.fuzz.t.sol new file mode 100644 index 0000000..e4e2f2a --- /dev/null +++ b/test/fuzz/Counter.fuzz.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Test } from "forge-std/Test.sol"; + +import { Counter } from "src/Counter.sol"; + +/// @notice Property-based tests. The fuzzer throws random inputs at the assumptions. +contract CounterFuzzTest is Test { + Counter internal counter; + address internal owner = makeAddr("owner"); + + function setUp() public { + counter = new Counter(owner); + } + + /// @dev Property: any value in valid range round-trips through `setNumber`. + function testFuzz_SetNumber_ValidRange(uint256 x) public { + x = bound(x, 0, counter.MAX_NUMBER()); + + counter.setNumber(x); + + assertEq(counter.number(), x); + } + + /// @dev Property: any value above MAX_NUMBER must revert with CounterOverflow. + function testFuzz_SetNumber_RevertsAboveMax(uint256 x) public { + x = bound(x, counter.MAX_NUMBER() + 1, type(uint256).max); + + vm.expectRevert(Counter.CounterOverflow.selector); + counter.setNumber(x); + } + + /// @dev Property: N successive increments produce `number == N`. + function testFuzz_Increment_NTimes(uint8 n) public { + for (uint256 i = 0; i < n; ++i) { + counter.increment(); + } + assertEq(counter.number(), n); + } + + /// @dev Property: only `owner` can reset, regardless of caller. + function testFuzz_Reset_OnlyOwner(address caller) public { + vm.assume(caller != owner); + counter.setNumber(1); + + vm.prank(caller); + vm.expectRevert(); + counter.reset(); + + assertEq(counter.number(), 1); + } +} diff --git a/test/invariant/Counter.invariant.t.sol b/test/invariant/Counter.invariant.t.sol new file mode 100644 index 0000000..d9fb266 --- /dev/null +++ b/test/invariant/Counter.invariant.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Test, console } from "forge-std/Test.sol"; + +import { Counter } from "src/Counter.sol"; +import { CounterHandler } from "./handlers/CounterHandler.sol"; + +/// @notice Stateful fuzzing. Random sequences of handler calls must never +/// violate the global invariants below. +contract CounterInvariantTest is Test { + Counter internal counter; + CounterHandler internal handler; + address internal owner = makeAddr("owner"); + + function setUp() public { + counter = new Counter(owner); + handler = new CounterHandler(counter, owner); + + // Direct the fuzzer at the handler only, keeps inputs bounded. + targetContract(address(handler)); + } + + /*////////////////////////////////////////////////////////////// + INVARIANTS + //////////////////////////////////////////////////////////////*/ + + /// @dev The counter must never exceed its declared cap. + function invariant_NeverExceedsMax() public view { + assertLe(counter.number(), counter.MAX_NUMBER()); + } + + /// @dev Owner is set at construction and is immutable thereafter. + function invariant_OwnerImmutable() public view { + assertEq(counter.owner(), owner); + } + + /*////////////////////////////////////////////////////////////// + CALL SUMMARY + //////////////////////////////////////////////////////////////*/ + + /// @dev Diagnostic-only, prints handler call counts. Run with `-vv`. + function invariant_CallSummary() public view { + console.log("increments :", handler.ghostIncrements()); + console.log("sets :", handler.ghostSets()); + console.log("resets :", handler.ghostResets()); + } +} diff --git a/test/invariant/handlers/CounterHandler.sol b/test/invariant/handlers/CounterHandler.sol new file mode 100644 index 0000000..b8c2e8d --- /dev/null +++ b/test/invariant/handlers/CounterHandler.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { CommonBase } from "forge-std/Base.sol"; +import { StdUtils } from "forge-std/StdUtils.sol"; +import { StdCheats } from "forge-std/StdCheats.sol"; + +import { Counter } from "src/Counter.sol"; + +/// @notice Handler for stateful fuzzing. Wraps Counter calls with bounded inputs. +/// @dev The invariant runner picks random functions from this contract. +/// Ghost variables track call counts for visibility. +contract CounterHandler is CommonBase, StdCheats, StdUtils { + Counter public immutable COUNTER; + address public immutable OWNER; + + // Ghost variables, observable from invariants. + uint256 public ghostIncrements; + uint256 public ghostSets; + uint256 public ghostResets; + + constructor(Counter counter_, address owner_) { + COUNTER = counter_; + OWNER = owner_; + } + + /// @notice Increment via a non-owner caller; skip if already at the cap. + function increment(address caller) external { + vm.assume(caller != address(0)); + if (COUNTER.number() >= COUNTER.MAX_NUMBER()) return; + + vm.prank(caller); + COUNTER.increment(); + ++ghostIncrements; + } + + /// @notice Set within the valid range via a non-owner caller. + function setNumber(address caller, uint256 x) external { + vm.assume(caller != address(0)); + x = bound(x, 0, COUNTER.MAX_NUMBER()); + + vm.prank(caller); + COUNTER.setNumber(x); + ++ghostSets; + } + + /// @notice Reset must come from the owner. + function reset() external { + vm.prank(OWNER); + COUNTER.reset(); + ++ghostResets; + } +} diff --git a/test/unit/Counter.t.sol b/test/unit/Counter.t.sol new file mode 100644 index 0000000..73ef0d1 --- /dev/null +++ b/test/unit/Counter.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.35; + +import { Test } from "forge-std/Test.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { Counter } from "src/Counter.sol"; + +/// @notice Deterministic, hand-crafted scenarios. The bedrock layer. +contract CounterUnitTest is Test { + Counter internal counter; + address internal owner = makeAddr("owner"); + address internal user = makeAddr("user"); + + event Incremented(address indexed by, uint256 newValue); + event NumberSet(address indexed by, uint256 newValue); + event Reset(address indexed by); + + function setUp() public { + counter = new Counter(owner); + } + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + function test_InitialNumberIsZero() public view { + assertEq(counter.number(), 0); + } + + function test_OwnerIsSetCorrectly() public view { + assertEq(counter.owner(), owner); + } + + /*////////////////////////////////////////////////////////////// + INCREMENT + //////////////////////////////////////////////////////////////*/ + + function test_Increment_EmitsEvent() public { + vm.expectEmit({ checkTopic1: true, checkTopic2: false, checkTopic3: false, checkData: true }); + emit Incremented(user, 1); + + vm.prank(user); + counter.increment(); + + assertEq(counter.number(), 1); + } + + function test_RevertWhen_IncrementAtMax() public { + counter.setNumber(counter.MAX_NUMBER()); + + vm.expectRevert(Counter.CounterOverflow.selector); + counter.increment(); + } + + /*////////////////////////////////////////////////////////////// + SET NUMBER + //////////////////////////////////////////////////////////////*/ + + function test_SetNumber_AnyoneCanCall() public { + vm.prank(user); + counter.setNumber(42); + assertEq(counter.number(), 42); + } + + function test_RevertWhen_SetNumberOverflows() public { + // Compute the offending value BEFORE vm.expectRevert; otherwise the + // view call to MAX_NUMBER() consumes the expectRevert cheatcode. + uint256 tooBig = counter.MAX_NUMBER() + 1; + + vm.expectRevert(Counter.CounterOverflow.selector); + counter.setNumber(tooBig); + } + + /*////////////////////////////////////////////////////////////// + RESET + //////////////////////////////////////////////////////////////*/ + + function test_Reset_OwnerSucceeds() public { + vm.prank(user); + counter.setNumber(100); + + vm.prank(owner); + counter.reset(); + + assertEq(counter.number(), 0); + } + + function test_RevertWhen_NonOwnerResets() public { + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + counter.reset(); + } +}