스테이블코인 시스템 맵
도입
스테이블코인을 처음 볼 때 가장 흔한 실수는 토큰 컨트랙트만 보는 것이다. balanceOf, transfer, approve만 보면 잔고 이동은 보이지만, 누가 발행량을 늘리는지, 어떤 준비자산이 있는지, 왜 특정 주소가 동결되는지, 결제 성공과 정산 완료가 왜 다른지 설명할 수 없다.
이 강의에서는 스테이블코인을 하나의 운영 시스템으로 그린다. 발행자, 준비자산, token contract, 결제 제품, 내부 원장, compliance, cross-chain route, risk dashboard가 어떤 데이터를 주고받는지 먼저 잡아야 이후 발행/상환, 서명 결제, CCTP, 정산, 대시보드 레슨이 제자리를 갖는다.
첫 번째 목표는 그림을 그리는 것이다. 사용자에게는 "USDC로 결제 완료"처럼 보이는 장면 뒤에 어떤 actor와 상태가 있는지 표시한다. 두 번째 목표는 개발자가 절대 가정하면 안 되는 값을 분리하는 것이다. chain ID, token address, decimals, native/bridged 구분은 제품 설정과 allowlist로 관리해야 한다.
학습 목표
- 스테이블코인 시스템의 핵심 행위자와 상태를 그린다.
- 결제, 발행, 준비자산, 정산을 한 흐름으로 설명한다.
- chain ID, token address, decimals, native/bridged 구분을 allowlist 기준으로 다룬다.
개념 설명
스테이블코인 시스템의 핵심 행위자와 상태를 그린다.
발행/상환과 결제 상태가 분리되는가
핵심 상태 6개 이상을 정의했다.
1. ERC-20 표면과 운영 시스템을 분리한다
ERC-20은 잔고와 allowance를 다루는 표면이다. 스테이블코인 제품은 그 아래에 더 많은 운영 결정을 갖는다. 발행자는 준비자산을 바탕으로 발행/상환을 운영하고, compliance는 특정 주소나 흐름을 제한할 수 있으며, payment product는 onchain transfer와 내부 주문/정산 상태를 맞춘다.
| 레이어 | 핵심 질문 | 개발자가 남겨야 할 설계 결정 |
|---|---|---|
| Token contract | 어떤 체인에서 어떤 contract를 쓸 것인가? | chain ID, token address, decimals, native/bridged 여부 |
| Issuer | 누가 mint, burn, freeze, pause 권한을 갖는가? | 역할 경계, admin event 모니터링, 운영자 개입 경로 |
| Reserve | 준비자산 정보는 어디서 확인하는가? | onchain invariant와 외부 disclosure를 분리 |
| Payment product | 결제 승인과 서비스 제공을 어떻게 분리하는가? | checkout 상태머신, refund, retry, manual review |
| Offchain ledger | onchain event와 내부 원장이 어긋나면 어떻게 잡는가? | confirmation, reconciliation, backfill 기준 |
| Compliance | blocklist, sanction, KYT 이벤트가 발생하면 무엇을 멈추는가? | sender, receiver, spender, authorizer별 차단 범위 |
| Cross-chain | native mint인지 bridge/wrapped token인지 어떻게 구분하는가? | route allowlist, finality, message/attestation 상태 |
이 표는 앞으로 이어질 Stablecoin Systems 트랙의 기준점이다. 발행/상환 레슨은 Issuer와 Reserve를 깊게 본다. signed payment 레슨은 Token contract와 Payment product 사이의 승인 흐름을 다룬다. CCTP 레슨은 Cross-chain 레이어를 별도 상태머신으로 분리한다. Reconciliation과 dashboard 레슨은 Offchain ledger와 운영 판단을 완성한다.
2. 전체 구조는 actor와 데이터 흐름으로 그린다
이 그림에서 Solidity 개발자가 직접 만지는 영역은 주로 Token contract, Payment product, Cross-chain route다. 하지만 실제 장애는 reserve disclosure 지연, compliance 조치, indexer lag, 내부 원장 불일치처럼 온체인 밖에서 시작될 수 있다. 그래서 첫 강의의 산출물은 컨트랙트 함수 목록이 아니라 시스템 맵이어야 한다.
3. 스테이블코인 유형마다 리스크의 위치가 다르다
| 유형 | 예시 | 핵심 리스크 |
|---|---|---|
| 법정화폐 담보형 | USDC, EURC, USDT | issuer, reserve disclosure, bank/custody, redemption, freeze |
| crypto 담보형 | DAI/USDS 계열 | oracle, liquidation, collateral volatility, governance |
| tokenized treasury/MMF 연계형 | T-bill, MMF 기반 상품 | NAV 산정, 상환 지연, permissioned transfer |
| 합성/수익형 | USDe 같은 synthetic dollar 모델 | hedge venue, funding rate, liquidity, counterparty |
| 알고리즘형 | 과거 UST류 | reflexive collapse, liquidity run, exit failure |
이 분류는 투자 상품 비교표가 아니라 개발 리스크 지도다. 예를 들어 fiat-backed 모델은 mint와 redeem이 issuer 운영과 강하게 연결된다. Crypto-backed 모델은 oracle과 liquidation이 핵심이다. Synthetic 모델은 온체인 토큰 잔고만 봐서는 hedge venue와 funding risk를 놓친다.
4. USDC 예시는 allowlist 설계를 훈련하는 데 쓴다
Circle 공식 개발자 문서는 USDC를 여러 블록체인에서 동작하는 digital dollar로 설명하고, highly liquid cash와 cash-equivalent assets로 backing되며 1:1 redeem 가능하다고 안내한다. Circle은 Transparency 페이지에서 reserve와 attestation 자료를 공개한다. 이 내용은 2026-05-14에 공식 문서 기준으로 확인했다.
개발자에게 중요한 결론은 네 가지다.
- USDC는 chain마다 contract address가 다르므로 symbol만 보고 입금을 허용하면 안 된다.
- 같은 이름의 USDC라도 native USDC와 bridged/wrapped USDC는 제품 리스크가 다르다.
- 준비자산 disclosure는 스마트컨트랙트 invariant가 아니라 발행자 운영과 외부 보고 레이어다.
- contract address 목록과 지원 네트워크는 바뀔 수 있으므로 설계 문서에는 source URL과 마지막 확인일이 필요하다.
지원 체인 allowlist에는 최소한 다음 필드가 있어야 한다.
| 필드 | 이유 |
|---|---|
chainId | 같은 주소 문자열이라도 체인이 다르면 전혀 다른 자산일 수 있다 |
nativeTokenAddress | 공식 contract address와 일치하는지 검증한다 |
decimals | 결제 금액 계산에서 18 decimals를 가정하지 않는다 |
tokenStandard | EVM ERC-20, Solana token, Move object 등 처리 방식이 다르다 |
sourceUrl | 주소와 지원 네트워크를 다시 확인할 공식 출처 |
lastVerifiedAt | 운영자가 언제 확인했는지 남긴다 |
bridgedTokenPolicy | wrapped/bridged USDC를 허용할지, 차단할지 결정한다 |
5. ERC-20만 보면 놓치는 질문을 먼저 적는다
컨트랙트 통합 전에 아래 질문에 답하지 못하면 시스템 맵이 아직 부족하다.
| 질문 | 놓치면 생기는 문제 |
|---|---|
| 누가 발행량을 늘릴 수 있는가? | 총 공급량 변화가 제품 잔고 변화처럼 보인다 |
| 누가 freeze 또는 pause를 실행할 수 있는가? | pending withdrawal이나 refund가 멈췄을 때 대응 절차가 없다 |
| 결제 성공과 서비스 제공 성공을 어떻게 분리하는가? | onchain transfer는 성공했지만 주문 처리가 실패한 상태를 잃는다 |
| 내부 ledger와 onchain event가 다르면 어디서 잡는가? | settlement mismatch가 늦게 발견된다 |
| chain별 token address와 decimals를 어디서 검증하는가? | 잘못된 자산 입금 또는 금액 단위 오류가 발생한다 |
| native와 bridged/wrapped token을 어떻게 구분하는가? | 서로 다른 redeemability와 bridge risk가 같은 자산처럼 처리된다 |
컨트랙트6. 첫 설계 체크리스트
- token address는 chain ID와 함께 고정한다.
decimals()를 18로 가정하지 않는다. USDC 같은 결제형 스테이블코인은 6 decimals인 경우가 많으므로 공식 문서와 contract call을 함께 확인한다.- token metadata만 신뢰하지 말고 allowlist를 둔다.
- ERC-20 return value 처리 방식이 다른 토큰을 고려한다.
- freeze/blacklist 검사는 sender, receiver뿐 아니라 spender, owner, authorizer까지 본다.
- offchain ledger와 onchain event의 reconciliation 기준을 정한다.
- reserve disclosure와 onchain balance invariant를 같은 검증으로 취급하지 않는다.
- contract address와 지원 네트워크는 공식 문서 기준으로 확인하고 접근일을 남긴다.
코드로 확인하기
위 설계 결정을 코드에서 확인한다. 상태, 서명, 원장 이동, 실패 처리가 어디에 놓이는지 따라 읽으면 앞의 모델이 실제 구현 경계로 내려온다.
백엔드지원 체인 token allowlist — TypeScript 데이터
chain ID + token address + decimals + native/bridged 정책을 한 객체로 묶는다. symbol 기반 매칭은 금지(USDC.e 같은 wrapped 가 native 처럼 인식될 수 있음).
export type SupportedToken = { chainId: number; symbol: string; nativeTokenAddress: `0x${string}`; decimals: number; tokenStandard: "erc20" | "spl" | "move-object"; sourceUrl: string; // 공식 문서 링크 lastVerifiedAt: string; // ISO date — 90일마다 재확인 bridgedTokenPolicy: "reject" | "warn" | "allow"; legacyBridgedTokenAddresses?: `0x${string}`[];};export const SUPPORTED_TOKENS: SupportedToken[] = [ { chainId: 1, symbol: "USDC", nativeTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6, tokenStandard: "erc20", sourceUrl: "https://developers.circle.com/stablecoins/usdc-contract-addresses", lastVerifiedAt: "2026-05-14", bridgedTokenPolicy: "reject" }, { chainId: 8453, // Base symbol: "USDC", nativeTokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, tokenStandard: "erc20", sourceUrl: "https://developers.circle.com/stablecoins/usdc-contract-addresses", lastVerifiedAt: "2026-05-14", bridgedTokenPolicy: "reject" }, { chainId: 137, // Polygon PoS symbol: "USDC", nativeTokenAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6, tokenStandard: "erc20", sourceUrl: "https://www.circle.com/blog/native-usdc-now-available-on-polygon-pos", lastVerifiedAt: "2026-05-19", bridgedTokenPolicy: "reject", legacyBridgedTokenAddresses: [ "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // Polygon PoS legacy bridged USDC.e ] }];export function findSupportedToken(chainId: number, address: `0x${string}`) { const lower = address.toLowerCase(); return SUPPORTED_TOKENS.find( (t) => t.chainId === chainId && t.nativeTokenAddress.toLowerCase() === lower );}export function findRejectedBridgedToken(chainId: number, address: `0x${string}`) { const lower = address.toLowerCase(); return SUPPORTED_TOKENS.find( (t) => t.chainId === chainId && t.legacyBridgedTokenAddresses?.some((legacyAddress) => legacyAddress.toLowerCase() === lower) );}백엔드입금 검증 — symbol 의존을 금지하는 가드
입금된 token이 allowlist에 정확히 있는지, decimals가 일치하는지, bridged가 정책 위반은 아닌지 매번 확인한다.
import { isAddress } from "viem";import { findRejectedBridgedToken, findSupportedToken } from "./allowlist";export function assertDepositable(input: { chainId: number; tokenAddress: `0x${string}`; reportedDecimals: number;}) { if (!isAddress(input.tokenAddress)) { throw new Error("invalid token address format"); } const supported = findSupportedToken(input.chainId, input.tokenAddress); const rejectedBridgedToken = findRejectedBridgedToken(input.chainId, input.tokenAddress); if (rejectedBridgedToken) { throw new Error( `token ${input.tokenAddress} is a legacy bridged token for ${rejectedBridgedToken.symbol}; use the native issuer contract` ); } if (!supported) { throw new Error( `token ${input.tokenAddress} on chain ${input.chainId} is not allowlisted (possibly bridged or unknown issuer)` ); } if (supported.decimals !== input.reportedDecimals) { throw new Error( `decimals mismatch: allowlist=${supported.decimals} onchain=${input.reportedDecimals}` ); } return supported;}컨트랙트최소한의 ERC-20 + 발행자 정보 인터페이스
통합 코드가 어떤 함수를 token 컨트랙트에서 호출하는지 명시.
decimals()는 호출해서 검증하고, 메타데이터 신뢰는 하지 않는다.
interface IStablecoinReadOnly { function balanceOf(address account) external view returns (uint256); function totalSupply() external view returns (uint256); function decimals() external view returns (uint8); function symbol() external view returns (string memory); function name() external view returns (string memory); // 발행자/규제 관점 — 모든 stablecoin이 갖지는 않지만 production 통합 시 확인 function isFrozen(address account) external view returns (bool); function paused() external view returns (bool);}강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 시스템 맵 | actor와 레이어를 한 그림에 배치했는가? | user, issuer, reserve, token contract, ledger, dashboard가 들어간 다이어그램 |
| 운영 개입 | mint, burn, freeze, pause, redeem은 어디서 발생하는가? | 운영자 개입 경로와 모니터링 이벤트 목록 |
| 결제 상태 | onchain transfer와 주문/정산 상태를 분리했는가? | requested, authorized, paid, settled, refunded, failed 상태 정의 |
| token allowlist | chain ID, address, decimals, native/bridged를 검증했는가? | 지원 체인별 allowlist 필드 목록 |
| 데이터 원천 | dashboard가 어떤 데이터를 보고 판단하는가? | onchain event, internal ledger, reserve disclosure, compliance signal 구분 |
실무 예시
백엔드[CONTRACT] [INDEXER] USDC checkout 버튼 하나를 만든다고 가정한다. 사용자는 버튼을 누르고 지갑에서 서명하거나 전송한다. 하지만 제품은 그 뒤에서 최소 일곱 단계를 기록해야 한다.
| 단계 | 시스템에서 일어나는 일 | 저장하거나 확인할 데이터 |
|---|---|---|
| 1 | 사용자가 결제할 chain과 token을 선택한다 | chain ID, token address, decimals, native/bridged 여부 |
| 2 | checkout API가 invoice를 만든다 | invoice ID, amount, merchant, expiry, accepted route |
| 3 | 사용자가 transfer, permit, ERC-3009 중 한 방식으로 승인/전송한다 | tx hash 또는 signature, nonce, deadline, domain |
| 4 | token contract에서 event가 발생한다 | Transfer, Approval, admin event, block number |
| 5 | indexer가 event를 internal ledger와 맞춘다 | confirmation count, payment status, reconciliation key |
| 6 | 서비스 제공 또는 merchant settlement가 진행된다 | deliveredAt, settledAt, refund 가능 상태 |
| 7 | risk dashboard가 route를 계속 허용할지 판단한다 | liquidity, peg, indexer lag, freeze event, mismatch count |
이 예시에서 Transfer event 하나만 저장하면 부족하다. 사용자가 어떤 chain에서 어떤 token을 골랐는지, 결제 요청이 언제 만료되는지, 내부 ledger와 onchain event가 어떻게 연결되는지, 운영자가 route를 끌 수 있는 조건이 무엇인지까지 같이 남아야 한다.
흔한 오해와 실패 시나리오
| 오해 | 실패 시나리오 | 바로잡는 방법 |
|---|---|---|
symbol이 USDC면 같은 자산이라고 본다 | bridged/wrapped token이 native USDC처럼 입금되어 redemption이나 risk가 달라진다 | chain ID와 official contract address allowlist를 둔다 |
| 결제 tx가 성공하면 주문도 완료라고 본다 | onchain transfer는 성공했지만 서비스 제공이나 merchant settlement가 실패한다 | payment, delivery, settlement 상태를 분리한다 |
| 준비자산은 컨트랙트에서 자동 검증된다고 본다 | reserve disclosure와 attestation을 onchain invariant처럼 오해한다 | issuer disclosure와 smart contract state를 별도 데이터 원천으로 표시한다 |
| freeze는 sender만 보면 된다고 생각한다 | spender, owner, authorizer 경로를 통해 승인/서명 기반 결제가 우회된다 | transfer, approve, permit, ERC-3009 경로 모두에서 제한 지점을 확인한다 |
| decimals를 18로 가정한다 | 결제 금액이 10^12배 틀어지는 치명적 단위 오류가 생긴다 | token별 decimals를 allowlist에 저장하고 금액 변환을 테스트한다 |
실습 과제
- 컨트랙트[OPS] 시스템 레이어와 운영 개입 표시하기: token contract, issuer, reserve, compliance, payment product, ledger, dashboard를 한 그림에 배치하고 mint, burn, freeze, pause, redeem이 어느 레이어에서 일어나는지 표시한다.
- 백엔드USDC checkout 시퀀스 작성하기: USDC 결제 버튼 하나를 기준으로 사용자 화면, API, token contract, onchain event, internal ledger, merchant settlement, dashboard signal을 순서대로 나열한다.
- 백엔드지원 체인 allowlist 필드 정의하기: 지원할 체인마다 chain ID, native token address, decimals, token standard, explorer URL, source URL, 마지막 확인일, bridged token 차단 정책을 포함한 allowlist 필드를 설계한다.
완료 기준
- 핵심 상태 6개 이상을 정의했다.
- 운영자 개입 경로를 표시했다.
- 대시보드에 필요한 데이터 원천을 구분했다.
- 지원 체인별 token allowlist에 필요한 필드를 정했다.
근거 자료
- 스테이블코인 학습맵: 01-스테이블코인/00-스테이블코인-학습맵.md
- 스테이블코인 시스템 개요: 01-스테이블코인/01-스테이블코인-시스템-개요.md
- Circle: What is USDC?: https://developers.circle.com/stablecoins/what-is-usdc
- Circle: USDC Contract Addresses: https://developers.circle.com/stablecoins/usdc-contract-addresses
- Circle Transparency: https://www.circle.com/transparency