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

  1. A New Schema: A priceFeedSchema to store price data.

  2. A Chainlink Reader: A script using viem to read the latestRoundData from Chainlink's ETH/USD feed on the Sepolia testnet.

  3. A Snapshot Bot: A script that reads from Chainlink (Sepolia) and writes to Somnia Data Streams (Somnia Testnet).

  4. 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, and dotenv installed.

  • 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_KEY

Project 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-node

1. 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 = sepoliaBase

2. 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: The updatedAt time from Chainlink.

  • price: The answer (e.g., ETH price).

  • roundId: The Chainlink round ID, to prevent duplicates.

  • pair: A string to identify the feed (e.g., "ETH/USD").

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:

  1. Fetch the latest price from Chainlink (using our module).

  2. Encode this data using our priceFeedSchema.

  3. 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 publisher address 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