Integrate a Chainlink-Style Price Feed (Mock) on Rootstock (Testnet)
By the end of this tutorial, you will have:
- A
PriceConsumercontract that reads from anAggregatorV3Interface - A local test setup using a mock price feed (no real oracle dependency)
- A deployment flow you can run on Rootstock testnet
Prerequisites: Follow the Shared Setup Guide before starting.
For background concepts and security review, see Rootstock DeFi 101.
This guide uses a mock Chainlink price feed for educational purposes only. Chainlink Price Feeds and VRF are not officially supported on Rootstock mainnet at this time – only CCIP is confirmed. Do not deploy price feed or VRF consumers on mainnet without checking official Chainlink documentation.
Official Chainlink references (verify support before production)
Part 1: Price Feeds (Mocked for local testing)
Step 1: Understand the Aggregator Interface
Chainlink price feeds follow the AggregatorV3Interface. Let's look at its key functions:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface AggregatorV3Interface {
// Returns the number of decimals the answer is represented in.
function decimals() external view returns (uint8);
// Returns a description of the feed (e.g., "BTC / USD").
function description() external view returns (string memory);
// Returns the version of the aggregator.
function version() external view returns (uint256);
// Returns the latest round data. This is the main function we'll use.
function latestRoundData()
external
view
returns (
uint80 roundId, // Round identifier
int256 answer, // The price (with decimals)
uint256 startedAt, // Timestamp when the round started
uint256 updatedAt, // Timestamp when the round was last updated
uint80 answeredInRound // Round in which the answer was computed
);
}
Important: The answer is an int256 (can be negative, but for price feeds it's positive). It includes decimals – for most feeds, it's 8 decimals (e.g., 3000000000 means $30,000.00000000). Always use decimals() to format it correctly.
Step 2: Write a Simple Price Consumer Contract (educational mock consumer)
Now let's build a contract that fetches the latest price. We'll add safety checks to ensure the price is fresh and valid.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./AggregatorV3Interface.sol";
contract PriceConsumer {
AggregatorV3Interface internal priceFeed;
/**
* @param _priceFeed Address of the Chainlink price feed (e.g., BTC/USD on testnet)
*/
constructor(address _priceFeed) {
priceFeed = AggregatorV3Interface(_priceFeed);
}
/**
* Returns the latest price with safety checks.
* @return price The latest price as an integer with 8 decimals.
*/
function getLatestPrice() public view returns (int256) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// 1. Check staleness: price should have been updated in the last hour.
require(block.timestamp - updatedAt <= 1 hours, "Price is stale");
// 2. Ensure the round is complete (answeredInRound >= roundId).
require(answeredInRound >= roundId, "Round incomplete");
// 3. Price should be positive.
require(price > 0, "Invalid price");
return price;
}
/**
* Returns the number of decimals the price feed uses.
*/
function getDecimals() public view returns (uint8) {
return priceFeed.decimals();
}
/**
* Returns a human-readable description of the feed.
*/
function getDescription() public view returns (string memory) {
return priceFeed.description();
}
}
Explanation of safety checks:
Staleness: If the price hasn't been updated for too long (here, 1 hour), it might be outdated. In a real protocol, you might want a shorter threshold (e.g., 30 minutes) depending on the asset volatility.
Round completeness: answeredInRound should be at least roundId – this ensures the price comes from a completed round, not a pending one.
Positive price: Obvious but good practice.
Step 3: Test Your Contract with Hardhat (Mock)
Since this guide is intentionally a mock implementation, we’ll test the consumer locally using a mock aggregator.
Create a mock aggregator in your test folder.
// test/mocks/MockAggregator.sol
pragma solidity ^0.8.0;
import "../../contracts/AggregatorV3Interface.sol";
contract MockAggregator is AggregatorV3Interface {
uint8 public decimals = 8;
string public description = "BTC/USD mock";
uint256 public version = 1;
int256 private mockPrice = 30000 * 1e8; // $30,000 with 8 decimals
function latestRoundData() external view override returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) {
return (1, mockPrice, block.timestamp, block.timestamp, 1);
}
// Allow tests to update the mock price
function setMockPrice(int256 _price) external {
mockPrice = _price;
}
}
Now the test file:
// test/PriceConsumer.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("PriceConsumer", function () {
let priceConsumer;
let mockAggregator;
beforeEach(async function () {
// Deploy mock aggregator
const MockAggregator = await ethers.getContractFactory("MockAggregator");
mockAggregator = await MockAggregator.deploy();
await mockAggregator.deployed();
// Deploy PriceConsumer with mock address
const PriceConsumer = await ethers.getContractFactory("PriceConsumer");
priceConsumer = await PriceConsumer.deploy(mockAggregator.address);
await priceConsumer.deployed();
});
it("Should return the correct price", async function () {
const price = await priceConsumer.getLatestPrice();
expect(price).to.equal(30000 * 1e8);
});
it("Should revert if price is stale", async function () {
// Simulate time passing (increase block timestamp)
await ethers.provider.send("evm_increaseTime", [2 * 3600]); // 2 hours
await ethers.provider.send("evm_mine", []); // mine a block
await expect(priceConsumer.getLatestPrice()).to.be.revertedWith("Price is stale");
});
it("Should revert if price is negative", async function () {
await mockAggregator.setMockPrice(-100);
await expect(priceConsumer.getLatestPrice()).to.be.revertedWith("Invalid price");
});
});
Deploy on Rootstock Testnet
Once your contract is tested, you can deploy it to the testnet. Use the Hardhat script:
The deployment example below uses a real address value in feedAddress. This guide’s core learning path is still a mock-based implementation. Do not deploy price feed or VRF consumers on Rootstock mainnet without official confirmation in Chainlink’s docs.
// scripts/deploy-price-consumer.js
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with account:", deployer.address);
const feedAddress = "0x76474B42B0c268a268fC6F0D9B0B6f6c3b3C8f"; // BTC/USD testnet feed address
const PriceConsumer = await ethers.getContractFactory("PriceConsumer");
const priceConsumer = await PriceConsumer.deploy(feedAddress);
await priceConsumer.deployed();
console.log("PriceConsumer deployed to:", priceConsumer.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with:
npx hardhat run scripts/deploy-price-consumer.js --network rsktestnet