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프로젝트로 돌아가기