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