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:

    undefined
  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.