Verify report data onchain (EVM)

Guide Versions

This guide is available in multiple versions. Choose the one that matches your needs.

In this tutorial, you will deploy a verifier contract to Arbitrum Sepolia, fund it with LINK, and use it to verify a Data Streams report onchain. You will also learn how to verify multiple reports in a single transaction using verifyBulkReports().

Before you begin

Make sure you understand how to fetch a signed report payload from the Streams API before working through this tutorial. The payload you receive from fetching a report is exactly what you pass to the verifier contract — no transformation needed.

Refer to the following tutorials:

Requirements

  • Testnet ETH and LINK on Arbitrum Sepolia. Both are available at faucets.chain.link.

  • A wallet private key for signing transactions. If you use MetaMask, follow the MetaMask guide to export your private key. Never share your private key or commit it to version control.

  • Foundry installed. If you don't have it yet, run:

    curl -L https://foundry.paradigm.xyz | bash
    

    Then restart your terminal and run:

    foundryup
    

    Verify the installation:

    forge --version
    

Set up your project

  1. Create a new directory and initialize a Foundry project:

    mkdir data-streams-verification
    cd data-streams-verification
    forge init --no-git
    
  2. Install the required npm packages. Foundry resolves @chainlink/contracts and @openzeppelin/contracts imports through node_modules:

    npm install @chainlink/contracts @openzeppelin/contracts
    
  3. Configure foundry.toml to include node_modules in the library search path and add the remappings:

    [profile.default]
    src = "src"
    out = "out"
    libs = ["lib", "node_modules"]
    remappings = [
      "@chainlink/contracts/=node_modules/@chainlink/contracts/",
      "@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/",
      "@openzeppelin/contracts@4.8.3/=node_modules/@openzeppelin/contracts/",
    ]
    
  4. Copy the ClientReportsVerifier.sol contract into your src/ directory. You can view the full contract below:

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import {Common} from "@chainlink/contracts/src/v0.8/llo-feeds/libraries/Common.sol";
    import {IVerifierFeeManager} from "@chainlink/contracts/src/v0.8/llo-feeds/v0.3.0/interfaces/IVerifierFeeManager.sol";
    import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
    
    using SafeERC20 for IERC20;
    
    /**
     * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE FOR DEMONSTRATION PURPOSES.
     * DO NOT USE THIS CODE IN PRODUCTION.
     *
     *  This contract verifies Chainlink Data Streams reports onchain and pays
     *  the verification fee in LINK (when required). It exposes two verification
     *  functions:
     *
     *  - `verifyReport()`      – verifies a single report payload.
     *  - `verifyBulkReports()` – verifies multiple payloads (across any feed IDs)
     *                             in a single transaction using `verifyBulk()`.
     *
     * - If `VerifierProxy.s_feeManager()` returns a non-zero address, the network
     *   expects you to interact with that FeeManager for every verification call:
     *   quote fees, approve the RewardManager, then call `verify()` / `verifyBulk()`.
     *
     * - If `s_feeManager()` returns the zero address, no FeeManager contract has
     *   been deployed on that chain. In that case there is nothing to quote or pay
     *   onchain, so the contract skips the fee logic entirely.
     *
     *  The `if (address(feeManager) != address(0))` check below chooses the
     *  correct path automatically, making the same bytecode usable on any chain.
     */
    
    // ────────────────────────────────────────────────────────────────────────────
    //  Interfaces
    // ────────────────────────────────────────────────────────────────────────────
    
    interface IVerifierProxy {
      /**
       * @notice Route a report to the correct verifier and (optionally) bill fees.
       * @param payload           Full report payload (header + signed report).
       * @param parameterPayload  ABI-encoded fee metadata.
       */
      function verify(
        bytes calldata payload,
        bytes calldata parameterPayload
      ) external payable returns (bytes memory verifierResponse);
    
      /**
       * @notice Route multiple reports to the correct verifier in a single call.
       * @param payloads          Array of full report payloads. Each entry may reference
       *                          a different feed ID. Order is preserved in the output.
       * @param parameterPayload  ABI-encoded fee token address shared across all reports
       *                          (or empty bytes if no FeeManager is deployed).
       * @return verifiedReports  Verified report bytes in the same order as the input.
       */
      function verifyBulk(
        bytes[] calldata payloads,
        bytes calldata parameterPayload
      ) external payable returns (bytes[] memory verifiedReports);
    
      function s_feeManager() external view returns (IVerifierFeeManager);
    }
    
    interface IFeeManager {
      /**
       * @return fee, reward, totalDiscount
       */
      function getFeeAndReward(
        address subscriber,
        bytes memory unverifiedReport,
        address quoteAddress
      ) external returns (Common.Asset memory, Common.Asset memory, uint256);
    
      function i_linkAddress() external view returns (address);
    
      function i_nativeAddress() external view returns (address);
    
      function i_rewardManager() external view returns (address);
    }
    
    // ────────────────────────────────────────────────────────────────────────────
    //  Contract
    // ────────────────────────────────────────────────────────────────────────────
    
    /**
     * @dev This contract implements functionality to verify Data Streams reports from
     * the Data Streams API, with payment in LINK tokens.
     */
    contract ClientReportsVerifier {
      // ----------------- Errors -----------------
      error NothingToWithdraw();
      error NotOwner(address caller);
      error InvalidReportVersion(uint16 version);
      error EmptyReportsArray();
    
      // ----------------- Report schemas -----------------
      // More info: https://docs.chain.link/data-streams/reference/report-schema-v3
      /**
       * @dev Data Streams report schema v3 (crypto streams).
       *      Prices, bids and asks use 8 or 18 decimals depending on the stream.
       */
      struct ReportV3 {
        bytes32 feedId;
        uint32 validFromTimestamp;
        uint32 observationsTimestamp;
        uint192 nativeFee;
        uint192 linkFee;
        uint32 expiresAt;
        int192 price;
        int192 bid;
        int192 ask;
      }
    
      /**
       * @dev Data Streams report schema v8 (RWA streams).
       */
      struct ReportV8 {
        bytes32 feedId;
        uint32 validFromTimestamp;
        uint32 observationsTimestamp;
        uint192 nativeFee;
        uint192 linkFee;
        uint32 expiresAt;
        uint64 lastUpdateTimestamp;
        int192 midPrice;
        uint32 marketStatus;
      }
    
      // ----------------- Storage -----------------
      IVerifierProxy public immutable i_verifierProxy;
      address private immutable i_owner;
    
      int192 public lastDecodedPrice;
      int192[] public lastDecodedPrices;
    
      // ----------------- Events -----------------
      event DecodedPrice(int192 price);
    
      // ----------------- Constructor / modifier -----------------
      /**
       * @param _verifierProxy Address of the VerifierProxy on the target network.
       *        Addresses: https://docs.chain.link/data-streams/crypto-streams
       */
      constructor(
        address _verifierProxy
      ) {
        i_owner = msg.sender;
        i_verifierProxy = IVerifierProxy(_verifierProxy);
      }
    
      modifier onlyOwner() {
        if (msg.sender != i_owner) revert NotOwner(msg.sender);
        _;
      }
    
      // ----------------- Public API -----------------
    
      /**
       * @notice Verify a Data Streams report (schema v3 or v8).
       *
       * @dev Steps:
       *  1. Decode the unverified report to get `reportData`.
       *  2. Read the first two bytes → schema version (`0x0003` or `0x0008`).
       *     - Revert if the version is unsupported.
       *  3. Fee handling:
       *     - Query `s_feeManager()` on the proxy.
       *       – Non-zero → quote the fee, approve the RewardManager,
       *         ABI-encode the fee token address for `verify()`.
       *       – Zero     → no FeeManager; skip quoting/approval and pass `""`.
       *  4. Call `VerifierProxy.verify()`.
       *  5. Decode the verified report into the correct struct and emit the price.
       *
       *  @param unverifiedReport Full payload returned by Streams Direct.
       *  @custom:reverts InvalidReportVersion when schema ≠ v3/v8.
       */
      function verifyReport(
        bytes memory unverifiedReport
      ) external {
        // ─── 1. & 2. Extract reportData and schema version ──
        (, bytes memory reportData) = abi.decode(unverifiedReport, (bytes32[3], bytes));
    
        uint16 reportVersion = (uint16(uint8(reportData[0])) << 8) | uint16(uint8(reportData[1]));
        if (reportVersion != 3 && reportVersion != 8) {
          revert InvalidReportVersion(reportVersion);
        }
    
        // ─── 3. Fee handling ──
        IFeeManager feeManager = IFeeManager(address(i_verifierProxy.s_feeManager()));
    
        bytes memory parameterPayload;
        if (address(feeManager) != address(0)) {
          // FeeManager exists — always quote & approve
          address feeToken = feeManager.i_linkAddress();
    
          (Common.Asset memory fee,,) = feeManager.getFeeAndReward(address(this), reportData, feeToken);
    
          IERC20(feeToken).approve(feeManager.i_rewardManager(), fee.amount);
          parameterPayload = abi.encode(feeToken);
        } else {
          // No FeeManager deployed on this chain
          parameterPayload = bytes("");
        }
    
        // ─── 4. Verify through the proxy ──
        bytes memory verified = i_verifierProxy.verify(unverifiedReport, parameterPayload);
    
        // ─── 5. Decode & store price ──
        if (reportVersion == 3) {
          int192 price = abi.decode(verified, (ReportV3)).price;
          lastDecodedPrice = price;
          emit DecodedPrice(price);
        } else {
          int192 price = abi.decode(verified, (ReportV8)).midPrice;
          lastDecodedPrice = price;
          emit DecodedPrice(price);
        }
      }
    
      /**
       * @notice Verify multiple Data Streams reports (schema v3 or v8) in one transaction.
       *
       * @dev Steps:
       *  1. Decode each payload to extract `reportData` and the schema version.
       *     - Revert if any version is unsupported.
       *  2. Fee handling (when FeeManager is deployed):
       *     - Quote the fee for each report individually via `getFeeAndReward`.
       *     - Accumulate the total fee across all reports.
       *     - Approve the RewardManager once for the combined total.
       *     - ABI-encode the fee token address for `verifyBulk()`.
       *  3. Call `VerifierProxy.verifyBulk()` once with all payloads.
       *  4. Decode each verified report, store prices in `lastDecodedPrices`,
       *     and emit a `DecodedPrice` event per report.
       *
       *  @param unverifiedReports Array of full payloads from Streams Direct.
       *         Each payload may reference a different feed ID.
       *  @custom:reverts EmptyReportsArray    when called with an empty array.
       *  @custom:reverts InvalidReportVersion when any schema version ≠ v3/v8.
       */
      function verifyBulkReports(
        bytes[] memory unverifiedReports
      ) external {
        if (unverifiedReports.length == 0) revert EmptyReportsArray();
    
        // ─── 1. Decode all payloads upfront ──
        bytes[] memory reportDataArray = new bytes[](unverifiedReports.length);
        uint16[] memory reportVersions = new uint16[](unverifiedReports.length);
    
        for (uint256 i = 0; i < unverifiedReports.length; i++) {
          (, bytes memory reportData) = abi.decode(unverifiedReports[i], (bytes32[3], bytes));
    
          uint16 reportVersion = (uint16(uint8(reportData[0])) << 8) | uint16(uint8(reportData[1]));
          if (reportVersion != 3 && reportVersion != 8) {
            revert InvalidReportVersion(reportVersion);
          }
    
          reportDataArray[i] = reportData;
          reportVersions[i] = reportVersion;
        }
    
        // ─── 2. Fee handling ──
        IFeeManager feeManager = IFeeManager(address(i_verifierProxy.s_feeManager()));
    
        bytes memory parameterPayload;
        if (address(feeManager) != address(0)) {
          address feeToken = feeManager.i_linkAddress();
          uint256 totalFee = 0;
    
          // Quote per-report fees and accumulate
          for (uint256 i = 0; i < reportDataArray.length; i++) {
            (Common.Asset memory fee,,) = feeManager.getFeeAndReward(address(this), reportDataArray[i], feeToken);
            totalFee += fee.amount;
          }
    
          // Single approval covers the combined cost of all reports
          IERC20(feeToken).approve(feeManager.i_rewardManager(), totalFee);
          parameterPayload = abi.encode(feeToken);
        } else {
          // No FeeManager deployed on this chain
          parameterPayload = bytes("");
        }
    
        // ─── 3. Verify all reports in one proxy call ──
        bytes[] memory verifiedReports = i_verifierProxy.verifyBulk(unverifiedReports, parameterPayload);
    
        // ─── 4. Decode verified reports, store prices, emit events ──
        int192[] memory prices = new int192[](verifiedReports.length);
    
        for (uint256 i = 0; i < verifiedReports.length; i++) {
          int192 price;
          if (reportVersions[i] == 3) {
            price = abi.decode(verifiedReports[i], (ReportV3)).price;
          } else {
            price = abi.decode(verifiedReports[i], (ReportV8)).midPrice;
          }
          prices[i] = price;
          emit DecodedPrice(price);
        }
    
        lastDecodedPrices = prices;
      }
    
      /**
       * @notice Withdraw all balance of an ERC-20 token held by this contract.
       * @param _beneficiary Address that receives the tokens.
       * @param _token       ERC-20 token address.
       */
      function withdrawToken(
        address _beneficiary,
        address _token
      ) external onlyOwner {
        uint256 amount = IERC20(_token).balanceOf(address(this));
        if (amount == 0) revert NothingToWithdraw();
        IERC20(_token).safeTransfer(_beneficiary, amount);
      }
    }
    
  5. Set up credentials for signing transactions. This tutorial uses Foundry's encrypted keystore, which stores your private key in an encrypted file rather than plaintext:

    cast wallet import dataStreamsWallet --interactive
    

    Foundry will prompt you for your private key and a password to encrypt it with. Verify it was saved:

    cast wallet list
    

    You should see dataStreamsWallet in the output. You will be prompted for the keystore password whenever you send a transaction.

  6. Set your RPC URL as an environment variable for the session:

    export RPC_URL=https://sepolia-rollup.arbitrum.io/rpc
    
  7. Compile the contract to verify the setup is correct:

    forge build
    

    You should see output ending with:

    Compiler run successful!
    

Deploy the verifier contract

Deploy ClientReportsVerifier to Arbitrum Sepolia, passing the Chainlink-deployed VerifierProxy address as the constructor argument. The VerifierProxy is a Chainlink-deployed contract that routes your verification calls to the correct Verifier contract and handles fee collection. You are not deploying the proxy yourself — you only point your contract at it. The VerifierProxy address for Arbitrum Sepolia is 0x2ff010DEbC1297f19579B4246cad07bd24F2488A. You can find addresses for other networks on the Stream Addresses page.

forge create src/ClientReportsVerifier.sol:ClientReportsVerifier \
  --rpc-url $RPC_URL \
  --account dataStreamsWallet \
  --broadcast \
  --constructor-args 0x2ff010DEbC1297f19579B4246cad07bd24F2488A

Expect output similar to:

[] Compiling...
No files changed, compilation skipped
Deployer: 0xYourWalletAddress
Deployed to: 0xYourContractAddress
Transaction hash: 0xYourTransactionHash

You can view the deployed contract on the Arbitrum Sepolia explorer. For a real example, see this deployed contract from a test run.

Save the Deployed to address — you'll need it for the remaining steps. Export it as an environment variable:

export CONTRACT_ADDRESS=0xYourContractAddress

Fund the verifier contract

This contract pays verification fees in LINK tokens on networks where a FeeManager is deployed. Arbitrum Sepolia requires fees, so the contract needs LINK before it can verify reports.

The LINK token contract address on Arbitrum Sepolia is 0xb1D4538B4571d411F07960EF2838Ce337FE1E80E. Transfer 1 testnet LINK to your deployed contract:

cast send 0xb1D4538B4571d411F07960EF2838Ce337FE1E80E \
  "transfer(address,uint256)" \
  $CONTRACT_ADDRESS 1000000000000000000 \
  --rpc-url $RPC_URL \
  --account dataStreamsWallet

1000000000000000000 is 1 LINK expressed in 18 decimal places. Confirm the transfer was successful by checking the contract's LINK balance:

cast call 0xb1D4538B4571d411F07960EF2838Ce337FE1E80E \
  "balanceOf(address)(uint256)" \
  $CONTRACT_ADDRESS \
  --rpc-url $RPC_URL

You should see a non-zero value returned.

Verify a single report

You need a signed report payload to verify. This is the fullReport value from a report you fetched using the Streams API — see the Fetch and decode reports tutorial for an example payload.

When you call verifyReport(), the contract does the following:

  1. Extract the report version: The payload is ABI-encoded as (bytes32[3], bytes). The contract decodes it and reads the first two bytes of reportData to determine the schema version:

    Unsupported versions revert with InvalidReportVersion.

  2. Handle fees: The contract calls s_feeManager() on the proxy to check whether a FeeManager is deployed on the current network. On networks with a FeeManager (Arbitrum, Avalanche, Base, Blast, Bob, Ink, Linea, OP, Scroll, Soneium, ZKSync), it quotes the fee via getFeeAndReward, approves the RewardManager to spend that LINK amount, and encodes the fee token address into parameterPayload. On networks without a FeeManager (zero address returned), it passes empty bytes and skips fee logic entirely. This check runs automatically, so the same contract works on any supported network without changes.

  3. Verify through the proxy: The contract calls VerifierProxy.verify(), which checks the DON's signatures and returns the verified report body.

  4. Decode and store the price: The verified bytes are decoded into the appropriate struct (ReportV3 or ReportV8). The price is stored in lastDecodedPrice and emitted as a DecodedPrice event.

Store the payload in your shell:

PAYLOAD=0x0006f9b553e393ced311551efd30d1decedb63d76ad41737462e2cdbbdff1578000000000000000000000000000000000000000000000000000000004f8e8a11000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000028001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba78200000000000000000000000000000000000000000000000000000000675e0a5b00000000000000000000000000000000000000000000000000000000675e0a5b00000000000000000000000000000000000000000000000000001787ff5c6fb8000000000000000000000000000000000000000000000000000c01807477ecd000000000000000000000000000000000000000000000000000000000675f5bdb0000000000000000000000000000000000000000000000d1865f8f627c4113300000000000000000000000000000000000000000000000d18572c6cdc3b915000000000000000000000000000000000000000000000000d1879ab8e98f743ad00000000000000000000000000000000000000000000000000000000000000002f3316e5c964d118f6683eecda454985fcc696e4ba34d65edb4a71a8d0cfe970676f465618c7d01196e433cc35b6994e7ad7b8189b0462b51458e663d601fdfaa0000000000000000000000000000000000000000000000000000000000000002219a4493fdf311421d664e0c8d69efa74b776461f8e252d191eda7edb980ab9a5cce69ec0ad35ba210cf60a201ceff6771b35b44860fda859f4aaba242c476bf

Call verifyReport() on your deployed contract:

cast send $CONTRACT_ADDRESS \
  "verifyReport(bytes)" \
  $PAYLOAD \
  --rpc-url $RPC_URL \
  --account dataStreamsWallet

Expect output similar to:

blockHash            0xecdaf4164a7b440557a7f3ac1dd6a95b74d6ff22a8f1dc8d02778c403e449d96
blockNumber          287851808
contractAddress
from                 0xYourWalletAddress
gasUsed              118541
status               1 (success)
to                   0xYourContractAddress
transactionHash      0xac09ea7b489fe0d41dad9dd82e7ed3ebabe9124f89ffe350ec7df746b9f8b6da

A status of 1 means the transaction succeeded and the report was verified. You can view the transaction on the Arbitrum Sepolia explorer.

Read the decoded price

After verification, the contract stores the decoded price in lastDecodedPrice. Read it:

cast call $CONTRACT_ADDRESS \
  "lastDecodedPrice()(int192)" \
  --rpc-url $RPC_URL

Example output:

1926616346301577350000

This is the ETH/USD price with 18 decimal places: ~1,926.62 USD. Each stream uses a different number of decimal places — see the Stream Addresses page for details.

Verify multiple reports

verifyBulkReports() lets you verify reports for multiple feed IDs in a single transaction. This is useful when your protocol needs prices from more than one stream atomically.

The function follows the same four stages as verifyReport(), extended to handle an array of payloads:

  1. All payloads are decoded and their versions validated in a loop before any fee or verification logic runs.
  2. getFeeAndReward is called once per report, and the individual fees are summed into a single totalFee. A single approve() covers the combined cost of the entire batch.
  3. All payloads are passed to VerifierProxy.verifyBulk() in one call.
  4. The verified reports are decoded and stored in lastDecodedPrices[], with a DecodedPrice event emitted per report.

Payloads in the array may reference different feed IDs — there is no requirement that they all be for the same stream.

Store two payloads (one per feed) as shell variables, then call the function with them as an array:

PAYLOAD_1=0xYOUR_FIRST_PAYLOAD
PAYLOAD_2=0xYOUR_SECOND_PAYLOAD
cast send $CONTRACT_ADDRESS \
  "verifyBulkReports(bytes[])" \
  "[$PAYLOAD_1,$PAYLOAD_2]" \
  --rpc-url $RPC_URL \
  --account dataStreamsWallet

Expect output similar to:

blockHash            0x5d89e4caf23eac7339e3a64348364821a622b0c30617954bdf250512e7ac78f1
blockNumber          287851901
contractAddress
from                 0xYourWalletAddress
gasUsed              207014
status               1 (success)
to                   0xYourContractAddress
transactionHash      0x67ba5902005ed54cb38826381ca3cede6938442996839bb5cb9341e732e6ec77

A status of 1 means the transaction succeeded and both reports were verified. You can view the transaction on the Arbitrum Sepolia explorer.

After the transaction succeeds, read the decoded prices. lastDecodedPrices is a dynamic array — you can read individual entries by index:

# First price (index 0) — ETH/USD
cast call $CONTRACT_ADDRESS \
  "lastDecodedPrices(uint256)(int192)" \
  0 \
  --rpc-url $RPC_URL

# Second price (index 1) — LINK/USD
cast call $CONTRACT_ADDRESS \
  "lastDecodedPrices(uint256)(int192)" \
  1 \
  --rpc-url $RPC_URL

Example output:

1926607236156530850000
8531864121895529000

These are the ETH/USD and LINK/USD prices with 18 decimal places: ~1,926.61 USD and ~8.53 USD respectively.

Recover tokens

The contract includes a withdrawToken() function that allows the owner to recover any ERC-20 tokens (including LINK) held by the contract. It reverts with NothingToWithdraw if the balance is zero.

What's next

Get the latest Chainlink content straight to your inbox.