Somnia 백서

Somnia

원문

bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 54쪽 중 16쪽

ipt 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", "aggregate": "ts-node src/scripts/aggregateData.ts" } ``` And run it: ```bash npm run aggregate ``` ## Conclusion In this tutorial, you learned how to manage a multi-publisher architecture with Somnia Data Streams. * **Publisher Side:** The logic remained unchanged. Each publisher independently published its data using its wallet and the `sdk.streams.set()` method. * **Aggregator Side:** This is where the main logic came in. 1. We maintained a list of publishers we were interested in. 2. We fetched the data for each publisher separately using the `getAllPublisherDataForSchema` method. 3. We combined the incoming data into a single array (`allRecords.push(...)`). 4. Finally, we sorted all the data on the client-side to display them in a meaningful order (e.g., by timestamp). This pattern can be scaled to support any number of publishers and provides a robust foundation for building decentralized, multi-source applications. # The DApp Publisher Proxy Pattern In the "[Working with Multiple Publishers](https://www.google.com/search?q=httpsa://emre-gitbook.gitbook.io/emre-gitbook-docs/data-streams/working-with-multiple-publishers-in-a-shared-stream)" tutorial, you learned the standard pattern for building an aggregator: 1. Maintain a list of all known publisher addresses. 2. Loop through this list. 3. Call `sdk.streams.getAllPublisherDataForSchema()` for each address. 4. Merge and sort the results on the client side. This pattern is simple and effective for a known, manageable number of publishers (e.g., 50 IoT sensors from a single company). **But what happens at a massive scale?** ## The Problem: The 10,000-Publisher Scenario Imagine you are building a popular on-chain game. You have a `leaderboardSchema` and 10,000 players actively publishing their scores. If you use the standard aggregator pattern, your "global leaderboard" DApp would need to: 1. Somehow find all 10,000 player addresses. 2. Perform **10,000 separate read calls** (`getAllPublisherDataForSchema`) to the Somnia RPC node. This is not scalable, fast, or efficient. It creates an enormous (and slow) data-fetching burden on your application. ## The Solution: The DApp Publisher Proxy This is an advanced architecture that inverts the model to solve the read-scalability problem. Instead of having 10,000 publishers write to Streams *directly*, they all write to **your DApp's smart contract**, which then publishes to Streams on their behalf. **The Flow:** 1. **User (Publisher):** Calls a function on your DApp's contract (e.g., `myGame.submitScore(100)`). The `msg.sender` is the user's address. 2. **DApp Contract (The Proxy):** Internally, your `submitScore` function: * Adds the user's address (`msg.sender`) *into the data payload* to preserve provenance. * Calls `somniaStreams.esstores(...)` using its *own* contract address. 3. **Somnia Data Streams:** Records the data. To the Streams contract, the **only publisher** is your DApp Contract's address. The Result: Your global leaderboard aggregator now only needs to make one single read call to fetch all 10,000 players' data: `sdk.streams.getAllPublisherDataForSchema(schemaId, YOUR_DAPP_CONTRACT_ADDRESS)` This is massively scalable and efficient for read-heavy applications. ## Tutorial: Building a `GameLeaderboard` Proxy Let's build a conceptual example of this pattern. ## What You'll Build 1. **A new Schema** that *includes* the original publisher's address. 2. **A `GameLeaderboard.sol`** smart contract that acts as the proxy. 3. **A Client Script** that writes to the *proxy contract* instead of Streams. 4. **A new Aggregator** that reads from the *proxy contract's* address. ## The Schema (Solving for Provenance) Since the `msg.sender` to the Streams contract will always be our *proxy contract*, we lose the built-in provenance. We must re-create it by adding the original player's address to the schema itself. **`src/lib/schema.ts`** ```typescript // Schema: 'uint64 timestamp, address player, uint256 score' export const leaderboardSchema = 'uint64 timestamp, address player, uint256 score' ``` ## The Proxy Smart Contract (Solidity) This is a new smart contract you would write and deploy for your DApp. It acts as the gatekeeper. {% hint style="info" %} **SDK set() vs. Contract esstores()**\ This example uses the low-level contract function esstores().\ When you use sdk.streams.set() in your client-side code, the SDK is calling the esstores() function on the Somnia Streams contract "under the hood."\ This proxy contract is simply calling that same function directly. {% endhint %} **`src/contracts/GameLeaderboard.sol`** ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // A simplified interface for the Somnia Streams contract interface IStreams { struct DataStream { bytes32 id; bytes32 schemaId; bytes data; } // This is the correct low-level function name function esstores(DataStream[] calldata streams) external; } /** * @title GameLeaderboard * This contract is a DApp Publisher Proxy. * Users call submitScore() here. * This contract then calls somniaStreams.esstores() as a single publisher. */ contract GameLeaderboard { IStreams public immutable somniaStreams; bytes32 public immutable leaderboardSchemaId; event ScoreSubmitted(address indexed player, uint256 score); /** * @param _streamsAddress The deployed address of the Somnia Streams contract * (e.g., 0x6AB397FF662e42312c003175DCD76EfF69D048Fc on Somnia Testnet). * @param _schemaId The pre-computed schemaId for 'uint64 timestamp, address player, uint256 score'. */ constructor(address _streamsAddress, bytes32 _schemaId) { somniaStreams = IStreams(_streamsAddress); leaderboardSchemaId = _schemaId; } /** * @notice Players call this function to submit their score. * @param score The player's score. */ function submitScore(uint256 score) external { // 1. Get the original publisher's address address player = msg.sender; uint64 timestamp = uint64(block.timestamp); // 2. Encode the data payload to match the schema // Schema: 'uint64 timestamp, address player, uint256 score' bytes memory data = abi.encode(timestamp, player, score); // 3. Create a unique dataId (e.g., hash of player and time) bytes32 dataId = keccak256(abi.encodePacked(player, timestamp)); // 4. Prepare the DataStream struct IStreams.DataStream[] memory d = new IStreams.DataStream[](1); d[0] = IStreams.DataStream({ id: dataId, schemaId: leaderboardSchemaId, data: data }); // 5. Call Somnia Streams. The `msg.sender` for this call // is THIS contract (GameLeaderboard). somniaStreams.esstores(d); // 6. Emit a DApp-specific event for good measure emit ScoreSubmitted(player, score); } } ``` ## The Client Script (Publishing to the Proxy) The client-side logic changes. The user no longer needs the Streams SDK to publish, but rather a way to call your DApp's `submitScore` function. **`src/scripts/publishScore.ts`** ```typescript import 'dotenv/config' import { createWalletClient, http, createPublicClient, parseAbi } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { somniaTestnet } from '../lib/chain' // From previous tutorials import { waitForTransactionReceipt } from 'viem/actions' // --- DApp Contract Setup --- // This is the address you get after deploying GameLeaderboard.sol const DAPP_CONTRACT_ADDRESS = '0x...' // Your deployed GameLeaderboard contract address // A minimal ABI for our GameLeaderboard contract const DAPP_ABI = parseAbi([ 'function submitScore(uint256 score) external', ]) // --- --- --- function getEnv(key: string): string { const value = process.env[key] if (!value) throw new Error(`Missing environment variable: ${key}`) return value } // We can use any publisher wallet const walletClient = createWalletClient({ account: privateKeyToAccount(getEnv('PUBLISHER_1_PK') as `0x${string}`), chain: somniaTestnet, transport: http(getEnv('RPC_URL')), }) const publicClient = createPublicClient({ chain: somniaTestnet, transport: http(getEnv('RPC_URL')), }) async function main() { const newScore = Math.floor(Math.random() * 10000) console.log(`Player ${walletClient.account.address} submitting score: ${newScore}...`) try { const { request } = await publicClient.simulateContract({ account: walletClient.account, address: DAPP_CONTRACT_ADDRESS, abi: DAPP_ABI, functionName: 'submitScore', args: [BigInt(newScore)], }) const txHash = await walletClient.writeContract(request) console.log(`Transaction sent, hash: ${txHash}`) await waitForTransactionReceipt(publicClient, { hash: txHash }) console.log('Score submitted successfully!') } catch (e: any) { console.error(`Failed to submit score: ${e.message}`) } } main().catch(console.error) ``` ## The Aggregator Script (Simple, Scalable Reads) This is the pay-off. The aggregator script is now *dramatically* simpler and more scalable. It only needs to know the single DApp contract address. **`src/scripts/readLeaderboard.ts`** ```typescript import 'dotenv/config' import { SDK, SchemaDecodedItem } from '@somnia-chain/streams' import { createPublicClient, http } from 'viem' import { somniaTestnet } from '../lib/chain' import { leaderboardSchema } from '../libL/schema' // Our new schema // --- DApp Contract Setup --- const DAPP_CONTRACT_ADDRESS = '0x...' // Your deployed GameLeaderboard contract address // --- --- --- function getEnv(key: string): string { const value = process.env[key] if (!value) throw new Error(`Missing environment variable: ${key}`) return value } const publicClient = createPublicClient({ chain: somniaTestnet, transport: http(getEnv('RPC_URL')), }) // Helper to decode the leaderboard data interface ScoreRecord { timestamp: number player: `0x${string}` score: bigint } function decodeScoreRecord(row: SchemaDecodedItem[]): ScoreRecord { const val = (field: any) => field?.value?.value ?? field?.value ?? '' return { timestamp: Number(val(row[0])), player: val(row[1]) as `0x${string}`, score: BigInt(val(row[2])), } } async function main() { // The aggregator only needs a public client const sdk = new SDK({ public: publicClient }) const schemaId = await sdk.streams.computeSchemaId(leaderboardSchema) if (!schemaId) throw new Error('Could not compute schemaId') console.log('--- Global Leaderboard Aggregator ---') console.log(`Reading all data from proxy: ${DAPP_CONTRACT_ADDRESS}\n`) // 1. Make ONE call to get all data for the DApp const data = await sdk.streams.getAllPublisherDataForSchema( schemaId, DAPP_CONTRACT_ADDRESS ) if (!data || data.length === 0) { console.log('No scores found.') return } // 2. Decode and sort the records const allScores = (data as SchemaDecodedItem[][]).map(decodeScoreRecord) allScores.sort((a, b) => (b.score > a.score ? 1 : -1)) // Sort descending by score // 3. Display the leaderboard console.log(`Total scores found: ${allScores.length}\n`) allScores.forEach((record, index) => { console.log( `#${index + 1}: Player ${record.player} - Score: ${record.score} (at ${new Date(record.timestamp).toISOString()})` ) }) } main().catch(console.error) ``` ## Trade-Offs & Considerations This pattern is powerful, but it's important to understand the trade-offs. | **Feature** | **Standard Pattern (Multi-Publisher)** | **Proxy Pattern (Single Publisher)** | | ---------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Read Scalability** | **Low.** Requires N read calls (N = # of publishers). | **High.** Requires 1 read call, regardless of publisher count. | | **Publisher Gas Cost** | **Low.** 1 transaction (`streams.set`). | **High.** 1 transaction + 1 internal transaction. User pays more gas. | | **Provenance** | **Automatic & Implicit.** `msg.sender` is the user. | **Manual.** Must be built into the schema (`address player`). | | **Complexity** | **Simple.** Requires only the SDK. | **Complex.** Requires writing, deploying, and maintaining a custom smart contract. | #### Conclusion The **DApp Publisher Proxy** is an advanced but essential pattern for any Somnia Data Streams application that needs to scale to thousands or millions of publishers (e.t., games, social media, large IoT networks). It simplifies the data aggregation logic from **`N+1`** read calls down to **`1`**, at the cost of higher gas fees for publishers and increased development complexity. For most DApps, we recommend starting with the simpler "Multi-Publisher Aggregator" pattern. When your application's read performance becomes a bottleneck due to a high number of publishers, you can evolve to this proxy pattern to achieve massive read scalability. # Build a Minimal On-Chain Chat App In this Tutorial, you’ll build a tiny chat app where messages are published on-chain using the Somnia Data Streams SDK and then read back using a Subscriber pattern (fixed schema ID and publisher). The User Interface updates with simple polling and does not rely on WebSocket. We will build a Next.js project using app router and Typescript, and create a simple chat schema for messages. Using Somnia Data Streams, we will create a publisher API that writes to the Somnia chain and create a Subscriber API that reads from the Somnia chain by `schemaId` and publisher. The User Interface will poll new messages every few seconds. ## Prerequisites * Node.js 20+ * A funded Somnia Testnet wallet. Kindly get some from the [Faucet](https://testnet.somnia.network/) * Basic familiarity with TypeScript and Next.js ## Project Setup Create the app by creating a directory where the app will live ```bash npx create-next-app@latest somnia-chat --ts --app --no-tailwind cd somnia-chat ``` Install the [Somnia Streams](https://www.npmjs.com/package/@somnia-chain/streams) and ViemJS dependencies ```bash npm i @somnia-chain/streams viem ``` Somnia Data Streams is a Typescript SDK with methods that power off-chain reactivity. The application requires the ViemJS `provider` and `wallet` methods to enable queries over `https` embedded in the Somnia Data Streams SDK. `viem` is a web3 library for the JS/TS ecosystem that simplifies reading and writing data from the Somnia chain. Importantly, it detaches wallets (and sensitive info) from the SDS SDK\ Set up the TypeScript environment by running the command: ```bash npm i -D @types/node ``` Next.js provides a simple, full-stack environment. ## Configure environment variables Create `.env.local` file. ```markup RPC_URL=https://dream-rpc.somnia.network PRIVATE_KEY=0xYOUR_FUNDED_PRIVATE_KEY CHAT_PUBLISHER=0xYOUR_WALLET_ADDRESS ``` The `RPC_URL` establishes the connection to Somnia Testnet, and the `PRIVATE_KEY` is used to sign and publish transactions. Note that it is kept server-side only. `CHAT_PUBLISHER` define what the Subscriber reads. {% hint style="danger" %} Never expose PRIVATE\_KEY to the browser. Keep all publishing code in API routes or server code only.\ \ NOTE: You can connect a Privy Wallet (or equivalent) to the SDK, avoiding the need entirely for private keys {% endhint %} ## Chain Configuration Create a `lib` folder and define the Somnia Testnet chain. This tells `viem` which chain we’re on, so clients know the RPC and formatting rules. `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'], webSocket: ['wss://dream-rpc.somnia.network/ws'] }, public: { http: ['https://dream-rpc.somnia.network'], webSocket: ['wss://dream-rpc.somnia.network/ws'] }, }, } as const) ``` ## SDK Clients Create public and wallet clients. Public client read-only RPC calls, and the Wallet client publishes transactions signed with your server wallet. We intentionally don’t set up WebSocket clients since we’re using polling for the User Interface. Create a file `clients` `src/lib/clients.ts` ```typescript import { createPublicClient, createWalletClient, http } from 'viem' import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts' import { somniaTestnet } from './chain' export function getPublicHttpClient() { return createPublicClient({ chain: somniaTestnet, transport: http(RPC_URL), }) } export function getWalletClient() { return createWalletClient({ account: privateKeyToAccount(need('PRIVATE_KEY') as `0x${string}`), chain: somniaTestnet, transport: http(RPC_URL), }) } export const publisherAddress = () => getAccount().address ``` ## Schema Chat schema is the structure of each message. This ordered list of typed fields defines how messages are encoded/decoded on-chain. Create a file `chatSchema` `src/lib/chatSchema.ts` ```typescript export const chatSchema = 'uint64 timestamp, bytes32 roomId, string content, string senderName, address sender' ``` ## Chat Service We’ll build `chatService.ts` in small pieces so it’s easy to follow. ### Imports and helpers ```typescript import { SDK, SchemaEncoder, zeroBytes32 } from '@somnia-chain/streams' import { getPublicHttpClient, getWalletClient, publisherAddress } from './clients' import { waitForTransactionReceipt } from 'viem/actions' import { toHex, type Hex } from 'viem' import { chatSchema } from './chatSchema' const encoder = new SchemaEncoder(chatSchema) const sdk = new SDK({ public: getPublicHttpClient(), wallet: getWalletClient(), }) ``` `SchemaEncoder` handles encoding/decoding for the exact schema string. `getSdk(true)` attaches the wallet client for publishing; read-only otherwise. `assertHex` ensures transaction hashes are hex strings. ### Ensure the schema is registered ```typescript // Register schema const schemaId = await sdk.streams.computeSchemaId(chatSchema) const isRegistered = await sdk.streams.isDataSchemaRegistered(schemaId) if (!isRegistered) { const ignoreAlreadyRegistered = true const txHash = await sdk.streams.registerDataSchemas( [{ schemaName: 'chat', schema: chatSchema, parentSchemaId: zeroBytes32 }], ignoreAlreadyRegistered ) if (!txHash) throw new Error('Failed to register schema') await waitForTransactionReceipt(getPublicHttpClient(), { hash: txHash }) } ``` If this schema wasn’t registered yet, we register it once. It’s safe to call this before sending the first message. ### Publish a message ```typescript const now = Date.now().toString() const roomId = toHex(room, { size: 32 }) const data: Hex = encoder.encodeData([ { name: 'timestamp', value: now, type: 'uint64' }, { name: 'roomId', value: roomId, type: 'bytes32' }, { name: 'content', value: content, type: 'string' }, { name: 'senderName', value: senderName, type: 'string' }, { name: 'sender', value: getWalletClient().account.address, type: 'address' }, ]) const dataId = toHex(`${room}-${now}`, { size: 32 }) const tx = await sdk.streams.set([{ id: dataId, schemaId, data }]) if (!tx) throw new Error('Failed to publish chat message') await waitForTransactionReceipt(getPublicHttpClient(), { hash: tx }) return { txHash: tx } ``` * We encode fields in the exact order specified in the schema. * `setAndEmitEvents` writes the encoded payload. The `sendMessage` function publishes a structured chat message to **Somnia Data Streams** while simultaneously emitting an event that can be captured in real time by subscribers. It creates a schema encoder for the chat message structure, encodes the message data, and prepares event topics for the `ChatMessage` event. Then, with a single `setAndEmitEvents()` transaction, it both stores the message and emits an event on-chain. Once the transaction is confirmed, the function r
프로젝트로 돌아가기