Invariant Testing 랩
도입
unit test는 우리가 생각한 시나리오를 확인한다. invariant test는 우리가 생각하지 못한 호출 순서에서도 절대 깨지면 안 되는 조건을 확인한다. Stablecoin checkout에서는 호출 순서가 복잡하다. 발행, 소각, 전송, freeze, pause가 섞이면 happy path 테스트만으로는 공급량, frozen balance, admin continuity 같은 속성을 충분히 검증하기 어렵다.
이 랩은 MockStablecoinInvariant.t.sol을 읽고, handler가 어떤 상태 공간을 만들며, 어떤 invariant가 stablecoin의 안전 조건으로 남아야 하는지 판단하는 수업이다. 실패 sequence가 나오면 그것을 버그 후보 또는 설계 의도 확인 질문으로 승격한다.
학습 목표
- stablecoin checkout의 핵심 불변식을 테스트한다.
- handler 기반 상태 공간을 설계한다.
개념 설명
핵심 가정 오류
랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가
운영 상태 누락
실패 로그가 남는가
학습 산출물 미흡
Invariant suite 작성
대상 파일은 08-실습/mock-stablecoin-lab/test/MockStablecoinInvariant.t.sol이다. StablecoinHandler가 actor set을 만들고 mint, burn, transfer, freeze/unfreeze, pause/unpause를 섞어 호출한다. invariant test는 handler를 target으로 삼아 다양한 순서를 실행한 뒤 조건을 검사한다.
| 구성 요소 | 역할 | 강의 중 확인할 것 |
|---|---|---|
| actor set | Alice, Bob, Cafe, Dood 같은 추적 계정 집합 | totalSupply 비교 대상이 추적 가능한 범위 안에 있는가 |
| handler action | mint, burn, transfer, freeze, unfreeze, pause | 불가능한 호출은 skip하고 가능한 호출만 상태 공간에 넣는가 |
| frozen floor | freeze 순간 balance를 기록 | frozen account의 balance가 줄어들지 않는가 |
| invariant function | 항상 참이어야 하는 조건 | 실패하면 어떤 호출 sequence가 조건을 깨는가 |
| target contract | Foundry가 호출할 handler 지정 | token을 직접 target으로 잡을 때와 handler를 target으로 잡을 때 차이는 무엇인가 |
현재 구현된 핵심 invariant는 세 개다.
| invariant | 의미 | 깨질 때 의심할 코드 |
|---|---|---|
totalSupplyEqualsTrackedBalances | 추적 actor의 balance 합이 totalSupply와 같다 | mint/burn/transfer의 balance 또는 supply 업데이트 |
frozenBalancesDoNotDecrease | frozen 계정의 balance가 freeze 시점보다 줄지 않는다 | freeze 우회 경로, transferFrom, authorization |
defaultAdminNeverDisappears | default admin 수가 1명 이상이다 | revoke/renounce/transfer admin 로직 |
실행은 작은 범위에서 시작한다. 실패 sequence를 읽을 수 있어야 하기 때문이다. 통과하면 runs와 depth를 늘리고, 실패하면 unit test로 축소해 재현한다.
코드로 확인하기
앞에서 만든 설계를 실습 코드로 연결한다. 예시는 그대로 외우는 대상이 아니라, 구현 파일에서 어떤 줄을 읽고 어떤 테스트를 붙일지 정하는 기준이다.
컨트랙트handler — 상태 공간을 만드는 actor 액션들
handler는 토큰을 직접 부르지 않고 actor를 통해 호출한다. mint·burn·transfer·freeze·pause 액션을 각각 별도 함수로 두어, foundry가 임의 순서로 호출해도 의도된 상태 공간만 탐색하게 한다.
컨트랙트StablecoinHandler — actor·action 정의 파일:
08-실습/mock-stablecoin-lab/test/MockStablecoinInvariant.t.sol(라인 10-95)어떤 행동을 모델에 포함하고 어떤 행동을 일부러 제외했는지 본다.
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]; }}컨트랙트핵심 invariant 3개
공급량 합 일치, frozen floor, default admin 연속성을 모두 한 곳에서 본다. 실패하면 어떤 호출 시퀀스가 조건을 깨뜨렸는지 추적한다.
컨트랙트MockStablecoinInvariantTest — invariant 함수들 파일:
08-실습/mock-stablecoin-lab/test/MockStablecoinInvariant.t.sol(라인 96-145)totalSupply, frozenBalance, defaultAdmin 세 속성의 정의와 ghost variable 사용을 본다.
contract MockStablecoinInvariantTest { InvariantVm internal constant vm = InvariantVm(address(uint160(uint256(keccak256("hevm cheat code"))))); StablecoinHandler internal handler; MockStablecoin internal token; function setUp() public { handler = new StablecoinHandler(); token = handler.token(); } function targetContracts() public view returns (address[] memory targets) { targets = new address[](1); targets[0] = address(handler); } function invariant_totalSupplyEqualsTrackedBalances() public view { uint256 sum; uint256 count = handler.actorCount(); for (uint256 i = 0; i < count; i++) { sum += token.balanceOf(handler.actorAt(i)); } assertEq(sum, token.totalSupply()); } function invariant_frozenBalancesDoNotDecrease() public view { uint256 count = handler.actorCount(); for (uint256 i = 0; i < count; i++) { address actor = handler.actorAt(i); if (token.isFrozen(actor)) { assertGe(token.balanceOf(actor), handler.frozenFloor(actor)); } } } function invariant_defaultAdminNeverDisappears() public view { assertGe(token.roleMemberCount(token.DEFAULT_ADMIN_ROLE()), 1); } function assertEq(uint256 actual, uint256 expected) internal pure { require(actual == expected, "uint mismatch"); } function assertGe(uint256 actual, uint256 minimum) internal pure { require(actual >= minimum, "below minimum"); }운영invariant 실행 명령
동일한 실패를 재현하려면
--seed가 필요하다. fail-fast로 카운터 예시를 확보한 다음 unit test로 축소한다.
forge test --match-contract MockStablecoinInvariantTest \ --fuzz-runs 256 \ --invariant-depth 50 \ --seed 0x1 \ -vvv강의 포인트
| 관점 | 확인할 질문 | 증거로 남길 것 |
|---|---|---|
| 속성 선택 | 어떤 조건이 "항상 참"이어야 하는가 | invariant 후보 목록 |
| 상태 공간 | handler가 어떤 행동을 생성하고 어떤 행동을 일부러 제외하는가 | handler action 표 |
| 실패 해석 | counterexample이 버그인지 테스트 모델 오류인지 구분했는가 | triage 메모 |
| 회귀 방지 | 실패 sequence를 unit test로 승격했는가 | 재현 테스트 이름 |
| capstone 연결 | checkout 전체에 적용할 invariant를 뽑았는가 | payment/escrow/cross-chain invariant 초안 |
실무 예시
컨트랙트[OPS] 예를 들어 frozenBalancesDoNotDecrease가 실패했다고 하자. 바로 token 버그라고 결론내리지 않는다.
| 확인 단계 | 질문 | 가능한 결론 |
|---|---|---|
| handler 확인 | freeze 이후 handler가 직접 burn을 허용했는가 | handler 모델 오류 |
| token 확인 | transferFrom 또는 authorization이 frozen check를 빠뜨렸는가 | token 버그 |
| invariant 정의 확인 | frozen 계정의 mint를 balance 감소로 착각했는가 | 속성 정의 오류 |
| unit test 축소 | 같은 sequence를 짧은 테스트로 재현할 수 있는가 | 회귀 테스트 후보 |
좋은 invariant 문서는 "어떤 속성을 검증했다"뿐 아니라 "이 속성이 왜 비즈니스 손실과 연결되는지"를 적는다. totalSupply 불일치는 reserve reconciliation 문제로 이어지고, frozen balance 감소는 제한 계정 우회 문제로 이어진다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| invariant를 많이 쓰면 자동으로 안전해진다고 본다. | 잘못 정의된 invariant는 쓸모가 없고, handler가 중요한 행동을 만들지 않으면 위험을 못 본다. |
| handler에서 실패 호출을 모두 무시하면 된다고 본다. | skip 조건이 과하면 실제 위험한 경로도 탐색하지 못한다. |
| counterexample을 단순 flaky test로 넘긴다. | 실패 sequence는 unit test로 축소해 버그인지 모델 오류인지 판단해야 한다. |
| totalSupply만 보면 충분하다고 본다. | authorization, escrow status, admin continuity, frozen floor처럼 도메인별 속성이 필요하다. |
| runs/depth를 크게 잡는 것이 먼저라고 본다. | 작은 설정에서 해석 가능한 실패를 확보한 뒤 탐색 범위를 늘린다. |
실습 과제
- 컨트랙트Invariant suite 작성: 현재 세 invariant를 읽고, payment status consistency와 authorization nonce 재사용 방지 invariant 후보를 추가 설계한다.
- 컨트랙트Handler action 설계: approve, transferFrom, transferWithAuthorization을 handler에 추가한다면 어떤 precondition이 필요한지 표로 작성한다.
- 운영실패 triage: 실패 sequence가 있다고 가정하고 bug, model error, expected revert, missing precondition 중 어디에 해당하는지 분류하는 템플릿을 만든다.
- 컨트랙트capstone invariant 초안: stablecoin checkout capstone에서 반드시 지켜야 할 5개 invariant를 작성한다.
완료 기준
- 핵심 invariant가 실행된다.
- handler action을 설계했다.
- 실패 재현 로그를 남겼다.
근거 자료
- Invariant Testing 랩: 08-실습/04-Invariant-Testing-랩.md
- Foundry Book: Invariant Testing: https://book.getfoundry.sh/forge/invariant-testing