본문으로 건너뛰기
블록체인Mar 28, 2026

Deep EVM #19: 스마트 컨트랙트를 위한 속성 기반 테스트 — Foundry 퍼징

OS
Open Soft Team

Engineering Team

퍼징이 유닛 테스트가 놓치는 버그를 잡는 이유

유닛 테스트는 특정 시나리오를 검증합니다: Alice에서 Bob에게 100 토큰을 전송하고 잔액이 업데이트되었는지 확인합니다. 하지만 스마트 컨트랙트는 모든 주소에서, 모든 값으로, 모든 순서의 적대적 입력에 직면합니다. 10가지 시나리오를 확인하는 유닛 테스트는 엣지 케이스를 찾기 위해 100,000개의 랜덤 입력을 생성하는 퍼저와 경쟁할 수 없습니다.

속성 기반 테스트는 패러다임을 뒤집습니다. 특정 입력에 대한 예상 출력을 지정하는 대신, 모든 입력에 대해 유지되어야 하는 속성을 정의합니다. 퍼저는 반례를 찾으려고 시도합니다:

// 유닛 테스트: 특정 입력
function test_transfer() public {
    token.transfer(bob, 100);
    assertEq(token.balanceOf(bob), 100);
}

// 속성 기반 테스트: 모든 입력에 대해
function testFuzz_transfer(address to, uint256 amount) public {
    uint256 totalBefore = token.balanceOf(alice) + token.balanceOf(to);
    vm.prank(alice);
    token.transfer(to, amount);
    uint256 totalAfter = token.balanceOf(alice) + token.balanceOf(to);
    assertEq(totalBefore, totalAfter, "Conservation of tokens");
}

Foundry의 퍼저는 지능적 전략을 사용합니다: 경계값(0, 1, type(uint256).max)으로 시작한 다음, 랜덤 값을 혼합하고, 커버리지 기반 변이를 사용하여 새로운 코드 경로를 탐색합니다.

퍼저 구성

Foundry의 퍼즈 구성은 탐색의 깊이와 폭을 제어합니다:

# foundry.toml
[profile.default.fuzz]
runs = 10000            # 테스트당 랜덤 입력 수
max_test_rejects = 100000  # 실패 전 최대 거부 입력 수
seed = 0x42             # 재현성을 위한 고정 시드
dictionary_weight = 40  # 사전 기반 변이 가중치

[profile.ci.fuzz]
runs = 100000           # CI에서 더 많은 실행

dictionary_weight 매개변수는 퍼저가 실행 중 발견한 흥미로운 값(스토리지에서 발견된 주소 등)을 재사용하는 빈도와 완전히 랜덤 입력을 생성하는 빈도를 제어합니다.

bound() vs assume(): 입력 제약

퍼징은 임의의 입력을 생성하지만, 컨트랙트에는 유효한 입력 범위가 있을 수 있습니다. 이를 처리하는 두 가지 방법이 있습니다:

assume(): 유효하지 않은 입력 건너뛰기

function testFuzz_withdraw(uint256 amount) public {
    vm.assume(amount > 0);
    vm.assume(amount <= token.balanceOf(alice));
    // 유효한 금액으로만 테스트
}

assume()은 조건을 충족하지 않는 입력을 폐기합니다. 문제: 대부분의 입력이 유효하지 않으면 퍼저가 대부분의 실행을 낭비합니다.

bound(): 유효하지 않은 입력 변환

function testFuzz_withdraw(uint256 amount) public {
    amount = bound(amount, 1, token.balanceOf(alice));
    // 모든 입력이 유효 — 낭비되는 실행 없음
}

bound()는 모듈러 산술을 사용하여 모든 uint256을 유효 범위로 매핑합니다. 실행이 낭비되지 않으므로 거의 항상 선호됩니다. 범위로 표현할 수 없는 복잡한 조건에만 assume()을 사용합니다.

불변성 테스트: 상태 기반 퍼징

위의 퍼즈 테스트는 상태 비저장입니다 — 각 실행이 독립적입니다. 불변성 테스트는 상태를 유지합니다: 퍼저가 랜덤 순서로 함수 시퀀스를 호출한 다음, 각 호출 후 불변성이 유지되는지 확인합니다:

// test/invariant/TokenInvariant.t.sol
contract TokenInvariantTest is Test {
    SimpleToken token;
    TokenHandler handler;

    function setUp() public {
        token = new SimpleToken();
        handler = new TokenHandler(token);
        targetContract(address(handler));
    }

    // 모든 호출 시퀀스 후에 반드시 유지되어야 함
    function invariant_totalSupplyConstant() public view {
        assertEq(
            token.totalSupply(),
            1_000_000e18,
            "Total supply must never change"
        );
    }

    function invariant_sumOfBalancesEqTotalSupply() public view {
        uint256 sum = token.balanceOf(address(handler.alice()))
            + token.balanceOf(address(handler.bob()))
            + token.balanceOf(address(handler.charlie()));
        assertEq(sum, token.totalSupply(), "Balances must sum to total");
    }
}

// 핸들러가 퍼저를 제한하고 안내
contract TokenHandler is Test {
    SimpleToken token;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    address public charlie = makeAddr("charlie");

    constructor(SimpleToken _token) {
        token = _token;
    }

    function transfer(
        uint256 fromSeed,
        uint256 toSeed,
        uint256 amount
    ) external {
        address from = _selectUser(fromSeed);
        address to = _selectUser(toSeed);
        amount = bound(amount, 0, token.balanceOf(from));
        vm.prank(from);
        token.transfer(to, amount);
    }

    function _selectUser(uint256 seed) internal view returns (address) {
        uint256 index = seed % 3;
        if (index == 0) return alice;
        if (index == 1) return bob;
        return charlie;
    }
}

불변성 테스트를 구성합니다:

[profile.default.invariant]
runs = 256          # 호출 시퀀스 수
depth = 128         # 시퀀스당 호출 수
fail_on_revert = false  # 핸들러 되돌림 시 실패하지 않음

퍼저는 128개의 랜덤 함수 호출로 구성된 256개의 시퀀스를 생성한 다음, 각 호출 후 모든 불변성을 확인합니다. 이는 상태 의존적 버그를 찾는 데 매우 강력합니다.

차등 테스트: Huff vs Yul vs Solidity

차등 테스트는 저수준 EVM 구현을 검증하는 최고의 기준입니다. 동일한 명세의 세 가지 구현을 배포하고, 모든 입력에 대해 동일한 결과를 생성하는지 검증합니다:

contract DifferentialFuzzTest is Test {
    IToken huffToken;
    IToken yulToken;
    IToken solToken;

    function setUp() public {
        huffToken = IToken(HuffDeployer.deploy("HuffToken"));
        yulToken = IToken(deployYulContract("YulToken"));
        solToken = IToken(address(new SolidityToken()));

        address[3] memory users = [alice, bob, charlie];
        for (uint i = 0; i < users.length; i++) {
            _setBalance(address(huffToken), users[i], 1000e18);
            _setBalance(address(yulToken), users[i], 1000e18);
            _setBalance(address(solToken), users[i], 1000e18);
        }
    }

    function testFuzz_transfer_differential(
        uint8 fromIdx,
        uint8 toIdx,
        uint256 amount
    ) public {
        address from = _user(fromIdx);
        address to = _user(toIdx);
        amount = bound(amount, 0, 1000e18);

        vm.prank(from);
        (bool s1,) = address(huffToken).call(
            abi.encodeCall(IToken.transfer, (to, amount))
        );
        vm.prank(from);
        (bool s2,) = address(yulToken).call(
            abi.encodeCall(IToken.transfer, (to, amount))
        );
        vm.prank(from);
        (bool s3,) = address(solToken).call(
            abi.encodeCall(IToken.transfer, (to, amount))
        );

        assertEq(s1, s2, "Huff vs Yul success mismatch");
        assertEq(s2, s3, "Yul vs Solidity success mismatch");

        if (s1) {
            assertEq(
                huffToken.balanceOf(from),
                solToken.balanceOf(from),
                "Sender balance mismatch"
            );
        }
    }
}

퍼징으로 발견된 실제 버그

퍼징은 치명적 버그를 잡는 입증된 실적을 가지고 있습니다:

버그 1: Huff ERC20의 팬텀 오버플로

Huff ERC20 구현이 오버플로 검사 없이 잔액을 더했습니다. 퍼저는 type(uint256).max - balance + 1 토큰을 전송하면 수신자의 잔액이 거의 0으로 래핑된다는 것을 발견했습니다.

버그 2: 자기 전송 이중 지출

from == to일 때, 순진한 Huff 구현은 잔액 슬롯에서 빼고 같은 슬롯에 더하여 실질적으로 토큰을 두 배로 만들었습니다.

// 퍼저가 from == to 엣지 케이스 발견
function testFuzz_selfTransfer(uint256 amount) public {
    amount = bound(amount, 1, token.balanceOf(alice));
    uint256 before = token.balanceOf(alice);
    vm.prank(alice);
    token.transfer(alice, amount);
    assertEq(token.balanceOf(alice), before, "Self-transfer must not change balance");
}

버그 3: 제로 주소 발행

퍼저는 Huff 토큰에서 address(0)으로 전송해도 되돌려지지 않아 총 공급량 업데이트 없이 토큰이 소각되는 것을 발견했습니다. 이는 sum(balances) == totalSupply 불변성을 깨뜨렸습니다.

CI에 퍼징 통합

CI에서 더 높은 반복 횟수로 퍼즈 테스트를 실행합니다:

# .github/workflows/test.yml
jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: foundry-rs/foundry-toolchain@v1
      - name: Run fuzz tests
        run: |
          forge test --match-path "test/fuzz/*" \
            --fuzz-runs 100000 \
            --fuzz-seed ${{ github.run_id }}
      - name: Run invariant tests
        run: |
          forge test --match-path "test/invariant/*" \
            --invariant-runs 512 \
            --invariant-depth 256

github.run_id를 시드로 사용하면 실행 간에 입력을 변화시키면서도 재현 가능성을 보장합니다.

결론

퍼징은 스마트 컨트랙트 테스트를 “이 10가지 케이스에서 작동하는가?“에서 “100,000개의 랜덤 입력 중 하나로 깨뜨릴 수 있는가?“로 변환합니다. bound()를 사용한 속성 기반 테스트, 상태 기반 시퀀스를 사용한 불변성 테스트, 구현 간 차등 테스트가 포괄적인 안전망을 형성합니다. 컴파일러가 안전 보장을 제공하지 않는 Huff와 Yul 컨트랙트에서 퍼징은 선택이 아닙니다 — 프로덕션 버그에 대한 주요 방어 수단입니다.