Somnia 백서

Somnia

원문

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

eturns the transaction hash, confirming the message was successfully written to the network. Complete code below: <details> <summary>chatService.ts</summary> ```typescript // src/lib/chatService.ts import { SDK, SchemaEncoder, zeroBytes32 } from '@somnia-chain/streams' import { getPublicHttpClient, getWalletClient } from './clients' import { waitForTransactionReceipt } from 'viem/actions' import { toHex, type Hex } from 'viem' import { chatSchema } from './chatSchema' const encoder = new SchemaEncoder(chatSchema) export async function sendMessage(room: string, content: string, senderName: string) { const sdk = new SDK({ public: getPublicHttpClient(), wallet: getWalletClient(), }) // Compute or 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( [{ id: 'chat', schema: chatSchema, parentSchemaId: zeroBytes32 }], ignoreAlreadyRegistered ) if (!txHash) throw new Error('Failed to register schema') await waitForTransactionReceipt(getPublicHttpClient(), { hash: txHash }) } 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 } } ``` </details> ### Read Messages Create a `chatMessages.ts` file to write the script for reading messages. * `getAllPublisherDataForSchema` reads all publisher data for your (schemaId, publisher). The `fetchChatMessages` function connects to the **Somnia Data Streams SDK** using a public client and derives the schema ID from the local chat schema. It then retrieves all data entries published by the specified wallet for that schema, decodes them into readable message objects, and filters them by room if a name is provided. Each message is timestamped, ordered chronologically, and limited to a given count before being returned. The result is a clean, decoded list of on-chain messages. Complete code below: <details> <summary>chatMessages.ts</summary> ```typescript 'use client' import { useEffect, useState, useCallback, useRef } from 'react' import { SDK } from '@somnia-chain/streams' import { getPublicHttpClient } from './clients' import { chatSchema } from './chatSchema' import { toHex, type Hex } from 'viem' // Helper to unwrap field values const val = (f: any) => f?.value?.value ?? f?.value // Message type export type ChatMsg = { timestamp: number roomId: `0x${string}` content: string senderName: string sender: `0x${string}` } /** * Fetch chat messages from Somnia Streams (read-only, auto-refresh, cumulative) */ export function useChatMessages( roomName?: string, limit = 100, refreshMs = 5000 ) { const [messages, setMessages] = useState<ChatMsg[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null) const timerRef = useRef<NodeJS.Timeout | null>(null) const loadMessages = useCallback(async () => { try { const sdk = new SDK({ public: getPublicHttpClient() }) // Compute schema ID from the chat schema const schemaId = await sdk.streams.computeSchemaId(chatSchema) const publisher = process.env.NEXT_PUBLIC_PUBLISHER_ADDRESS ?? '0x0000000000000000000000000000000000000000' // Fetch all publisher data for schema const resp = await sdk.streams.getAllPublisherDataForSchema(schemaId, publisher) // Ensure array structure (each row corresponds to an array of fields) const rows: any[][] = Array.isArray(resp) ? (resp as any[][]) : [] if (!rows.length) { setMessages([]) setLoading(false) return } // Convert room name to bytes32 for filtering (if applicable) const want = roomName ? toHex(roomName, { size: 32 }).toLowerCase() : null const parsed: ChatMsg[] = [] for (const row of rows) { if (!Array.isArray(row) || row.length < 5) continue const ts = Number(val(row[0])) const ms = String(ts).length <= 10 ? ts * 1000 : ts // handle seconds vs ms const rid = String(val(row[1])) as `0x${string}` // Skip messages from other rooms if filtered if (want && rid.toLowerCase() !== want) continue parsed.push({ timestamp: ms, roomId: rid, content: String(val(row[2]) ?? ''), senderName: String(val(row[3]) ?? ''), sender: (String(val(row[4])) as `0x${string}`) ?? '0x0000000000000000000000000000000000000000', }) } // Sort by timestamp (ascending) parsed.sort((a, b) => a.timestamp - b.timestamp) // Deduplicate and limit setMessages((prev) => { const combined = [...prev, ...parsed] const unique = combined.filter( (msg, index, self) => index === self.findIndex( (m) => m.timestamp === msg.timestamp && m.sender === msg.sender && m.content === msg.content ) ) return unique.slice(-limit) }) setError(null) } catch (err: any) { console.error('❌ Failed to load chat messages:', err) setError(err.message || 'Failed to load messages') } finally { setLoading(false) } }, [roomName, limit]) // Initial load + polling useEffect(() => { setLoading(true) loadMessages() timerRef.current = setInterval(loadMessages, refreshMs) return () => timerRef.current && clearInterval(timerRef.current) }, [loadMessages, refreshMs]) return { messages, loading, error, reload: loadMessages } } ``` </details> ## API Routes To parse the data to the NextJS UI, we will create API route files that will enable us to call `sendMessage` and `fetchChatMessages` functions ### Write Messages Endpoint `src/app/api/send/route.ts` ```typescript import { NextResponse } from 'next/server' import { sendMessage } from '@/lib/chatService' export async function POST(req: Request) { try { const { room, content, senderName } = await req.json() if (!room || !content) throw new Error('Missing fields') const { txHash } = await sendMessage(room, content, senderName) return NextResponse.json({ success: true, txHash }) } catch (e: any) { console.error(e) return NextResponse.json({ error: e.message || 'Failed to send' }, { status: 500 }) } } ``` Publishes messages with the server wallet. We validate the input and return the tx hash. ## Frontend Update `src/app/page.tsx` Connect the Somnia Data Streams logic to a simple frontend. Fetch messages stored on-chain, display them in real time, and send new ones. #### Component Setup ```tsx use client' import { useState } from 'react' import { useChatMessages } from '@/lib/chatMessages' export default function Page() { const [room, setRoom] = useState('general') const [content, setContent] = useState('') const [senderName, setSenderName] = useState('Victory') const [error, setError] = useState<string | null>(null) const { messages, loading, error: fetchError, reload, } = useChatMessages(room, 200) // --- Send new message via API route --- async function send() { try { if (!content.trim()) { setError('Message content cannot be empty') return } const res = await fetch('/api/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, content, senderName }), }) const data = await res.json() if (!res.ok) throw new Error(data?.error || 'Failed to send message') setContent('') setError(null) reload() // refresh after sending } catch (e: any) { console.error('❌ Send message failed:', e) setError(e?.message || 'Failed to send message') } } ``` * `room`, `content`, and `senderName` store user input. * `useChatMessages(room, 200)` reads the latest 200 messages from Somnia Data Streams using a **read-only SDK instance**.\ The hook automatically polls for new messages every few seconds. * The `send()` function publishes a new message by calling the `/api/send` endpoint, which writes on-chain using `sdk.streams.setAndEmitEvents()`.\ After a message is successfully sent, the input clears and `reload()` is called to refresh the messages list. The hook handles loading and error states internally, while the component keeps a separate `error` state for send failures. #### UI Rendering ```tsx return ( <main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}> <h1>💬 Somnia Streams Chat (read-only)</h1> <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <input value={room} onChange={(e) => setRoom(e.target.value)} placeholder="room" /> <input value={senderName} onChange={(e) => setSenderName(e.target.value)} placeholder="name" /> <button onClick={reload} disabled={loading}> Refresh </button> </div> <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}> <input style={{ flex: 1 }} value={content} onChange={(e) => setContent(e.target.value)} placeholder="Type a message" /> <button onClick={send}>Send</button> </div> {(error || fetchError) && ( <div style={{ color: 'crimson', marginBottom: 12 }}> Error: {error || fetchError} </div> )} {loading ? ( <p>Loading messages...</p> ) : !messages.length ? ( <p>No messages yet.</p> ) : ( <ul style={{ paddingLeft: 16 }}> {messages.map((m, i) => ( <li key={i}> <small>{new Date(m.timestamp).toLocaleTimeString()} </small> <b>{m.senderName || m.sender}</b>: {m.content} </li> ))} </ul> )} </main> ) } ``` * The top input fields let users change the chat room or display name, and manually refresh messages if needed. * The second input and **Send** button allow posting new messages. * Error messages appear in red if either sending or fetching fails. * Below, the app dynamically renders one of three states: * “Loading messages…” while fetching. * “No messages yet.” if the room is empty. * A chat list showing messages with timestamps, names, and content. Each message represents **on-chain data** fetched via Somnia Data Streams, fully verified, timestamped, and appended as part of the schema structure. We clear the input and trigger a delayed refresh so the message appears soon after mining. #### How It Works This component bridges the Somnia SDK’s on-chain capabilities with React’s reactive rendering model.\ Whenever the user sends a message, the SDK publishes it to Somnia Data Streams via the backend `/api/send` route.\ Meanwhile, `useChatMessages` polls the blockchain for updates, decoding structured data stored by the same schema.\ As a result, each message displayed in the chat window is **a verifiable blockchain record**, yet the experience feels as fluid and fast as a typical Web2 chat. ```markup +-----------+ +--------------------+ +---------------------+ | User UI | ---> | Next.js API /send | ---> | Somnia Data Streams | +-----------+ +--------------------+ +---------------------+ ^ | | | v | | +------------------+ | | | Blockchain | | | | (Transaction) | | | +------------------+ | | | | |<----------------------| | | Poll via SDK or Subscribe (useChatMessages) | +---------------------------------------------------+ ``` Complete code below: <details> <summary>page.tsx</summary> ```typescript 'use client' import { useState } from 'react' import { useChatMessages } from '@/lib/chatMessages' export default function Page() { const [room, setRoom] = useState('general') const [content, setContent] = useState('') const [senderName, setSenderName] = useState('Victory') const [error, setError] = useState<string | null>(null) const { messages, loading, error: fetchError, reload, } = useChatMessages(room, 200) // --- Send new message via API route --- async function send() { try { if (!content.trim()) { setError('Message content cannot be empty') return } const res = await fetch('/api/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, content, senderName }), }) const data = await res.json() if (!res.ok) throw new Error(data?.error || 'Failed to send message') setContent('') setError(null) reload() // refresh after sending } catch (e: any) { console.error('❌ Send message failed:', e) setError(e?.message || 'Failed to send message') } } // --- Render UI --- return ( <main style={{ padding: 24, fontFamily: 'system-ui, sans-serif', maxWidth: 640, margin: '0 auto', }} > <h1>💬 Somnia Data Streams Chat</h1> <p style={{ color: '#666' }}> Messages are stored <b>onchain</b> and read using Somnia Data Streams. </p> {/* Room + Name inputs */} <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <input value={room} onChange={(e) => setRoom(e.target.value)} placeholder="room name" style={{ flex: 1, padding: 6 }} /> <input value={senderName} onChange={(e) => setSenderName(e.target.value)} placeholder="your name" style={{ flex: 1, padding: 6 }} /> <button onClick={reload} disabled={loading} style={{ background: '#0070f3', color: 'white', border: 'none', padding: '6px 12px', cursor: 'pointer', borderRadius: 4, }} > Refresh </button> </div> {/* Message input */} <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}> <input style={{ flex: 1, padding: 6 }} value={content} onChange={(e) => setContent(e.target.value)} placeholder="Type your message..." /> <button onClick={send} style={{ background: '#28a745', color: 'white', border: 'none', padding: '6px 12px', cursor: 'pointer', borderRadius: 4, }} > Send </button> </div> {/* Error messages */} {(error || fetchError) && ( <div style={{ color: 'crimson', marginBottom: 12 }}> Error: {error || fetchError} </div> )} {/* Message list */} {loading ? ( <p>Loading messages...</p> ) : !messages.length ? ( <p>No messages yet.</p> ) : ( <ul style={{ paddingLeft: 16, listStyle: 'none' }}> {messages.map((m, i) => ( <li key={i} style={{ marginBottom: 8 }}> <small style={{ color: '#666' }}> {new Date(m.timestamp).toLocaleTimeString()} </small>{' '} <b>{m.senderName || m.sender}</b>: {m.content} </li> ))} </ul> )} </main> ) } ``` </details> ## Run the App Run the program using the command: ```bash npm run dev ``` Your app will be LIVE at <http://localhost:3000> in your browser {% hint style="success" %} Tip Open two browser windows to simulate two users watching the same room. Both will see new messages as the poller fetches fresh data.<br> {% endhint %} ## Codebase <https://github.com/emmaodia/somnia-streams-chat-demo> # Build a Realtime On-Chain Game Build a Tap-to-Play Onchain Game This tutorial shows how to build a Tap-to-Play Onchain Game using Somnia Data Streams, where every player’s tap is written directly to the blockchain and the leaderboard updates in realtime. Each tap is stored onchain as a structured data record following a schema.\ The game uses MetaMask for wallet identity and Somnia Streams SDK to: * Store tap events onchain using `sdk.streams.set()` * Retrieve and rank all players from onchain data By the end of this guide, you’ll have:\ \- A working Next.js app\ \- Onchain data storage using Somnia Data Streams\ \- A live leaderboard that reads blockchain state\ \- MetaMask integration for identity and transaction signing *** ## 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 Initialize a new Next.js app and install dependencies. 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 ``` Create a .env.local file for storing secrets and environmental variables ```bash NEXT_PUBLIC_PUBLISHER_ADDRESS=0xb6e4fa6ff2873480590c68D9Aa991e5BB14Dbf03 NEXT_PUBLIC_RPC_URL=https://dream-rpc.somnia.network ``` {% 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 %} *** ## Define Tap Schema <a href="#chain-configuration" id="chain-configuration"></a> Create a file `lib/schema.ts`: ```typescript // lib/schema.ts export const tapSchema = 'uint64 timestamp, address player' ``` This schema defines the structure of each tap event: * `timestamp`: when the tap occurred * `player`: who tapped (wallet address) * A `nonce` will be added when the schema is deployed to ensures each record is unique The Schema ID will be automatically computed by the SDK from this schema. *** ## Setup Clients Create `lib/serverClient.ts` for server-side reads: ```typescript // lib/serverClient.ts import { createPublicClient, http } from 'viem' import { somniaTestnet } from 'viem/chains' export function getServerPublicClient() { return createPublicClient({ chain: somniaTestnet, transport: http(process.env.RPC_URL || 'https://dream-rpc.somnia.network'), }) } ``` Create `lib/clients.ts` for client-side access: ```typescript // lib/clients.ts 'use client' import { createPublicClient, http } from 'viem' import { somniaTestnet } from 'viem/chains' export function getPublicHttpClient() { return createPublicClient({ chain: somniaTestnet, transport: http(process.env.NEXT_PUBLIC_RPC_URL || 'https://dream-rpc.somnia.network'), }) } ``` *** ## Writing Tap Data Onchain Each tap is recorded onchain with the `sdk.streams.set()` method. In this section, we’ll walk through how each part of the `sendTap()` logic works from wallet connection to writing structured schema data onchain. *** ### **Set up state variables** We’ll start by tracking a number of states, such as: * the connected wallet address * the wallet client (MetaMask connection) * and a few helper states for loading, cooldowns, and errors. ```tsx const [address, setAddress] = useState('') const [walletClient, setWalletClient] = useState<any>(null) const [cooldownMs, setCooldownMs] = useState(0) const [pending, setPending] = useState(false) const [error, setError] = useState('') ``` These ensure that you can access the connected wallet address (`address`) and track transaction state (`pending`). It also prevents spam taps with a 1-second cooldown (`cooldownMs`) *** ### **Connect MetaMask** We use the browser’s `window.ethereum` API to connect to MetaMask.\ Once connected, we create a **wallet client** that Somnia’s SDK can use for signing transactions. ```tsx async function connectWallet() { if (typeof window !== "undefined" && window.ethereum !== undefined) try { await window.ethereum.request({ method: "eth_requestAccounts" }); const walletClient = createWalletClient({ chain: somniaDream, transport: custom(window.ethereum), }); const [account] = await walletClient.getAddresses(); setWalletClient(walletClient) setAddress(account) } catch (e: any) { setError(e?.message || String(e)) } setWalletClient(wallet) } ``` `createWalletClient` from Viem wraps MetaMask into a signer object that the Somnia SDK can use.\ This is how the UI and the blockchain are bridged securely. *** ### **Initialize the SDK** The **Somnia Data Streams SDK** provides methods to compute schema IDs, encode structured data, and publish to the blockchain. We initialize it with both the **public client** (for chain access) and the **wallet client** (for signing transactions). ```tsx const sdk = new SDK({ public: getPublicHttpClient(), wallet: walletClient, }) ``` This gives you full
프로젝트로 돌아가기