CCTP 시뮬레이터 랩
도입
CCTP 시뮬레이터는 실제 Circle API를 붙이는 랩이 아니다. cross-chain USDC 이동에서 사용자가 경험하는 핵심 상태를 작은 contract로 재현하는 랩이다. source token을 burn하고, attester가 메시지를 승인하거나 지연/실패로 표시하고, destination token을 mint한다.
이 랩의 핵심은 "burn이 성공했으니 결제가 끝났다"는 오해를 깨는 것이다. burn 이후에는 attestation 지연, 실패, destination mint 대기 상태가 남는다. checkout 제품은 이 중간 상태를 사용자와 운영자에게 설명할 수 있어야 한다.
이 랩은 앞의 두 CCTP 강의를 구현으로 확인하는 단계다. 결제 상태 문구는 CCTP와 USDC 크로스체인 결제, trust boundary 판단은 CCTP와 브릿지 신뢰 모델을 기준으로 삼는다.
학습 목표
- burn, attestation, mint 단계를 시뮬레이션한다.
- cross-chain 결제 지연과 실패를 상태머신으로 테스트한다.
개념 설명
burn, attestation, mint 단계를 시뮬레이션한다.
실패 로그가 남는가
CCTP flow 시뮬레이션
세 단계 상태가 구현되거나 문서화됐다.
대상 코드는 08-실습/mock-stablecoin-lab/src/CctpSimulator.sol이고, 테스트는 test/CctpSimulator.t.sol이다. source token과 destination token은 모두 MockStablecoin으로 두고, simulator가 source token burn 권한과 destination token mint 권한을 가진다.
| 구성 요소 | 역할 | 확인할 질문 |
|---|---|---|
| source token | source chain의 mock USDC | burn이 sender balance와 source totalSupply를 줄이는가 |
| destination token | destination chain의 mock USDC | mint가 recipient balance와 destination totalSupply를 늘리는가 |
| transfer registry | transfers[transferId] | transferId 재사용과 unknown transfer를 막는가 |
| attester | attestation, delay, fail 권한 | 권한 없는 caller가 상태를 바꿀 수 없는가 |
| combined supply | source + destination supply | burn/mint 완료 후 합계가 보존되는가 |
상태는 다섯 개로 충분히 작지만, cross-chain UX의 핵심을 담고 있다.
테스트가 확인하는 내용은 다음과 같다.
| 테스트 관점 | 의미 |
|---|---|
| burn, attest, mint 후 supply 보존 | wrapped bridge가 아니라 burn/mint 모델로 읽는 연습 |
| attestation 없는 mint 실패 | destination mint는 source burn만으로 충분하지 않다 |
| double mint 실패 | final state에서 재실행을 막아야 한다 |
| zero recipient 실패 | burn 전에 입력 검증이 끝나야 한다 |
| delayed transfer | 사용자에게 pending 상태를 보여줄 수 있어야 한다 |
| unauthorized attester | attestation 권한은 운영 핵심 권한이다 |
| duplicate transferId | message registry가 replay와 중복 요청을 막아야 한다 |
코드로 확인하기
앞에서 만든 설계를 실습 코드로 연결한다. 예시는 그대로 외우는 대상이 아니라, 구현 파일에서 어떤 줄을 읽고 어떤 테스트를 붙일지 정하는 기준이다.
컨트랙트Status / TransferRequest / 에러 정의
Burned/Delayed/Attested/Minted/Failed 5상태가 enum으로 박혀 있다. 각 에러는 어느 상태에서 어떤 호출이 실패해야 하는지 명시한다.
컨트랙트CctpSimulator — enum/struct/error/modifier 파일:
08-실습/mock-stablecoin-lab/src/CctpSimulator.sol(라인 9-55)attester modifier와 상태별 에러를 한꺼번에 확인한다.
contract CctpSimulator { enum Status { Unknown, Burned, Delayed, Attested, Minted, Failed } struct TransferRequest { address sender; address recipient; uint256 amount; Status status; } ICctpMintBurnToken public immutable sourceToken; ICctpMintBurnToken public immutable destinationToken; address public immutable attester; mapping(bytes32 transferId => TransferRequest request) public transfers; event TransferBurned( bytes32 indexed transferId, address indexed sender, address indexed recipient, uint256 amount ); event TransferDelayed(bytes32 indexed transferId); event TransferAttested(bytes32 indexed transferId); event TransferMinted(bytes32 indexed transferId, address indexed recipient, uint256 amount); event TransferFailed(bytes32 indexed transferId); error InvalidAddress(); error InvalidAmount(); error UnauthorizedAttester(address caller); error TransferAlreadyExists(bytes32 transferId); error UnknownTransfer(bytes32 transferId); error TransferNotBurned(bytes32 transferId, Status status); error TransferNotAttested(bytes32 transferId, Status status); error TransferFinalized(bytes32 transferId, Status status); modifier onlyAttester() { if (msg.sender != attester) revert UnauthorizedAttester(msg.sender); _; }컨트랙트requestTransfer — source burn 진입점
burn은 사용자 트랜잭션 한 번에 끝나지만, attestation과 mint는 별도 트랜잭션이다. 사용자에게는 한 결제로 보이지만 컨트랙트는 세 단계로 기록한다.
컨트랙트requestTransfer 본문 파일:
08-실습/mock-stablecoin-lab/src/CctpSimulator.sol(라인 69-86)duplicate transferId, zero recipient, zero amount를 입력 단계에서 차단한다.
function requestTransfer(bytes32 transferId, address recipient, uint256 amount) external { if (transfers[transferId].status != Status.Unknown) { revert TransferAlreadyExists(transferId); } if (recipient == address(0)) revert InvalidAddress(); if (amount == 0) revert InvalidAmount(); transfers[transferId] = TransferRequest({ sender: msg.sender, recipient: recipient, amount: amount, status: Status.Burned }); sourceToken.burn(msg.sender, amount); emit TransferBurned(transferId, msg.sender, recipient, amount); }컨트랙트attest / fail / mint — attester 권한 흐름
attester만 호출 가능한 onlyAttester modifier가 attestation 흐름의 신뢰 경계를 정의한다. mint는 누구나 호출할 수 있지만 status가 Attested여야만 통과한다.
컨트랙트markDelayed/attest/mint/fail 본문 파일:
08-실습/mock-stablecoin-lab/src/CctpSimulator.sol(라인 88-130)각 상태 전이의 권한 분리와 final state 보호.
function markDelayed(bytes32 transferId) external onlyAttester { TransferRequest storage request = _existingTransfer(transferId); if (request.status != Status.Burned) { revert TransferNotBurned(transferId, request.status); } request.status = Status.Delayed; emit TransferDelayed(transferId); } function attest(bytes32 transferId) external onlyAttester { TransferRequest storage request = _existingTransfer(transferId); if (request.status != Status.Burned && request.status != Status.Delayed) { revert TransferNotBurned(transferId, request.status); } request.status = Status.Attested; emit TransferAttested(transferId); } function mint(bytes32 transferId) external { TransferRequest storage request = _existingTransfer(transferId); if (request.status != Status.Attested) { revert TransferNotAttested(transferId, request.status); } request.status = Status.Minted; destinationToken.mint(request.recipient, request.amount); emit TransferMinted(transferId, request.recipient, request.amount); } function fail(bytes32 transferId) external onlyAttester { TransferRequest storage request = _existingTransfer(transferId); if (request.status == Status.Minted || request.status == Status.Failed) { revert TransferFinalized(transferId, request.status); } request.status = Status.Failed; emit TransferFailed(transferId); } function _existingTransfer(bytes32 transferId)클라이언트사용자에게 보여줄 상태 매핑
5개 컨트랙트 상태를 사용자 UI의 "결제 진행 중", "재시도 안내" 같은 문구로 변환하는 헬퍼.
type ContractStatus = "Burned" | "Delayed" | "Attested" | "Minted" | "Failed";export function userCopyFor(status: ContractStatus, elapsedSeconds: number) { switch (status) { case "Burned": return { tone: "processing", text: "출발 체인에서 소각을 확인 중입니다." }; case "Delayed": return { tone: elapsedSeconds > 600 ? "warn" : "processing", text: elapsedSeconds > 600 ? "공증이 지연되고 있습니다. 잠시 후 자동으로 다시 시도됩니다." : "공증을 기다리고 있습니다." }; case "Attested": return { tone: "processing", text: "도착 체인에서 발행을 준비 중입니다." }; case "Minted": return { tone: "success", text: "도착 체인에서 수령이 완료됐습니다." }; case "Failed": return { tone: "error", text: "전송이 실패했습니다. 지원팀이 복구 경로를 확인 중입니다." }; }}강의 포인트
| 관점 | 확인할 질문 | 증거로 남길 것 |
|---|---|---|
| 상태 정의 | Burned, Delayed, Attested, Minted, Failed가 사용자 화면에서 어떻게 보이는가 | 상태별 사용자 문구 |
| 권한 | 누가 attest, delay, fail을 호출할 수 있는가 | attester 권한표 |
| replay 방지 | transferId 중복과 double mint를 막는가 | 실패 테스트 증거 |
| supply 보존 | source burn과 destination mint가 합계 보존으로 이어지는가 | combined supply 계산 |
| 복구 | delay 또는 fail 상태에서 어떤 retry/manual recovery가 필요한가 | runbook 초안 |
실무 예시
클라이언트[OPS] 사용자가 source chain에서 100 USDC를 destination chain으로 보내는 checkout을 시작했다. source burn transaction은 성공했고 사용자의 source balance는 줄었다. 그러나 attestation이 아직 도착하지 않았다.
| 내부 상태 | 사용자 문구 | 운영자가 볼 증거 |
|---|---|---|
Burned | 전송 요청을 접수했고 source chain에서 소각을 확인 중입니다 | transferId, source tx, sender, amount |
Delayed | attestation이 지연되고 있어 완료까지 시간이 더 걸립니다 | delay timestamp, attester status |
Attested | destination mint를 준비 중입니다 | attestation event, recipient |
Minted | destination chain에서 수령이 완료됐습니다 | destination mint tx, recipient balance |
Failed | 전송이 실패했습니다. 지원팀이 복구 경로를 확인합니다 | fail reason, manual recovery owner |
이 표가 없으면 사용자는 "돈이 사라졌다"고 느낀다. cross-chain 결제 UI의 핵심은 중간 상태를 숨기지 않고 정확하게 표현하는 것이다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| source burn 성공을 결제 완료로 본다. | destination mint 전까지는 pending 또는 processing 상태다. |
| attestation 지연을 예외로만 본다. | 지연은 정상 운영 상태 중 하나이며 timeout과 user copy가 필요하다. |
| double mint는 테스트할 필요가 없다고 본다. | final state 재실행은 공급량 증가 사고로 이어질 수 있다. |
| transferId를 단순 로그 id로 본다. | transferId는 message registry의 중복 방지 키다. |
| 권한 없는 attest 실패를 생략한다. | attester 권한은 cross-chain trust boundary를 대표한다. |
실습 과제
- 컨트랙트CCTP flow 시뮬레이션:
requestTransfer,markDelayed,attest,mint,fail을 상태 전이표로 정리한다. - 운영복구 경로 검수:
Burned또는Delayed상태가 일정 시간 이상 지속될 때 retry, manual recovery, user notification을 어떻게 처리할지 쓴다. - 클라이언트사용자 상태 문구 작성: 위의 5개 상태마다 사용자에게 보여줄 짧은 문구와 운영 로그 필드를 작성한다.
- 백엔드capstone 연결: final checkout 설계에서 cross-chain payment를
Paid,PendingAttestation,Minted,Failed같은 제품 상태로 어떻게 매핑할지 정한다.
완료 기준
- 세 단계 상태가 구현되거나 문서화됐다.
- timeout/retry 정책을 만들었다.
- 사용자 상태 문구를 작성했다.
근거 자료
- CCTP 시뮬레이터 랩: 08-실습/05-CCTP-시뮬레이터-랩.md
- Circle CCTP Documentation: https://developers.circle.com/cctp