안전하게 Chainlink 연동하기: 피드와 해킹 사이의 모든 검사
“실전 오라클” 시리즈 2편 — 스마트 컨트랙트 엔지니어를 위한 실습형 연재.
수백 개 튜토리얼에서 보게 되는 Chainlink 연동은 이렇습니다.
(, int256 price, , , ) = feed.latestRoundData();
uint256 usd = uint256(price);
컴파일되고, 숫자를 반환하고, 프로토콜을 날려 먹을 만큼 위험합니다. 실제로 배포할 버전을 검사 하나씩 쌓아 만들면서, 왜 각 줄이 자기 자리를 차지하는지 이해해 봅시다.
데이터 피드란 실제로 무엇인가
Chainlink은 푸시 오라클입니다. 오프체인 탈중앙 오퍼레이터 집합이 여러 거래소의 가격을 관측하고 집계해, median을 주기적으로 온체인 aggregator에 씁니다. 여러분은 AggregatorV3Interface를 노출하는 프록시를 통해 읽습니다.
function latestRoundData() external view returns (
uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound
);
초심자를 곧바로 무는 두 가지. 첫째, answer는 uint256이 아니라 int256입니다. 피드는 결함 시 음수/0을 보고할 수 있습니다(2020년 유가가 음수가 된 게 유명하죠). 둘째, 피드는 매 블록 갱신되지 않습니다. 가격이 임계치를 넘어 편차가 나거나 heartbeat 간격이 지났을 때 갱신됩니다(ETH/USD는 메인넷 3600초, Arbitrum 86400초). 갱신 사이에는 latestRoundData()가 마지막 값을 그대로 반환합니다. 나이를 검사하지 않으면 몇 시간 묵은 스냅샷을 믿는 셈입니다.
deprecation도 유의하세요. latestAnswer()는 deprecated고(타임스탬프가 없어 신선도 검사 불가 — 절대 쓰지 마세요), answeredInRound는 이제 그냥 roundId와 같아서 옛날 answeredInRound < roundId staleness 휴리스틱은 오늘날 무의미합니다. 신선도 신호는 updatedAt입니다.
안전한 읽기 만들기
신선도와 유효성부터.
(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
if (answer <= 0) revert InvalidPrice(answer);
if (block.timestamp - updatedAt > maxAge) revert StalePrice(updatedAt, maxAge);
maxAge는 전역 상수가 아니라, 해당 피드의 heartbeat에 마진을 더해 피드별로 설정해야 합니다. 3600초 피드와 86400초 피드는 서로 다른 임계치가 필요합니다. 하나로 퉁치면 신선한 데이터를 거부하거나 낡은 데이터를 받아들입니다.
다음은 sanity bound. “신선하고 양수”인 값도 aggregator 자체가 손상되거나 잘못 설정되면 쓰레기일 수 있습니다. 하드 floor/ceiling이 터무니없는 읽기를 청산이 아니라 revert로 바꿉니다.
if (answer < minPrice || answer > maxPrice) revert PriceOutOfBounds(answer);
마지막으로 decimals. 8을 절대 가정하지 마세요. 읽어서 알려진 내부 단위로 정규화합니다. 18자리 WAD가 관례입니다.
function getPriceWad() external view returns (uint256) {
(int256 price, uint8 dec) = getPrice();
uint256 p = uint256(price);
if (dec < 18) return p * (10 ** (18 - dec));
if (dec > 18) return p / (10 ** (dec - 18));
return p;
}
모두가 잊는 L2 게이트
옵티미스틱 롤업(Arbitrum, Base, Optimism)에는 L1에 없는 실패 모드가 있습니다. 시퀀서가 다운됐다 재시작하면, 온체인 피드가 따라잡는 동안 현실보다 지연될 수 있고, 그 구간에서 낡았지만 “신선해 보이는” 가격이 악용될 수 있습니다. Chainlink은 바로 이걸 위해 시퀀서 가동(uptime) 피드를 제공합니다. 모든 읽기를 여기에 게이트하고, 시퀀서가 돌아온 뒤 grace period를 강제하세요.
function _checkSequencer() internal view {
if (address(sequencerUptimeFeed) == address(0)) return; // L1: 검사할 것 없음
(, int256 up, uint256 startedAt, , ) = sequencerUptimeFeed.latestRoundData();
// answer == 0 => 시퀀서 정상, == 1 => 다운
if (up != 0) revert SequencerDown();
if (block.timestamp - startedAt <= GRACE_PERIOD) revert GracePeriodNotOver();
}
L1에서는 address(0)을 넘겨 깔끔히 건너뜁니다.
하나로 합치기
function getPrice() public view returns (int256 price, uint8 priceDecimals) {
_checkSequencer();
(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
if (answer <= 0) revert InvalidPrice(answer);
if (block.timestamp - updatedAt > maxAge) revert StalePrice(updatedAt, maxAge);
if (answer < minPrice || answer > maxPrice) revert PriceOutOfBounds(answer);
return (answer, feed.decimals());
}
본문 다섯 줄, 독립적인 실패 모드 네 개를 닫고, 각각 이름 있는 custom error를 달아 트레이스에서 사고를 디버깅할 수 있습니다. 로컬 EVM에서 설정 가능한 mock aggregator로 검증했습니다. 신선·범위 내 가격은 정상 반환·정규화되고, stale·음수/0·범위 밖은 모두 revert하며, 시퀀서 경로는 다운·grace 구간 내에서 정확히 revert하고 정상·grace 경과 시 정상 서빙합니다.
해피 패스에 없는 운영 노트
- 감사된 피드 주소를 하드코딩하세요. 신뢰할 수 없는 호출자로부터 런타임 파라미터로 피드 주소를 받지 마세요. 공격자가 고른 값을 반환하는 가짜 aggregator는 위의 모든 검사를 무력화합니다.
maxAge를 실제 heartbeat에서 설정하고, 새 체인에 배포할 때 다시 확인하세요. 같은 페어라도 네트워크마다 heartbeat가 다릅니다.- 여유가 되면 divergence guard를 돌리세요. Chainlink을 TWAP(3편)과 교차 검증하고 큰 괴리에서 일시정지하세요. 두 소스 중 하나가 조용히 실패하는 데 대한 값싼 보험입니다.
- 큰 점프를 서킷 브레이크하세요. 유효한 피드도 실제 이벤트 중엔 격하게 움직일 수 있습니다. 업데이트당 편차 캡 + 관리자가 해제 가능한 일시정지로, 조사하는 동안 피해를 제한합니다.
Chainlink은 여러분이 스스로 결코 모을 수 없는 소싱의 폭을 줍니다. 하지만 검증을 건너뛸 권한을 주진 않습니다. 튜토리얼 읽기와 배포 읽기의 차이는 전적으로 검사에 있고, 그 차이가 바로 일의 전부입니다.
다음: Uniswap V3 TWAP으로 체인에서 직접 가격을 읽습니다. 위협 모델이 낡음에서 조작됨으로 뒤집힙니다.
Tags: Chainlink, Solidity, DeFi, SmartContracts, Ethereum