Chapter 14 / 15
Oracle Integration
Chainlink price feeds — staleness checks and typed errors.
Price oracles are a primary attack surface in DeFi — stale prices, flash-loan
manipulation, and round-skipping have caused hundreds of millions in losses.
This chapter shows how to consume Chainlink's latestRoundData
correctly: checking freshness, validating sequencer uptime (for L2s), and
handling the full tuple return.
price_feed.cov
interface IChainlinkAggregator {
view latestRoundData() -> (
roundId: u80,
answer: i256,
startedAt: u256,
updatedAt: u256,
answeredInRound: u80
);
view decimals() -> u8;
}
record PriceConsumer {
feed: address;
max_staleness: duration = 3600s; // 1 hour
error StalePrice(age: duration, max_staleness: duration);
error InvalidPrice(answer: i256);
error RoundIncomplete(round_id: u80, answered_in: u80);
view eth_usd_price() -> i256 {
let agg = IChainlinkAggregator(self.feed);
let (round_id, answer, _, updated_at, answered_in) =
agg.latestRoundData();
// Staleness check
let age = block.timestamp - updated_at;
if age > self.max_staleness {
revert_with StalePrice(age, self.max_staleness);
}
// Sanity checks
if answer <= 0 {
revert_with InvalidPrice(answer);
}
if answered_in < round_id {
revert_with RoundIncomplete(round_id, answered_in);
}
return answer;
}
}lending.cov — oracle-gated borrow
record SimpleLend {
price_consumer: address;
collateral: map(address => amount);
borrowed: map(address => u256);
ltv: u8 = 75; // 75% loan-to-value ratio
error Undercollateralized(collateral_usd: u256, borrow_usd: u256);
action borrow(usdc_amount: u256) {
// Get current ETH price (8 decimals from Chainlink)
let eth_price = PriceConsumer(self.price_consumer).eth_usd_price();
let collat_eth = self.collateral[msg.sender];
let collat_usd = collat_eth * eth_price as u256 / 1e8;
let max_borrow = collat_usd * self.ltv / 100;
let already_borrowed = self.borrowed[msg.sender];
if already_borrowed + usdc_amount > max_borrow {
revert_with Undercollateralized(collat_usd, already_borrowed + usdc_amount);
}
self.borrowed[msg.sender] += usdc_amount;
// ... transfer USDC to borrower
}
}Annotations
| Staleness check | Always validate block.timestamp - updatedAt <= maxStaleness. A stale price is an open attack vector. |
| Round completeness | answeredInRound >= roundId confirms the round closed normally. An incomplete round may signal a paused feed. |
| Negative price | Chainlink answer is int256 — a zero or negative value indicates a feed error. Always reject answer <= 0. |
| L2 sequencer | On L2s (Arbitrum, Optimism), also check the sequencer uptime feed before trusting prices. |
Key takeaways
- Three checks are mandatory: staleness, positive price, round completeness.
- Use typed errors (
StalePrice,InvalidPrice) so callers can programmatically handle each failure mode. - On L2s, add a sequencer uptime check — a down sequencer can expose stale L1 prices.