From 50304ab2afdbbe2bde619edb3377cfd94c4dbcfe Mon Sep 17 00:00:00 2001 From: Andrei Eres Date: Fri, 27 Mar 2026 17:35:05 +0100 Subject: [PATCH 1/3] Add statement-store example --- examples/statement-chat/.gitignore | 4 + examples/statement-chat/README.md | 44 +++++ examples/statement-chat/dev.sh | 50 +++++ examples/statement-chat/index.html | 78 ++++++++ examples/statement-chat/main.js | 278 +++++++++++++++++++++++++++ examples/statement-chat/package.json | 16 ++ examples/statement-chat/server.js | 63 ++++++ examples/statement-chat/statement.js | 215 +++++++++++++++++++++ 8 files changed, 748 insertions(+) create mode 100644 examples/statement-chat/.gitignore create mode 100644 examples/statement-chat/README.md create mode 100755 examples/statement-chat/dev.sh create mode 100644 examples/statement-chat/index.html create mode 100644 examples/statement-chat/main.js create mode 100644 examples/statement-chat/package.json create mode 100644 examples/statement-chat/server.js create mode 100644 examples/statement-chat/statement.js diff --git a/examples/statement-chat/.gitignore b/examples/statement-chat/.gitignore new file mode 100644 index 0000000000..14c261afea --- /dev/null +++ b/examples/statement-chat/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +package-lock.json +public/ diff --git a/examples/statement-chat/README.md b/examples/statement-chat/README.md new file mode 100644 index 0000000000..07b2e79a3e --- /dev/null +++ b/examples/statement-chat/README.md @@ -0,0 +1,44 @@ +# Statement Chat + +A simple chat application demonstrating the statement distribution protocol via smoldot light client. + +## Prerequisites + +1. A running local relay chain with at least one parachain +2. Chain specs for both the relay chain and parachain + +## Quick Start + +If you have a running local network with polkadot-omni-node or polkadot-parachain: + +```bash +./dev.sh +``` + +The script automatically extracts chain specs, builds smoldot, and starts the dev server at http://localhost:5173. + +## Manual Setup + +1. Start a local network +2. Extract chain specs from running nodes + - `public/chain-specs/relay.json` + - `public/chain-specs/parachain.json` +3. Run the dev server `npm run dev` + + +## Usage + +1. Open http://localhost:5173 in your browser +2. Wait for smoldot to connect to both chains +3. Enter a topic (32-byte hex) or use the default +4. Click "Subscribe" +5. Send messages - they'll be distributed to all peers on the same topic + +## How It Works + +The app uses smoldot's statement store protocol: +- `statement_subscribeStatement` - Subscribe to topics on a chain +- `statement_submit` - Broadcast signed statements to the network +- `statement_statement` (notification) - Receive statements from other peers + +Messages are signed with Ed25519 keys (stored in localStorage) and distributed peer-to-peer without central servers. diff --git a/examples/statement-chat/dev.sh b/examples/statement-chat/dev.sh new file mode 100755 index 0000000000..bb7f64b39d --- /dev/null +++ b/examples/statement-chat/dev.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")" + +echo "Extracting chain specs from running node..." +OMNI_CMD=$(ps aux | awk '/polkadot-(parachain|omni-node)/ && /--bootnodes/ && !/awk/ {print; exit}') + +if [ -z "$OMNI_CMD" ]; then + echo "Error: No running polkadot-parachain with --bootnodes found" + exit 1 +fi + +PARACHAIN_SPEC=$(echo "$OMNI_CMD" | sed 's/ -- .*//' | sed -n 's/.*--chain \([^ ]*\).*/\1/p; q') +RELAY_SPEC=$(echo "$OMNI_CMD" | sed 's/.* -- //' | sed -n 's/.*--chain \([^ ]*\).*/\1/p; q') +BOOTNODE=$(echo "$OMNI_CMD" | sed 's/ -- .*//' | sed -n 's/.*--bootnodes \([^ ]*\).*/\1/p; q') +RPC_PORT=$(echo "$OMNI_CMD" | sed -n 's/.*--rpc-port \([^ ]*\).*/\1/p; q') +RPC_PORT=${RPC_PORT:-9944} + +echo "Parachain spec: $PARACHAIN_SPEC" +echo "Relay chain spec: $RELAY_SPEC" +echo "Bootnode: $BOOTNODE" +echo "RPC port: $RPC_PORT" + +mkdir -p public/chain-specs + +cp "$RELAY_SPEC" public/chain-specs/relay.json +echo "Copied relay chain spec to public/chain-specs/relay.json" + +jq ".id = \"parachain\" | .bootNodes = [\"$BOOTNODE\"]" "$PARACHAIN_SPEC" > public/chain-specs/parachain.json +echo "Copied parachain spec to public/chain-specs/parachain.json (id changed to 'parachain', bootnode added)" + +echo "" +echo "Building smoldot..." +cd ../../wasm-node/javascript +npm run build + +echo "" +echo "Installing dependencies with npm..." +npm install + +echo "" +echo "Starting dev server with npm..." +cd ../../examples/statement-chat +echo "Installing statement-chat dependencies with npm..." +npm install + +pkill -f "server.js" 2>/dev/null || true +sleep 1 +npm run dev diff --git a/examples/statement-chat/index.html b/examples/statement-chat/index.html new file mode 100644 index 0000000000..2951bb5304 --- /dev/null +++ b/examples/statement-chat/index.html @@ -0,0 +1,78 @@ + + + + + + + Statement Chat + + + + +

Statement Chat

+ +
+
+ + +
+
Initializing...
+
+ +
+
+
No messages yet. Subscribe to a topic and start chatting!
+
+
+
+ + +
+
+
+ +
+ + + + + diff --git a/examples/statement-chat/main.js b/examples/statement-chat/main.js new file mode 100644 index 0000000000..e35c35df9c --- /dev/null +++ b/examples/statement-chat/main.js @@ -0,0 +1,278 @@ +import { start } from "smoldot"; +import { + createSignedStatement, + decodeStatement, + getPublicKey, + toHex, +} from "./statement.js"; + +const LOG = { + ERROR: 1, + WARN: 2, + INFO: 3, + DEBUG: 4, + TRACE: 5, +}; + +const LOG_LABEL = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "DEBUG", + 5: "TRACE", +}; + +const BASE_LOG_LEVEL = LOG.INFO; +const TARGET = "app"; + +const statusEl = document.getElementById("status"); +const topicInput = document.getElementById("topicInput"); +const subscribeBtn = document.getElementById("subscribeBtn"); +const messagesEl = document.getElementById("messages"); +const messageInput = document.getElementById("messageInput"); +const sendBtn = document.getElementById("sendBtn"); +const logEl = document.getElementById("log"); + +let chain = null; +let currentTopic = null; +let requestId = 1; +let subscriptionId = null; +let myPublicKey = null; +const pendingRequests = new Map(); + +function log(level, target, message) { + if (level > BASE_LOG_LEVEL) return; + + const label = LOG_LABEL[level] || "UNKNOWN"; + const entry = document.createElement("small"); + entry.style.display = "block"; + entry.textContent = `${label} [${target}] ${message}`; + logEl.appendChild(entry); + logEl.scrollTop = logEl.scrollHeight; + console.log(`${label} [${target}] ${message}`); +} + +function addMessage(content, type) { + const placeholder = document.getElementById("no-messages"); + if (placeholder) placeholder.remove(); + + const msg = document.createElement("div"); + msg.className = "section"; + const time = new Date().toLocaleTimeString(); + const label = type === "sent" ? "You" : "Received"; + + const small = document.createElement("small"); + small.style.display = "block"; + small.textContent = `${label} - ${time}`; + + msg.appendChild(small); + msg.appendChild(document.createTextNode(content)); + + messagesEl.appendChild(msg); + messagesEl.scrollTop = messagesEl.scrollHeight; +} + +async function initialize() { + try { + log(LOG.INFO, TARGET, "Starting smoldot for statement store..."); + statusEl.textContent = "Starting smoldot..."; + + const smoldot = start({ + maxLogLevel: BASE_LOG_LEVEL, + logCallback: log, + }); + + log(LOG.DEBUG, TARGET, "Loading chain specs..."); + const relayChainSpec = await loadChainSpec("/chain-specs/relay.json"); + const parachainSpec = await loadChainSpec("/chain-specs/parachain.json"); + + log(LOG.DEBUG, TARGET, "Adding relay chain..."); + const relayChain = await smoldot.addChain({ chainSpec: relayChainSpec }); + + log(LOG.DEBUG, TARGET, "Adding parachain..."); + chain = await smoldot.addChain({ + chainSpec: parachainSpec, + potentialRelayChains: [relayChain], + statementStore: {}, + }); + + log(LOG.DEBUG, TARGET, "Setting up JSON-RPC handler..."); + setupJsonRpcHandler(chain); + + statusEl.textContent = "Connected to parachain"; + + myPublicKey = await getPublicKey(); + log(LOG.INFO, TARGET, `Statement signing key: ${myPublicKey}`); + + subscribeBtn.disabled = false; + topicInput.value = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + } catch (error) { + log(LOG.ERROR, TARGET, `Failed to initialize: ${error.message}`); + statusEl.textContent = `Error: ${error.message}`; + console.error(error); + } +} + +async function loadChainSpec(path) { + const response = await fetch(path); + if (!response.ok) { + throw new Error( + `Failed to load chain spec from ${path}: ${response.statusText}`, + ); + } + return await response.text(); +} + +async function subscribeToTopic() { + const topic = topicInput.value.trim(); + + if (!topicInput.checkValidity()) { + log(LOG.WARN, TARGET, "Invalid topic format. Must be 0x followed by 64 hex characters."); + return; + } + + try { + if (subscriptionId) { + try { + await sendJsonRpc("statement_unsubscribeStatement", [subscriptionId]); + } catch (e) { + log(LOG.WARN, TARGET, `Failed to unsubscribe from previous topic: ${e.message}`); + } + } + + log(LOG.DEBUG, TARGET, `Subscribing to topic: ${topic}`); + subscriptionId = await sendJsonRpc("statement_subscribeStatement", [ + { matchAny: [topic] }, + ]); + log(LOG.DEBUG, TARGET, `Subscription ID: ${subscriptionId}`); + + currentTopic = topic; + log(LOG.INFO, TARGET, "Subscribed successfully!"); + statusEl.textContent = `Subscribed to: ${topic}`; + + messageInput.disabled = false; + sendBtn.disabled = false; + subscribeBtn.textContent = "Change"; + } catch (error) { + log(LOG.ERROR, TARGET, `Failed to subscribe: ${error.message}`); + console.error(error); + } +} + +async function sendStatement() { + const message = messageInput.value.trim(); + if (!message || !currentTopic) return; + + try { + const statementHex = await createSignedStatement(currentTopic, message); + log(LOG.DEBUG, TARGET, `Sending signed statement: ${message}`); + + const result = await sendJsonRpc("statement_submit", [statementHex]); + + if (result?.status === "new") { + messageInput.value = ""; + addMessage(message, "sent"); + log(LOG.INFO, TARGET, "Statement broadcast to peers"); + } else if (result?.status === "known") { + messageInput.value = ""; + log(LOG.INFO, TARGET, "Statement already known"); + } else if (result?.status === "invalid") { + log(LOG.ERROR, TARGET, `Invalid statement: ${result.reason}`); + } else if (result?.status === "internalError") { + log(LOG.ERROR, TARGET, `Failed to send: ${result.error}`); + } else { + messageInput.value = ""; + log(LOG.DEBUG, TARGET, `Statement submit result: ${JSON.stringify(result)}`); + } + } catch (error) { + log(LOG.ERROR, TARGET, `Failed to send statement: ${error.message}`); + console.error(error); + } +} + +function handleStatementNotification(statementHex) { + try { + const decoded = decodeStatement(statementHex); + if (decoded.data) { + const signerHex = decoded.proof?.signer + ? toHex(decoded.proof.signer) + : null; + const isOurs = signerHex === myPublicKey; + addMessage(decoded.data, isOurs ? "sent" : "received"); + } + } catch (e) { + log(LOG.ERROR, TARGET, `Failed to decode statement: ${e.message}`); + } +} + +async function sendJsonRpc(method, params) { + const id = requestId++; + const request = JSON.stringify({ + jsonrpc: "2.0", + id: id.toString(), + method, + params, + }); + + const promise = new Promise((resolve, reject) => { + pendingRequests.set(id.toString(), { resolve, reject }); + }); + + chain.sendJsonRpc(request); + return promise; +} + +function setupJsonRpcHandler(chainInstance) { + chain = chainInstance; + + (async () => { + while (true) { + const response = await chain.nextJsonRpcResponse(); + try { + const parsed = JSON.parse(response); + + if (parsed.method === "statement_statement" && parsed.params?.result) { + const event = parsed.params.result; + if (event.event === "newStatements" && event.data?.statements) { + for (const stmt of event.data.statements) { + handleStatementNotification(stmt); + } + } + } else if (parsed.id) { + const pending = pendingRequests.get(parsed.id); + if (pending) { + pendingRequests.delete(parsed.id); + if (parsed.error) { + pending.reject( + new Error(`JSON-RPC error: ${parsed.error.message}`), + ); + } else { + pending.resolve(parsed.result); + } + } else { + log(LOG.WARN, TARGET, `Unexpected response for unknown request ID: ${parsed.id}`); + } + } + } catch (e) { + log(LOG.ERROR, TARGET, `Failed to handle JSON-RPC response: ${e.message}`); + console.error("JSON-RPC handler error:", e); + } + } + })(); +} + +subscribeBtn.addEventListener("click", subscribeToTopic); +sendBtn.addEventListener("click", sendStatement); +messageInput.addEventListener("keypress", (e) => { + if (e.key === "Enter" && !sendBtn.disabled) { + sendStatement(); + } +}); +topicInput.addEventListener("keypress", (e) => { + if (e.key === "Enter" && !subscribeBtn.disabled) { + subscribeToTopic(); + } +}); + +initialize(); diff --git a/examples/statement-chat/package.json b/examples/statement-chat/package.json new file mode 100644 index 0000000000..3558c8f41f --- /dev/null +++ b/examples/statement-chat/package.json @@ -0,0 +1,16 @@ +{ + "name": "statement-chat", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "esbuild main.js --bundle --outdir=dist --splitting --format=esm", + "dev": "npm run build && node server.js" + }, + "dependencies": { + "@noble/ed25519": "^3.0.0", + "smoldot": "file:../../wasm-node/javascript" + }, + "devDependencies": { + "esbuild": "^0.27.3" + } +} diff --git a/examples/statement-chat/server.js b/examples/statement-chat/server.js new file mode 100644 index 0000000000..b7938e863e --- /dev/null +++ b/examples/statement-chat/server.js @@ -0,0 +1,63 @@ +import { createServer } from 'node:http'; +import { readFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { constants } from 'node:fs'; + +const PORT = process.env.PORT ? parseInt(process.env.PORT) : 5173; + +const mimeTypes = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm', +}; + +async function fileExists(path) { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://localhost:${PORT}`); + let pathname = url.pathname === '/' ? '/index.html' : url.pathname; + + const currentPath = join(process.cwd(), pathname); + const publicPath = join(process.cwd(), 'public', pathname); + + let filePath = (await fileExists(currentPath)) ? currentPath : publicPath; + + if (!(await fileExists(filePath))) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + + try { + const content = await readFile(filePath); + const ext = pathname.substring(pathname.lastIndexOf('.')); + const contentType = mimeTypes[ext] || 'application/octet-stream'; + + res.writeHead(200, { + 'Content-Type': contentType, + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }); + res.end(content); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + } +}); + +server.listen(PORT, () => { + console.log(`Server: http://localhost:${PORT}/`); +}); diff --git a/examples/statement-chat/statement.js b/examples/statement-chat/statement.js new file mode 100644 index 0000000000..35d90a3bc4 --- /dev/null +++ b/examples/statement-chat/statement.js @@ -0,0 +1,215 @@ +import * as ed from "@noble/ed25519"; + +export function toHex(bytes) { + return ( + "0x" + + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + ); +} + +function encodeCompact(value) { + if (value < 0x40) { + return new Uint8Array([value << 2]); + } else if (value < 0x4000) { + const v = (value << 2) | 0x01; + return new Uint8Array([v & 0xff, v >> 8]); + } else if (value < 0x40000000) { + const v = (value << 2) | 0x02; + return new Uint8Array([ + v & 0xff, + (v >> 8) & 0xff, + (v >> 16) & 0xff, + v >> 24, + ]); + } else { + throw new Error("Value too large for compact encoding"); + } +} + +function concat(...arrays) { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +function hexToTopic(hex) { + if (hex.startsWith("0x")) hex = hex.slice(2); + if (hex.length !== 64) { + throw new Error(`Topic must be 32 bytes (64 hex chars), got ${hex.length}`); + } + return new Uint8Array(hex.match(/.{2}/g).map((b) => parseInt(b, 16))); +} + +function encodeEd25519Proof(signature, publicKey) { + return concat(new Uint8Array([1]), signature, publicKey); +} + +function buildSignatureMaterial(topic, data) { + const parts = []; + + parts.push(new Uint8Array([2])); + parts.push(new Uint8Array(new BigUint64Array([0xffffffffffffffffn]).buffer)); + + parts.push(new Uint8Array([4])); + parts.push(hexToTopic(topic)); + + const dataBytes = new TextEncoder().encode(data); + parts.push(new Uint8Array([8])); + parts.push(encodeCompact(dataBytes.length)); + parts.push(dataBytes); + + return concat(...parts); +} + +function encodeStatement(proof, topic, data) { + const parts = []; + + parts.push(encodeCompact(4)); + + parts.push(new Uint8Array([0])); + parts.push(proof); + + parts.push(new Uint8Array([2])); + parts.push(new Uint8Array(new BigUint64Array([0xffffffffffffffffn]).buffer)); + + parts.push(new Uint8Array([4])); + parts.push(hexToTopic(topic)); + + const dataBytes = new TextEncoder().encode(data); + parts.push(new Uint8Array([8])); + parts.push(encodeCompact(dataBytes.length)); + parts.push(dataBytes); + + return concat(...parts); +} + +async function getOrCreateKeypair() { + const stored = localStorage.getItem("statement-chat-keypair"); + if (stored) { + const { privateKey, publicKey } = JSON.parse(stored); + return { + privateKey: new Uint8Array(privateKey), + publicKey: new Uint8Array(publicKey), + }; + } + + const privateKey = ed.utils.randomSecretKey(); + const publicKey = await ed.getPublicKeyAsync(privateKey); + + localStorage.setItem( + "statement-chat-keypair", + JSON.stringify({ + privateKey: Array.from(privateKey), + publicKey: Array.from(publicKey), + }), + ); + + return { privateKey, publicKey }; +} + +export async function createSignedStatement(topic, data) { + const { privateKey, publicKey } = await getOrCreateKeypair(); + const signatureMaterial = buildSignatureMaterial(topic, data); + const signature = await ed.signAsync(signatureMaterial, privateKey); + const proof = encodeEd25519Proof(signature, publicKey); + const statement = encodeStatement(proof, topic, data); + return toHex(statement); +} + +export async function getPublicKey() { + const { publicKey } = await getOrCreateKeypair(); + return toHex(publicKey); +} + +export function decodeStatement(hexData) { + if (hexData.startsWith("0x")) hexData = hexData.slice(2); + const bytes = new Uint8Array( + hexData.match(/.{2}/g).map((b) => parseInt(b, 16)), + ); + + let offset = 0; + const [numFields, fieldCountSize] = readCompact(bytes, offset); + offset += fieldCountSize; + + let proof = null; + let topic = null; + let data = null; + + for (let i = 0; i < numFields; i++) { + const discriminant = bytes[offset++]; + + switch (discriminant) { + case 0: + const proofType = bytes[offset++]; + if (proofType === 1) { + const signature = bytes.slice(offset, offset + 64); + offset += 64; + const signer = bytes.slice(offset, offset + 32); + offset += 32; + proof = { type: "Ed25519", signature, signer }; + } else { + throw new Error(`Unsupported proof type: ${proofType}`); + } + break; + case 1: + offset += 32; + break; + case 2: + offset += 8; + break; + case 3: + offset += 32; + break; + case 4: + case 5: + case 6: + case 7: + topic = bytes.slice(offset, offset + 32); + offset += 32; + break; + case 8: + const [dataLen, dataLenSize] = readCompact(bytes, offset); + offset += dataLenSize; + data = bytes.slice(offset, offset + dataLen); + offset += dataLen; + break; + default: + throw new Error(`Unknown field discriminant: ${discriminant}`); + } + } + + return { + proof, + topic: topic ? toHex(topic) : null, + data: data ? new TextDecoder().decode(data) : null, + }; +} + +function readCompact(bytes, offset) { + const mode = bytes[offset] & 0x03; + if (mode === 0) { + return [bytes[offset] >> 2, 1]; + } else if (mode === 1) { + const value = (bytes[offset] | (bytes[offset + 1] << 8)) >> 2; + return [value, 2]; + } else if (mode === 2) { + const value = + (bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24)) >> + 2; + return [value, 4]; + } else { + throw new Error( + `Big integer compact encoding not supported (mode: ${mode}, offset: ${offset})`, + ); + } +} From 1ac35d57a08611f22809c6e077aa53792e99d816 Mon Sep 17 00:00:00 2001 From: Andrei Eres Date: Thu, 2 Apr 2026 14:13:28 +0200 Subject: [PATCH 2/3] Fix running script --- examples/statement-chat/dev.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/statement-chat/dev.sh b/examples/statement-chat/dev.sh index bb7f64b39d..9960c09b3f 100755 --- a/examples/statement-chat/dev.sh +++ b/examples/statement-chat/dev.sh @@ -33,11 +33,8 @@ echo "Copied parachain spec to public/chain-specs/parachain.json (id changed to echo "" echo "Building smoldot..." cd ../../wasm-node/javascript -npm run build - -echo "" -echo "Installing dependencies with npm..." npm install +npm run build echo "" echo "Starting dev server with npm..." From b5f5ed639bd2fd6557e932a0d78f3febd75d5940 Mon Sep 17 00:00:00 2001 From: Andrei Eres Date: Thu, 2 Apr 2026 14:13:54 +0200 Subject: [PATCH 3/3] Add falsePositiveRate for Statement Protocol V2 --- examples/statement-chat/main.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/statement-chat/main.js b/examples/statement-chat/main.js index e35c35df9c..2677e9cc49 100644 --- a/examples/statement-chat/main.js +++ b/examples/statement-chat/main.js @@ -93,7 +93,9 @@ async function initialize() { chain = await smoldot.addChain({ chainSpec: parachainSpec, potentialRelayChains: [relayChain], - statementStore: {}, + statementStore: { + falsePositiveRate: 0.01, + }, }); log(LOG.DEBUG, TARGET, "Setting up JSON-RPC handler...");