오라클, 청산, Proof of Reserve
도입
스테이블코인 시스템은 스스로 세계의 가격과 준비자산을 알지 못한다. 담보 가격, reserve 규모, tokenized fund의 NAV, wrapped asset의 backing 상태는 외부 데이터가 들어와야 판단할 수 있다. 이때 oracle과 Proof of Reserve는 단순한 보조 기능이 아니라 발행, 청산, 신규 결제 허용, dashboard 경고를 움직이는 신뢰 경계가 된다.
이 강의에서는 oracle을 "가격을 읽는 함수"로 끝내지 않는다. 어떤 값이 어디서 오고, 얼마나 최신이며, stale 또는 0이 되었을 때 어떤 product state로 전환되는지까지 설계한다. PoR도 "감사 완료"가 아니라 reserve data를 제품 정책에 연결하는 장치로 읽는다.
학습 목표
- 오라클과 준비자산 증명의 목적 차이를 설명한다.
- 청산과 보고 지연이 시스템 리스크로 전이되는 흐름을 추적한다.
- stale data, zero value, reserve shortfall을 제품 상태와 circuit breaker로 바꾼다.
개념 설명
오라클과 준비자산 증명의 목적 차이를 설명한다.
발행/상환과 결제 상태가 분리되는가
오라클과 PoR을 구분했다.
1. Oracle, liquidation, PoR은 서로 다른 질문에 답한다
세 용어는 한 강의에 같이 나오지만 같은 기능이 아니다. 먼저 질문을 분리한다.
| 개념 | 답하는 질문 | 잘못 쓰면 생기는 문제 |
|---|---|---|
| Price oracle | 이 담보나 자산의 현재 기준 가격은 얼마인가? | 과소/과대 청산, 잘못된 LTV, 잘못된 checkout quote |
| Liquidation | 담보 가치가 debt를 충분히 덮지 못할 때 어떻게 회수할 것인가? | keeper 손실, auction backlog, bad debt, 불공정 청산 |
| Proof of Reserve | 발행된 token liability를 뒷받침할 reserve가 있는가? | reserve 부족 상태에서 신규 mint 또는 결제 허용 |
Oracle은 데이터를 가져오는 통로이고, liquidation은 부족담보를 처리하는 상태 전이이며, PoR은 reserve data를 onchain 또는 product policy로 끌어오는 검증 레이어다. 결제 제품에서는 세 가지를 섞지 않아야 한다. 가격 feed가 정상이라고 해서 reserve가 충분하다는 뜻은 아니고, PoR feed가 정상이라고 해서 redemption liquidity가 즉시 충분하다는 뜻도 아니다.
2. Oracle이 쓰이는 위치를 먼저 그린다
| 위치 | 필요한 값 | 실패 영향 |
|---|---|---|
| Vault collateral | 담보 가격과 업데이트 시각 | 안전한 vault가 청산되거나, 위험한 vault가 방치된다. |
| Liquidation | auction 시작 가격, debt, penalty, keeper incentive | auction이 너무 싸게 시작되거나 아무도 입찰하지 않아 bad debt가 커진다. |
| Reserve proof | backing asset 규모와 source 범위 | 과발행 또는 reserve shortfall을 제품이 늦게 감지한다. |
| Cross-chain asset | wrapped asset과 native backing의 관계 | unbacked wrapped token을 담보로 인정한다. |
| RWA NAV | fund/share price, valuation time, redemption window | 잘못된 NAV로 mint/redeem이 실행된다. |
이 표를 작성한 뒤에야 contract integration을 시작한다. Feed 주소만 붙이는 작업은 마지막 단계다. 먼저 feed가 가격인지 reserve인지 NAV인지, 어떤 frequency로 갱신되는지, stale이면 어떤 함수가 멈춰야 하는지 정해야 한다.
3. Oracle consumer는 값보다 timestamp를 먼저 본다
Chainlink Data Feeds API의 latestRoundData()는 answer뿐 아니라 startedAt, updatedAt, roundId 같은 정보를 반환한다. 반대로 timestamp 없이 최신 answer만 주는 오래된 함수들은 freshness 판단에 부적합하다. 그래서 consumer contract나 backend policy는 다음 검사를 가져야 한다.
| 검사 | 왜 필요한가 | 실패 시 상태 |
|---|---|---|
answer > 0 | 0 또는 음수 가격/준비금은 정상 quote로 쓰면 안 된다. | quote 중지, mint 중지 |
block.timestamp - updatedAt <= maxStaleness | 오래된 가격은 현재 시장 가격이 아니다. | stale oracle 상태, fallback route 확인 |
| deviation threshold | 직전 값 또는 secondary source와 크게 다르면 조작/시장 급변 가능성이 있다. | manual review 또는 tighter limit |
| feed decimals | decimal mismatch는 100배, 1만 배 오류를 만든다. | 배포 전 config test 실패 |
| operator/aggregator change | feed 구현이나 권한 변경은 risk review 대상이다. | admin approval 전 high-risk 상태 |
학습자는 여기서 중요한 습관을 가져야 한다. "Chainlink feed를 썼다"가 완료 기준이 아니다. 어떤 feed를 어떤 한계값과 fallback으로 소비하는지가 완료 기준이다.
4. Proof of Reserve는 감사가 아니라 policy input이다
Chainlink는 Proof of Reserve를 stablecoin, tokenized asset, wrapped asset의 reserve monitoring과 token mint logic에 연결할 수 있는 방식으로 설명한다. 이때 개발자가 해야 할 일은 PoR feed를 절대 진실로 취급하는 것이 아니라, 데이터 원천과 제품 정책을 분리해 기록하는 것이다.
| 체크 질문 | 제품 정책으로 바꿀 내용 |
|---|---|
| reserve value가 bank API, custodian report, auditor report, onchain wallet 중 어디서 오는가? | reserveSourceKind, reportingDelay, sourceOwner |
| 업데이트 주기는 얼마인가? | maxReserveAge, dashboard freshness badge |
| stale value일 때 mint/redeem이 멈추는가? | mint pause, checkout warning, treasury transfer hold |
| reserve가 token liability보다 낮으면 어떤 circuit breaker가 작동하는가? | 신규 mint 중지, 신규 결제 중지, withdrawal review |
| oracle signer/operator가 바뀌면 누가 승인하는가? | governance/admin approval, incident ticket |
PoR은 "은행 계좌에 돈이 있다"는 법률 의견을 대체하지 않는다. 또한 attestation의 시간 차이, reserve 범위, liability 계산 방식, chain별 wrapped supply 차이를 해결해 주지 않는다. 그러므로 PoR은 제품의 risk input이지 최종 보증 문구가 아니다.
5. PoR feed를 product policy로 감싼다
원본 문서의 흐름은 아래와 같은 상태머신으로 확장할 수 있다.
이 흐름은 컨트랙트와 backend 양쪽에 나눠 들어갈 수 있다. 컨트랙트가 mint를 막고, backend가 checkout token을 숨기며, dashboard가 마지막 feed update와 reserve ratio를 보여주는 식이다.
6. 청산은 oracle 오류를 증폭시킬 수 있다
Crypto-backed stablecoin에서 oracle은 liquidation의 시작점이다. Sky Protocol 문서는 부족담보 vault의 collateral과 debt가 protocol로 이동하고, auction이 시작되어 debt를 회수하려는 구조를 설명한다. Dog는 undercollateralized vault를 confiscate하고 auction module로 넘기며, Clipper는 auction을 시작한다. 이 설명은 개발자가 청산을 "자동 판매"로 단순화하면 안 된다는 뜻이다.
| 청산 단계 | 필요한 데이터/행위 | 실패 시나리오 |
|---|---|---|
| Vault 평가 | collateral price, debt, liquidation ratio | stale price로 안전한 vault를 청산하거나 위험 vault를 놓친다. |
| Liquidation trigger | authorized caller, circuit breaker, gas cost | keeper incentive가 부족해 liquidation이 지연된다. |
| Auction start | initial price, lot, tab, penalty | 시작 가격이 잘못돼 collateral이 헐값에 팔리거나 안 팔린다. |
| Auction execution | keeper participation, market liquidity | auction backlog가 쌓이고 bad debt가 늘어난다. |
| 후처리 | 남은 debt, returned collateral, user notice | 사용자/운영 원장이 실제 상태와 달라진다. |
결제 시스템이 DAI/USDS 같은 crypto-backed stablecoin을 받는다면 단순히 peg price만 보지 말고 oracle freshness, liquidation backlog, keeper activity, governance parameter change를 dashboard signal로 둬야 한다.
7. 테스트는 happy path보다 실패값을 먼저 넣는다
| 테스트 입력 | 기대 결과 |
|---|---|
| oracle answer가 0 | mint, collateral deposit, quote 생성이 중단된다. |
updatedAt이 maxStaleness를 초과 | stale 상태로 전환되고 fallback 또는 manual review가 실행된다. |
| collateral price가 30% 급락 | liquidation 후보가 계산되고 dashboard에 위험 vault가 표시된다. |
| reserve < circulating supply | 신규 mint와 checkout acceptance가 중단된다. |
| feed decimals가 잘못 설정 | 배포 전 config validation이 실패한다. |
| oracle operator/aggregator가 변경 | admin approval 없이는 high-risk 상태로 남는다. |
코드로 확인하기
위 설계 결정을 코드에서 확인한다. 상태, 서명, 원장 이동, 실패 처리가 어디에 놓이는지 따라 읽으면 앞의 모델이 실제 구현 경계로 내려온다.
컨트랙트Chainlink Aggregator 안전 호출 패턴
latestRoundData()를 직접 받지 말고updatedAt,answeredInRound,answer > 0을 모두 확인해 stale/zero/예전 라운드를 차단한다.
interface AggregatorV3Interface { function decimals() external view returns (uint8); function latestRoundData() external view returns ( uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound );}contract OracleConsumer { AggregatorV3Interface public immutable feed; uint256 public maxStaleness = 1 hours; error StaleOracle(uint256 updatedAt, uint256 nowTime); error InvalidAnswer(int256 answer); error StaleRound(uint80 roundId, uint80 answeredInRound); constructor(address feedAddr) { feed = AggregatorV3Interface(feedAddr); } function safePrice() public view returns (uint256 price, uint8 priceDecimals) { (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) = feed.latestRoundData(); if (answer <= 0) revert InvalidAnswer(answer); if (block.timestamp - updatedAt > maxStaleness) revert StaleOracle(updatedAt, block.timestamp); if (answeredInRound < roundId) revert StaleRound(roundId, answeredInRound); return (uint256(answer), feed.decimals()); }}컨트랙트Proof of Reserve 게이트 — mint 차단
reserve < circulating 일 때 mint를 차단한다. PoR feed가 stale 이면 보수적으로 mint도 막는다.
interface IPoRFeed { function latestAnswer() external view returns (int256); // reserve in USD function latestTimestamp() external view returns (uint256);}contract ReserveGatedMinter { IPoRFeed public por; IERC20Mintable public token; uint256 public porMaxStaleness = 24 hours; error ReserveStale(uint256 updatedAt); error ReserveBelowCirculating(int256 reserve, uint256 circulating); function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { if (block.timestamp - por.latestTimestamp() > porMaxStaleness) { revert ReserveStale(por.latestTimestamp()); } int256 reserve = por.latestAnswer(); uint256 newCirculating = token.totalSupply() + amount; if (reserve <= 0 || uint256(reserve) < newCirculating) { revert ReserveBelowCirculating(reserve, newCirculating); } token.mint(to, amount); }}백엔드Oracle freshness + circuit breaker — off-chain consumer
contract 측 검사 외에 백엔드도 같은 검사를 한 번 더 한다. circuit breaker는 운영자가 즉시 mint/route 를 막을 수 있는 권한.
import { createPublicClient, http, parseAbi } from "viem";const aggregatorAbi = parseAbi([ "function latestRoundData() view returns (uint80, int256, uint256, uint256, uint80)", "function decimals() view returns (uint8)"]);export async function isFeedHealthy(client: any, feed: `0x${string}`, maxStalenessSec: number) { const [, answer, , updatedAt, answeredInRound] = await client.readContract({ address: feed, abi: aggregatorAbi, functionName: "latestRoundData" }); const now = BigInt(Math.floor(Date.now() / 1000)); if (answer <= 0n) return { healthy: false, reason: "answer<=0" }; if (now - updatedAt > BigInt(maxStalenessSec)) { return { healthy: false, reason: `stale by ${Number(now - updatedAt)}s` }; } if (answeredInRound === 0n) return { healthy: false, reason: "round id zero" }; return { healthy: true };}export class CircuitBreaker { private tripped = false; trip(reason: string) { this.tripped = true; console.error(`circuit breaker tripped: ${reason}`); } reset(operator: string) { this.tripped = false; console.log(`circuit breaker reset by ${operator}`); } assertOpen(action: string) { if (this.tripped) throw new Error(`${action} blocked: circuit breaker is tripped`); }}강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 데이터 원천 | 가격, reserve, NAV가 각각 어디서 오는가? | feed/source register |
| freshness | updatedAt과 heartbeat 기준을 어떻게 잡았는가? | stale data policy |
| 제품 상태 | reserve shortfall 또는 oracle failure가 사용자 화면에서 무엇으로 보이는가? | checkout pause, warning, admin review 전이 |
| 청산 후처리 | liquidation trigger 후 auction, bad debt, user notice를 추적하는가? | liquidation runbook |
실무 예시
컨트랙트[BACKEND] 상황: checkout 서비스가 세 종류의 토큰을 지원한다. USDC는 issuer reserve disclosure를 참고하고, DAI/USDS는 oracle과 liquidation health를 보며, wrapped BTC는 PoR feed로 backing을 확인한다. 이때 하나의 priceUsd 필드로 모든 것을 처리하면 위험하다.
| 토큰 유형 | 필요한 feed | 제품 상태 |
|---|---|---|
| fiat-backed stablecoin | reserve disclosure, issuer status, chain별 공식 주소 | reserve report stale이면 신규 merchant settlement 제한 |
| crypto-backed stablecoin | price oracle, liquidation backlog, governance parameter | oracle stale이면 신규 결제 중지, refund route 점검 |
| wrapped/tokenized asset | PoR feed, native backing, bridge/custodian status | reserve shortfall이면 collateral 사용 중지 |
이 예시에서 좋은 설계는 asset.priceFeed, asset.reserveFeed, asset.navFeed, asset.maxStaleness, asset.pausePolicy를 분리한다. 그래야 가격은 정상인데 reserve가 stale인 경우, reserve는 정상인데 liquidity가 부족한 경우를 서로 다른 상태로 처리할 수 있다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| Oracle price가 있으면 모든 자산이 안전하다고 본다. | price, reserve, NAV, liquidity는 서로 다른 데이터다. |
| PoR을 완전한 감사 또는 법적 보증처럼 표시한다. | PoR은 reserve data input이며 source 범위와 reporting delay를 함께 보여줘야 한다. |
answer만 읽고 timestamp를 보지 않는다. | stale data로 mint, redeem, liquidation이 실행될 수 있다. |
| 청산은 자동이므로 운영 runbook이 필요 없다고 본다. | keeper shortage, auction backlog, bad debt, 사용자 안내가 모두 후처리 대상이다. |
실습 과제
- 컨트랙트Oracle consumer 검증표 만들기: price/reserve/NAV feed별로 source, heartbeat, deviation threshold, updatedAt, zero/negative answer, fallback, pauser 권한을 표로 정리한다.
- 컨트랙트[BACKEND] PoR 기반 mint/acceptance policy 작성하기: reserve feed 정상, stale, reserve < circulating supply, operator change 상황에서 mint, redeem, checkout acceptance가 어떻게 바뀌는지 상태 전이를 작성한다.
- 운영청산 실패 후처리 runbook 쓰기: oracle stale, collateral price gap, keeper shortage, auction backlog, bad debt 발생 시 사용자 안내, 운영 조치, dashboard 지표를 연결한다.
완료 기준
- 오라클과 PoR을 구분했다.
- stale data 대응 조건을 정의했다.
- 청산 실패 후처리를 문서화했다.
- oracle consumer가 검사해야 할 timestamp, answer, fallback, 권한 변경 항목을 정리했다.
근거 자료
- 오라클 청산 Proof of Reserve: 01-스테이블코인/10-오라클-청산-Proof-of-Reserve.md
- Chainlink Proof of Reserve: https://chain.link/proof-of-reserve
- Chainlink Data Feeds API Reference: https://docs.chain.link/data-feeds/api-reference
- Chainlink: What Are Proof of Reserves?: https://chain.link/education-hub/proof-of-reserves
- Sky Protocol: Collateral Liquidation: https://developers.sky.money/protocol/vaults/collateral-liquidation/