Foundry 정리

realsung·2025년 3월 14일

web3

목록 보기
1/5
post-thumbnail

Installation

curl -L https://foundry.paradigm.xyz | bash
env 새로고침
foundryup

Project Setup

forge init [프로젝트명]
forge build # 컴파일
forge test # 테스트코드 실행 (함수명 ^test로 시작해야함)
forge install OpenZeppelin/openzeppelin-contracts # 외부 라이브러리 추가 / 뒤에 @v0.0.1 형태로 버전 명시 가능
forge remappings # 설치한 라이브러리 경로 재매핑
forge remove [라이브러리명]
forge clone [주소] [폴더명] # on-chain상에 존재하는 컨트랙트 코드 올라가 있는 것 클론
forge script script/[스크립트].s.sol # 배포 스크립트
<your_rpc_url> --private-key <your_private_key> src/MyContract.sol:MyContract # 스마트계약 배포

soldeer 패키지 dependencies 찾기

참고

  • Foundry의 --hh 옵션은 컴파일 결과물을 Hardhat과 호환되는 형식으로 생성하도록 해주는 옵션, --lib-paths node_modules --contracts contracts 와 함께 사용

배포

  • 하나의 Solidity 파일에는 여러 개의 계약이 포함될 수 있어서 forge create할 때, :MyContract 형태로 특정 컨트랙트를 지정할 수 있다.
  • --constructor-args flag 이용해서 생성자 인수 전달 가능
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {ERC20} from "solmate/tokens/ERC20.sol";

contract MyToken is ERC20 {
    constructor(
        string memory name,
        string memory symbol,
        uint8 decimals,
        uint256 initialSupply
    ) ERC20(name, symbol, decimals) {
        _mint(msg.sender, initialSupply);
    }
}

아래와 같이 인수 넘겨주면 됨

$ forge create --rpc-url <your_rpc_url> \
    --constructor-args "ForgeUSD" "FUSD" 18 1000000000000000000000 \
    --private-key <your_private_key> \
    --etherscan-api-key <your_etherscan_api_key> \
    --verify \
    src/MyToken.sol:MyToken
  • --verify 옵션을 통해 etherscan api등을 쓰는 경우, 검증된 컨트랙트임을 함께 푸쉬 하는 옵션

Mulit-Chain Deploy

  • Cheatcodes를 사용하면 여러 체인에 걸쳐 여러 컨트랙트를 한번에 배포하고 검증할 수 있다. 예를 들어, 단일 명령어로 Sepolia Mainnet과 Base Sepolia에 특정 컨트랙트를 배포하고 싶다면 RPC 엔드포인트와 verify api key를 설정해주면 된다.
[rpc_endpoints]
sepolia = "${SEPOLIA_URL}"
base-sepolia = "${BASE_SEPOLIA_URL}"

[etherscan]
sepolia = { key = "${SEPOLIA_KEY}" }
base-sepolia = { key = "${BASE_SEPOLIA_KEY}" }

Script는 다음과 같이 작성해주면 된다. Sepolia Mainnet fork를 생성하여 컨트랙트 배포 및 검증하고, Base Sepolia로 체인을 전환해서 배포를 한다. Script는 다음과 같이 작성해주면 된다.

contract CounterScript is Script {
    function run() public {
        vm.createSelectFork("sepolia");
        vm.startBroadcast();
        new Counter();
        vm.stopBroadcast();

        vm.createSelectFork("base-sepolia");
        vm.startBroadcast();
        new Counter();
        vm.stopBroadcast();
    }
}
  • forge script script/CounterScript.s.sol --slow --multi --broadcast --private-key <your_private_key> --verify

  • 이외에 필요에 따라 forge verify-contract 를 통해 이미 배포된 컨트랙트를 검증할 수 있다.

Testing

foundry 프로젝트의 test/ 경로에 위치하며, [파일명].t.sol 형태로 관리한다.

특정 test 컨트랙트 실행하고 싶을 때

  • forge test --match-contract [컨트랙트명] --match-test [^test함수]

특정 test 컨트랙트 빼고 실행하고 싶을 때

  • forge test --no-match-contract [컨트랙트명] --no-match-test

특정 test 스크립트 실행하고 싶을 때

  • forge test --match-path test/[파일명]

특정 test 스크립트 빼고 실행하고 싶을 때

  • forge test --no-match-path test/[파일명]

로그 레벨

  • -vv : Logs emitted during tests are also displayed. That includes assertion errors from tests, showing information such as expected vs actual.
  • -vvv : Stack traces for failing tests are also displayed.
  • -vvvv : Stack traces for all tests are displayed, and setup traces for failing tests are displayed.
  • -vvvvv : Stack traces and setup traces are always displayed.

watch 모드

  • forge test --watch : 테스트 실행 후에도 프로젝트 내 파일(스마트 컨트랙트 코드나 테스트 파일)의 변경 사항을 지속적으로 모니터링하여, 변경이 감지되면 자동으로 테스트를 다시 실행

함수명

  • setUp() : 테스트 케이스 실행전 실행되는 생성자 느낌
  • test*() : 성공해야하는 케이스
  • testFail*() : 실패해야하는 케이스

import

  • import {Test} from "forge-std/Test.sol";

beforeTestSetup, Shared setups

  • 이런것도 있긴한데 나중에 유용할듯

CheatCodes

  • Foundry에서 Cheatcode를 제공해주는데 이를 통해 block number, identity 등을 마음대로 수정할 수 있다.
  • 치트코드를 실행하게되면 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D 주소의 함수를 호출하는 것이랑 동일하다.
  • vm 인스턴스를 통해 Cheatcode에 접근할 수 있다.

Test Code

pragma solidity 0.8.10;

import {Test} from "forge-std/Test.sol";

error Unauthorized();

contract OwnerUpOnly {
    address public immutable owner;
    uint256 public count;

    constructor() {
        owner = msg.sender;
    }

    function increment() external {
        if (msg.sender != owner) {
            revert Unauthorized();
        }
        count++;
    }
}

contract OwnerUpOnlyTest is Test {
    OwnerUpOnly upOnly;

    function setUp() public {
        upOnly = new OwnerUpOnly();
    }

    function test_IncrementAsOwner() public {
        assertEq(upOnly.count(), 0);
        upOnly.increment();
        assertEq(upOnly.count(), 1);
    }

    function test_RevertWhen_CallerIsNotOwner() public {
        vm.startPrank(address(0x123));
        vm.expectRevert(Unauthorized.selector);
        upOnly.increment();
        vm.stopPrank();
    }

    function test_RevertWhen_CallerIsNotOwner2() public {
        vm.expectRevert(Unauthorized.selector);
        vm.prank(address(0x0));
        upOnly.increment();
        vm.stopPrank();
    }
}

실행 결과 PASS는 됐지만 Stack Trace를 보면 Revert가 된걸 확인할 수 있다.

foundry/counter [ forge test --match-path test/test1.t.sol -vvvv
[⠢] Compiling...
[⠑] Compiling 1 files with Solc 0.8.10
[⠃] Solc 0.8.10 finished in 421.70ms
Compiler run successful!

Ran 3 tests for test/test1.t.sol:OwnerUpOnlyTest
[PASS] test_IncrementAsOwner() (gas: 32537)
Traces:
  [32537] OwnerUpOnlyTest::test_IncrementAsOwner()
    ├─ [2262] OwnerUpOnly::count() [staticcall]
    │   └─ ← [Return] 0
    ├─ [0] VM::assertEq(0, 0) [staticcall]
    │   └─ ← [Return]
    ├─ [20398] OwnerUpOnly::increment()
    │   └─ ← [Stop]
    ├─ [262] OwnerUpOnly::count() [staticcall]
    │   └─ ← [Return] 1
    ├─ [0] VM::assertEq(1, 1) [staticcall]
    │   └─ ← [Return]
    └─ ← [Stop]

[PASS] test_RevertWhen_CallerIsNotOwner() (gas: 9075)
Traces:
  [9075] OwnerUpOnlyTest::test_RevertWhen_CallerIsNotOwner()
    ├─ [0] VM::startPrank(0x0000000000000000000000000000000000000123)
    │   └─ ← [Return]
    ├─ [0] VM::expectRevert(custom error 0xc31eb0e0: 82b4290000000000000000000000000000000000000000000000000000000000)
    │   └─ ← [Return]
    ├─ [247] OwnerUpOnly::increment()
    │   └─ ← [Revert] Unauthorized()
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return]
    └─ ← [Stop]

[PASS] test_RevertWhen_CallerIsNotOwner2() (gas: 9018)
Traces:
  [9018] OwnerUpOnlyTest::test_RevertWhen_CallerIsNotOwner2()
    ├─ [0] VM::expectRevert(custom error 0xc31eb0e0: 82b4290000000000000000000000000000000000000000000000000000000000)
    │   └─ ← [Return]
    ├─ [0] VM::prank(0x0000000000000000000000000000000000000000)
    │   └─ ← [Return]
    ├─ [247] OwnerUpOnly::increment()
    │   └─ ← [Revert] Unauthorized()
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return]
    └─ ← [Stop]

Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 632.64µs (390.33µs CPU time)

Ran 1 test suite in 5.04ms (632.64µs CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Event Validate

  • Solidity의 이벤트는 컨트랙트의 상속 가능한 멤버이며, 이벤트를 emit하면 그 인자들이 블록체인에 기록된다.
  • 인덱스된 파라미터(indexed)를 최대 3개까지 지정할 수 있는데, 이들은 토픽(topic)을 형성하여 나중에 이벤트를 검색할 때 유용하게 활용된다.
  • 이벤트는 컨트랙트 내부에서 발생한 특정 사건(예: 토큰 전송)을 블록체인에 기록하여, 외부에서 로그를 확인하거나 필터링할 수 있도록 한다. 이벤트 파라미터 중 indexed가 붙은 값들은 토픽으로 저장된다.
  • vm.expectEmit는 다음에 발생할 이벤트의 특정 요소들을 검증하기 위한 함수다.
  • Transfer함수로 예를 들면, event Transfer(address indexed from, address indexed to, uint256 amount); 가 있다고 가정한다.
  • vm.expectEmit(true, true, false, true); 를 호출하게되면 from, to 토픽 검증, false는 3번째 인자인데 indexed된게 아니므로 패스하고, 4번째 true는 uint256 amount를 가르키게 된다.
  • test_ExpectEmit_DoNotCheckData의 경우에 emit Transfer(address(this), address(1337), 1338); 형태로 호출하지만, emitter.t()를 호출했을 때 발생하는 amount가 1337이다. 하지만 amount 데이터는 false로 설정해놨으므로, 테스트 통과하게 된다.
pragma solidity 0.8.10;

import {Test} from "forge-std/Test.sol";

contract EmitContractTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 amount);

    function test_ExpectEmit() public {
        ExpectEmit emitter = new ExpectEmit();
        // Check that topic 1, topic 2, and data are the same as the following emitted event.
        // Checking topic 3 here doesn't matter, because `Transfer` only has 2 indexed topics.
        vm.expectEmit(true, true, false, true);
        // The event we expect
        emit Transfer(address(this), address(1337), 1337);
        // The event we get
        emitter.t();
    }

    function test_ExpectEmit_DoNotCheckData() public {
        ExpectEmit emitter = new ExpectEmit();
        // Check topic 1 and topic 2, but do not check data
        vm.expectEmit(true, true, false, false);
        // The event we expect
        emit Transfer(address(this), address(1337), 1338);
        // The event we get
        emitter.t();
    }
}

contract ExpectEmit {
    event Transfer(address indexed from, address indexed to, uint256 amount);

    function t() public {
        emit Transfer(msg.sender, address(1337), 1337);
    }
}
  • vm.expectEmit(true, true, false, true);, emit Transfer(address(this), address(1337), 1338); 로 수정하고 test하니 revert가 뜨는 것을 확인 가능하다.
foundry/counter [ forge test --match-path test/test2.t.sol -vvvv
[⠢] Compiling...
[⠑] Compiling 1 files with Solc 0.8.10
[⠃] Solc 0.8.10 finished in 408.91ms
Compiler run successful!

Ran 2 tests for test/test2.t.sol:EmitContractTest
[FAIL: log != expected log] test_ExpectEmit() (gas: 72648)
Traces:
  [72648] EmitContractTest::test_ExpectEmit()
    ├─ [33287] → new ExpectEmit@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
    │   └─ ← [Return] 166 bytes of code
    ├─ [0] VM::expectEmit(true, true, false, true)
    │   └─ ← [Return]
    ├─ emit Transfer(from: EmitContractTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000539, amount: 1338)
    ├─ [1940] ExpectEmit::t()
    │   ├─ emit Transfer(from: EmitContractTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000539, amount: 1337)
    │   └─ ← [Stop]
    └─ ← [Revert] log != expected log

[PASS] test_ExpectEmit_DoNotCheckData() (gas: 72697)
Traces:
  [72697] EmitContractTest::test_ExpectEmit_DoNotCheckData()
    ├─ [33287] → new ExpectEmit@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
    │   └─ ← [Return] 166 bytes of code
    ├─ [0] VM::expectEmit(true, true, false, false)
    │   └─ ← [Return]
    ├─ emit Transfer(from: EmitContractTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000539, amount: 1338)
    ├─ [1940] ExpectEmit::t()
    │   ├─ emit Transfer(from: EmitContractTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000539, amount: 1337)
    │   └─ ← [Stop]
    └─ ← [Stop]

Suite result: FAILED. 1 passed; 1 failed; 0 skipped; finished in 403.38µs (170.30µs CPU time)

Ran 1 test suite in 586.63ms (403.38µs CPU time): 1 tests passed, 1 failed, 0 skipped (2 total tests)

Failing tests:
Encountered 1 failing test in test/test2.t.sol:EmitContractTest
[FAIL: log != expected log] test_ExpectEmit() (gas: 72648)

Encountered a total of 1 failing tests, 1 tests succeeded

Forge Std

  • import {Test} from "forge-std/Test.sol"; 형태로 import하면 해당 Test를 컨트랙트에 상속해서 이용한다. (contract Name is Test { ... })
  • Hardhat 스타일의 console도 사용 존재한다.
  • Forge Std에는 6개의 라이브러리를 지원한다. (Std Logs, Std Assertions, Std Cheats, Std Errors, Std Storage, Std Math)

Test Code Trace

  • 위에서 forge test에 -vvvv 등의 옵션을 주어 추적을 할 수 있었다. 해당 Trace 구조는 다음과 같다.
  [<Gas Usage>] <Contract>::<Function>(<Parameters>)
    ├─ [<Gas Usage>] <Contract>::<Function>(<Parameters>)
    │   └─ ← <Return Value>
    └─ ← <Return Value>
  • 터미널이 색깔 지원 되면 다음과 같이 뜬다.
Green: For calls that do not revert
Red: For reverting calls
Blue: For calls to cheat codes
Cyan: For emitted logs
Yellow: For contract deployments
  • 상위 트레이스의 gas양은 하위 트레이스의 gas양들의 합보다 좀 많은데, 이건 내부에서 산술 연산이나, r/w 등 때문에 gas양이 추가되서 그렇다.

  • ABI 추적이 안되는 경우는 calldata가 그대로 로그에 나온다.

  [<Gas Usage>] <Address>::<Calldata>
    └─ ← <Return Data>

실패한 코드만 재실행

  • forge test --rerun
  • 실패한 테스트 케이스는 ~/.foundry/cache/test-failures 경로에 저장된다. 성공한 테스트케이스를 제외하고, 실패 테스트케이스만 실행하고 싶을 때 사용하면 된다.

Gas Tracing

Remix처럼 Foundry에서도 스마트 컨트랙트가 gas를 얼마나 소모할지 추정할 수 있다.
우선 Forge에서 계약에 대한 가스 보고서를 생성할 수 있는데 foundry.toml의 gas_reports 필드를 통해 특정 계약의 gas 보고서를 생성할 수 있다.

  • gas_reports = ["MyContract", "MyContractFactory"] or gas_reports = ["*"]
  • gas_reports_ignore = ["FaucetContract"] # 특정 계약 가스 보고서 생성 x
  • forge test --gas-report 옵션 사용

모든 테스트 함수 기능에 대한 gas snapshot이라는 기능을 제공하는데, 이는 계약에 따라 얼마나 많은 가스가 소모되는지, 최적화 등에 대한 비교하는데 주로 사용되는 것 같다.

  • forge snapshot --snap [output_filename] [--asc/--desc] [--min {value}/--max {value}]
  • 스냅샷 비교 : forge snapshot [--diff/--check {filename}] # --diff는 전체 출력, --check는 바뀐 부분만 출력

Forge에서는 테스트 함수 내에서 임의의 구간에 대해 gas 사용량 스냅샷을 캡처할 수 있다. 이를 통해 외부 호출과 내부 연산에 소비되는 gas를 세밀하게 측정할 수 있으며, 코드 최적화나 성능 모니터링에 유용하다. cheatcodes를 활용하면 된다.

스냅샷 시작 (startSnapshotGas)

  • startSnapshotGas(string calldata name) : 현재까지 사용된 gas를 캡처 시작하며, 여기서 name은 스냅샷의 이름으로, 기본적으로 계약 이름에서 그룹이 파생된다.
  • startSnapshotGas(string calldata group, string calldata name) : 사용자 지정 그룹 이름(group)과 스냅샷 이름(name)을 함께 사용해 캡처를 시작할 수 있다.

스냅샷 종료 (stopSnapshotGas)

  • stopSnapshotGas() : 가장 최근에 시작된 스냅샷을 종료하고, 시작 시점 이후로 사용된 gas의 양을 반환한다.
  • stopSnapshotGas(string calldata name), stopSnapshotGas(string calldata group, string calldata name) : 특정 스냅샷(또는 그룹 내 스냅샷)을 종료하고 사용된 gas를 측정한다.

임의 값 캡처 (snapshotValue)

  • snapshotValue(string calldata name, uint256 value) : 숫자 값(예: 컨트랙트의 바이트코드 크기 등)을 스냅샷으로 기록한다.
  • snapshotValue(string calldata group, string calldata name, uint256 value) : 사용자 지정 그룹 내에서 값을 기록할 수 있다.

마지막 호출의 gas 측정 (snapshotGasLastCall)

  • snapshotGasLastCall(string calldata name) : 최근 호출의 gas 사용량을 캡처하여 반환한다.
  • snapshotGasLastCall(string calldata group, string calldata name) : 그룹 내에서 마지막 호출의 gas 사용량을 캡처한다.

테스트 실행 시 gas 사용량이 이전 스냅샷과 달라졌는지 비교할 수 있다. 이를 위해 아래 중 하나를 설정하면 된다. 이 설정이 활성화되면 이전 스냅샷과의 차이가 있을 경우 테스트가 실패된다.

  • 환경 변수: FORGE_SNAPSHOT_CHECK=true
  • foundry.toml 파일에 gas_snapshot_check = true 설정
  • Command line flag: --gas-snapshot-check=true

스냅샷 생성을 원하지 않는다면 아래 중 하나를 사용해 비활성화할 수 있다.

  • 환경 변수: FORGE_SNAPSHOT_EMIT=false
  • foundry.toml 파일에 gas_snapshot_emit = false 설정
  • Command line flag: --gas-snapshot-emit=false

이 cheatcode들은 Isolated Test 모드에서 정확하게 작동한다. Isolated Test를 활성화하려면 --isolate 플래그를 사용하거나 테스트 함수에 /// forge-config: default.isolate = true를 추가해야한다.

예시1(외부 호출의 gas 사용량 측정)

  • 외부 컨트랙트의 함수 호출 전후에 스냅샷을 찍어 사용된 gas를 측정한다.
contract SnapshotGasTest is Test {
    Flare public flare;

    function setUp() public {
        flare = new Flare();
    }

    function testSnapshotGas() public {
        vm.startSnapshotGas("externalA");
        flare.run(256);
        uint256 gasUsed = vm.stopSnapshotGas();
    }
}

예시2(내부 상태 변경의 여러 섹션 측정)

  • 여러 코드 섹션(내부 상태 변경)에 대해 각각 별도의 스냅샷을 기록
contract SnapshotGasTest is Test {
    uint256 public slot0;

    function testSnapshotGas() public {
        vm.startSnapshotGas("internalA");
        slot0 = 1;
        vm.stopSnapshotGas();

        vm.startSnapshotGas("internalB");
        slot0 = 2;
        vm.stopSnapshotGas();

        vm.startSnapshotGas("internalC");
        slot0 = 0;
        vm.stopSnapshotGas();
    }
}

예시3(내부 상태 변경과 외부 호출을 동시에 측정)

  • 한 섹션 내에서 외부 호출과 내부 상태 변경 모두를 포함해 gas 사용량을 측정
contract SnapshotGasTest is Test {
    uint256 public slot0;
    Flare public flare;

    function setUp() public {
        flare = new Flare();
    }

    function testSnapshotGas() public {
        vm.startSnapshotGas("combinedA");
        flare.run(256);
        slot0 = 1;
        vm.stopSnapshotGas();
    }
}

예시4(임의값 스냅샷)

contract SnapshotGasTest is Test {
    uint256 public slot0;

    function testSnapshotValue() public {
        uint256 a = 123;
        uint256 b = 456;
        uint256 c = 789;

        vm.snapshotValue("valueA", a);
        vm.snapshotValue("valueB", b);
        vm.snapshotValue("valueC", c);
    }
}

예시5(마지막 호출의 gas 사용량 측정)

  • 호출된 함수의 gas 사용량을 캡처
contract SnapshotGasTest is Test {
    Flare public flare;

    function setUp() public {
        flare = new Flare();
    }

    function testSnapshotGasLastCall() public {
        flare.run(1);
        vm.snapshotGasLastCall("lastCallA");
    }
}

예시6(그룹을 이용한 스냅샷 기록)

  • 그룹을 사용하여 스냅샷들을 커스텀 그룹 파일에 저장할 수 있다.
contract SnapshotGasTest is Test {
    uint256 public slot0;

    function testSnapshotGas() public {
        vm.startSnapshotGas("CustomGroup", "internalA");
        slot0 = 1;
        vm.stopSnapshotGas();

        vm.startSnapshotGas("CustomGroup", "internalB");
        slot0 = 2;
        vm.stopSnapshotGas();

        vm.startSnapshotGas("CustomGroup", "internalC");
        slot0 = 0;
        vm.stopSnapshotGas();
    }
}

Fork Testing

Fork는 실제 블록체인 네트워크의 현재 상태를 복제하여 로컬 테스트 환경에서 시뮬레이션할 수 있도록 하는 기능이다.

Forking Mode

  • forge test --fork-url flag를 이용해서 RPC URL을 전달하여 single fork가 가능하다.
  • --fork-block-number [번호] 형태로 flag를 제공하면 특정 block number 포크가 가능하다.
  • --fork-url, --fork-block-number를 사용하게되면 캐싱이 되는데, forge clean으로 아티팩트와 캐시 디렉토리 내용을 지울 수 있음 (~/.foundry/cache/rpc/<체인 이름>/<블록 번호>에 캐싱됨, 아니면 --no-storage-caching 옵션을 통해 cache 저장 안하도록 할 수 있다.)
  • Etherscan의 데이터를 활용하여, 블록체인에 배포된 검증된 컨트랙트의 메타데이터를 가져올 수 있다. forge test --fork-url <your_rpc_url> --etherscan-api-key <your_etherscan_api_key>

Forking Cheatcodes

  • Forking Cheatcodes는 Solidity 테스트 코드 내에서 프로그래밍 방식으로 forking 모드를 제어할 수 있도록 하는 Foundry의 기능이다.

  • 각 fork는 완전히 독립적인 EVM으로 동작하며, 저장소도 분리되어 있다. 단, msg.sender와 테스트 컨트랙트의 상태(변수)는 모든 fork에서 공유된다. 필요한 경우 vm.makePersistent(address) 를 통해 특정 계정을 모든 fork에서 지속적으로 사용 가능하도록 만들 수 있다.

  • vm.createFork(RPC_URL) : 지정된 RPC URL을 기반으로 새로운 fork를 생성하며, 고유한 uint256 식별자(forkId)를 반환

  • vm.createSelectFork(RPC_URL) : fork를 생성하면서 동시에 선택하는 한 줄짜리 함수

  • vm.selectFork(forkId) : 특정 forkId를 가진 fork를 활성화

  • vm.activeFork() : 현재 활성화된 fork의 식별자를 반환, 테스트 내에서 여러 fork를 생성해두고 필요에 따라 전환할 수 있음

  • vm.rollFork(blockNumber) : 활성 fork의 블록 넘버를 설정할 수 있다.

테스트 코드

contract ForkTest is Test {
    // the identifiers of the forks
    uint256 mainnetFork;
    uint256 optimismFork;

    //Access variables from .env file via vm.envString("varname")
    //Replace ALCHEMY_KEY by your alchemy key or Etherscan key, change RPC url if need
    //inside your .env file e.g:
    //MAINNET_RPC_URL = 'https://eth-mainnet.g.alchemy.com/v2/ALCHEMY_KEY'
    //string MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
    //string OPTIMISM_RPC_URL = vm.envString("OPTIMISM_RPC_URL");

    // create two _different_ forks during setup
    function setUp() public {
        mainnetFork = vm.createFork(MAINNET_RPC_URL);
        optimismFork = vm.createFork(OPTIMISM_RPC_URL);
    }

    // demonstrate fork ids are unique
    function testForkIdDiffer() public {
        assert(mainnetFork != optimismFork);
    }

    // select a specific fork
    function testCanSelectFork() public {
        // select the fork
        vm.selectFork(mainnetFork);
        assertEq(vm.activeFork(), mainnetFork);

        // from here on data is fetched from the `mainnetFork` if the EVM requests it and written to the storage of `mainnetFork`
    }

    // manage multiple forks in the same test
    function testCanSwitchForks() public {
        vm.selectFork(mainnetFork);
        assertEq(vm.activeFork(), mainnetFork);

        vm.selectFork(optimismFork);
        assertEq(vm.activeFork(), optimismFork);
    }

    // forks can be created at all times
    function testCanCreateAndSelectForkInOneStep() public {
        // creates a new fork and also selects it
        uint256 anotherFork = vm.createSelectFork(MAINNET_RPC_URL);
        assertEq(vm.activeFork(), anotherFork);
    }

    // set `block.number` of a fork
    function testCanSetForkBlockNumber() public {
        vm.selectFork(mainnetFork);
        vm.rollFork(1_337_000);

        assertEq(block.number, 1_337_000);
    }
}

Fuzzing Test

Forge는 property-based testing을 지원한다. 이는, 개별 시나리오가 아닌 일반적인 행동을 테스트하는 방식이다.

단위테스트를 작성하고, 이를 property 기반 테스트로 변환해보겠다.

pragma solidity 0.8.10;

import {Test} from "forge-std/Test.sol";

contract Safe {
    receive() external payable {}

    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

contract SafeTest is Test {
    Safe safe;

    // 테스트 컨트랙트 자체가 이더를 받을 수 있어야 withdraw 시 이더를 받을 수 있음.
    receive() external payable {}

    function setUp() public {
        safe = new Safe();
    }

    function test_Withdraw() public {
        payable(address(safe)).transfer(1 ether);
        uint256 preBalance = address(this).balance;
        safe.withdraw();
        uint256 postBalance = address(this).balance;
        assertEq(preBalance + 1 ether, postBalance);
    }
}

위의 Unit Test는 safe 컨트랙트로부터 이더를 인출할 수 있음을 테스트하지만 1 eth에 대해서만 검증을 수행한다. 즉, safe 컨트랙트에 있는 모든 이더가 인출되어야 한다는 일반적인 property를 테스트하는 것이다. 일반적인 property는 “safe에 잔고가 있을 때, 인출하면 safe에 있는 모든 이더가 인출되어야 한다”이다.

Forge는 매개변수를 하나 이상 받는 테스트 함수를 프로퍼티 기반 테스트로 실행하기 때문에, 테스트를 다음과 같이 재작성할 수 있다.

테스트 컨트랙트에 기본으로 제공되는 이더의 양은 2**96 wei 이므로, 적당히 uint를 제한해줘야한다. uint256 amount로 매개변수를 받으면 실패한다.

특정 경우를 제외하고 싶다면 vm.assume cheatcode를 이용할 수 있다.

  • vm.assume(amount > 0.1 ether); 형태로 사용하게되면 amount가 0.1 ether 이상인 경우만 테스트하고, 밑의 나머지 코드는 패스한다.
contract SafeTest is Test {
    // ...

    function testFuzz_Withdraw(uint96 amount) public {
        payable(address(safe)).transfer(amount);
        uint256 preBalance = address(this).balance;
        safe.withdraw();
        uint256 postBalance = address(this).balance;
        assertEq(preBalance + amount, postBalance);
    }
}

Fuzzing 테스트를 수행했을 때 결과

  • runs : Fuzzer가 테스트한 시나리오의 수 (일반적으로 256개지만, 수정 가능)
  • μ : 해당 뮤는 퍼징 실행에서 사용한 평균 gas양을 나타낸다.
  • ~ : 해당 틸드는 모든 퍼징 실행에 사용한 중앙 gas양이다.

Fuzzing 테스트할때 Fixtures 라는게 존재하는데, 퍼징 매개변수로 사용될 입력 값의 집합을 명시적으로 지정하고 싶을 때 정의할 수 있다.

Storage arrays

  • fixture 접두어와 퍼징할 변수이름을 함께 배열을 만들어주면 된다.
  • e.g. uint32[] public fixtureAmount = [1, 5, 555];

Function

  • 위와 동일하게 fixture 접두어와 함께 정의하면 되는데, 함수는 고정 크기나 동적 배열 값을 returns 해야한다.
  • e.g. function fixtureOwner() public returns (address[] memory)

Fixture 사용해서 DSChief 취약점 재현

  • 이 취약점은 etch를 호출하기 전에 voteSlate를 호출하여, slate 값이 yay 주소의 해시가 되도록 함으로써 재현할 수 있다.
function etch(address yay) public returns (bytes32 slate) {
    bytes32 hash = keccak256(abi.encodePacked(yay));
    slates[hash] = yay;
    return hash;
}

function voteSlate(bytes32 slate) public {
    uint weight = deposits[msg.sender];
    subWeight(weight, votes[msg.sender]);
    votes[msg.sender] = slate;
    addWeight(weight, votes[msg.sender]);
}
  • 퍼저가 동일한 실행에서 yay 주소에서 파생된 slate 값을 포함하도록 하기 위해, 다음과 같은 Fixture를 정의하면 된다.
address[] public fixtureYay = [
    makeAddr("yay1"),
    makeAddr("yay2"),
    makeAddr("yay3")
];

bytes32[] public fixtureSlate = [
    keccak256(abi.encodePacked(makeAddr("yay1"))),
    keccak256(abi.encodePacked(makeAddr("yay2"))),
    keccak256(abi.encodePacked(makeAddr("yay3")))
];

Fixture 방식와 일반 Fuzzing을 구분해서 차이를 보여주는 그림이다.

이외에도 Invariant Testing, Differential Testing이 존재한다.

Debugger

forge test, forge script를 사용할 때 --debug flag를 붙여 디버깅을 수행할 수 있다.

  • forge test --debug "testSomethingFunction()"

  • 0-9 + k: 숫자만큼 뒤로 이동 (또는 마우스로 위로 스크롤)
  • 0-9 + j: 숫자만큼 앞으로 이동 (또는 마우스로 아래로 스크롤)
  • g: 트랜잭션의 시작으로 이동
  • G: 트랜잭션의 끝으로 이동
  • c: 이전 호출 유형 명령어(CALL, STATICCALL, DELEGATECALL, CALLCODE 등)로 이동
  • C: 다음 호출 유형 명령어로 이동
  • a: 이전 JUMP 또는 JUMPI 명령어로 이동
  • s: 다음 JUMPDEST 명령어로 이동
  • ’ + a-z: vm.breakpoint cheatcode로 설정된 <문자> 브레이크포인트로 이동
  • Ctrl + j: 메모리 뷰를 아래로 스크롤
  • Ctrl + k: 메모리 뷰를 위로 스크롤
  • m: 메모리를 UTF8 형식으로 표시
  • J: 스택 뷰를 아래로 스크롤
  • K: 스택 뷰를 위로 스크롤
  • t: 현재 연산이 소비할 항목을 확인하기 위해 스택에 라벨 표시

치트 코드

cast

cast는 Ethereum 애플리케이션과 상호 작용하기 위한 도구로, 스마트 계약 호출을 하거나, 거래를 보내거나, 모든 유형의 체인 데이터를 검색할 수 있는 커맨드라인 도구다.
abi-encode : 인자들을 ABI 인코딩

  • cast abi-encode "constructor(string,string,uint8,uint256)" "ForgeUSD" "FUSD" 18 1000000000000000000000

이더리움 메인넷 최신 블록 번호 확인

  • cast block-number --rpc-url https://eth.merkle.io

비탈릭 부테린 이더 개수 확인

  • cast balance vitalik.eth --ether --rpc-url https://eth.merkle.io

거래 재생 및 추적

  • cast run 0x9c32042f5e997e27e67f82583839548eb19dc78c4769ad6218657c17f2a5ed31 --rpc-url https://eth.merkle.io

DAI token 총 공급량 검색

  • cast call 0x6b175474e89094c44da98b954eedeac495271d0f "totalSupply()(uint256)" --rpc-url https://eth-mainnet.alchemyapi.io/v2/Lc7oIGYeL_QvInzI0Wiu_pOZZDEKBrdf

calldata 디코딩

  • cast 4byte-decode 0x1F1F897F676d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e7

Anvil 계정간에 메시지 전송

  • cast send --private-key <PRIVATE_KEY> 0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc $(cast from-utf8 "hello world") --rpc-url http://127.0.0.1:8545/

anvil

Anvil은 로컬에 테스트 이더리움 넷을 구축할 수 있는 도구다. 그냥 anvil 치면 된다. 아니면 rpc url을 통해 fork도 가능하다. --fork-url https://eth.merkle.io 형태로 플래그 주면 된다.

chisel

Chisel은 솔리디티 REPL이다.

profile
Let's go $HYPE, $PURR

0개의 댓글