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

  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

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

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')),
})

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
  }
}

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