[Foundry] Foundry Book으로 마스터하기

드림보이즈·2025년 4월 11일
2

Smart Contract

목록 보기
11/11

참조 : https://book.getfoundry.sh/

0. 소개

Rust로 만들어진 초고속 Solidity 개발 프레임워크로,
Solidity 프로젝트를 컴파일 → 테스트 → 배포 → 검증까지 전부 지원하는 툴이다.

2. 시작

$ forge init hello_foundry
$ cd hello_foundry
$ tree . -d -L 1
.
├── lib
├── script
├── src
└── test

5 directories
forge build
forge test

4. 이더스캔으로 부터 온체인 컨트랙트 복제

이더스캔 등에 코드가 공개된 유명한 컨트랙트 같은 경우 복제해올 수 있다.

forge clone 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 ProjectName

5. Dependencies

프로젝트 내에서 외부 라이브러리 사용하는 것

forge install vectorized/solady

lib 폴더 안에 다운로드됨.

remappings

Import 쉽게 사용 가능하다.

solidity 문법으로는 import "@openzeppelin/contracts/token/ERC20/ERC20.sol”

경로로 파일을 불러오는데, Foundry에서 사용하려면 로컬 경로로 바꿔야 하니까 Remapping이 필요함.

forge remappings

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/
forge-std/=lib/forge-std/src/
halmos-cheatcodes/=lib/openzeppelin-contracts/lib/halmos-cheatcodes/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/
solady/=lib/solady/src/

만약 따로 설정하고 싶다면, remappings.txt 혹은 foundry.toml 에서 설정 가능

경로 전체를 입력안하고 약어로 접근 가능하다.

Remapping conflicts

만약 라이브러리 2개가

  • lib/one
  • lib/two

내부적으로 @openzepplin 사용한다면, Foundry는 내부적으로 하나의 remapping만 생성한다.

예를 들어

$ forge remappings  
@openzeppelin/=lib/lib_1/node_modules/@openzeppelin/  

lib/two 내부의 @openzeppelin는 무시된다.

해결법

Remappings.txt를 따로 프로젝트 루트에 만든다.

라이브러리마다 고유한 Rempping 설정해야 된다.

lib/lib_1/:@openzeppelin/=lib/lib_1/node_modules/@openzeppelin/
lib/lib_2/:@openzeppelin/=lib/lib_2/node_modules/@openzeppelin/

업데이트 및 삭제

forge update
forge remove solady
forge remappings > remappings.txt

리매핑 경로를 txt 파일로 저장

6. Soldeer as package manager (신기술)

NPM 같은 버전 기반 패키지 관리 시스템 제공

지금까지는 gitsubmodules 형식으로 외부 라이브러리를 가져오는 형식이었음.

7. 프로젝트 구조

.
├── README.md
├── foundry.toml
├── lib
│   └── forge-std
│       ├── CONTRIBUTING.md
│       ├── LICENSE-APACHE
│       ├── LICENSE-MIT
│       ├── README.md
│       ├── foundry.toml
│       ├── package.json
│       ├── scripts
│       ├── src
│       └── test
├── script // 컨트랙트 배포하너가 실제환경에서 실행할 스크립트 저장
│   └── Counter.s.sol
├── src
│   └── Counter.sol
└── test
    └── Counter.t.sol

9 directories, 11 files

9. Tests

forge test

// 특정 테스트 함수만 실행
forge test --match-contract ComplicatedContractTest --match-test test_Deposit

// 특정 경로만 실행
forge test --match-path test/ContractB.t.sol

// 테스트 파일 저장할 때 마다 테스트 실행
forge test --watch.
레벨명령어출력 내용주 용도
1 (기본)forge test통과/실패 요약만결과만 빠르게 보고 싶을 때
2forge test -vvconsole.log 등 로그 + assertion 실패 메시지로그 찍어서 값 확인하거나 expected vs actual 비교할 때
3forge test -vvv위 + 실패한 테스트의 스택 트레이스실패 위치와 호출 흐름까지 보고 싶을 때
4forge test -vvvv위 + setup 트레이스까지 보여줌 (실패 테스트 한정)테스트 준비 코드까지 분석하고 싶을 때
5forge test -vvvvv모든 테스트의 스택 & setup 트레이스성공한 테스트까지 전부 추적하고 싶을 때 (풀 디버깅)

9-1. Writing tests

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol"; // Test 상속받아 실행
import "../src/MyContract.sol";

contract MyContractTest is Test {
    MyContract myContract; // Sol 객체 가져오기

    function setUp() public { //각 테스트 전에 실행되어 촟기상태 설정
        myContract = new MyContract(); // 컨트랙트 배포
        vm.addr(1); //실행자 1로 변경

    }

    function testSetVal() public { // 테스트 함수 : public or external
        myContract.setVal(50); // 함수 실행
        assertEq(myContract.val(), 50); // 비교
    }
    function testRevert_Subtract43() public {
        vm.expectRevert();
        testNumber -= 43;
    }
    function test_CannotSubtract43() public {
        vm.expectRevert(stdError.arithmeticError); // 좀 더 정확한 에러 파악 
        testNumber -= 43;
    }
}
forge test

Before test setups

forge test 는 각 테스트 함수가 독립적으로 실행. 따라서 전파가 안됨.

그러나 테스트 함수 간 의존성 만들거나 공유 상태를 사용해야 할 때 있음.

beforeTestSetup() : 특정 테스트 실행 전에 다른 트랜잭션 먼저 실행시키는 기능

  • testC 전에 A,B 먼저 실행하고 실행
contract ContractTest is Test {
    uint256 a;
    uint256 b;

    function beforeTestSetup(
        bytes4 testSelector
    ) public pure returns (bytes[] memory beforeTestCalldata) {
        if (testSelector == this.testC.selector) {
            beforeTestCalldata = new bytes[](2);
            beforeTestCalldata[0] = abi.encodePacked(this.testA.selector);
            beforeTestCalldata[1] = abi.encodeWithSignature("setB(uint256)", 1);
        }
    }

    function testA() public {
        require(a == 0);
        a += 1;
    }

    function setB(uint256 value) public {
        b = value;
    }

    function testC() public {
        assertEq(a, 1);
        assertEq(b, 1);
    }
}

Shared Setups

아 여러 컨트랙트 객체 작성할 때 setup을 한번만 코드 쓰게 하려고

abstract contract HelperContract {
    address constant IMPORTANT_ADDRESS = 0x543d...;
    SomeContract someContract;

    constructor() {  // 생성자를 이용해 기본 설정을 적용할 수도 있음
        // 기본 상태 초기화 가능
    }
}

contract MyContractTest is Test, HelperContract {
    function setUp() public override {
        someContract = new SomeContract(0, IMPORTANT_ADDRESS);
    }
}

contract MyOtherContractTest is Test, HelperContract {
    function setUp() public override {
        someContract = new SomeContract(1000, IMPORTANT_ADDRESS);
    }
}

9-2. cheat codes

테스트 중 EVM 상태 조작, 시뮬레이션 가능

블록 번호 변경, 계정 스푸핑

vm 인스턴스로 접근

  • prank : msg.sender 주소 설정
  • assume : 조건이 false면 새로운 퍼즈 실행
  • expectEmit() : 다음 호출에서 특정 이벤트 발생하는지 확인

9-3. Forge standard lib

테스트를 좀더 쉽게 작성할 수 있도록 돕는 컨트랙트 모음

Vm.sol

치트코드설명
vm.prank(addr)다음 트랜잭션을 addr이 보낸 것처럼 실행
vm.startPrank(addr) / vm.stopPrank()연속해서 prank
vm.warp(time)블록 타임 변경
vm.roll(blockNum)블록 넘버 변경
vm.deal(addr, amount)주소에 ETH 입금
vm.expectRevert()revert 예상될 때 감싸는 코드
vm.snapshot() / vm.revertTo(id)상태 저장 & 되돌리기 (shared setup 용도 등)
  • console.sol
  • Script.sol
  • Test.sol

9-4. Understanding Trace

트랜잭션 상세한 실행 과정을 추적하여 디버깅, 최적화 돕는 Trace

  • -vvv : 실패만
  • -vvvv : 모든 테스트 + 실패한 테스트의 설정 트레이스
  • -vvvvv : 모든 테스트 전체 트레이스, 설정 트레이스

9-5. Fork testing

실제 블록체인 상태 복제하여 로컬에서 테스트 수행

기본은 로컬에서 내가 만든 컨트랙트만 테스트 하는 건데,

  • 이미 배포된 Defi 프로토콜 호출
  • 실제 USDC 컨트랙트와 상호작용

실제 체인을 내 로컬로 포크해서 테스트 할 수 있게.

즉, 특정 시점의 이더리움 상태를 읽을 수 있는 환경을 만들고, 로컬에서 EVM 시뮬레이션으로 처리.

메인넷 배포된 USDC 컨트랙트를 로컬에 세팅해 상호작용 가능

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
}

contract ForkUSDCBalance is Test {
    address usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
    address target = address(0xde589C867174C349d00e9b582867aF5c13A74679);

    function setUp() public {
        // 메인넷 포크해서 선택
        vm.createSelectFork(vm.envString("MAINNET_RPC_URL"));
    }

    function testUSDCBalance() public {
        uint256 balance = IERC20(usdc).balanceOf(target);
        console.log("USDC balance of target:", balance);
        assertEq(balance, 0);
    }
}

9-6. Replaying failures

이전 테스트 실행에서 실패한 테스트만을 선택적으로 다시 실행 가능

forge test --rerun

fuzz, Invariant 테스트 실패 재실행

fuzz 테스트에서 발견된 실패 사례는 ~/.foundry/cache/fuzz/failures에 저장됨.

이를 통해 재실행 가능


10. Advanced Testing

10-1. Fuzz

단일 시나리오 아닌 일반적인 동작 검증

다양한 입력값에 대해 일관된 결과를 반환하는지 확인 가능

퍼즈테스트 설정은 foundry.toml에서

[fuzz]
runs = 1000

개별 함수마다 다른 설정은

contract MyTest is Test {
    /// forge-config: default.fuzz.runs = 100
    /// forge-config: ci.fuzz.runs = 500
    /// forge-config: default.fuzz.show-logs = true
    function test_SimpleFuzzTest(uint256 x) public {
        // 테스트 코드
        
    vm.assume(amount > 0.1 ether); // 이 이상만 fuzz 실행
    // snip
    }
}

10-2. Invariant Testing

컨트랙트에서 특정 조건이 항상 유지되는지 검증하는 테스트 방식.

  • Uniswap에서는 x * y = k라는 공식이 항상 성립해야 하고,
  • ERC20 토큰에서는 모든 계정의 잔액 합이 totalSupply와 같아야 해.

랜덤한 함수 호출 시퀀스 작성 ⇒ 각 호출 후에 정의된 불변 조건 검증.

  • runs: 테스트를 몇 번 반복할지 설정. 기본값은 256회.
  • depth: 각 반복에서 몇 개의 함수 호출을 할지 설정. 기본값은 500회.
  • invariant_* 함수: 불변 조건을 정의하는 함수들. 예를 들어:
[invariant]
runs = 300
depth = 600
fail_on_revert = true

혹은 테스트 파일 내에서

/// forge-config: default.invariant.runs = 300
/// forge-config: default.invariant.depth = 600

규칙

항목반드시 지정?설명
✅ 테스트 함수 이름 접두어invariant_로 시작해야 인식됨
targetContract()✅ 어떤 컨트랙트에서 함수들을 fuzz할지 지정
또는 targetSelector()⛔ 선택 (함수별로 더 좁히고 싶을 때만)
또는 targetArtifacts()⛔ 선택 (멀티컨트랙트 테스트 시)
✅ 핸들러 contract (보통 필요)✅ 함수 호출을 위임할 핸들러가 있어야 실제 동작
is Test 상속✅ 당연히 필요 (forge-std의 Test.sol)
.t.sol 확장자✅ 아니면 자동으로 forge가 테스트로 인식 못 함
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Bank.sol";

contract BankHandler { // 무작위로 호출할 함수들의 집합
    Bank public bank;

    constructor(Bank _bank) {
        bank = _bank;
    }

    function deposit() public payable { // 이 함수
        bank.deposit{value: msg.value}();
    }

    function withdraw(uint256 amount) public { // 이 함수만 랜덤호출 수행.
        try bank.withdraw(amount) {} catch {}
    }

    receive() external payable {}
}

contract BankInvariantTest is Test {
    Bank bank;
    BankHandler handler;

    function setUp() public {
        bank = new Bank();
        handler = new BankHandler(bank);

        // 대상 컨트랙트/핸들러 등록
        targetContract(address(handler));
        targetSender(address(1));
        vm.deal(address(1), 100 ether); // 테스트 계정에 자금 지급
    }

    // 불변 조건: 전체 balances 합 = totalDeposits
    function invariant_TotalBalanceEqualsTotalDeposits() public {
        assertEq(address(bank).balance, bank.totalDeposits());
    }
}

핸들러 없이 : targetSelector() + targetContract() 조합

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Bank.sol";

contract BankInvariantTest is Test {
    Bank bank;

    function setUp() public {
        bank = new Bank();

        targetContract(address(bank)); // ✅ 직접 대상 지정

        // ✅ 선택적으로 실행할 함수들 지정
        bytes4 ;
        selectors[0] = Bank.deposit.selector;
        selectors[1] = Bank.withdraw.selector;
        targetSelectors(address(bank), selectors);

        // 테스트 대상 주소에게 자금 주입
        address testUser = address(1);
        vm.deal(testUser, 100 ether);
        targetSender(testUser);
    }

    function invariant_TotalDepositsMatchBalance() public {
        assertEq(address(bank).balance, bank.totalDeposits());
    }
}
forge test --match-path test/BankInvariant.t.sol -vvvv

10-3. Differential Testing

특정입력에 대해 두 개 이상의 구현이 동일한 출력을 생성하는 지 확인하는 방식.

예를 들어 내가 Merkle tree를 만들었는데,

Openzepplin에도 같은 기능이 있다면, 동작을 비교해야 돼.

JS,Rust 구현 등이라면 외부 프로그램을 실행해서 결과를 가져오는 FFI를 실행.


11. Deploying and Verifying

기본 배포 : forge create

forge create --rpc-url <RPC_URL> --private-key <PRIVATE_KEY> src/MyContract.sol:MyContract
# 예시 출력:
# Deployer: 0x123...
# Deployed to: 0xabc...
# Transaction hash: 0xdef...&#8203;:contentReference[oaicite:4]{index=4}

스크립트 활용 배포 : forge script

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import "../src/MyContract.sol";

contract DeployScript is Script {
    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(privateKey);
        new MyContract();
        vm.stopBroadcast();
    }
}&#8203;:contentReference[oaicite:16]{index=16}

배포 후 검증 : forge verify-contracct

forge verify-contract \
  --chain-id <CHAIN_ID> \
  --constructor-args $(cast abi-encode "constructor(string,string,uint8,uint256)" "TokenName" "SYM" 18 1000000000000000000000) \
  --etherscan-api-key <ETHERSCAN_API_KEY> \
  --compiler-version v0.8.10+commit.fc410830 \
  <CONTRACT_ADDRESS> \
  src/MyContract.sol:MyContract&#8203;:contentReference[oaicite:26]{index=26}

멀티체인 배포 : vm.createSelectFork

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

        vm.createSelectFork("base-sepolia");
        vm.startBroadcast();
        new MyContract();
        vm.stopBroadcast();
    }
}&#8203;:contentReference[oaicite:40]{index=40}

외부 라이브러리 링크

--libraries src/Math.sol:MathLib:<LIBRARY_ADDRESS>&#8203;:contentReference[oaicite:46]{index=46}

12. Gas Tracking

1. gas reports : 각 함수가 소비하는 gas 양 측정

forge test --match-test "test_Increment" --gas-report
forge test --gas-report

2. gas snapshot : 가스 사용 양 저장해두기

테스트 함수 단위로

forge snapshot
forge snapshot --diff
forge snapshot --check

.snapshot 파일에


13. Debugger

forge test --debug "테스트함수명"


14. cast

이더리움 앱과 상호작용 가능한 명령줄 도구

cast block-number --rpc-url https://eth.merkle.io
cast balance vitalik.eth --ether --rpc-url https://eth.merkle.io
cast balance vitalik.eth --ether --rpc-url https://eth.merkle.io
cast call 0x컨트랙트주소 "함수명()(반환타입)" --rpc-url https://eth-mainnet.alchemyapi.io/v2/YourApiKey

15. Anvil : foundry 제공 로컬 이더리움 체인 (ganache)

로컬 이더리움 노드 or 라이브 이더리움 네트워크 노드

anvil --fork-url https:주소

16. Chisel

코드를 대화형으로 작성하고 테스트할 수 있는 REPL(Read Eval Print Loop)

컨트랙트의 변수, 함수 등 작은 단위를 만들 때 빠르게 실험해 자세한 정보를 타나내기.

chisel

한 문장 칠 때마다 에러 확인 가능, 변수 입력시 다양한 정보 보여줌.

!source 입력시 현재까지 코드 확인 가능

!save 시 현재 세션 저장

  • chisel load ID
  • chisel list

!traces : 트랜잭션 실행과정 추적

profile
시리즈 클릭하셔서 카테고리 별로 편하게 보세용

0개의 댓글