Tutorial: Integrating Chainlink Oracles with Streams
Somnia Data Streams provides a powerful, on-chain, and composable storage layer. Chainlink Oracles provide secure, reliable, and decentralized external data feeds.
When you combine them, you unlock a powerful new capability: creating historical, queryable, on-chain data streams from real-world data.
Chainlink Price Feeds are designed to provide the latest price of an asset. They are not designed to provide a queryable history. You cannot easily ask a Price Feed, "What was the price of ETH 48 hours ago?"
By integrating Chainlink with Somnia Streams, you can build a "snapshot bot" that reads from Chainlink at regular intervals and appends the price to a Somnia Data Stream. This creates a permanent, verifiable, and historical on-chain feed that any other DApp or user can read and trust.
Objectives & Deliverable
Objective: Fetch off-chain data (a price feed) with Chainlink and store it historically via Somnia Streams.
Key Takeaway: Combining external "truth" sources with Somnia's composable storage to create new, valuable on-chain data products.
Deliverable: A hybrid "Snapshot Bot" that reads from Chainlink on the Sepolia testnet and publishes to a historical price feed on the Somnia Testnet.
What You'll Build
A New Schema: A
priceFeedSchemato store price data.A Chainlink Reader: A script using
viemto read thelatestRoundDatafrom Chainlink's ETH/USD feed on the Sepolia testnet.A Snapshot Bot: A script that reads from Chainlink (Sepolia) and writes to Somnia Data Streams (Somnia Testnet).
A History Reader: A script to read our new historical price feed from Somnia Data Streams.
This tutorial demonstrates a true hybrid-chain application.
Prerequisites
Node.js 18+ and npm.
@somnia-chain/streams,viem, anddotenvinstalled.A wallet with Somnia Testnet tokens (for publishing) and Sepolia testnet ETH (for gas, though we are only reading, so a public RPC is fine).
Environment Setup
Create a .env file. You will need RPC URLs for both chains and a private key for the Somnia Testnet (to pay for publishing).
# .env
RPC_URL_SOMNIA=[https://dream-rpc.somnia.network]
RPC_URL_SEPOLIA=[https://sepolia.drpc.org]
PRIVATE_KEY_SOMNIA=0xYOUR_SOMNIA_PRIVATE_KEYProject Setup
Set up your project with viem and the Streams SDK.
npm i @somnia-chain/streams viem dotenv
npm i -D @types/node typescript ts-node1. Chain Configuration
We need to define both chains we are interacting with.
src/lib/chain.ts
import { defineChain } from 'viem'
import { sepolia as sepoliaBase } from 'viem/chains'
// 1. Somnia Testnet
export const somniaTestnet = defineChain({
id: 50312,
name: 'Somnia Testnet',
network: 'somnia-testnet',
nativeCurrency: { name: 'STT', symbol: 'STT', decimals: 18 },
rpcUrls: {
default: { http: [process.env.RPC_URL_SOMNIA || ''] },
public: { http: [process.env.RPC_URL_SOMNIA || ''] },
},
} as const)
// 2. Sepolia Testnet (for Chainlink)
export const sepolia = sepoliaBase2. Client Configuration
We will create two separate clients:
A Somnia SDK client (with a wallet) to write data.
A Sepolia Public Client (read-only) to read from Chainlink.
src/lib/clients.ts
import 'dotenv/config'
import { createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { SDK } from '@somnia-chain/streams'
import { somniaTestnet, sepolia } from './chain'
function getEnv(key: string): string {
const value = process.env[key]
if (!value) throw new Error(`Missing environment variable: ${key}`)
return value
}
// === Client 1: Somnia SDK (Read/Write) ===
const somniaWalletClient = createWalletClient({
account: privateKeyToAccount(getEnv('PRIVATE_KEY_SOMNIA') as `0x${string}`),
chain: somniaTestnet,
transport: http(getEnv('RPC_URL_SOMNIA')),
})
const somniaPublicClient = createPublicClient({
chain: somniaTestnet,
transport: http(getEnv('RPC_URL_SOMNIA')),
})
export const somniaSdk = new SDK({
public: somniaPublicClient,
wallet: somniaWalletClient,
})
// === Client 2: Sepolia Public Client (Read-Only) ===
export const sepoliaPublicClient = createPublicClient({
chain: sepolia,
transport: http(getEnv('RPC_URL_SEPOLIA')),
})Step 1: Define the Price Feed Schema
Our schema will store the core data from Chainlink's feed.
src/lib/schema.ts
// This schema will store historical price snapshots
export const priceFeedSchema =
'uint64 timestamp, int256 price, uint80 roundId, string pair'timestamp: TheupdatedAttime from Chainlink.price: Theanswer(e.g., ETH price).roundId: The Chainlink round ID, to prevent duplicates.pair: A string to identify the feed (e.g., "ETH/USD").
Step 2: Create the Chainlink Reader
Let's create a dedicated file to handle fetching data from Chainlink. We will use the ETH/USD feed on Sepolia.
src/lib/chainlinkReader.ts
import { parseAbi, Address } from 'viem'
import { sepoliaPublicClient } from './clients'
// Chainlink ETH/USD Feed on Sepolia Testnet
const CHAINLINK_FEED_ADDRESS: Address = '0x694AA1769357215DE4FAC081bf1f309aDC325306'
// Minimal ABI for AggregatorV3Interface
const CHAINLINK_ABI = parseAbi([
'function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)',
'function decimals() external view returns (uint8)',
])
export interface PriceData {
roundId: bigint
price: bigint
timestamp: bigint
decimals: number
}
/**
* Fetches the latest price data from the Chainlink ETH/USD feed on Sepolia.
*/
export async function fetchLatestPrice(): Promise<PriceData> {
console.log('Fetching latest price from Chainlink on Sepolia...')
try {
const [roundData, decimals] = await Promise.all([
sepoliaPublicClient.readContract({
address: CHAINLINK_FEED_ADDRESS,
abi: CHAINLINK_ABI,
functionName: 'latestRoundData',
}),
sepoliaPublicClient.readContract({
address: CHAINLINK_FEED_ADDRESS,
abi: CHAINLINK_ABI,
functionName: 'decimals',
})
])
const [roundId, answer, , updatedAt] = roundData
console.log(`Chainlink data received: Round ${roundId}, Price ${answer}`)
return {
roundId,
price: answer,
timestamp: updatedAt,
decimals,
}
} catch (error: any) {
console.error(`Failed to read from Chainlink: ${error.message}`)
throw error
}
}Step 3: Build the Snapshot Bot (The Hybrid App)
This is the core of our project. This script will:
Fetch the latest price from Chainlink (using our module).
Encode this data using our
priceFeedSchema.Publish the data to Somnia Data Streams.
src/scripts/snapshotBot.ts
import 'dotenv/config'
import { somniaSdk } from '../lib/clients'
import { priceFeedSchema } from '../lib/schema'
import { fetchLatestPrice } from '../lib/chainlinkReader'
import { SchemaEncoder, zeroBytes32 } from '@somnia-chain/streams'
import { toHex, Hex } from 'viem'
import { waitForTransactionReceipt } from 'viem/actions'
const PAIR_NAME = "ETH/USD"
async function main() {
console.log('--- Starting Snapshot Bot ---')
// 1. Initialize SDK and Encoder
const sdk = somniaSdk
const encoder = new SchemaEncoder(priceFeedSchema)
const publisherAddress = sdk.wallet.account?.address
if (!publisherAddress) throw new Error('Wallet client not initialized.')
// 2. Compute Schema ID
const schemaId = await sdk.streams.computeSchemaId(priceFeedSchema)
if (!schemaId) throw new Error('Could not compute schemaId')
console.log('Registering priceFeedSchema (if not already registered)...')
// UPDATED: Using schemaName and ignoreRegisteredSchemas flag
const ignoreAlreadyRegisteredSchemas = true
const regTx = await sdk.streams.registerDataSchemas([
{
schemaName: 'price-feed-v1',
schema: priceFeedSchema,
parentSchemaId: zeroBytes32
}
], ignoreAlreadyRegisteredSchemas)
// regTx will be null if the schema was already registered.
if (regTx) {
console.log('Schema registration transaction sent:', regTx)
await waitForTransactionReceipt(sdk.public, { hash: regTx })
console.log('Schema registered successfully!')
} else {
console.log('Schema was already registered. No transaction sent.')
}
// 3. Fetch data from Chainlink
const priceData = await fetchLatestPrice()
// 4. Encode data for Somnia Streams
const encodedData: Hex = encoder.encodeData([
{ name: 'timestamp', value: priceData.timestamp.toString(), type: 'uint64' },
{ name: 'price', value: priceData.price.toString(), type: 'int256' },
{ name: 'roundId', value: priceData.roundId.toString(), type: 'uint80' },
{ name: 'pair', value: PAIR_NAME, type: 'string' },
])
// 5. Create a unique Data ID (using the roundId to prevent duplicates)
const dataId = toHex(`price-${PAIR_NAME}-${priceData.roundId}`, { size: 32 })
// 6. Publish to Somnia Data Streams
console.log(`Publishing price data to Somnia Streams...`)
const txHash = await sdk.streams.set([
{ id: dataId, schemaId, data: encodedData }
])
if (!txHash) throw new Error('Failed to publish to Streams')
await waitForTransactionReceipt(sdk.public, { hash: txHash })
console.log('\n--- Snapshot Complete! ---')
console.log(` Publisher: ${publisherAddress}`)
console.log(` Schema ID: ${schemaId}`)
console.log(` Data ID: ${dataId}`)
console.log(` Tx Hash: ${txHash}`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})To run your bot:
Add a script to package.json: "snapshot": "ts-node src/scripts/snapshotBot.ts"
Run it: npm run snapshot
You can run this script multiple times. It will only add new data if Chainlink's roundId has changed.
Step 4: Read Your Historical Price Feed
Now for the payoff. Let's create a script that reads our new on-chain history from Somnia Streams.
src/scripts/readHistory.ts
import 'dotenv/config'
import { somniaSdk } from '../lib/clients'
import { priceFeedSchema } from '../lib/schema'
import { SchemaDecodedItem } from '@somnia-chain/streams'
// Helper to decode the SDK's output
interface PriceRecord {
timestamp: number
price: bigint
roundId: bigint
pair: string
}
function decodePriceRecord(row: SchemaDecodedItem[]): PriceRecord {
const val = (field: any) => field?.value?.value ?? field?.value ?? ''
return {
timestamp: Number(val(row[0])),
price: BigInt(val(row[1])),
roundId: BigInt(val(row[2])),
pair: String(val(row[3])),
}
}
async function main() {
console.log('--- Reading Historical Price Feed from Somnia Streams ---')
const sdk = somniaSdk
// Use the *publisher address* from your .env file
const publisherAddress = sdk.wallet.account?.address
if (!publisherAddress) throw new Error('Wallet client not initialized.')
const schemaId = await sdk.streams.computeSchemaId(priceFeedSchema)
if (!schemaId) throw new Error('Could not compute schemaId')
console.log(`Reading all data for publisher: ${publisherAddress}`)
console.log(`Schema: ${schemaId}\n`)
// Fetch all data for this schema and publisher
const data = await sdk.streams.getAllPublisherDataForSchema(schemaId, publisherAddress)
if (!data || data.length === 0) {
console.log('No price history found. Run the snapshot bot first.')
return
}
const records = (data as SchemaDecodedItem[][]).map(decodePriceRecord)
// Sort by timestamp
records.sort((a, b) => a.timestamp - b.timestamp)
console.log(`Found ${records.length} historical price points:\n`)
records.forEach(record => {
// We assume the decimals are 8 for this ETH/USD feed
const priceFloat = Number(record.price) / 10**8
console.log(
`[${new Date(record.timestamp * 1000).toISOString()}] ${record.pair} - $${priceFloat.toFixed(2)} (Round: ${record.roundId})`
)
})
}
main().catch((e) => {
console.error(e)
process.exit(1)
})To read the history:
Add to package.json: "history": "ts-node src/scripts/readHistory.ts"
Run it: npm run history
Expected Output:
--- Reading Historical Price Feed from Somnia Streams ---
...
Found 3 historical price points:
[2025-11-06T14:30:00.000Z] ETH/USD - $3344.50 (Round: 110...)
[2025-11-06T14:35:00.000Z] ETH/USD - $3362.12 (Round: 111...)
[2025-11-06T14:40:00.000Z] ETH/USD - $3343.90 (Round: 112...)Conclusion: Key Takeaways
You have successfully built a hybrid, cross-chain application.
You combined an external "truth source" (Chainlink) with Somnia's composable storage layer (Somnia Data Streams).
You created a new, valuable, on-chain data product: a historical, queryable price feed that any dApp on Somnia can now read from and trust.
You demonstrated the power of the
publisheraddress as a verifiable source. Any dApp can now consume your feed, knowing it was published by your trusted bot.
This pattern can be extended to any external data source: weather, sports results, IoT data, and more. You can run the snapshotBot.ts script as a cron job or serverless function to create a truly autonomous, on-chain oracle.
Last updated