Integrate Chainlink Oracles
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-nodeChain 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 = sepoliaBaseClient 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')),
})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").
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
}
}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 and Register (idempotent)
const schemaId = await sdk.streams.computeSchemaId(priceFeedSchema)
if (!schemaId) throw new Error('Could not compute schemaId')
const ignoreAlreadyRegisteredSchemas = true
const regTx = await sdk.streams.registerDataSchemas([
{ id: 'price-feed-v1', schema: priceFeedSchema }
], ignoreAlreadyRegisteredSchemas)
if (!regTx) throw new Error('Failed to register schema')
await waitForTransactionReceipt(sdk.public, { hash: regTx })
// 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.
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