Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/update-sdk-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ For **Rust SDK** changes, read:

For **TypeScript SDK** changes, read:
- `sdk/typescript/src/index.ts` — all re-exports
- `sdk/typescript/src/client.ts` — AsyncBulletinClient
- `sdk/typescript/src/client.ts` — BulletinClient
- `sdk/typescript/src/builder.ts` — StoreBuilder, CallBuilder, AuthCallBuilder
- `sdk/typescript/src/preparer.ts` — BulletinPreparer
- `sdk/typescript/src/mock.ts` — MockBulletinClient
Expand Down Expand Up @@ -82,7 +82,7 @@ When updating documentation:
- **Code examples must compile/run**: Use actual SDK APIs with correct signatures. Never invent methods or parameters.
- **API reference pages are comprehensive**: Every public class, method, type, enum, constant must be listed in `{lang}/api-reference.md`.
- **Constructor examples must be complete**: Show all required parameters, not zero-arg constructors if the real constructor requires args.
- **Introduction and quickstart use the high-level clients**: TypeScript uses `AsyncBulletinClient`, Rust uses `TransactionClient`.
- **Introduction and quickstart use the high-level clients**: TypeScript uses `BulletinClient`, Rust uses `TransactionClient`.
- **Keep existing page structure**: Don't reorganize sections unless the API change requires it.
- **Don't add features that don't exist**: If a method was removed, remove it from docs. If return types changed, update them.
- **Match the exact type names**: `StoreOptions` not `StoreOpts`, `HashAlgorithm` not `HashAlgo`, etc.
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ jobs:
cache-dependency-path: |
examples/package.json
console-ui/package-lock.json
sdk/typescript/package-lock.json

- name: Build TypeScript SDK
# Several examples import from `../sdk/typescript/dist/index.mjs` —
# ensure the SDK is built before any test step runs.
working-directory: sdk/typescript
run: |
npm ci
npm run build

- name: Install subxt-cli
run: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Multi-language client SDKs for submitting data, managing authorizations, and gen

Published as `@parity/bulletin-sdk` on npm. Browser and Node.js compatible (requires Node >= 22).

- `AsyncBulletinClient` for end-to-end storage workflows
- `BulletinClient` for end-to-end storage workflows
- `FixedSizeChunker` and `UnixFsDagBuilder` for large file handling
- Built on `polkadot-api` (PAPI)

Expand Down
37 changes: 37 additions & 0 deletions console-ui/src/hooks/useUploadProgressHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback } from "react";
import type { UploadEvent } from "@parity/bulletin-sdk";
import { UploadStatus } from "@parity/bulletin-sdk";

/**
* Progress handler for `client.upload()` / `client.uploadFile()` callbacks.
* Maps per-item UploadEvent into a human-readable status string. For a
* single-item upload the `(N/M)` prefix is dropped to keep the line short.
*/
export function useUploadProgressHandler(
setTxStatus: (status: string) => void,
): (event: UploadEvent) => void {
return useCallback(
(event: UploadEvent) => {
const prefix = event.total > 1 ? `(${event.index + 1}/${event.total}) ` : "";
switch (event.type) {
case UploadStatus.ItemStarted:
setTxStatus(`${prefix}Broadcasting...`);
break;
case UploadStatus.ItemInBlock:
setTxStatus(`${prefix}In block #${event.blockNumber}...`);
break;
case UploadStatus.ItemFinalized:
setTxStatus(
event.total > 1 && event.index + 1 < event.total
? `${prefix}Finalized @ #${event.blockNumber}`
: "Finalized!",
);
break;
case UploadStatus.ItemFailed:
setTxStatus(`${prefix}Failed: ${event.error.message}`);
break;
}
},
[setTxStatus],
);
}
154 changes: 46 additions & 108 deletions console-ui/src/pages/Upload/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import {
} from "@/state/storage.state";
import { addStorageEntry } from "@/state/history.state";
import { formatBytes } from "@/utils/format";
import { getContentHash, CidCodec, HashAlgorithm, WaitFor } from "@parity/bulletin-sdk";
import { useProgressHandler } from "@/hooks/useProgressHandler";
import { getContentHash, CidCodec, HashAlgorithm, UploadStatus, WaitFor, type UploadEvent } from "@parity/bulletin-sdk";
import { useUploadProgressHandler } from "@/hooks/useUploadProgressHandler";
import { bytesToHex } from "@/utils/format";

const HASH_ALGORITHMS: { value: HashAlgorithm; label: string }[] = [
Expand Down Expand Up @@ -63,7 +63,7 @@ export function Upload() {
const preimageAuth = usePreimageAuth();
const preimageAuthLoading = usePreimageAuthLoading();
const [txStatus, setTxStatus] = useState<string | null>(null);
const handleProgress = useProgressHandler(setTxStatus);
const handleUploadProgress = useUploadProgressHandler(setTxStatus);

const [inputMode, setInputMode] = useState<"text" | "file">("text");
const [textData, setTextData] = useState("");
Expand Down Expand Up @@ -164,129 +164,67 @@ export function Upload() {
const contentHash = await getContentHash(data, hashAlgorithm);
const contentHashHex = bytesToHex(contentHash);

if (hasPreimageAuth) {
// Unsigned submission via raw PAPI (no wallet/signer needed)
const isCustomCid = hashAlgorithm !== HashAlgorithm.Blake2b256 || cidCodec !== CidCodec.Raw;

const toHashingEnum = (alg: HashAlgorithm) => {
switch (alg) {
case HashAlgorithm.Blake2b256: return { type: "Blake2b256" as const, value: undefined };
case HashAlgorithm.Sha2_256: return { type: "Sha2_256" as const, value: undefined };
case HashAlgorithm.Keccak256: return { type: "Keccak256" as const, value: undefined };
default: return { type: "Blake2b256" as const, value: undefined };
}
};

const tx = isCustomCid
? api.tx.TransactionStorage.store_with_cid_config({
cid: {
codec: BigInt(cidCodec),
hashing: toHashingEnum(hashAlgorithm),
},
data,
})
: api.tx.TransactionStorage.store({
data,
});

setTxStatus("Submitting unsigned transaction...");

const bareTx = await tx.getBareTx();

const result = await new Promise<{ blockHash?: string; blockNumber?: number; index?: number }>((resolve, reject) => {
let resolved = false;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleEvent = (ev: any) => {
if (ev.type === "txBestBlocksState" && ev.found && !resolved) {
resolved = true;
subscription.unsubscribe();

let index: number | undefined;
if (ev.events) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storedEvent = ev.events.find((e: any) =>
e.type === "TransactionStorage" && e.value?.type === "Stored"
);
if (storedEvent?.value?.value?.index !== undefined) {
index = storedEvent.value.value.index;
}
}

resolve({
blockHash: ev.block.hash,
blockNumber: ev.block.number,
index,
});
}
};

const subscription = client.submitAndWatch(bareTx).subscribe({
next: handleEvent,
error: (err) => {
if (!resolved) {
resolved = true;
reject(err);
}
},
});

setTimeout(() => {
if (!resolved) {
resolved = true;
subscription.unsubscribe();
reject(new Error("Transaction timed out"));
}
}, 120000);
});
// Capture final block info + extrinsicIndex directly from the
// SDK's ItemFinalized event — no separate System.Events lookup
// needed since the pipeline surfaces them via `TransactionByContentHash`.
let finalBlockHash: string | undefined;
let finalBlockNumber: number | undefined;
let storedIndex: number | undefined;
const captureFinal = (ev: UploadEvent) => {
if (ev.type === UploadStatus.ItemFinalized) {
finalBlockHash = ev.blockHash;
finalBlockNumber = ev.blockNumber;
storedIndex = ev.extrinsicIndex;
}
handleUploadProgress(ev);
};

// Calculate CID for display
const { calculateCid } = await import("@parity/bulletin-sdk");
const cid = await calculateCid(data, cidCodec, hashAlgorithm);
const cidStr = cid.toString();
if (hasPreimageAuth) {
// Unsigned (preimage-authorized). The SDK accepts no signer; the
// chain validates via the preimage authorization for the item's
// content hash.
const bulletinClient = createBulletinClient!();
const { cids } = await bulletinClient
.upload([{ data, codec: cidCodec, hashAlgo: hashAlgorithm }])
.asUnsigned()
.withCallback(captureFinal)
.send();
const cidStr = cids[0]?.toString() ?? "";

const uploadResultData: UploadResult = {
setUploadResult({
cid: cidStr,
contentHash: contentHashHex,
blockHash: result.blockHash,
blockNumber: result.blockNumber,
index: result.index,
blockHash: finalBlockHash,
blockNumber: finalBlockNumber,
index: storedIndex,
size: data.length,
unsigned: true,
};

setUploadResult(uploadResultData);
});
} else {
// Signed submission via SDK
const bulletinClient = createBulletinClient!(selectedAccount!.polkadotSigner);

const result = await bulletinClient
.store(data)
.withCodec(cidCodec)
.withHashAlgorithm(hashAlgorithm)
.withCallback(handleProgress)
const { cids } = await bulletinClient
.upload([{ data, codec: cidCodec, hashAlgo: hashAlgorithm }])
.withCallback(captureFinal)
.withWaitFor(WaitFor.Finalized)
.send();
const cidStr = cids[0]?.toString() ?? "";

const cidStr = result.cid?.toString() ?? "";
const uploadResultData: UploadResult = {
setUploadResult({
cid: cidStr,
contentHash: contentHashHex,
blockNumber: result.blockNumber,
index: result.extrinsicIndex,
size: result.size,
};

setUploadResult(uploadResultData);
blockNumber: finalBlockNumber,
index: storedIndex,
size: data.length,
});

// Save to history for easy renewal later (only for signed transactions)
if (result.blockNumber !== undefined && result.extrinsicIndex !== undefined) {
if (finalBlockNumber !== undefined && storedIndex !== undefined) {
addStorageEntry({
blockNumber: result.blockNumber,
index: result.extrinsicIndex,
blockNumber: finalBlockNumber,
index: storedIndex,
cid: cidStr,
contentHash: contentHashHex,
size: result.size,
size: data.length,
account: selectedAccount!.address,
networkId: network.id,
label: fileName || undefined,
Expand Down
31 changes: 26 additions & 5 deletions console-ui/src/state/chain.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getWsProvider } from "polkadot-api/ws";
import { getSmProvider } from "polkadot-api/sm-provider";
import { startFromWorker } from "polkadot-api/smoldot/from-worker";
import { BehaviorSubject, map, shareReplay, combineLatest } from "rxjs";
import { useMemo } from "react";
import { bind } from "@react-rxjs/core";
import { bulletin_westend, bulletin_paseo, bulletin_paseo_next_v2, bulletin_polkadot, web3_storage } from "@polkadot-api/descriptors";
import {
Expand All @@ -11,7 +12,7 @@ import {
DEFAULT_NETWORKS,
type Network,
} from "../config/networks";
import { AsyncBulletinClient } from "@parity/bulletin-sdk";
import { BulletinClient } from "@parity/bulletin-sdk";

export type StorageType = "bulletin" | "web3storage";

Expand Down Expand Up @@ -391,7 +392,7 @@ export const [useClient] = bind(clientSubject, undefined);
export const [useSudoKey] = bind(sudoKeySubject, undefined);

/**
* Hook that returns a factory for creating AsyncBulletinClient instances.
* Hook that returns a factory for creating BulletinClient instances.
* Returns undefined if not connected. Call with a signer to get a client.
*
* @example
Expand All @@ -401,11 +402,31 @@ export const [useSudoKey] = bind(sudoKeySubject, undefined);
* const bulletinClient = createBulletinClient?.(signer);
* ```
*/
export function useCreateBulletinClient(): ((signer: PolkadotSigner) => AsyncBulletinClient) | undefined {
export function useCreateBulletinClient(): ((signer?: PolkadotSigner) => BulletinClient) | undefined {
const api = useApi();
const client = useClient();
if (!api || !client) return undefined;
return (signer: PolkadotSigner) => new AsyncBulletinClient(api, signer, client.submit);
const network = useNetwork();
// Memoize so the per-instance pipelineBootstrap cache (metadata + offline
// API) survives renders. Without this every upload would re-bootstrap.
return useMemo(() => {
if (!api || !client) return undefined;
// pipelineStore (signed uploads) requires at least one wsUrl. Smoldot
// light-client setups have no ws endpoint to forward to — those clients
// can only do unsigned uploads (`asUnsigned()`) until the SDK grows a
// smoldot-native pipeline.
const wsUrls = network?.lightClient ? [] : network?.endpoints ?? [];
const cache = new WeakMap<object, BulletinClient>();
const NO_SIGNER = {} as object;
return (signer?: PolkadotSigner) => {
const key = (signer as unknown as object | undefined) ?? NO_SIGNER;
let bulletin = cache.get(key);
if (!bulletin) {
bulletin = new BulletinClient(api, signer, client.submitAndWatch, { wsUrls });
cache.set(key, bulletin);
}
return bulletin;
};
}, [api, client, network]);
}

// Direct access to subjects for non-React code
Expand Down
4 changes: 2 additions & 2 deletions docs/book/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ See [Data Retrieval](./concepts/retrieval.md) for details.

```typescript
// TypeScript - Store data
import { AsyncBulletinClient } from "@parity/bulletin-sdk";
import { BulletinClient } from "@parity/bulletin-sdk";
import { createClient, Binary } from "polkadot-api";
import { getWsProvider } from "polkadot-api/ws-provider/node";

const papiClient = createClient(getWsProvider("wss://paseo-bulletin-rpc.polkadot.io"));
const api = papiClient.getTypedApi(bulletinDescriptor);
const client = new AsyncBulletinClient(api, signer, papiClient.submit);
const client = new BulletinClient(api, signer, papiClient.submit);

const result = await client.store(Binary.fromText("Hello, Bulletin!")).send();
console.log("CID:", result.cid.toString());
Expand Down
4 changes: 2 additions & 2 deletions docs/book/src/concepts/manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ The root manifest contains links to all chunks with their sizes, allowing client
### TypeScript

```typescript
import { AsyncBulletinClient } from "@parity/bulletin-sdk";
import { BulletinClient } from "@parity/bulletin-sdk";

const client = new AsyncBulletinClient(api, signer, papiClient.submit);
const client = new BulletinClient(api, signer, papiClient.submit);
const largeFile = new Uint8Array(10_000_000); // 10 MB

// Automatically chunks and creates a DAG-PB manifest
Expand Down
4 changes: 2 additions & 2 deletions docs/book/src/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ npm install @parity/bulletin-sdk polkadot-api
### Step 2: Store Data

```typescript
import { AsyncBulletinClient } from "@parity/bulletin-sdk";
import { BulletinClient } from "@parity/bulletin-sdk";
import { createClient, Binary } from "polkadot-api";
import { getWsProvider } from "polkadot-api/ws-provider/node";
import { bulletin } from "@polkadot-api/descriptors"; // Generate with papi
Expand All @@ -60,7 +60,7 @@ async function main() {
const api = papiClient.getTypedApi(bulletin);

// 2. Create SDK client with PAPI client, signer, and submit function
const client = new AsyncBulletinClient(api, signer, papiClient.submit);
const client = new BulletinClient(api, signer, papiClient.submit);

// 3. Store data (requires authorization - use Faucet first!)
const data = Binary.fromText("Hello, Bulletin Chain!");
Expand Down
4 changes: 2 additions & 2 deletions docs/book/src/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The `@parity/bulletin-sdk` package provides a modern, type-safe client for Node.
## Quick Example

```typescript
import { AsyncBulletinClient } from '@parity/bulletin-sdk';
import { BulletinClient } from '@parity/bulletin-sdk';
import { createClient } from 'polkadot-api';
import { getWsProvider } from 'polkadot-api/ws-provider/node';

Expand All @@ -40,7 +40,7 @@ const papiClient = createClient(wsProvider);
const api = papiClient.getTypedApi(bulletinDescriptor);

// Create SDK client with PAPI client, signer, and submit function
const client = new AsyncBulletinClient(api, signer, papiClient.submit);
const client = new BulletinClient(api, signer, papiClient.submit);

// Store any size file using builder pattern
const data = new Uint8Array(50_000_000); // 50 MB
Expand Down
Loading
Loading