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 four flows in order of complexity: a Hello World store and retrieve, a larger file upload, long-lived data with renewal, and Preimage submission.
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.
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:
- Completed the Install Desktop and Pair and Get TestNet Tokens guides; your account needs PAS funds and a Bulletin Chain authorization. If you have not obtained a Bulletin Chain authorization yet, request one from the Bulletin Chain authorization page
- A Polkadot Product project running locally (see Set Up Your Project)
Install the SDK¶
This guide builds on createApp, which only the umbrella package provides, so install the umbrella:
Every snippet imports from @parity/product-sdk, including SignerManager and CloudStorageClient, which the umbrella re-exports from its root, so no other package is needed. See Umbrella or Individual Packages for how the install styles compare.
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:
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.',
);
}
createApp() 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 and accounts are reused across the simple sections that follow, and the same setup file is shared with Publish and Subscribe to Off-Chain Data.
The Host supplies your Product's identity
Since @parity/product-sdk v0.30.0, createApp takes no name: it derives the user's account from the product ID the Host loaded your Product under. Releases before v0.30.0 require name and derive the account from it instead. If the Host declines to derive an account, wallet.connect() resolves with zero accounts rather than failing, so the only symptom is an empty list. Every upload on this page needs a selected account, so guard the list before going further. See Product SDK for the full createApp contract.
Store a Hello World¶
The simplest write: a short string, one line.
// Place this in your Product, after the setup from `setup-app.ts`.
import { app } from './setup-app';
const stored = await app.cloudStorage!.upload('Hello, Bulletin!');
if (!stored.ok) throw new Error(`Upload failed: ${stored.error.message}`);
console.log(`CID: ${stored.value}`);
app.cloudStorage.upload(data) accepts a string or Uint8Array, signs the underlying transaction with your paired account, and resolves with a Result whose value is 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:
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:
// Place this in your Product, after the setup from `setup-app.ts`.
import { app } from './setup-app';
const CID_STRING = 'INSERT_CID';
const fetched = await app.cloudStorage!.fetch(CID_STRING);
if (!fetched.ok) throw new Error(`Fetch failed: ${fetched.error.message}`);
const bytes = fetched.value;
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, which the umbrella re-exports from @parity/product-sdk-cloud-storage. This is also the path you use for the next two sections (authorization checks, renewal). CloudStorageClient needs a signer, and App does not expose the one createApp holds, so this setup builds its own with SignerManager. Set its dappName to the product ID the Host loads your Product under, so it derives the same account as app.wallet:
import { CloudStorageClient, SignerManager } from '@parity/product-sdk';
// `App` does not expose its signer, so the advanced sections build their own.
// Unlike `createApp`, `SignerManager` does not read your Product's identity from
// the Host: set `dappName` to the product ID the Host loads you under (your
// `.dot` name) so it derives the same account that `app.wallet` does.
const signerManager = new SignerManager({ dappName: 'my-product.dot' });
const connected = await signerManager.connect();
if (!connected.ok) throw new Error(connected.error.message);
const accounts = connected.value;
if (accounts.length === 0) {
throw new Error(
'The Host returned no account for this Product. Check that you are signed in to Polkadot Desktop.',
);
}
// A real Product would render an account picker; here we pick the first one.
const selected = signerManager.selectAccount(accounts[0].address);
if (!selected.ok) throw new Error(selected.error.message);
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 = selected.value;
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:
// 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. If a store is rejected, confirm the authorization is on the account that signs: see uploads are rejected in troubleshooting.
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:
// Place this in your Product, after the setup from `setup-client.ts`.
import { client, account } from './setup-client';
// `checkAuthorization` resolves with a `Result`, so unwrap it before reading
// the status. The fields live on `auth.value`, never on `auth` itself.
const auth = await client.checkAuthorization(account.address);
if (!auth.ok) {
console.error(`Could not read the authorization: ${auth.error.message}`);
} else if (!auth.value.authorized) {
console.log(`No authorization found for ${account.address}`);
} else {
console.log(`Remaining transactions: ${auth.value.remainingTransactions}`);
console.log(`Remaining bytes: ${auth.value.remainingBytes}`);
console.log(`Expires at block: ${auth.value.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`);
checkAuthorization resolves with a Result, not with the status directly. Check auth.ok first; the status is on auth.value, and reading the fields off the wrapper yields undefined and silently takes the failure branch. On success, auth.value carries an AuthorizationStatus:
authorized:truewhen an authorization record exists for the account.remainingTransactions: Number ofstorecalls 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() resolves with a Result whose value is only the CID string — no block or index. To get the bookkeeping pair, use CloudStorageClient.store(...).send() instead, which returns a full StoreResult (cid, blockNumber, extrinsicIndex, size, all but size optional). Persist (blockNumber, extrinsicIndex) for each record you intend to renew.
As you approach the expiry block (current block + retention period), submit a renewal:
// 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.
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 2 MiB |
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.
-
Guide Read On-Chain Data
Pair Bulletin writes with chain reads via the Host API's PAPI provider.
-
External Product SDK API Reference
The full
product-sdksurface beyond this recipe: every package, class, and method.
| Created: June 16, 2026