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:

  1. 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
  2. Call latestBundle(): Retrieve the feed's latest onchain data as a bytes array.
  3. Check data staleness: Use latestBundleTimestamp() to compare against current time and verify it hasn't exceeded your maximum acceptable staleness threshold.
  4. Decode the data: Convert the bytes array into the known struct type (as documented for each feed).
  5. Apply decimals (if needed): For numeric fields, scale the raw values by dividing by 10^decimals[i] to get the true numerical values.
  6. 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. Import the IBundleAggregatorProxy Interface

Your contract will need to interact with the MVR feed's proxy contract. The IBundleAggregatorProxy interface provides the necessary functions:

Import it directly from the @chainlink/contracts library:

import {IBundleAggregatorProxy} from "@chainlink/contracts/src/v0.8/data-feeds/interfaces/IBundleAggregatorProxy.sol";

You will then use this interface to create an instance of the proxy 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:

  1. Find the feed's heartbeat interval on the MVR Feeds Addresses page (click "Show more details")
  2. Set a threshold that aligns with this interval, usually the heartbeat plus a small buffer
  3. 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 update
  • storeDecimals(): 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 represents 18.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:

FieldRaw ValueDecimalsActual Value
netAssetValue1850000000818.5
assetsUnderManagement5000000000850.0
outstandingShares125002125.0
netIncomeExpenses7540275.4
openToNewInvestorstrue0true (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
undefined

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.

What's next

Get the latest Chainlink content straight to your inbox.