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:
| Environment | Fetch | Stream |
|---|---|---|
| Go SDK | Fetch and decode reports | Stream and decode reports |
| TypeScript SDK | Fetch and decode reports | Stream and decode reports |
| Rust SDK | Fetch and decode reports | Stream and decode reports |
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 | bashThen restart your terminal and run:
foundryupVerify the installation:
forge --version
Set up your project
-
Create a new directory and initialize a Foundry project:
mkdir data-streams-verification cd data-streams-verification forge init --no-git -
Install the required npm packages. Foundry resolves
@chainlink/contractsand@openzeppelin/contractsimports throughnode_modules:npm install @chainlink/contracts @openzeppelin/contracts -
Configure
foundry.tomlto includenode_modulesin 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/", ] -
Copy the
ClientReportsVerifier.solcontract into yoursrc/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); } } -
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 --interactiveFoundry will prompt you for your private key and a password to encrypt it with. Verify it was saved:
cast wallet listYou should see
dataStreamsWalletin the output. You will be prompted for the keystore password whenever you send a transaction. -
Set your RPC URL as an environment variable for the session:
export RPC_URL=https://sepolia-rollup.arbitrum.io/rpc -
Compile the contract to verify the setup is correct:
forge buildYou 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:
-
Extract the report version: The payload is ABI-encoded as
(bytes32[3], bytes). The contract decodes it and reads the first two bytes ofreportDatato determine the schema version:Schema version Report type 0x0003Crypto Advanced (v3) 0x0008RWA Standard (v8) 0x0007Redemption Rates (v7) 0x0009SmartData (v9) 0x000ATokenized Asset (v10) 0x000BRWA Advanced (v11) Unsupported versions revert with
InvalidReportVersion. -
Verify through the proxy: The contract calls
VerifierProxy.verify()with empty fee metadata inparameterPayload. 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. -
Decode and store the price: The verified bytes are decoded into the appropriate struct (
ReportV3orReportV8). The price is stored inlastDecodedPriceand emitted as aDecodedPriceevent.
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:
- All payloads are decoded and their versions are validated in a loop before verification runs.
- All payloads are passed to
VerifierProxy.verifyBulk()in one call with empty fee metadata inparameterPayload. - The verified reports are decoded and stored in
lastDecodedPrices[], with aDecodedPriceevent 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.