bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 54쪽 중 15쪽
: 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`**
```typescript
// 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").
## 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`**
```typescript
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`**
<pre class="language-typescript"><code class="lang-typescript">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
<strong> const regTx = await sdk.streams.registerDataSchemas([
</strong> { 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)
})
</code></pre>
**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`**
```typescript
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:**
```bash
--- 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.
# Working with Multiple Publishers in a Shared Stream
The core architecture of Somnia Data Streams decouples data schemas from publishers. This allows multiple different accounts (or devices) to publish data using the **same schema**. The data conforms to the same data structure, regardless of who published it.
This model is perfect for multi-source data scenarios, such as:
* **Multi-User Chat:** Multiple users sending messages under the same `chatMessage` schema.
* **IoT (Internet of Things):** Hundreds of sensors submitting data under the same `telemetry` schema.
* **Gaming:** All players in a game publishing their positions and scores under the same `playerUpdate` schema.
In this tutorial, we will demonstrate how to build an "aggregator" application that collects and merges data from two different "devices" (two separate wallet accounts) publishing to the same "telemetry" schema.
## Prerequisites
* Node.js 20+
* `@somnia-chain/streams` and `viem` libraries installed
* An `RPC_URL` for access to the Somnia Testnet
* **Two (2)** funded Somnia Testnet wallets for publishing data.
## What You’ll Build
In this tutorial, we will build two main components:
1. A **Publisher Script** that simulates two different wallets sending data to the same telemetry schema.
2. An **Aggregator Script** that fetches *all* data from a specified list of publishers, merges them into a single list, and sorts them by timestamp.
## Project Setup
Create a new directory for your application and install the necessary packages.
```bash
mkdir somnia-aggregator
cd somnia-aggregator
npm init -y
npm i @somnia-chain/streams viem dotenv
npm i -D @types/node typescript ts-node
```
Create a `tsconfig.json` file in your project root:
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
```
## Configure Environment Variables
Create a `.env` file in your project root. For this tutorial, we will need **two** different private keys.
```bash
# .env
RPC_URL=https://dream-rpc.somnia.network/
# Simulates two different devices/publishers
PUBLISHER_1_PK=0xPUBLISHER_ONE_PRIVATE_KEY
PUBLISHER_2_PK=0xPUBLISHER_TWO_PRIVATE_KEY
```
{% hint style="info" %}
**IMPORTANT:** Never expose private keys in client-side (browser) code or public repositories. The scripts in this tutorial are intended to be run server-side.
{% endhint %}
## Chain and Client Configuration
Create a folder named `src/lib` and set up your `chain.ts` and `clients.ts` files.
**`src/lib/chain.ts`**
```typescript
import { defineChain } from 'viem'
export const somniaTestnet = defineChain({
id: 50312,
name: 'Somnia Testnet',
network: 'somnia-testnet',
nativeCurrency: { name: 'STT', symbol: 'STT', decimals: 18 },
rpcUrls: {
default: { http: ['[https://dream-rpc.somnia.network]'] },
public: { http: ['[https://dream-rpc.somnia.network]'] },
},
} as const)
```
**`src/lib/clients.ts`**
```typescript
import 'dotenv/config'
import { createPublicClient, createWalletClient, http, PublicClient } from 'viem'
import { privateKeyToAccount, PrivateKeyAccount } from 'viem/accounts'
import { somniaTestnet } from './chain'
function getEnv(key: string): string {
const value = process.env[key]
if (!value) {
throw new Error(`Missing environment variable: ${key}`)
}
return value
}
// A single Public Client for read operations
export const publicClient: PublicClient = createPublicClient({
chain: somniaTestnet,
transport: http(getEnv('RPC_URL')),
})
// Two different Wallet Clients for simulation
export const walletClient1 = createWalletClient({
account: privateKeyToAccount(getEnv('PUBLISHER_1_PK') as `0x${string}`),
chain: somniaTestnet,
transport: http(getEnv('RPC_URL')),
})
export const walletClient2 = createWalletClient({
account: privateKeyToAccount(getEnv('PUBLISHER_2_PK') as `0x${string}`),
chain: somniaTestnet,
transport: http(getEnv('RPC_URL')),
})
```
## Define the Shared Schema
Let's define the common schema that all publishers will use.
**`src/lib/schema.ts`**
```typescript
// This schema will be used by multiple devices
export const telemetrySchema =
'uint64 timestamp, string deviceId, int32 x, int32 y, uint32 speed'
```
## Create the Publisher Script
Now, let's create a script that simulates how two different publishers will send data to this schema. This script will take which publisher to use as a command-line argument.
**`src/scripts/publishData.ts`**
```typescript
import 'dotenv/config'
import { SDK, SchemaEncoder, zeroBytes32 } from '@somnia-chain/streams'
import { publicClient, walletClient1, walletClient2 } from '../lib/clients'
import { telemetrySchema } from '../lib/schema'
import { toHex, Hex, WalletClient } from 'viem'
import { waitForTransactionReceipt } from 'viem/actions'
// Select which publisher to use
async function getPublisher(): Promise<{ client: WalletClient, deviceId: string }> {
const arg = process.argv[2] // 'p1' or 'p2'
if (arg === 'p2') {
console.log('Using Publisher 2 (Device B)')
return { client: walletClient2, deviceId: 'device-b-002' }
}
console.log('Using Publisher 1 (Device A)')
return { client: walletClient1, deviceId: 'device-a-001' }
}
// Helper function to encode the data
function encodeTelemetry(encoder: SchemaEncoder, deviceId: string): Hex {
const now = Date.now().toString()
return encoder.encodeData([
{ name: "timestamp", value: now, type: "uint64" },
{ name: "deviceId", value: deviceId, type: "string" },
{ name: "x", value: Math.floor(Math.random() * 1000).toString(), type: "int32" },
{ name: "y", value: Math.floor(Math.random() * 1000).toString(), type: "int32" },
{ name: "speed", value: Math.floor(Math.random() * 120).toString(), type: "uint32" },
])
}
async function main() {
const { client, deviceId } = await getPublisher()
const publisherAddress = client.account.address
console.log(`Publisher Address: ${publisherAddress}`)
const sdk = new SDK({ public: publicClient, wallet: client })
const encoder = new SchemaEncoder(telemetrySchema)
// 1. Compute the Schema ID
const schemaId = await sdk.streams.computeSchemaId(telemetrySchema)
if (!schemaId) throw new Error('Could not compute schemaId')
console.log(`Schema ID: ${schemaId}`)
// 2. Register Schema (Updated for new API)
console.log('Registering schema (if not already registered)...')
const ignoreAlreadyRegisteredSchemas = true
const regTx = await sdk.streams.registerDataSchemas([
{
schemaName: 'telemetry', // Updated: 'id' is now 'schemaName'
schema: telemetrySchema,
parentSchemaId: zeroBytes32
}
], ignoreAlreadyRegisteredSchemas)
if (regTx) {
console.log('Schema registration transaction sent:', regTx)
await waitForTransactionReceipt(publicClient, { hash: regTx })
console.log('Schema registered successfully!')
} else {
console.log('Schema was already registered. No transaction sent.')
}
// 3. Encode the data
const encodedData = encodeTelemetry(encoder, deviceId)
// 4. Publish the data
// We make the dataId unique with a timestamp and device ID
const dataId = toHex(`${deviceId}-${Date.now()}`, { size: 32 })
const txHash = await sdk.streams.set([
{ id: dataId, schemaId, data: encodedData }
])
if (!txHash) throw new Error('Failed to publish data')
console.log(`Publishing data... Tx: ${txHash}`)
await waitForTransactionReceipt(publicClient, { hash: txHash })
console.log('Data published successfully!')
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
```
**To run this script:**
Add the following scripts to your `package.json` file:
```json
"scripts": {
"publish:p1": "ts-node src/scripts/publishData.ts p1",
"publish:p2": "ts-node src/scripts/publishData.ts p2"
}
```
Now, open two different terminals and send data from each:
```bash
# Terminal 1
npm run publish:p1
# Terminal 2
npm run publish:p2
```
Repeat this a few times to build up a dataset from both publishers.
## Create the Aggregator Script
Now that we have published our data, we can write the "aggregator" script that collects, merges, and sorts all data from these two (or more) publishers.
**`src/scripts/aggregateData.ts`**
```typescript
import 'dotenv/config'
import { SDK, SchemaDecodedItem } from '@somnia-chain/streams'
import { publicClient, walletClient1, walletClient2 } from '../lib/clients'
import { telemetrySchema } from '../lib/schema'
import { Address } from 'viem'
// LIST OF PUBLISHERS TO TRACK
// You could also fetch this list dynamically (e.g., from a contract or database).
const TRACKED_PUBLISHERS: Address[] = [
walletClient1.account.address,
walletClient2.account.address,
]
// Helper function to convert SDK data into a cleaner object
// (Similar to the 'val' function in the Minimal On-Chain Chat App Tutorial)
function decodeTelemetryRecord(row: SchemaDecodedItem[]): TelemetryRecord {
const val = (field: any) => field?.value?.value ?? field?.value ?? ''
return {
timestamp: Number(val(row[0])),
deviceId: String(val(row[1])),
x: Number(val(row[2])),
y: Number(val(row[3])),
speed: Number(val(row[4])),
}
}
// Type definition for our data
interface TelemetryRecord {
timestamp: number
deviceId: string
x: number
y: number
speed: number
publisher?: Address // We will add this field later
}
async function main() {
// The aggregator doesn't need to write data, so it only uses the publicClient
const sdk = new SDK({ public: publicClient })
const schemaId = await sdk.streams.computeSchemaId(telemetrySchema)
if (!schemaId) throw new Error('Could not compute schemaId')
console.log(`Aggregator started. Tracking ${TRACKED_PUBLISHERS.length} publishers...`)
console.log(`Schema ID: ${schemaId}\n`)
const allRecords: TelemetryRecord[] = []
// 1. Loop through each publisher
for (const publisherAddress of TRACKED_PUBLISHERS) {
console.log(`--- Fetching data for ${publisherAddress} ---`)
// 2. Fetch all data for the publisher based on the schema
// Note: The SDK automatically decodes the data if the schema is registered
const data = await sdk.streams.getAllPublisherDataForSchema(schemaId, publisherAddress)
if (!data || data.length === 0) {
console.log('No data found for this publisher.\n')
continue
}
// 3. Transform the data and add the 'publisher' field
const records: TelemetryRecord[] = (data as SchemaDecodedItem[][]).map(row => ({
...decodeTelemetryRecord(row),
publisher: publisherAddress // To know where the data came from
}))
console.log(`Found ${records.length} records.`)
// 4. Add all records to the main list
allRecords.push(...records)
}
// 5. Sort all data by timestamp
console.log('\n--- Aggregation Complete ---')
console.log(`Total records fetched: ${allRecords.length}`)
allRecords.sort((a, b) => a.timestamp - b.timestamp)
// 6. Display the result
console.log('\n--- Combined and Sorted Telemetry Log ---')
allRecords.forEach(record => {
console.log(
`[${new Date(record.timestamp).toISOString()}] [${record.publisher}] - Device: ${record.deviceId}, Speed: ${record.speed}`
)
})
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
```
**To run this script:**
Add the scr프로젝트로 돌아가기