Mock 스테이블코인 랩
도입
첫 번째 랩은 학습용 stablecoin을 직접 읽고 검증하는 과정이다. 이 token은 단순한 ERC-20이 아니다. mint, burn, freeze, pause, permit, transferWithAuthorization, role admin 보호가 모두 들어 있어 이후 checkout과 escrow 실습의 공통 기반이 된다.
이 랩에서 중요한 질문은 "토큰을 전송할 수 있는가"가 아니라 "어떤 권한과 상태가 전송을 막아야 하는가"다. freeze가 transfer만 막고 permit은 열어두면, 사용자는 얼어붙은 계정으로도 승인 경로를 만들 수 있다. pause가 mint/burn에 적용되지 않으면 비상 중에도 공급량이 변할 수 있다. 이런 판단을 코드와 테스트로 확인한다.
학습 목표
- mint, burn, transfer, pause가 있는 mock token을 구현한다.
- 권한과 이벤트를 테스트한다.
개념 설명
핵심 개념
- mint, burn, transfer, pause가 있는 mock token을 구현한다.
- 권한과 이벤트를 테스트한다.
검증 지점
- 랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가
- 실패 로그가 남는가
- 핵심 함수 테스트가 통과했다.
실습 산출물
- Mock token 구현
- 권한 검수
- 운영 대시보드까지 닫히는가
코드 위치는 08-실습/mock-stablecoin-lab/src/MockStablecoin.sol이다. 테스트는 test/MockStablecoin.t.sol과 test/MockStablecoinInvariant.t.sol에서 확인한다.
먼저 권한과 상태를 분리해서 읽는다.
컨트랙트코드 영역별 점검 표
| 영역 | 코드 요소 | 확인할 질문 |
|---|---|---|
| 발행/소각 | MINTER_ROLE, BURNER_ROLE, mint, burn | 공급량 변경이 role과 pause/freeze 조건을 모두 통과하는가 |
| 이동 제한 | paused, isFrozen, _requireCanMove | transfer, transferFrom, signed transfer가 같은 이동 제한을 공유하는가 |
| 승인 | approve, permit, _approve | frozen owner 또는 spender가 approval을 만들 수 없는가 |
| 서명 전송 | transferWithAuthorization, authorizationState | valid window와 nonce 재사용 방지가 전송 전에 확인되는가 |
| 관리자 보호 | DEFAULT_ADMIN_ROLE, roleMemberCount, LastAdmin | 마지막 admin을 실수로 제거할 수 없는가 |
테스트를 실행한다.
forge test이 랩의 흐름은 아래와 같다.
강의 포인트
| 관점 | 확인할 질문 | 증거로 남길 것 |
|---|---|---|
| 권한 | mint, burn, freeze, pause 권한이 분리되어 있는가 | role matrix |
| 상태 제한 | pause와 freeze가 transfer, approve, permit, authorization에 일관되게 적용되는가 | 실패 테스트 목록 |
| 공급량 | mint와 burn이 totalSupply와 balances를 같은 방향으로 갱신하는가 | balance/supply 증거 |
| 서명 | permit nonce와 authorization nonce가 어떤 방식으로 재사용을 막는가 | nonce 비교표 |
| 운영 | 마지막 admin 보호와 이벤트가 운영 추적에 충분한가 | event schema 점검 |
코드로 확인하기
앞에서 만든 설계를 실습 코드로 연결한다. 예시는 그대로 외우는 대상이 아니라, 구현 파일에서 어떤 줄을 읽고 어떤 테스트를 붙일지 정하는 기준이다.
이 강의에서 등장하는 모든 개념은 아래의 실제 컨트랙트 코드 안에서 일어난다. 표·다이어그램과 매핑해 가며 읽는다.
컨트랙트EIP-712 도메인과 typehash 상수
도메인 분리자, permit, ERC-3009 typehash가 컴파일타임 상수로 박혀 있다. 같은 서명이 다른 컨트랙트·체인에서 재사용되지 않도록 한다.
컨트랙트MockStablecoin 도메인·typehash 상수 파일:
08-실습/mock-stablecoin-lab/src/MockStablecoin.sol(라인 1-30)DEFAULT_ADMIN_ROLE/MINTER_ROLE 등 5개 역할과 PERMIT_TYPEHASH, TRANSFER_WITH_AUTHORIZATION_TYPEHASH가 정의된 헤더.
// SPDX-License-Identifier: MITpragma solidity 0.8.24;contract MockStablecoin { bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant PERMIT_TYPEHASH = keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" ); bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256( "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); string public name; string public symbol; string public constant version = "1"; uint8 public immutable decimals; uint256 public totalSupply; bytes32 public immutable DOMAIN_SEPARATOR; mapping(address account => uint256 balance) private _balances; mapping(address owner => mapping(address spender => uint256 allowance)) private _allowances; mapping(address owner => uint256 nonce) public nonces; mapping(address authorizer => mapping(bytes32 nonce => bool used)) public authorizationState;컨트랙트이동 제한 게이트 _requireCanMove
transfer/transferFrom/permit 후의 transferFrom/ERC-3009 모든 경로가 같은 게이트를 통과해야 freeze·pause가 빠짐없이 적용된다. 우회 가능 함수가 있는지 확인하는 게 이 랩의 핵심이다.
컨트랙트이동 제한 공통 게이트 파일:
08-실습/mock-stablecoin-lab/src/MockStablecoin.sol(라인 265-280)freeze와 pause를 모든 이동 경로가 공유하도록 하는 내부 헬퍼.
function _requireCanMove(address from, address to) internal view { _requireNotPaused(); _requireNotFrozen(from); _requireNotFrozen(to); } function _requireNotPaused() internal view { if (paused) revert TokenPaused(); } function _requireNotFrozen(address account) internal view { if (account == address(0)) revert ZeroAddress(); if (isFrozen[account]) revert FrozenAccount(account); } function _transfer(address from, address to, uint256 amount) internal {컨트랙트ERC-3009 서명 결제 transferWithAuthorization
validAfter/validBefore 시간 창, authorizationState의 nonce 사용 표시, ecrecover 검증, 그리고 위의
_requireCanMove가 같은 함수 안에 모여 있다.
컨트랙트transferWithAuthorization 본문 파일:
08-실습/mock-stablecoin-lab/src/MockStablecoin.sol(라인 206-241)기간·nonce 재사용·이동 제한·서명 검증 순서로 결제를 실행한다.
function transferWithAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s ) external { if (block.timestamp <= validAfter) revert AuthorizationNotYetValid(validAfter); if (block.timestamp >= validBefore) revert AuthorizationExpired(validBefore); if (authorizationState[from][nonce]) revert AuthorizationAlreadyUsed(from, nonce); _requireCanMove(from, to); bytes32 structHash = keccak256( abi.encode( TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce ) ); bytes32 digest = _hashTypedData(structHash); if (ecrecover(digest, v, r, s) != from) revert InvalidSignature(); authorizationState[from][nonce] = true; emit AuthorizationUsed(from, nonce); _transfer(from, to, value); }운영Foundry 테스트 실행 커맨드
위 컨트랙트는
forge test로 즉시 돌려볼 수 있다. 모든 권한·서명·freeze 분기를 테스트가 다룬다.
cd 08-실습/mock-stablecoin-labforge test --match-contract MockStablecoin -vv실무 예시
컨트랙트예를 들어 규제 대응으로 특정 계정을 freeze해야 한다고 가정한다. 이때 단순히 transfer만 막으면 부족하다.
| 경로 | freeze 적용이 필요한 이유 | 기대 결과 |
|---|---|---|
transfer | frozen account가 직접 자금을 보낼 수 있다 | revert |
approve | frozen owner가 spender에게 권한을 남길 수 있다 | revert |
transferFrom | 제3자가 frozen account의 allowance를 소진할 수 있다 | revert |
permit | frozen owner가 gasless approval을 만들 수 있다 | revert |
transferWithAuthorization | frozen signer의 signed payment가 나중에 실행될 수 있다 | revert |
이 표를 테스트 이름으로 바꾸면 좋은 실습 산출물이 된다. 각 테스트는 "어떤 계정이 얼어붙었는지", "어떤 함수가 호출됐는지", "어떤 error가 나와야 하는지"를 명시해야 한다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| ERC-20 함수가 있으면 stablecoin 학습이 끝났다고 본다. | 발행자 권한, freeze, pause, reserve 연결, 이벤트 인덱싱까지 검토해야 한다. |
| freeze는 잔액 이동만 막으면 된다고 본다. | approval과 signed authorization도 미래 이동 권한을 만들기 때문에 함께 막아야 한다. |
| sequential nonce와 random nonce를 같은 문제로 본다. | permit은 owner별 증가 nonce를 쓰고, ERC-3009 스타일 authorization은 bytes32 nonce 사용 여부를 기록한다. |
학습용 ecrecover 구현을 그대로 재사용한다. | production 후보에서는 OpenZeppelin ECDSA/EIP712와 signature malleability 검토가 필요하다. |
| role revoke 테스트를 생략한다. | 마지막 admin 제거 방지가 없으면 운영자가 권한 복구 불능 상태를 만들 수 있다. |
실습 과제
- 컨트랙트Mock token 구현 검토:
MockStablecoin.sol에서 role, pause, freeze, permit, authorization 흐름을 읽고 위의 role matrix를 자신의 말로 완성한다. - 컨트랙트권한 검수: mint, burn, freeze, pause, grantRole, revokeRole, transferDefaultAdminRole마다 caller, 성공 조건, 실패 조건, event를 표로 정리한다.
- 컨트랙트freeze 우회 테스트 설계: transfer, approve, transferFrom, permit, transferWithAuthorization 각각에 대해 frozen owner/spender/from/to 케이스를 테스트 목록으로 작성한다.
- 운영다음 invariant 후보 기록: totalSupply와 balances 합계, authorization nonce 재사용, 마지막 admin 보호 중 최소 2개를 invariant 후보로 남긴다.
완료 기준
- 핵심 함수 테스트가 통과했다.
- 이벤트 스키마를 검토했다.
- 다음 invariant 후보를 기록했다.
근거 자료
- Mock 스테이블코인 랩: 08-실습/01-Mock-스테이블코인-랩.md
- README: 08-실습/mock-stablecoin-lab/README.md