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 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 on Arbitrum Sepolia. You can get it from 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 {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. 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()`.
     *
     *  Data Streams uses subscription-based billing. Verification calls do not
     *  require this contract to hold LINK or approve a FeeManager.
     */
    
    // ────────────────────────────────────────────────────────────────────────────
    //  Interface
    // ────────────────────────────────────────────────────────────────────────────
    
    interface IVerifierProxy {
      /**
       * @notice Route a report to the correct verifier.
       * @param payload           Full report payload (header + signed report).
       * @param parameterPayload  Empty fee metadata for Data Streams subscription billing.
       */
      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  Empty fee metadata for Data Streams subscription billing.
       * @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);
    }
    
    // ────────────────────────────────────────────────────────────────────────────
    //  Contract
    // ────────────────────────────────────────────────────────────────────────────
    
    /**
     * @dev This contract implements functionality to verify Data Streams reports from
     * the Data Streams API.
     */
    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. Call `VerifierProxy.verify()` with empty fee metadata. Data Streams
       *     uses subscription billing, so no fee token address is required.
       *  4. 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. Verify through the proxy ──
        bytes memory verified = i_verifierProxy.verify(unverifiedReport, bytes(""));
    
        // ─── 4. 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. Call `VerifierProxy.verifyBulk()` once with all payloads and an empty
       *     parameter payload.
       *  3. 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. Verify all reports in one proxy call ──
        bytes[] memory verifiedReports = i_verifierProxy.verifyBulk(unverifiedReports, bytes(""));
    
        // ─── 3. 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. 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

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. Verify through the proxy: The contract calls VerifierProxy.verify() with empty fee metadata in parameterPayload. The proxy ABI still includes this parameter for legacy fee-manager integrations, but current Data Streams billing is subscription-based, so no fee token address is required and the contract does not need LINK funding for per-verification fees.

  3. 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 are validated in a loop before verification runs.
  2. All payloads are passed to VerifierProxy.verifyBulk() in one call with empty fee metadata in parameterPayload.
  3. 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.