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..9960c09b3f
--- /dev/null
+++ b/examples/statement-chat/dev.sh
@@ -0,0 +1,47 @@
+#!/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 install
+npm run build
+
+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
+
+
+
+
+ Subscribe
+
+
Initializing...
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/statement-chat/main.js b/examples/statement-chat/main.js
new file mode 100644
index 0000000000..2677e9cc49
--- /dev/null
+++ b/examples/statement-chat/main.js
@@ -0,0 +1,280 @@
+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: {
+ falsePositiveRate: 0.01,
+ },
+ });
+
+ 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})`,
+ );
+ }
+}