Foundry fuzz와 invariant 설계
도입
Unit test는 한 함수의 특정 입력을 확인한다. Fuzz test는 입력 공간을 넓힌다. Invariant test는 여러 함수 호출 순서를 흔든 뒤에도 절대 깨지면 안 되는 성질을 확인한다. 스테이블코인과 checkout 시스템에서는 이 차이가 중요하다. mint, burn, transfer, freeze, pause, permit, refund가 섞이면 단일 happy path 테스트로는 상태 오류를 찾기 어렵다.
Foundry 문서의 invariant testing은 사전에 정한 contract function call sequence를 무작위로 실행하고, 각 호출 뒤 invariant expression을 확인하는 방식으로 설명된다. 이 강의의 목표는 Foundry 기능을 나열하는 것이 아니라, stablecoin 보안 요구사항을 handler와 invariant 후보로 바꾸는 것이다.
학습 목표
- fuzz 테스트와 invariant 테스트의 목적을 구분한다.
- 스테이블코인 공급량, 잔고, 권한에 대한 불변식을 작성한다.
- handler action, actor set, ghost variable을 이용해 의미 있는 상태 공간을 만든다.
개념 설명
fuzz 테스트와 invariant 테스트의 목적을 구분한다.
불변조건이 실패 상태를 잡는가
Fuzz와 invariant 차이 설명하기
fuzz와 invariant 차이를 설명했다.
1. 테스트 종류를 목적별로 나눈다
| 종류 | 무엇을 확인하나 | Stablecoin checkout 예시 |
|---|---|---|
| Unit | 특정 입력의 특정 결과 | minter만 mint 가능 |
| Fuzz | 넓은 입력 범위 | 임의 amount transfer 후 underflow 없음 |
| Invariant | 여러 호출 뒤 항상 참인 성질 | balances 합과 totalSupply 관계 |
| Scenario | 제품 플로우 | invoice 생성 -> 결제 -> delivery -> refund |
| Fork | 실제 배포 상태와 integration | USDC 실제 contract와 permit/transferFrom 연동 |
테스트 종류는 우열이 아니라 역할이 다르다. Unit test 없이 invariant만 만들면 실패 원인을 좁히기 어렵고, unit test만 있으면 호출 순서 조합을 놓친다.
2. Fuzz는 입력을 넓히되 의미 없는 입력을 줄인다
Fuzz test는 Foundry가 함수 인자를 자동 생성하게 한다. 좋은 fuzz는 입력을 무작위로 던지는 데서 끝나지 않고, 의미 있는 범위로 shaping한다.
| 좋은 fuzz | 이유 |
|---|---|
bound(amount, 1, maxReasonableAmount) | underflow/overflow가 아니라 비즈니스 범위를 흔든다. |
| edge case를 명시적으로 포함 | 0, 1, max, deadline boundary를 놓치지 않는다. |
| 실패 메시지가 명확 | 실패가 권한 문제인지 금액 문제인지 바로 본다. |
vm.assume을 남발하지 않음 | 입력 대부분을 버리면 실제 탐색이 얕아진다. |
나쁜 fuzz는 "거의 모든 입력을 assume으로 버리고 몇 개만 실행"하는 형태다. 이렇게 되면 무작위 테스트처럼 보여도 실제 상태 공간은 좁다.
3. Invariant campaign의 구성 요소
Foundry invariant campaign은 아래 축으로 설계한다.
| 요소 | 질문 |
|---|---|
runs | 서로 다른 sequence를 몇 번 만들 것인가? |
depth | 한 sequence에서 몇 번 호출할 것인가? |
| target contract | 어떤 contract를 흔들 것인가? |
| target selector | 어떤 함수들을 랜덤 호출 대상으로 둘 것인가? |
| target sender | 어떤 actor가 호출하게 할 것인가? |
| handler | 랜덤 입력을 의미 있는 action으로 바꾸는 adapter가 있는가? |
| invariant function | 매 호출 뒤 확인할 성질은 무엇인가? |
초기에는 작게 시작한다. runs와 depth를 크게 올리기 전에 실패를 읽을 수 있는 handler와 invariant 문장을 만들어야 한다.
4. Handler가 필요한 이유
무작위 호출만 하면 대부분 revert해서 의미 있는 상태가 만들어지지 않는다. Handler는 "테스트용 사용자와 운영자"를 만들고, 호출 조건을 현실적인 범위로 바꾸는 역할을 한다.
| Handler 책임 | 예시 |
|---|---|
| actor 집합 관리 | Alice, Bob, Merchant, Minter, Freezer |
| amount shaping | 보유 balance 이하로 transfer amount 제한 |
| 권한 action 분리 | mint는 minter actor만 호출 |
| nonce 기록 | permit/ERC-3009 nonce 사용 여부 ghost state |
| expected supply 추적 | mint/burn/treasury movement를 ghost variable로 계산 |
| pause/freeze 상태 관리 | paused/frozen actor에서 기대 revert 확인 |
Handler가 너무 관대하면 실제 버그를 숨기고, 너무 제한적이면 중요한 호출 순서를 탐색하지 못한다. 리뷰 메모의 role matrix를 handler action으로 옮기는 것이 좋은 출발점이다.
5. Stablecoin invariant 후보
| Invariant | 설명 | 주의할 점 |
|---|---|---|
| Supply conservation | 추적 계정 balances 합과 totalSupply가 일치한다. | untracked address, burn, treasury, handler balance를 포함한다. |
| Authorization uniqueness | ERC-3009 authorization nonce는 한 번만 사용된다. | 실패한 authorization과 성공한 authorization을 구분한다. |
| Permit nonce monotonicity | permit nonce는 감소하지 않는다. | domain separator와 chainId 변경도 같이 본다. |
| Frozen immobility | frozen 계정은 어떤 경로로도 balance를 줄일 수 없다. | transfer, transferFrom, permit, ERC-3009 모두 포함한다. |
| Pause blocks movement | pause 상태에서 movement 함수가 성공하지 않는다. | refund/emergency path 예외는 별도 정책으로 둔다. |
| Role isolation | minter/freezer/pauser 권한이 서로 대체되지 않는다. | admin role은 별도 threat model로 분리한다. |
| Admin continuity | admin이 0명이 될 수 없다. | two-step transfer와 renounce 테스트가 필요하다. |
| Payment idempotency | payment/refund는 한 번만 처리된다. | settlement와 refund 상태 전이를 같이 본다. |
Invariant는 문장으로 먼저 쓴다. 문장이 애매하면 테스트 코드도 애매해진다.
6. Frozen immobility는 경로별로 쪼갠다
Freeze 테스트는 transfer 하나만 보면 부족하다.
| 경로 | 기대 결과 |
|---|---|
frozen sender -> transfer | 실패 |
frozen owner -> approve | 정책에 따라 실패 또는 제한 |
frozen owner -> permit | 실패 |
frozen owner -> 기존 allowance의 transferFrom | 실패 |
| frozen authorizer -> ERC-3009 | 실패 |
| frozen recipient | 받을 수 있는지 정책 결정 후 테스트 |
이 표는 AccessControl/Pausable 강의의 role matrix와 연결된다. Freeze가 compliance 기능이라면 approve와 signed authorization 우회도 막아야 한다.
7. Supply invariant의 함정
아래 테스트는 직관적이지만 production에서는 바로 깨질 수 있다.
function invariant_totalSupplyMatchesBalances() public view { uint256 sum; for (uint256 i = 0; i < actors.length; i++) { sum += token.balanceOf(actors[i]); } assertEq(sum, token.totalSupply());}놓치기 쉬운 부분은 다음과 같다.
| 함정 | 해결 |
|---|---|
| handler가 모르는 주소로 transfer | actor set을 제한하거나 untracked bucket을 둔다. |
| burn은 supply와 balance를 함께 줄임 | expectedSupply ghost variable을 둔다. |
| mint 대상이 actor set 밖 | mint recipient를 actor set으로 제한한다. |
| fee-on-transfer token | 일반 ERC-20 invariant와 분리한다. |
| cross-chain burn/mint | 단일 chain supply가 아니라 route state까지 본다. |
8. Payment invariant도 필요하다
결제 contract가 있으면 token invariant 외에 product invariant를 둔다.
| Invariant | 실패하면 생기는 일 |
|---|---|
Paid payment는 다시 Paid가 될 수 없다. | 중복 결제 credit |
Refunded payment는 merchant가 claim할 수 없다. | 환불 후 정산 |
Settled payment는 정책 없이 refund되지 않는다. | 회계 불일치 |
| expired invoice는 payment 실행 불가 | 만료된 quote 악용 |
| payment amount와 transfer amount가 일치 | 부분 결제 credit |
| relayer failure는 nonce를 애매하게 남기지 않는다. | 사용자 재시도 불가 또는 replay |
9. 실습 workflow
- 기존 unit test를 읽고 어떤 함수가 상태를 바꾸는지 표시한다.
- invariant 후보를 문장으로 적는다.
- handler actor와 action을 정의한다.
- target selector를 좁게 잡아 작은 campaign부터 돌린다.
runs와depth를 늘린다.- 실패 sequence를 읽고 최소 재현 unit test로 승격한다.
- 원인 수정 후 invariant와 unit regression을 함께 유지한다.
실패 sequence는 보물이다. 그대로 로그로 흘려보내지 말고, 사람이 읽을 수 있는 시나리오로 바꾼다. 예: "pause 후 기존 allowance transferFrom이 성공했다"는 freeze/pause 우회 버그로 분류한다.
코드로 확인하기
앞의 보안 기준을 코드와 테스트로 확인한다. 함수가 어떤 전제를 세우고, 테스트가 어떤 실패 조건을 고정하는지 함께 읽는다.
컨트랙트Fuzz test vs Invariant test 비교
fuzz는 함수 한 번 호출에 임의 입력을 넣고 가정을 확인한다. invariant는 임의 호출 시퀀스 전체에서 성질이 깨지지 않는지 본다.
import {Test} from "forge-std/Test.sol";contract StablecoinTest is Test { MockStablecoin token; function setUp() public { token = new MockStablecoin(); } // Fuzz test — 단일 호출의 인풋 변화만 본다 function testFuzz_TransferRespectsBalance(address from, address to, uint256 amount) public { vm.assume(from != address(0) && to != address(0) && from != to); amount = bound(amount, 1, 1e30); deal(address(token), from, amount); vm.prank(from); token.transfer(to, amount); assertEq(token.balanceOf(from), 0); assertEq(token.balanceOf(to), amount); }}컨트랙트Invariant Handler — 호출 시퀀스를 만드는 actor
handler 패턴은 actor set과 action을 명시적으로 정의해 fuzz가 의미 있는 시퀀스만 탐색하게 한다. 실제 사용 예는
MockStablecoinInvariant.t.sol참조.
컨트랙트StablecoinHandler — 임의 시퀀스를 만드는 actor 액션 파일:
08-실습/mock-stablecoin-lab/test/MockStablecoinInvariant.t.sol(라인 10-95)actor 인덱스 시드로 호출 대상을 좁히고, freeze/pause/mint/burn/transfer를 임의 순서로 섞는다.
contract StablecoinHandler { InvariantVm internal constant vm = InvariantVm(address(uint160(uint256(keccak256("hevm cheat code"))))); MockStablecoin public token; address[] internal actors; mapping(address actor => uint256 balanceAtFreeze) public frozenFloor; constructor() { token = new MockStablecoin("Invariant USD", "iUSD", 6, address(this)); actors.push(address(0xA11CE)); actors.push(address(0xB0B)); actors.push(address(0xCAFE)); actors.push(address(0xD00D)); for (uint256 i = 0; i < actors.length; i++) { token.mint(actors[i], 1_000e6); } } function actorCount() external view returns (uint256) { return actors.length; } function actorAt(uint256 index) external view returns (address) { return actors[index]; } function mint(uint256 actorSeed, uint256 amountSeed) external { address to = _actor(actorSeed); if (token.paused() || token.isFrozen(to)) return; uint256 amount = (amountSeed % 100e6) + 1; token.mint(to, amount); } function burn(uint256 actorSeed, uint256 amountSeed) external { address from = _actor(actorSeed); if (token.paused() || token.isFrozen(from)) return; uint256 balance = token.balanceOf(from); if (balance == 0) return; uint256 amount = (amountSeed % balance) + 1; token.burn(from, amount); } function transfer(uint256 fromSeed, uint256 toSeed, uint256 amountSeed) external { address from = _actor(fromSeed); address to = _actor(toSeed); if (token.paused() || token.isFrozen(from) || token.isFrozen(to)) return; uint256 balance = token.balanceOf(from); if (balance == 0) return; uint256 amount = amountSeed % (balance + 1); vm.prank(from); token.transfer(to, amount); } function freeze(uint256 actorSeed) external { address actor = _actor(actorSeed); token.setFrozen(actor, true); frozenFloor[actor] = token.balanceOf(actor); } function unfreeze(uint256 actorSeed) external { address actor = _actor(actorSeed); token.setFrozen(actor, false); frozenFloor[actor] = 0; } function setPaused(bool shouldPause) external { if (shouldPause && !token.paused()) { token.pause(); } else if (!shouldPause && token.paused()) { token.unpause(); } } function _actor(uint256 seed) internal view returns (address) { return actors[seed % actors.length]; }}운영foundry.toml에서 fuzz·invariant 파라미터 분리
두 테스트의 비용 특성이 다르므로 profile로 분리해 CI는 짧게, nightly는 충분히 돌린다.
# foundry.toml[profile.default]src = "src"test = "test"optimizer = true[profile.default.fuzz]runs = 256max_test_rejects = 65536[profile.default.invariant]runs = 64depth = 50fail_on_revert = falsecall_override = true[profile.ci.fuzz]runs = 64[profile.ci.invariant]runs = 8depth = 20[profile.nightly.invariant]runs = 512depth = 200운영실패 시퀀스를 unit test로 승격하는 절차
Foundry는 실패 시 호출 시퀀스를 콘솔에 찍는다. 그것을 그대로 unit test로 옮겨 회귀를 막는다.
# 1) invariant 실행해 카운터 예시 확보forge test --match-test invariant_totalSupplyEqualsTrackedBalances -vvv# 2) 출력에 나온 sequence 를 복사해 새 테스트 함수에 박는다# 3) 단일 시드 재현 검증forge test --match-test testRegression_FreezeBypassViaTransferFrom --seed 0xdeadbeef -vvv강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 테스트 목적 | unit, fuzz, invariant가 각각 무엇을 잡는가? | 테스트 종류 비교표 |
| Handler | actor/action/amount shaping이 현실적인가? | handler action 목록 |
| Invariant | 어떤 성질이 항상 참이어야 하는가? | invariant 후보 5개 |
| Triage | 실패 sequence를 어떻게 재현 test로 바꾸는가? | triage 절차 |
실무 예시
컨트랙트상황: MockStablecoin에 mint, burn, transfer, approve, transferFrom, freeze, pause, transferWithAuthorization이 있다. Handler는 Alice, Bob, Merchant, Minter, Freezer를 actor로 둔다.
| Action | 호출 actor | 입력 shaping | 기대되는 탐색 |
|---|---|---|---|
mint | Minter | amount를 1~cap으로 bound | supply 증가와 role isolation |
burn | token holder | balance 이하로 bound | supply 감소와 balance accounting |
transfer | Alice/Bob/Merchant | balance 이하 | frozen/paused 이동 제한 |
approve | holder | allowance 범위 제한 | approval race와 freeze policy |
transferFrom | spender | allowance와 balance 이하 | existing allowance 우회 |
freeze | Freezer | actor set 중 대상 선택 | frozen immobility |
pause | Pauser | toggle 빈도 제한 | paused movement |
authorization | signer/relayer | nonce ghost state | ERC-3009 uniqueness |
이 표를 작성한 뒤에야 invariant_totalSupply, invariant_frozenCannotMove, invariant_nonceUnique 같은 테스트가 의미를 가진다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| fuzz는 랜덤 입력을 많이 넣으면 충분하다고 본다. | 의미 있는 범위와 edge case를 설계해야 한다. |
| invariant는 balances 합 하나면 된다고 본다. | 권한, freeze, nonce, payment state invariant가 필요하다. |
| revert가 많으면 보안성이 높다고 본다. | 무의미한 revert만 반복되면 상태 공간을 탐색하지 못한다. |
| 실패 sequence를 고친 뒤 로그를 버린다. | 최소 재현 unit test로 승격해야 regression을 막는다. |
실습 과제
- 컨트랙트Fuzz와 invariant 차이 설명하기: unit, fuzz, invariant, scenario, fork test를 각각 무엇을 검증하는지와 stablecoin checkout 예시로 구분한다.
- 컨트랙트Mock stablecoin invariant campaign 설계하기: actor 5명, action 8개, handler rule, ghost variable, invariant 5개, runs/depth 초깃값을 표로 작성한다.
- 운영실패 sequence triage 절차 만들기: Foundry invariant 실패가 나왔을 때 sequence 저장, 원인 분류, 최소 재현 unit test 작성, regression suite 반영 절차를 작성한다.
완료 기준
- fuzz와 invariant 차이를 설명했다.
- handler action 목록을 만들었다.
- 불변식 후보 5개를 적었다.
- 실패 sequence를 재현 unit test로 승격하는 절차를 작성했다.
근거 자료
- Foundry Fuzz Invariant: 02-보안-테스트/03-Foundry-Fuzz-Invariant.md
- 스테이블코인 Invariant: 02-보안-테스트/04-스테이블코인-Invariant.md
- Foundry Book: Invariant Testing: https://book.getfoundry.sh/forge/invariant-testing
- Foundry Docs: Fuzz Testing: https://getfoundry.sh/forge/advanced-testing/fuzz-testing
- ERC-3009: Transfer With Authorization: https://eips.ethereum.org/EIPS/eip-3009
- ERC-2612: Permit Extension for EIP-20 Signed Approvals: https://eips.ethereum.org/EIPS/eip-2612