Skip to content

Store Data on Chain

Introduction

This guide covers the Bulletin Chain, Polkadot's content-addressed storage layer for Products. You write data, the chain returns a Content Identifier (CID), and anyone with that CID can fetch the data back from the network. Data is retained for about two weeks by default and can be renewed. Access is gated by a per-account storage authorization, not a token balance. The guide walks through five flows in order of complexity: a Hello World store and retrieve, a larger file upload, long-lived data with renewal, cross-chain storage via People Chain, and Preimage submission.

Storage options for your Product

The Bulletin Chain is the right layer for content that needs to outlive a session and be fetched later by hash. For other shapes of data, reach for a different layer:

  • Local KvStore (this page): Per-Product, per-device key-value. User preferences, drafts, cached values. Not synced across devices.
  • Bulletin Chain: Content-addressed, on-chain, retained ~2 weeks by default and renewable. Content readers fetch later by hash: 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 messages, presence, typing indicators. See Publish and Subscribe to Off-Chain Data.

Network

The flows on this page target Paseo Next, the default environment in Polkadot Desktop development builds. If you switched environments during setup, select the matching network from the environment selector in Polkadot Desktop.

Prerequisites

Before starting, ensure you have:

Install the SDK

Install the SDK in your Product's project:

npm install @parity/product-sdk

The umbrella package brings in @parity/product-sdk-cloud-storage (where CloudStorageClient lives, used later for advanced operations), @parity/product-sdk-host (the Preimage manager used later), and polkadot-api itself. To keep your bundle smaller, you can install those individual packages directly instead. See Umbrella or Individual Packages for the tradeoff.

Set Up Your Storage Client

Every snippet in this guide is Product code: modules you place inside the Product running at localhost:3000 (per Set Up Your Project), loaded by Polkadot Desktop. Run nothing from a terminal; the snippets execute in the browser context Polkadot Desktop's localhost bypass loads them into. The signer for every Bulletin transaction comes from the Host (your paired account in Polkadot Desktop); your Product never derives keys.

Create the SDK app and connect the wallet:

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

export const app = await createApp({ name: 'my-product' });
await app.wallet.connect();

createApp({ name }) returns an App with app.wallet, app.localStorage, app.chain, and app.cloudStorage, the high-level Bulletin Chain API exposing upload(), fetch(), and computeCid(). The exported app is reused across the simple sections that follow.

Store a Hello World

The simplest write: a short string, one line.

hello-bulletin.ts
// Place this in your Product, after the setup from `setup-app.ts`.

import { app } from './setup-app';

const cid = await app.cloudStorage!.upload('Hello, Bulletin!');

console.log(`CID: ${cid}`);

app.cloudStorage.upload(data) accepts a string or Uint8Array, signs the underlying transaction with your paired account, and resolves with the CID, a Blake2b-256 content hash encoded as a CIDv1 string. Internally the SDK uses the chunking pipeline with a DAG-PB manifest, so the same call shape works for any payload size; see Store a Larger File for the chunk-level controls.

You should see something like:

CID: bafk2bzacea6wlxyalo6gbajlwuubv7w5dvss3vmfqmavlqy63e4vypth2ov6u

Retrieve Your Data

The Bulletin Chain follows a "write-to-chain, read-from-network" model: the chain holds the storage commitment, and the data itself lives at collator nodes addressable by CID. Reading is permissionless. No authorization, fees, or signature are required. app.cloudStorage.fetch(cid) routes through the Host's preimage subscription with caching:

retrieve-data.ts
// Place this in your Product, after the setup from `setup-app.ts`.

import { app } from './setup-app';

const CID_STRING = 'INSERT_CID';

const bytes = await app.cloudStorage!.fetch(CID_STRING);
console.log(`Retrieved ${bytes.length} bytes`);
console.log(new TextDecoder().decode(bytes));

// Verify the fetched bytes match the CID you asked for.
const recomputed = await app.cloudStorage!.computeCid(bytes);
console.log(`CID verified: ${recomputed === CID_STRING}`);

The snippet verifies the bytes by recomputing their CID with app.cloudStorage.computeCid(bytes) and comparing to the CID you asked for. If the host returned the wrong bytes, the recomputed CID does not match; that is the integrity property content addressing gives you.

For libp2p / Helia / Smoldot retrieval paths (when you want to fetch outside a Polkadot Desktop container), see Retrieve Your Data in the canonical tutorial.

Store a Larger File

app.cloudStorage.upload() chunks transparently above a 2 MiB threshold and stores a DAG-PB manifest that references each chunk's CID, returning the manifest CID. For most Products, that is all you need. Pass any Uint8Array to upload() and the SDK handles chunking, manifest generation, and the underlying transactions for you.

For finer control, such as custom chunk size, per-chunk progress callbacks, or access to the individual chunk CIDs, drop one level lower to CloudStorageClient from @parity/product-sdk-cloud-storage. This is also the path you use for the next two sections (authorization checks, renewal), so the setup snippet pays off immediately:

setup-client.ts
import { SignerManager } from '@parity/product-sdk';
import { CloudStorageClient } from '@parity/product-sdk-cloud-storage';

const signerManager = new SignerManager({ dappName: 'my-product' });
await signerManager.connect();

const { accounts } = signerManager.getState();
if (accounts.length === 0) {
  throw new Error('No accounts available — pair your Polkadot Desktop with a signer.');
}

// A real Product would render an account picker; here we pick the first one.
signerManager.selectAccount(accounts[0].address);

const signer = signerManager.getSigner();
if (!signer) throw new Error('Could not build a signer from the selected account.');

export const client = await CloudStorageClient.create({
  environment: 'paseo',
  signer,
});

export const account = signerManager.getState().selectedAccount!;

CloudStorageClient.create({ environment: 'paseo', signer }) resolves with client.store / client.renew / client.checkAuthorization / client.fetchBytes / client.estimateAuthorization for the high-level operations, plus client.api for the typed Bulletin Chain API when you need to drop one level lower still. The exported client and account are reused by the advanced sections that follow.

The client.store(...) builder takes the same chunking pipeline app.cloudStorage.upload uses internally, with the controls exposed:

store-large-file.ts
// Place this in your Product, after the setup from `setup-client.ts`.

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

// e.g., a File the user dropped, an asset bundled with your Product.
declare const largeFile: Uint8Array;

const estimate = client.estimateAuthorization(largeFile.length);
console.log(
  `Need ${estimate.transactions} txs / ${estimate.bytes} bytes of authorization`,
);

const result = await client
  .store(largeFile)
  .withChunkSize(1024 * 1024)
  .withManifest(true)
  .withCallback((event) => {
    console.log(`Progress: ${JSON.stringify(event)}`);
  })
  .send();

console.log(`Root CID (manifest): ${result.cid?.toString()}`);
if (result.chunks) {
  console.log(`Chunks: ${result.chunks.numChunks}`);
  for (const [i, cid] of result.chunks.chunkCids.entries()) {
    console.log(`  [${i}] ${cid.toString()}`);
  }
}

The returned StoreResult.cid is the manifest CID; StoreResult.chunks.chunkCids lists each chunk's individual CID. withCallback() receives per-chunk progress events as the upload streams. The Bulletin Chain has a per-transaction byte limit of about 8 MiB on TestNet; see Size Limits. Chunks must stay under that.

Chunked uploads are not atomic. Each chunk is a separate transaction, and the manifest is one more on top. If chunk N fails after chunks 0..N-1 have already landed, the earlier chunks remain on chain and consume your authorization. There is no rollback. Inspect the chunkCids on the thrown error and either resume from the failed chunk or, if the use case demands it, abandon the partial upload.

Get Authorization

The Bulletin Chain has no token balance for storage; every account needs an explicit authorization. You should already have one from Get TestNet Tokens; if not, request your storage quota directly from the Bulletin Chain authorization page.

Note

The authorize_account extrinsic requires Root origin. You cannot self-authorize programmatically; on Polkadot TestNet, use the Bulletin Chain authorization page before submitting any store extrinsic from your Product.

CloudStorageClient (introduced in Store a Larger File) exposes client.checkAuthorization(address) as a pre-flight check before submitting a store:

check-authorization.ts
// Place this in your Product, after the setup from `setup-client.ts`.

import { client, account } from './setup-client';

const auth = await client.checkAuthorization(account.address);

if (!auth.authorized) {
  console.log(`No authorization found for ${account.address}`);
} else {
  console.log(`Remaining transactions: ${auth.remainingTransactions}`);
  console.log(`Remaining bytes:        ${auth.remainingBytes}`);
  console.log(`Expires at block:       ${auth.expiration}`);
}

// Estimate authorization needed for a hypothetical 2 MiB payload.
const estimate = client.estimateAuthorization(2 * 1024 * 1024);
console.log(`To store 2 MiB you need ~${estimate.transactions} txs, ${estimate.bytes} bytes`);

The returned AuthorizationStatus:

  • authorized: true when an authorization record exists for the account.
  • remainingTransactions: Number of store calls remaining in the quota.
  • remainingBytes: Bytes remaining across those calls (bigint).
  • expiration: The block at which any unused quota expires.

client.estimateAuthorization(dataSize) returns the { transactions, bytes } you would need to authorize a hypothetical payload of dataSize bytes, which is useful before requesting a quota top-up.

Renew Long-Lived Data

Stored data is retained for roughly two weeks. If your Product needs the data to outlive that window, renew the storage record before it expires.

Renewal needs the (block, index) pair from the Stored event of the original write. That is where app.cloudStorage.upload() runs out of road. upload() returns only the CID string. To get the bookkeeping pair, use CloudStorageClient.store(...).send() instead, which returns a full StoreResult (cid, blockNumber, extrinsicIndex, size). Persist (blockNumber, extrinsicIndex) for each record you intend to renew.

As you approach the expiry block (current block + retention period), submit a renewal:

renew-data.ts
// Place this in your Product, after the setup from `setup-client.ts`.

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

// Captured from the Stored event of the original store; persist these in
// your Product. Each renewal returns a NEW (block, index) — track the
// latest values, reusing the original ones on a future renewal will fail.
declare const lastBlock: number;
declare const lastIndex: number;

const receipt = await client.renew(lastBlock, lastIndex).send();

console.log(`Renewed in block:  ${receipt.blockHash}`);
console.log(`Tx hash:           ${receipt.txHash}`);
console.log(`Block number:      ${receipt.blockNumber}`);

client.renew(block, index).send() builds and submits the call, returning a TransactionReceipt with blockHash, txHash, and blockNumber. The Renewed event carries the new (block, index) pair; capture it so the next renewal uses the latest values.

The SDK's typed Bulletin API does not expose RetentionPeriod or the Utility pallet directly. If you need to read the retention period from chain state, or batch many renewals atomically via Utility.batch_all, generate the full PAPI bulletin descriptor with npx papi add bulletin -w <RPC> and submit through polkadot-api directly because @parity/product-sdk-cloud-storage is intentionally a narrow surface.

Warning

Each renewal generates a new (block, index) pair. Track the values from the latest Renewed event for any subsequent renewal. Using the original values after a renewal will fail. The Bulletin Chain pallet does not emit retention events ahead of expiry; your Product needs its own scheduler (cron job, queue, or background worker) to renew before the storage expires.

Cross-Chain Storage from People Chain

PoP-gated identity lives on the People Chain. When a Product needs to attach content to that identity (for example, a PoP-Lite communication identifier or a verified-person attestation), the store has to be initiated on People Chain and dispatched to the Bulletin Chain via XCM.

Provisional

The cross-chain path is in flight. The flow described here is the intended shape; XCM message format and authorization model may change before the path is finalized.

The flow has three phases:

  1. People Chain authorizes your account against its local transactionStorage instance (authorization on People Chain is independent of your Bulletin Chain authorization).
  2. Your account submits transactionStorage.store(data) on People Chain. The receipt yields a People-Chain-side (block, index) pair plus the computed CID.
  3. People Chain dispatches an XCM message to the Bulletin Chain that mirrors the storage record. Once XCM execution completes, the data is addressable from the Bulletin Chain's collator network with the same CID. Your Product can read it via client.fetchBytes(cid) exactly as if you had written directly to Bulletin.

Until the XCM dispatch is wired up, treat this section as the design contract for the path; the hand-rolled cross-chain code samples will be added once the pallet shape stabilizes.

Submit a Preimage

Bulletin Chain has a second authorization model alongside the per-account quota you've been using. Instead of authorizing your account to store transactions and bytes, a privileged caller (Root on Bulletin, or the People Chain via the cross-chain dispatch covered in Cross-Chain Storage from People Chain) can pre-authorize a specific content hash via the authorize_preimage extrinsic. Once that authorization is in place, anyone (including your Product) can submit the matching bytes via an unsigned transaction: no fees, no per-account quota debited.

This is the right path when:

  • A sponsor (an app, a parachain, or governance) pre-authorizes content for someone else to upload.
  • The People Chain → Bulletin XCM flow described in Cross-Chain Storage from People Chain authorizes a hash on Bulletin, and the actual bytes get submitted by the user's Product.

The Host API exposes the submission side through getPreimageManager from @parity/product-sdk-host (already installed via the umbrella package in Set Up). Polkadot Desktop mediates the call. The Product never holds a signer for this path because the underlying transaction is unsigned.

submit-preimage.ts
import { getPreimageManager } from '@parity/product-sdk-host';

const preimageManager = await getPreimageManager();
if (!preimageManager) {
  throw new Error('No preimage manager — Product is not loaded inside Polkadot Desktop.');
}

const payload = new TextEncoder().encode('preimage payload bytes');

const key = await preimageManager.submit(payload);

console.log(`Preimage submitted. Key (resource lookup): ${key}`);

preimageManager.submit(payload) resolves with the Blake2b-256 hash of the payload, which is the same hash format as a Bulletin CID. The submission is rejected if no authorize_preimage exists for that hash. Reading is permissionless; subscribe via preimageManager.lookup(key, callback).

Provisional

The Bulletin Chain preimage authorization flow is live on TestNet today, but the cross-chain authorization path (People Chain → Bulletin) and production environment endpoints are not yet finalized. The submission has a Host-side timeout (~120s on the current dev build) before it resolves; production timeouts may shift. The @parity/product-sdk API surface is pre-1.0; minor API changes are expected during the 0.x line.

The mechanics:

  • Some upstream caller (Root on Bulletin, or People Chain via XCM) calls authorize_preimage(contentHash, maxSize).
  • Your Product calls preimageManager.submit(payload).
  • Polkadot Desktop computes the Blake2b-256 hash of the payload; the chain accepts the submission only if a matching authorization exists.
  • The bytes are stored on Bulletin Chain via an unsigned transaction with no fees and no per-account quota debited.
  • Reading is permissionless: any account can fetch the bytes by hash via preimageManager.lookup.
  • Retention is roughly two weeks per the standard Bulletin Chain retention window; renewal works the same way as for account-authorized stores.
  • Per-transaction byte limit is the same ~8 MiB; larger payloads are split into chunks and authorized as a DAG-PB manifest plus the chunk hashes.

For the underlying pallet surface (authorize_preimage, refresh_preimage_authorization, remove_expired_preimage_authorization), see Preimage Authorization in the Data Storage reference.

Storage Paths at a Glance

The flows in this guide target the same chain but differ in authorization, atomicity, and consumer access. Use this table to pick the right path before writing.

Path Authorization Atomicity Retention Use When
Bulletin store (small) Bulletin authorization Single tx ~2 weeks (renewable) Most Product writes
Bulletin store (chunked) Bulletin authorization Multi-tx + DAG-PB manifest ~2 weeks (renewable) Files larger than 8 MiB
Cross-chain via People Chain People-Chain authorization XCM (eventually consistent) ~2 weeks (renewable) PoP-attached writes
Bulletin preimage submission Pre-authorized hash (no per-account quota, no fees) Single unsigned tx ~2 weeks (renewable) Sponsored uploads; receiving People Chain → Bulletin XCM dispatches

For deeper comparison and the full pallet reference, see Data Storage Reference.

Where to Go Next

  • Guide Publish and Subscribe to Off-Chain Data


    Your Product can store durable content; next, add real-time state between users via the Statement Store.

    Publish and Subscribe to Off-Chain Data

  • Guide Read On-Chain Data


    Pair Bulletin writes with chain reads via the Host API's PAPI provider.

    Read On-Chain Data

  • External Product SDK API Reference


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

    Visit Site

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