Using MVR Feeds on EVM Chains
This guide explains how to use Multiple-Variable Response (MVR) feeds data in your consumer contracts on EVM chains using Solidity.
MVR feeds store multiple data points in a single byte array onchain. To consume this data in your contract:
- Obtain the proxy address and data structure:
- Find the
BundleAggregatorProxy
address for the specific MVR feed you want to read on the SmartData Addresses page - Expand the "MVR Bundle Info" section to see the exact data structure, field types, and decimals
- Note these details as you'll need to match this structure exactly in your code
- Find the
- Call
latestBundle()
: Retrieve the feed's latest onchain data as a bytes array. - Check data staleness: Use
latestBundleTimestamp()
to compare against current time and verify it hasn't exceeded your maximum acceptable staleness threshold. - Decode the data: Convert the bytes array into the known struct type (as documented for each feed).
- Apply decimals (if needed): For numeric fields, scale the raw values by dividing by
10^decimals[i]
to get the true numerical values. - Use in your dApp: Store or process the decoded values as required by your application.
Step-by-Step Example
Below is a step-by-step explanation, followed by a full example contract that ties everything together.
1. Define a Data Structure
Each MVR feed publishes data in a specific layout. For example, imagine an investment feed that reports:
struct Data {
uint256 netAssetValue; // e.g., 8 decimal places
uint256 assetsUnderManagement; // e.g., 8 decimal places
uint256 outstandingShares; // e.g., 2 decimal places
uint256 netIncomeExpenses; // e.g., 2 decimal places
bool openToNewInvestors; // boolean, no decimals
}
Your consumer contract must replicate this structure in the exact same order and with the same data types to decode the feed data correctly.
2. Set Up the BundleAggregatorProxy
Your contract will need a reference to the IBundleAggregatorProxy
interface so it can:
- Retrieve the raw bytes via
latestBundle()
. - Retrieve decimals via
bundleDecimals()
if the feed includes numeric fields. - Check the timestamp of the last update via
latestBundleTimestamp()
.
interface IBundleAggregatorProxy {
function latestBundle() external view returns (bytes memory);
function bundleDecimals() external view returns (uint8[] memory);
function latestBundleTimestamp() external view returns (uint256);
// Additional inherited functions omitted for brevity
}
You will set the correct proxy address in your consumer contract.
3. Validate Data Staleness
Before using the data, it's best practice to verify it has not become stale by checking the timestamp of the latest update against the current time. Stale data can lead to incorrect business decisions or vulnerabilities in your application.
Staleness checking examples:
Option 1: Simple boolean check (recommended)
function isDataFresh() public view returns (bool) {
uint256 lastUpdateTime = s_proxy.latestBundleTimestamp();
return (block.timestamp - lastUpdateTime) <= STALENESS_THRESHOLD;
}
// In your main function:
if (!isDataFresh()) {
revert StaleData(lastUpdateTime, block.timestamp, STALENESS_THRESHOLD);
}
Option 2: Direct validation
uint256 lastUpdateTime = s_proxy.latestBundleTimestamp();
require(block.timestamp - lastUpdateTime <= stalenessThreshold, "MVR feed data is stale");
Important: Don't use arbitrary values for staleness thresholds. The appropriate threshold should be determined by:
- Find the feed's heartbeat interval on the MVR Feeds Addresses page (click "Show more details")
- Set a threshold that aligns with this interval, usually the heartbeat plus a small buffer
- Consider your specific use case requirements (some applications may need very fresh data)
4. Add Error Handling and Safety Checks
For production contracts, include proper error handling with custom errors and safety checks:
// Custom errors for better debugging
error StaleData(uint256 lastUpdateTimestamp, uint256 blockTimestamp, uint256 threshold);
error InsufficientDecimals(uint256 expected, uint256 actual);
// Check that the decimals array has enough elements before accessing by index
if (decimals.length < 4) {
revert InsufficientDecimals(4, decimals.length);
}
Helper functions for testing and debugging:
isDataFresh()
: Returns a simple true/false for data freshness (excellent for block explorer testing)getLatestBundleTimestamp()
: Returns the timestamp of the most recent updatestoreDecimals()
: Fetches and stores the decimals array for repeated use
5. Read and Decode the Feed Data
Use abi.decode
to convert the returned bytes
array into your Data
struct:
bytes memory rawBundle = s_proxy.latestBundle();
Data memory decodedData = abi.decode(rawBundle, (Data));
This will populate each field in decodedData
according to your struct definition.
6. Handle Decimals (if applicable)
Similar to how ETH has 18 decimals, numeric fields in MVR feeds are typically stored with fixed-point precision. This means you need to divide raw values by a power of 10 to get their true numerical values for accurate calculations.
The bundleDecimals()
function returns an array that tells you how many decimal places each field uses. Each index in this array corresponds to a field in your struct (in the same order).
- Raw Value: What you get directly from the feed (e.g.,
1850000000
for a value that represents18.5
) - Actual Value: What the number actually represents mathematically after proper decimal scaling
- Conversion:
actualValue = rawValue / (10 ^ decimals)
For example, if your MVR feed has the following fields and decimals:
Field | Raw Value | Decimals | Actual Value |
---|---|---|---|
netAssetValue | 1850000000 | 8 | 18.5 |
assetsUnderManagement | 5000000000 | 8 | 50.0 |
outstandingShares | 12500 | 2 | 125.0 |
netIncomeExpenses | 7540 | 2 | 75.4 |
openToNewInvestors | true | 0 | true (no decimals) |
Then bundleDecimals()
would return an array like [8, 8, 2, 2, 0]
.
In your code, you would convert the values like this:
// For netAssetValue with 8 decimals
uint256 scaledValue = rawValue / 10**8; // 1850000000 / 10^8 = 18 (decimals truncated)
Important:
- In Solidity, division with
uint256
results in integer division with truncation of any fractional part. If your application needs to maintain decimal precision, store the raw values and perform decimal conversion in your frontend application. - Always confirm the actual decimal configuration from the feed's documentation (see MVR Feeds Addresses). Different feeds may use different decimal patterns or skip them entirely for non-numeric fields.
7. Store or Use the Decoded Values
Finally, you can store the decoded values in your contract state or process them right away.
Full Example: Consumer Contract
Below is a full example that demonstrates how to:
- Initialize the proxy in the constructor
- Validate data staleness
- Retrieve and decode the latest bundle
- Adjust numeric fields by the correct decimal factor
- Store both the raw onchain values and the scaled values
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
// NOTE: The required interfaces will soon be available in the Chainlink package for you to import.
interface IBundleAggregatorProxy {
/**
* @notice Returns the latest bundle data
* @return bundle The latest bundle as raw bytes
*/
function latestBundle() external view returns (bytes memory);
/**
* @notice Returns the timestamp of the latest bundle
* @return timestamp The timestamp of the latest bundle
*/
function latestBundleTimestamp() external view returns (uint256);
/**
* @notice Returns the decimals for each field in the bundle
* @return decimalsArray Array of decimals for each value in the bundle
*/
function bundleDecimals() external view returns (uint8[] memory);
}
/**
* @notice This struct defines the exact data structure of the MVR feed
* @dev The order and types must match exactly what's defined in the feed
*/
struct Data {
uint256 netAssetValue;
uint256 assetsUnderManagement;
uint256 outstandingShares;
uint256 netIncomeExpenses;
bool openToNewInvestors;
}
contract MVRDataConsumer {
// Reference to the MVR feed proxy
IBundleAggregatorProxy public s_proxy;
// Maximum allowed staleness duration for the data
// IMPORTANT: This should be configured based on the specific feed's heartbeat interval
// Check the feed's documentation for the appropriate value instead of using this example value
uint256 public immutable STALENESS_THRESHOLD;
// Storage for scaled values (after dividing by decimals)
uint256 public netAssetValue;
uint256 public assetsUnderManagement;
uint256 public outstandingShares;
uint256 public netIncomeExpenses;
bool public openToNewInvestors;
// Storage for original onchain values (no decimal adjustments)
uint256 public rawNetAssetValue;
uint256 public rawAssetsUnderManagement;
uint256 public rawOutstandingShares;
uint256 public rawNetIncomeExpenses;
// Keep track of decimals for each field in the struct.
// Non-numeric fields (e.g., bool) typically return 0.
uint8[] public decimals;
// Error for stale data
error StaleData(
uint256 lastUpdateTimestamp,
uint256 blockTimestamp,
uint256 threshold
);
// Error for insufficient decimals array
error InsufficientDecimals(uint256 expected, uint256 actual);
/**
* @notice Constructor that sets the staleness threshold for the feed
* @param _proxy The address of the MVR feed's proxy contract
* @param _stalenessThreshold Maximum time (in seconds) since last update before data is considered stale
* @dev The threshold should be based on the feed's heartbeat interval from documentation
* For example, if a feed updates every 24 hours (86400s), you might set this to 86400s + some buffer
*/
constructor(IBundleAggregatorProxy _proxy, uint256 _stalenessThreshold) {
s_proxy = _proxy;
STALENESS_THRESHOLD = _stalenessThreshold;
}
/**
* @notice Stores the decimals array in your contract for repeated usage.
* @dev Index mapping for this example:
* 0 -> netAssetValue,
* 1 -> assetsUnderManagement,
* 2 -> outstandingShares,
* 3 -> netIncomeExpenses,
* 4 -> openToNewInvestors (likely returns 0).
*/
function storeDecimals() external {
decimals = s_proxy.bundleDecimals();
}
/**
* @notice Returns the timestamp of the most recent MVR feed update.
*/
function getLatestBundleTimestamp() external view returns (uint256) {
return s_proxy.latestBundleTimestamp();
}
/**
* @notice Simple boolean check for data freshness (block explorer friendly)
* @return true if data is fresh, false if stale
*/
function isDataFresh() public view returns (bool) {
uint256 lastUpdateTime = s_proxy.latestBundleTimestamp();
return (block.timestamp - lastUpdateTime) <= STALENESS_THRESHOLD;
}
/**
* @notice Fetches and decodes the latest MVR feed data, then stores both the raw and scaled values.
* @dev This process demonstrates the complete flow of consuming MVR feed data:
* 1. Check data freshness
* 2. Fetch the raw bytes
* 3. Decode into the struct matching the feed's data structure
* 4. Store raw values (preserving original precision)
* 5. Apply decimal conversions to get the true numerical values
*/
function consumeData() external {
// Check data freshness before proceeding
if (!isDataFresh()) {
uint256 lastUpdateTime = s_proxy.latestBundleTimestamp();
revert StaleData(
lastUpdateTime,
block.timestamp,
STALENESS_THRESHOLD
);
}
// Ensure we have the decimals array - if not, fetch it
if (decimals.length == 0) {
decimals = s_proxy.bundleDecimals();
}
// Verify we have enough decimal values for our struct fields
if (decimals.length < 4) {
revert InsufficientDecimals(4, decimals.length);
}
// 1. Retrieve the raw bytes from the MVR feed
// This is the encoded form of all data fields packed together
bytes memory b = s_proxy.latestBundle();
// 2. Decode the raw bytes into our known struct
// The struct Data must match exactly what the feed encodes
Data memory d = abi.decode(b, (Data));
// 3. Store the raw (original onchain) values
// These preserve the full precision as reported by the feed
rawNetAssetValue = d.netAssetValue;
rawAssetsUnderManagement = d.assetsUnderManagement;
rawOutstandingShares = d.outstandingShares;
rawNetIncomeExpenses = d.netIncomeExpenses;
openToNewInvestors = d.openToNewInvestors; // Boolean, no need for decimal adjustment
// 4. Convert values by dividing by 10^decimals[i]
// This removes the decimal scaling factor to get the human-readable representation
// Note: This uses integer division which truncates decimal places
// For example, if decimals[0] = 8 and rawNetAssetValue = 1850000000,
// then netAssetValue = 18 (integer division, decimals are truncated)
netAssetValue = d.netAssetValue / (10 ** decimals[0]);
assetsUnderManagement = d.assetsUnderManagement / (10 ** decimals[1]);
outstandingShares = d.outstandingShares / (10 ** decimals[2]);
netIncomeExpenses = d.netIncomeExpenses / (10 ** decimals[3]);
// Note: We don't need to apply decimals to boolean fields
// The openToNewInvestors field typically has 0 decimals in the array
}
}
Key Points:
- Exact Order Matters: The struct fields and their types must match the feed's definition.
- Decimals: Use
bundleDecimals()
to learn how to scale the numeric fields. Non-numeric fields (e.g., bool) do not need scaling. - Timestamps:
latestBundleTimestamp()
returns the block timestamp of the last report and should be used to validate data staleness. - Staleness Threshold: Set appropriate staleness thresholds based on each feed's documented heartbeat interval, not arbitrary values.
- Error Handling: Use custom errors (
StaleData
,InsufficientDecimals
) for better debugging and include safety checks for array bounds. - Testing Functions: Implement helper functions like
isDataFresh()
for easy testing and debugging. - No Historical Data: MVR feeds typically only store the latest data onchain. If you need historical data, you must capture it in your own contract or via an offchain indexer.