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
Prerequisites: Follow the Shared Setup Guide before starting.
For background concepts and security review, see Rootstock DeFi Developer Guide.
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.30;
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.30;
// AggregatorV3Interface inlined so this file is self-contained for Remix.
// In a multi-file project, import it instead: import "./AggregatorV3Interface.sol";
interface AggregatorV3Interface {
function decimals() external view returns (uint8);
function description() external view returns (string memory);
function version() external view returns (uint256);
function latestRoundData()
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
}
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();
}
}
Want to deploy and interact with PriceConsumer without any local setup? Use the button below to open it directly in the Remix IDE (the AggregatorV3Interface is inlined so it compiles as a single file). Pass a price-feed address to the constructor, for example a deployed MockAggregator. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
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.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
// AggregatorV3Interface inlined so this file is self-contained for Remix.
// In a multi-file project, import it instead: import "./AggregatorV3Interface.sol";
interface AggregatorV3Interface {
function decimals() external view returns (uint8);
function description() external view returns (string memory);
function version() external view returns (uint256);
function latestRoundData()
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
}
contract MockAggregator is AggregatorV3Interface {
uint8 public override decimals = 8;
string public override description = "BTC/USD mock";
uint256 public override 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;
}
}
Want to deploy and interact with MockAggregator without any local setup? Use the button below to open it directly in the Remix IDE (the AggregatorV3Interface is inlined so it compiles as a single file). You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
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 rootstockTestnet