Skip to content

Publish and Subscribe to Off-Chain Data

Introduction

This guide covers the Statement Store, a pub/sub primitive on Polkadot's People Chain for real-time signaling between users of your Product. Statements are small, signed payloads that propagate peer-to-peer via the node's gossip layer without entering chain storage, making them the right tool for chat messages, presence indicators, multiplayer cursors, and typing indicators. Submissions are allowance-gated (no fees); reading is permissionless. The guide covers four steps: setting up the client, subscribing to incoming statements, publishing a typed statement, and using a channel for last-write-wins state.

Storage options for your Product

Match the data to the layer built for it; see Where to Store Data for the full decision guide.

  • Local storage: Per-Product, per-device key-value for preferences, drafts, and cached values. Not synced across devices. See Persist Data Locally.
  • Bulletin Chain: Content-addressed, on-chain, retained ~2 weeks by default and renewable. Fetched later by CID: profile photos, published articles, app bundles. See Store Data on Chain.
  • Statement Store: Gossip-distributed, short-lived (default 30s TTL), allowance-gated. Real-time signaling between users: chat, presence, typing indicators. See Publish and Subscribe to Off-Chain Data.

The Statement Store and Bulletin Chain compose well: Polkadot App's Chat uses the Statement Store for signaling (who's online, session handshakes) and the Bulletin Chain for the encrypted message content. Many Products follow the same split: Statement Store for ephemeral state, Bulletin Chain for content that needs to survive longer than a session.

Prerequisites

Before starting, ensure you have:

  • Completed Install Desktop and Pair so you have a paired Polkadot Desktop and a signer for product-scoped accounts
  • A Statement Store allowance for your account; the People Chain's pallet-statement-store gates submissions on a per-account allowance (max_count live statements and max_size total bytes)
  • A Polkadot Product project running locally (see Set Up Your Project if you don't have one yet)

Provisional: obtaining an allowance

The process for obtaining a Statement Store allowance on TestNet is not yet documented. Ask in the developer community for the current access paths.

Note

PAS funds are not required for the Statement Store itself; submissions don't pay transaction fees. The allowance is the gating mechanism; PAS is only relevant if your Product later interacts with chains that charge fees.

Install the SDK

This guide builds on createApp, which only the umbrella package provides, and on statement-store, which has no umbrella subpath, so install both:

npm install @parity/product-sdk @parity/product-sdk-statement-store

Versions the snippets target

The snippets target @parity/product-sdk v0.30.0 and @parity/product-sdk-statement-store v0.6.11; every SDK surface they use is present on that line. If you pin an earlier release, pass createApp a name (required before v0.30.0) and check the return types first: v0.18.0 moved StatementStoreClient.publish and ChannelStore.write from Promise<boolean> to a typed Result. A Result object is always truthy, so code written for the older shape keeps compiling and stops working.

See Umbrella or Individual Packages for how the install styles compare.

Set Up Your Statement Store Client

Every snippet in this guide is Product code: modules placed inside the Product running at localhost:3000 (per Set Up Your Project), loaded by Polkadot Desktop. Signing requests route through the Host (Polkadot Desktop) to the user's paired Polkadot App on their phone, which holds the signing keys; your Product never derives, sees, or holds keys.

StatementStoreClient is the high-level client. It wraps the People Chain node's statement_submit and statement_subscribeStatement JSON-RPC methods, handles JSON encoding of your payload, requests authentication proofs from the Host, and deduplicates incoming statements. Connect it once at the top of your Product:

Start with the shared app setup, which creates the SDK app and connects the wallet. It is the same file Store Data on Chain uses:

setup-app.ts
import { createApp } from '@parity/product-sdk';

// The Host supplies your Product's identity: `createApp` reads the product ID
// the Host loaded you under and derives the user's product account from it.
// If the Host declines to derive an account, `connect()` resolves with an empty
// list instead of failing, so check the list before you rely on it.
export const app = await createApp();

export const { accounts } = await app.wallet.connect();
if (accounts.length === 0) {
  throw new Error(
    'The Host returned no account for this Product. Check that you are signed in to Polkadot Desktop.',
  );
}

Then create the Statement Store client for the first connected account:

setup-statement-store.ts
// Place this in your Product, after the setup from `setup-app.ts`.
import { StatementStoreClient } from '@parity/product-sdk-statement-store';
import { accounts } from './setup-app';

// `appName` is the gossip topic this client subscribes to, not a dotNS name.
export const client = new StatementStoreClient({ appName: 'my-product' });
await client.connect({
  mode: 'host',
  accountId: [accounts[0].address, 42], // 42 = generic SS58 prefix
});

console.log('Statement Store connected as', accounts[0].address);

new StatementStoreClient({ appName }) creates the client. The appName is hashed with Blake2b-256 and used as the statement's primary topic; every submission your Product makes carries that topic, and client.subscribe() filters on it at the node boundary, so other instances of your Product see your statements while traffic from other Products on the network is dropped before it reaches your subscriber. client.connect({ mode: 'host', accountId }) wires the client to the Host API transport and delegates proof creation to the Host; Polkadot Desktop routes the proof request to the user's paired Polkadot App, which signs and returns the proof. The accountId is a tuple of [ss58Address, chainPrefix]; 42 is the generic Substrate SS58 prefix.

Delivery is best-effort

The Statement Store does not retry, acknowledge, or guarantee ordering; those are network-layer guarantees the gossip protocol doesn't provide. If your Product needs to know a statement reached a peer, the peer publishes an ack statement; reliability is composed at the application layer. If the content needs to outlive the TTL, store it on the Bulletin Chain and publish a CID as the statement payload.

Polkadot App's Chat is exactly this composition: Statement Store for signaling, Bulletin Chain for the encrypted message content.

Subscribe to Incoming Statements

client.subscribe<T>(callback, options?) registers a topic filter with the connected node. Internally, the SDK calls statement_subscribeStatement with a filter scoped to your Product's appName topic; the node returns the current statement pool matching that filter and streams any further submissions that match. The SDK decodes the JSON payload into your typed T and dedupes by channel and content hash before invoking your callback:

subscribe-statements.ts
// Place this in your Product, after the setup from `setup-statement-store.ts`.

import type { ReceivedStatement } from '@parity/product-sdk-statement-store';
import { client } from './setup-statement-store';

// JSON-serialized when published; decoded back to this type on receive.
interface ChatMessage {
  text: string;
  from: string;
  ts: number;
}

const subscription = client.subscribe<ChatMessage>(
  (statement: ReceivedStatement<ChatMessage>) => {
    console.log(
      `[${statement.signerHex?.slice(0, 10)}…] ${statement.data.from}: ${statement.data.text}`,
    );
  },
  { topic2: 'room-42' },
);

// subscription.unsubscribe() when you no longer want updates.

The optional topic2 is hashed with Blake2b-256 and added to the filter; scope to a room id, a document id, or any other secondary key within your Product. The returned Unsubscribable exposes unsubscribe() to tear down the JSON-RPC subscription when you no longer need it.

A subscription receives every statement on its topic, regardless of payload shape

The filter matches on topics, not on your TypeScript type. A subscriber on a given topic2 receives all statements published to that topic, including channel writes (the next section) and any other payload type your Product publishes there. The SDK decodes each one as your declared T, so a statement with a different shape arrives with undefined fields rather than an error.

If your Product publishes more than one kind of payload (for example, chat messages and presence updates), either give each kind its own topic2 (room-42-chat vs room-42-presence) or include a discriminator field in the payload and branch on it inside the callback. Reusing one topic2 for distinct shapes will cross-deliver them.

Publish a Typed Statement

client.publish<T>(data, options?) builds a Statement, JSON-encodes the payload, requests a proof through the Host (Polkadot Desktop forwards the request to the user's paired Polkadot App, which signs), and submits via the node's statement_submit JSON-RPC. Every instance of your Product subscribed to the matching topic will receive it as the gossip propagates:

publish-statement.ts
// Place this in your Product, after the setup from `setup-statement-store.ts`.

import { client } from './setup-statement-store';

interface ChatMessage {
  text: string;
  from: string;
  ts: number;
}

// `publish` resolves with a `Result`. A `Result` is always truthy, so check
// `.ok` rather than the returned object itself.
const accepted = await client.publish<ChatMessage>(
  {
    text: 'Hello, room!',
    from: 'alice',
    ts: Date.now(),
  },
  {
    topic2: 'room-42', // scope to a specific room, doc, or context
    ttlSeconds: 60, // override the default 30s TTL
  },
);

if (accepted.ok) {
  console.log('Statement accepted into the gossip layer');
} else {
  console.warn(`Statement rejected: ${accepted.error.message}`);
}

publish returns a Result. Check .ok rather than the returned object: a Result is always truthy, so if (accepted) passes for a rejected publish as readily as an accepted one. On failure, .error carries the reason — a StatementSubmitError when pallet-statement-store's validity check rejects it (typically allowance or proof failure), a StatementDataTooLargeError when the JSON-encoded payload exceeds the per-statement size limit of 512 bytes, and a StatementConnectionError when the client is not connected.

Two limits worth designing around:

  • 512-byte payload limit: Measured after JSON encoding. For larger content, store it on the Bulletin Chain and publish the CID as a small statement.
  • 30-second default TTL: The pallet enforces a maximum retention window. The SDK defaults to 30 seconds, and you can shorten or lengthen per-statement via ttlSeconds up to that cap. After expiry, the node evicts the statement from its pool.

PublishOptions covers the common cases: topic2 (secondary Blake2b-256 topic for room/doc scoping), channel (last-write-wins; see the next section), ttlSeconds (override the default), and decryptionKey (an opaque hint subscribers can match on to discover encrypted content).

Use a Channel for Last-Write-Wins State

pallet-statement-store supports an optional channel field on every statement: when a new submission carries the same channel as an existing live statement from the same account, the pallet replaces the older one in the node's pool. The gossip layer then propagates the replacement, and every subscribed peer drops the old statement in favor of the new one. The SDK abstracts this with ChannelStore<T>; each channel name maps to a single live value:

channel-presence.ts
// Place this in your Product, after the setup from `setup-statement-store.ts`.

import { ChannelStore } from '@parity/product-sdk-statement-store';
import { client } from './setup-statement-store';

interface Presence {
  status: 'online' | 'away' | 'offline';
  timestamp: number;
}

const channels = new ChannelStore<Presence>(client, { topic2: 'room-42' });

// `write` forwards to `publish`, so it resolves with a `Result` too.
const written = await channels.write('presence/alice', {
  status: 'online',
  timestamp: Date.now(),
});
if (!written.ok) {
  console.warn(`Channel write rejected: ${written.error.message}`);
}

// A second write on the same channel replaces the first.
await channels.write('presence/alice', {
  status: 'away',
  timestamp: Date.now(),
});

// `onChange` and `readAll` key channels by their hex hash, not by the readable
// name you wrote. Use `channels.read(name)` to look a channel up by name.
channels.onChange((channelHash, value, previous) => {
  console.log(
    `${channelHash}: ${previous?.status ?? '<none>'} → ${value.status}`,
  );
});

for (const [channelHash, value] of channels.readAll()) {
  console.log(`${channelHash}: ${value.status}`);
}

console.log(`alice is ${channels.read('presence/alice')?.status ?? 'unknown'}`);

channels.write(name, value) hashes the channel name with Blake2b-256 and publishes, forwarding publish's Result to you; channels.read(name) returns the latest value seen on that channel; channels.readAll() returns the full map, keyed by channel hash rather than by the readable name; channels.onChange(callback) fires on every transition, and passes that same hash to the callback. ChannelStore stamps timestamp for you if the value omits it. Channel scope is per-account; the pallet's replacement rule only matches statements from the same signer, so one user cannot overwrite another user's channel.

ChannelStore is the right primitive for soft state where only the latest version matters: presence indicators, multiplayer cursors, "now playing" status. For append-only events such as chat messages, action logs, social-feed posts, keep using client.publish directly so each event lives independently until its TTL.

Where to Go Next

  • Guide Persist Data Locally


    Your Product can sync state between users; next, keep device-local data (preferences, drafts, caches) across sessions.

    Persist Data Locally

  • Guide Store Data on Chain


    For durable, content-addressed payloads, route through the Bulletin Chain. Pair with the Statement Store by publishing the CID as a Statement.

    Store Data on Chain

  • External Product SDK API Reference


    The full product-sdk surface beyond this recipe: every package, class, and method.

    Visit Site

Last update: September 24, 2026
| Created: June 16, 2026