SettleLab
전체 코스
LESSON 05Labs and Capstone

Invariant Testing 랩

심화1시간 10분근거 2

학습 결과

  • stablecoin checkout의 핵심 불변식을 테스트한다.
  • handler 기반 상태 공간을 설계한다.

선행 조건

  • ERC-3009 Escrow 랩
  • Foundry fuzz와 invariant 설계

완료 기준

  • 핵심 invariant가 실행된다.
  • handler action을 설계했다.
  • 실패 재현 로그를 남겼다.

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 Testing 랩 위험 매트릭스이 시각화는 랩 산출물을 캡스톤 설계로 옮길 때 가능성, 영향, 감지 신호, 대응 소유자를 어떻게 묶을지를 보여주며, 'Invariant Testing 랩'에서 남겨야 할 설계 증거를 좁힌다.
Impact highLikelihood medium

핵심 가정 오류

랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가

Impact mediumLikelihood medium

운영 상태 누락

실패 로그가 남는가

Impact mediumLikelihood low

학습 산출물 미흡

Invariant suite 작성

크게 보기
Impact highLikelihood medium

핵심 가정 오류

랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가

Impact mediumLikelihood medium

운영 상태 누락

실패 로그가 남는가

Impact mediumLikelihood low

학습 산출물 미흡

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 setAlice, Bob, Cafe, Dood 같은 추적 계정 집합totalSupply 비교 대상이 추적 가능한 범위 안에 있는가
handler actionmint, burn, transfer, freeze, unfreeze, pause불가능한 호출은 skip하고 가능한 호출만 상태 공간에 넣는가
frozen floorfreeze 순간 balance를 기록frozen account의 balance가 줄어들지 않는가
invariant function항상 참이어야 하는 조건실패하면 어떤 호출 sequence가 조건을 깨는가
target contractFoundry가 호출할 handler 지정token을 직접 target으로 잡을 때와 handler를 target으로 잡을 때 차이는 무엇인가
크게 보기
구성 요소역할강의 중 확인할 것
actor setAlice, Bob, Cafe, Dood 같은 추적 계정 집합totalSupply 비교 대상이 추적 가능한 범위 안에 있는가
handler actionmint, burn, transfer, freeze, unfreeze, pause불가능한 호출은 skip하고 가능한 호출만 상태 공간에 넣는가
frozen floorfreeze 순간 balance를 기록frozen account의 balance가 줄어들지 않는가
invariant function항상 참이어야 하는 조건실패하면 어떤 호출 sequence가 조건을 깨는가
target contractFoundry가 호출할 handler 지정token을 직접 target으로 잡을 때와 handler를 target으로 잡을 때 차이는 무엇인가

현재 구현된 핵심 invariant는 세 개다.

표 자료가로 스크롤 · 크게 보기 지원
invariant의미깨질 때 의심할 코드
totalSupplyEqualsTrackedBalances추적 actor의 balance 합이 totalSupply와 같다mint/burn/transfer의 balance 또는 supply 업데이트
frozenBalancesDoNotDecreasefrozen 계정의 balance가 freeze 시점보다 줄지 않는다freeze 우회 경로, transferFrom, authorization
defaultAdminNeverDisappearsdefault admin 수가 1명 이상이다revoke/renounce/transfer admin 로직
크게 보기
invariant의미깨질 때 의심할 코드
totalSupplyEqualsTrackedBalances추적 actor의 balance 합이 totalSupply와 같다mint/burn/transfer의 balance 또는 supply 업데이트
frozenBalancesDoNotDecreasefrozen 계정의 balance가 freeze 시점보다 줄지 않는다freeze 우회 경로, transferFrom, authorization
defaultAdminNeverDisappearsdefault 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)

어떤 행동을 모델에 포함하고 어떤 행동을 일부러 제외했는지 본다.

CODE SURFACEsolidity
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 사용을 본다.

CODE SURFACEsolidity
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로 축소한다.

CODE SURFACEshell
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 초안
크게 보기
관점확인할 질문증거로 남길 것
속성 선택어떤 조건이 "항상 참"이어야 하는가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를 짧은 테스트로 재현할 수 있는가회귀 테스트 후보
크게 보기
확인 단계질문가능한 결론
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를 많이 쓰면 자동으로 안전해진다고 본다.잘못 정의된 invariant는 쓸모가 없고, handler가 중요한 행동을 만들지 않으면 위험을 못 본다.
handler에서 실패 호출을 모두 무시하면 된다고 본다.skip 조건이 과하면 실제 위험한 경로도 탐색하지 못한다.
counterexample을 단순 flaky test로 넘긴다.실패 sequence는 unit test로 축소해 버그인지 모델 오류인지 판단해야 한다.
totalSupply만 보면 충분하다고 본다.authorization, escrow status, admin continuity, frozen floor처럼 도메인별 속성이 필요하다.
runs/depth를 크게 잡는 것이 먼저라고 본다.작은 설정에서 해석 가능한 실패를 확보한 뒤 탐색 범위를 늘린다.

실습 과제

  1. 컨트랙트Invariant suite 작성: 현재 세 invariant를 읽고, payment status consistency와 authorization nonce 재사용 방지 invariant 후보를 추가 설계한다.
  2. 컨트랙트Handler action 설계: approve, transferFrom, transferWithAuthorization을 handler에 추가한다면 어떤 precondition이 필요한지 표로 작성한다.
  3. 운영실패 triage: 실패 sequence가 있다고 가정하고 bug, model error, expected revert, missing precondition 중 어디에 해당하는지 분류하는 템플릿을 만든다.
  4. 컨트랙트capstone invariant 초안: stablecoin checkout capstone에서 반드시 지켜야 할 5개 invariant를 작성한다.

완료 기준

  1. 핵심 invariant가 실행된다.
  2. handler action을 설계했다.
  3. 실패 재현 로그를 남겼다.

근거 자료

Final checkpoint

읽기를 마쳤다면 여기서 기록한다

아래 버튼은 읽기 진도를 저장한다. 체크리스트, 과제, 랩 산출물은 위 Workbook에서 따로 관리한다.

  • 핵심 invariant가 실행된다.
  • handler action을 설계했다.
  • 실패 재현 로그를 남겼다.

학습 자료 근거

Invariant Testing 랩
이 LMS 레슨의 개념, 예시, 과제 구성을 잡는 데 사용한 근거 문서.
내부 참고 문서
Foundry Book: Invariant Testing
https://book.getfoundry.sh/forge/invariant-testing