@parity/product-sdk-bulletin 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -1,59 +1,96 @@
1
- import { getChainAPI } from "@parity/product-sdk-chain-client";
2
- import type { PolkadotSigner } from "polkadot-api";
1
+ import {
2
+ AsyncBulletinClient,
3
+ type AuthCallBuilder,
4
+ type BulletinTypedApi,
5
+ type CallBuilder,
6
+ type ClientConfig,
7
+ type StoreBuilder,
8
+ type SubmitFn,
9
+ } from "@parity/bulletin-sdk";
10
+ import { createChainClient, getChainAPI } from "@parity/product-sdk-chain-client";
11
+ import { createLogger } from "@parity/product-sdk-logger";
12
+ import type { PolkadotClient, PolkadotSigner } from "polkadot-api";
3
13
 
4
14
  import { checkAuthorization } from "./authorization.js";
5
- import {
6
- type CidCodec,
7
- type HashAlgorithm,
8
- cidToPreimageKey,
9
- computeCid,
10
- hashToCid,
11
- } from "./cid.js";
12
- import { cidExists, getGateway, gatewayUrl } from "./gateway.js";
15
+ import type { BulletinChain, BulletinEnvironment } from "./networks.js";
13
16
  import { executeQuery } from "./query.js";
14
17
  import { resolveQueryStrategy, type QueryStrategy } from "./resolve-query.js";
15
- import { batchUpload, upload } from "./upload.js";
16
- import type {
17
- AuthorizationStatus,
18
- BatchUploadItem,
19
- BatchUploadOptions,
20
- BatchUploadResult,
21
- BulletinApi,
22
- Environment,
23
- QueryOptions,
24
- UploadOptions,
25
- UploadResult,
26
- } from "./types.js";
18
+ import type { AuthorizationStatus, BulletinApi, QueryOptions } from "./types.js";
19
+ import { verifyOnChain, type ChainStoredEntry, type VerifyOnChainOptions } from "./verify.js";
20
+
21
+ const log = createLogger("bulletin");
22
+
23
+ /**
24
+ * Options for {@link BulletinClient.create}.
25
+ *
26
+ * One of two construction shapes is supported:
27
+ *
28
+ * - **Environment shorthand** — pass an `environment` string keyed by
29
+ * {@link BulletinChain}. Wires up the chain-client automatically.
30
+ * - **Explicit network** — pass `genesisHash` and `descriptor` directly
31
+ * (e.g., spread from a {@link BulletinChain} entry, or supply custom
32
+ * values for a private chain).
33
+ */
34
+ export type CreateBulletinClientOptions =
35
+ | (CreateBulletinClientCommon & { environment: BulletinEnvironment })
36
+ | (CreateBulletinClientCommon & {
37
+ genesisHash: `0x${string}`;
38
+ descriptor: (typeof BulletinChain)[BulletinEnvironment]["descriptor"];
39
+ });
40
+
41
+ interface CreateBulletinClientCommon {
42
+ /** Signer for transaction submission. Required — every store needs a signer. */
43
+ signer: PolkadotSigner;
44
+ /** Optional config forwarded to {@link AsyncBulletinClient}. */
45
+ config?: Partial<ClientConfig>;
46
+ }
27
47
 
28
48
  /**
29
49
  * Ergonomic entry point for Bulletin Chain operations.
30
50
  *
31
- * Bundles a typed Bulletin API (from chain-client) and an IPFS gateway URL
32
- * so callers don't need to re-pass them on every call.
51
+ * Wraps {@link AsyncBulletinClient} from `@parity/bulletin-sdk` (which handles
52
+ * chunking, DAG-PB manifests, CID calculation, and progress events) and adds:
53
+ *
54
+ * - **Network presets** via {@link BulletinClient.create} and {@link BulletinChain}.
55
+ * - **Read helpers** ({@link fetchBytes}, {@link fetchJson}) routed through
56
+ * the host's preimage subscription — upstream is upload-only and the SDK
57
+ * is container-only by design (no public-gateway fetches).
58
+ * - **Pre-flight authorization check** ({@link checkAuthorization}) for
59
+ * friendlier UX before submitting a store.
60
+ *
61
+ * For uploads, mirror upstream's fluent builders:
62
+ *
63
+ * ```ts
64
+ * const client = await BulletinClient.create({ environment: "paseo", signer });
65
+ * const result = await client.store(data).send();
66
+ * ```
33
67
  *
34
- * Both upload and query paths use the host container APIs:
35
- * - **Uploads** — the host preimage API signs and submits automatically.
36
- * - **Queries** (`fetchBytes`/`fetchJson`) — uses host preimage lookup with caching.
68
+ * For chunked uploads with progress:
37
69
  *
38
- * @example
39
70
  * ```ts
40
- * const bulletin = await BulletinClient.create("paseo");
41
- * const result = await bulletin.upload(fileBytes);
42
- * const metadata = await bulletin.fetchJson<Metadata>(result.cid);
71
+ * const result = await client
72
+ * .store(largeFile)
73
+ * .withChunkSize(1 << 20)
74
+ * .withCallback((evt) => console.log(evt))
75
+ * .send();
43
76
  * ```
44
77
  */
45
78
  export class BulletinClient {
79
+ /** Underlying upstream client — exposed for power users. */
80
+ readonly inner: AsyncBulletinClient;
81
+ /** Typed Bulletin Chain API. */
46
82
  readonly api: BulletinApi;
47
- readonly gateway: string;
48
83
 
84
+ /** Lazy-resolved host-preimage query strategy, cached for the client lifetime. */
49
85
  private queryStrategyPromise: Promise<QueryStrategy> | null = null;
50
86
 
51
- private constructor(api: BulletinApi, gateway: string) {
87
+ /** Constructed via {@link create} or {@link from}. */
88
+ private constructor(inner: AsyncBulletinClient, api: BulletinApi) {
89
+ this.inner = inner;
52
90
  this.api = api;
53
- this.gateway = gateway;
54
91
  }
55
92
 
56
- /** Lazily resolve and cache the query strategy for the client lifetime. */
93
+ /** Resolve and cache the host query strategy on first use. */
57
94
  private resolveQuery(): Promise<QueryStrategy> {
58
95
  if (!this.queryStrategyPromise) {
59
96
  this.queryStrategyPromise = resolveQueryStrategy();
@@ -61,195 +98,217 @@ export class BulletinClient {
61
98
  return this.queryStrategyPromise;
62
99
  }
63
100
 
64
- /** Create from an environment — resolves API via chain-client, gateway from known list. */
65
- static async create(env: Environment): Promise<BulletinClient> {
66
- const chain = await getChainAPI(env);
67
- return new BulletinClient(chain.bulletin, getGateway(env));
68
- }
69
-
70
- /** Create from an explicit API and gateway (custom setups, testing). */
71
- static from(api: BulletinApi, gateway: string): BulletinClient {
72
- return new BulletinClient(api, gateway);
73
- }
74
-
75
- /** Compute CID without uploading. Static — no instance needed. */
76
- static computeCid(data: Uint8Array): string {
77
- return computeCid(data);
78
- }
79
-
80
101
  /**
81
- * Reconstruct a CID from a `0x`-prefixed hex hash. Static no instance needed.
102
+ * Create a client from an environment shorthand or an explicit network.
82
103
  *
83
- * Useful for converting on-chain hashes back to CIDs for IPFS gateway lookups.
84
- * Pass optional hash algorithm and codec to match the on-chain CID configuration.
104
+ * Environment form uses our `getChainAPI(env)` to resolve the typed API.
105
+ * Explicit form skips the environment lookup and lets you pass any
106
+ * genesis/descriptor combo.
85
107
  *
86
- * @see {@link hashToCid} for full documentation.
108
+ * @example
109
+ * ```ts
110
+ * // Shorthand
111
+ * const client = await BulletinClient.create({ environment: "paseo", signer });
112
+ *
113
+ * // Explicit (custom network)
114
+ * const client = await BulletinClient.create({
115
+ * ...BulletinChain.paseo,
116
+ * signer,
117
+ * config: { defaultChunkSize: 1 << 20 },
118
+ * });
119
+ * ```
87
120
  */
88
- static hashToCid(hexHash: `0x${string}`, hashCode?: HashAlgorithm, codec?: CidCodec): string {
89
- return hashToCid(hexHash, hashCode, codec);
121
+ static async create(options: CreateBulletinClientOptions): Promise<BulletinClient> {
122
+ if ("environment" in options) {
123
+ const chain = await getChainAPI(options.environment);
124
+ const inner = new AsyncBulletinClient(
125
+ chain.bulletin as BulletinTypedApi,
126
+ options.signer,
127
+ chain.raw.bulletin.submit as SubmitFn,
128
+ options.config,
129
+ () => chain.destroy(),
130
+ );
131
+ log.info("BulletinClient created (environment shorthand)", {
132
+ environment: options.environment,
133
+ });
134
+ return new BulletinClient(inner, chain.bulletin);
135
+ }
136
+
137
+ // Explicit form — caller owns the descriptor choice. We still need a
138
+ // PolkadotClient to feed AsyncBulletinClient. Going through
139
+ // chain-client keeps connection management consistent across the SDK.
140
+ const { genesisHash, descriptor, signer, config } = options;
141
+ // Catch the obvious foot-gun where caller mixes a genesis from one
142
+ // network with a descriptor from another — the connection would
143
+ // succeed but typed calls would silently target the wrong chain.
144
+ // The descriptor's own `.genesis` field is the on-chain truth; the
145
+ // user-supplied `genesisHash` is informational today (createChainClient
146
+ // doesn't use it because host routes connections) but kept on the
147
+ // option shape for future RPC-direct paths.
148
+ if (descriptor.genesis && genesisHash.toLowerCase() !== descriptor.genesis.toLowerCase()) {
149
+ throw new Error(
150
+ `BulletinClient.create: genesisHash (${genesisHash}) does not match descriptor.genesis (${descriptor.genesis}). These must refer to the same network — check that you're pairing the right descriptor with the right genesis hash.`,
151
+ );
152
+ }
153
+ const chain = await createChainClient({
154
+ chains: { bulletin: descriptor },
155
+ rpcs: { bulletin: [] },
156
+ });
157
+ const inner = new AsyncBulletinClient(
158
+ chain.bulletin as BulletinTypedApi,
159
+ signer,
160
+ chain.raw.bulletin.submit as SubmitFn,
161
+ config,
162
+ () => chain.destroy(),
163
+ );
164
+ log.info("BulletinClient created (explicit network)");
165
+ return new BulletinClient(inner, chain.bulletin);
90
166
  }
91
167
 
92
168
  /**
93
- * Upload data to the Bulletin Chain.
169
+ * Construct from a pre-built `AsyncBulletinClient` and PAPI typed API.
94
170
  *
95
- * @param data - Raw bytes to store.
96
- * @param signer - Optional signer. When omitted, uses the host preimage API.
97
- * @param options - Upload options (timeout, waitFor, status callback).
171
+ * Use this when you already own the connection lifecycle (BYOD setups,
172
+ * tests). The caller is responsible for calling `papiClient.destroy()`
173
+ * this client's {@link destroy} only tears down the upstream's
174
+ * `onDestroy` hook.
98
175
  */
99
- async upload(
100
- data: Uint8Array,
101
- signer?: PolkadotSigner,
102
- options?: Omit<UploadOptions, "gateway">,
103
- ): Promise<UploadResult> {
104
- return upload(this.api, data, signer, { ...options, gateway: this.gateway });
176
+ static from(inner: AsyncBulletinClient, api: BulletinApi): BulletinClient {
177
+ return new BulletinClient(inner, api);
105
178
  }
106
179
 
107
- /**
108
- * Upload multiple items sequentially.
109
- *
110
- * @param items - Array of items to upload, each with data and a label.
111
- * @param signer - Optional signer. When omitted, auto-resolved.
112
- * @param options - Batch upload options (timeout, progress callback).
113
- */
114
- async batchUpload(
115
- items: BatchUploadItem[],
116
- signer?: PolkadotSigner,
117
- options?: Omit<BatchUploadOptions, "gateway">,
118
- ): Promise<BatchUploadResult[]> {
119
- return batchUpload(this.api, items, signer, { ...options, gateway: this.gateway });
180
+ // ─── Upload + authorization (forwarded to upstream) ────────────────
181
+
182
+ /** Build a store transaction. See upstream `StoreBuilder` for chained options. */
183
+ store(data: Uint8Array): StoreBuilder {
184
+ return this.inner.store(data);
120
185
  }
121
186
 
187
+ /** Authorize an account to store data on the chain (sudo required on most networks). */
188
+ authorizeAccount(who: string, transactions: number, bytes: bigint): AuthCallBuilder {
189
+ return this.inner.authorizeAccount(who, transactions, bytes);
190
+ }
191
+
192
+ /** Authorize content storage by hash (anyone can store; no fees). */
193
+ authorizePreimage(contentHash: Uint8Array, maxSize: bigint): AuthCallBuilder {
194
+ return this.inner.authorizePreimage(contentHash, maxSize);
195
+ }
196
+
197
+ /** Renew a stored transaction by block + index. */
198
+ renew(block: number, index: number): CallBuilder {
199
+ return this.inner.renew(block, index);
200
+ }
201
+
202
+ /** Estimate the authorization (transactions + bytes) needed for `dataSize`. */
203
+ estimateAuthorization(dataSize: number): { transactions: number; bytes: number } {
204
+ return this.inner.estimateAuthorization(dataSize);
205
+ }
206
+
207
+ // ─── Read side (our own helpers) ───────────────────────────────────
208
+
122
209
  /**
123
- * Fetch raw bytes by CID.
210
+ * Fetch raw bytes for a CID via the host's preimage lookup.
211
+ *
212
+ * Container-only — outside a Polkadot Browser / Desktop host this
213
+ * throws {@link BulletinHostUnavailableError}. The chain stores
214
+ * content metadata (`content_hash`, size, codec) but the bytes
215
+ * themselves are surfaced through the host's preimage subscription.
124
216
  *
125
- * Uses host preimage lookup with caching.
217
+ * Use {@link verifyOnChain} if you only need to confirm a CID was
218
+ * recorded on-chain (no byte fetch).
126
219
  */
127
220
  async fetchBytes(cid: string, options?: QueryOptions): Promise<Uint8Array> {
128
221
  const strategy = await this.resolveQuery();
129
222
  return executeQuery(strategy, cid, options);
130
223
  }
131
224
 
132
- /**
133
- * Fetch and parse JSON by CID.
134
- *
135
- * Auto-resolves query path (same as {@link fetchBytes}).
136
- */
225
+ /** Fetch and parse JSON for a CID. */
137
226
  async fetchJson<T>(cid: string, options?: QueryOptions): Promise<T> {
138
227
  const bytes = await this.fetchBytes(cid, options);
139
228
  return JSON.parse(new TextDecoder().decode(bytes)) as T;
140
229
  }
141
230
 
142
- /** Check if a CID exists on the gateway. */
143
- async cidExists(cid: string): Promise<boolean> {
144
- return cidExists(cid, this.gateway);
145
- }
146
-
147
- /** Build the full gateway URL for a CID. */
148
- gatewayUrl(cid: string): string {
149
- return gatewayUrl(cid, this.gateway);
231
+ /** Pre-flight: check whether `address` can store on the bulletin chain. */
232
+ async checkAuthorization(address: string): Promise<AuthorizationStatus> {
233
+ return checkAuthorization(this.api, address);
150
234
  }
151
235
 
152
236
  /**
153
- * Check whether an account is authorized to store data on the Bulletin Chain.
154
- *
155
- * Use as a pre-flight check before {@link upload} to provide clear UX
156
- * instead of letting the transaction fail mid-execution.
157
- *
158
- * @param address - SS58-encoded account address to check.
159
- * @returns Authorization status with remaining quota.
237
+ * Verify that a CID was recorded on-chain at the given block.
160
238
  *
161
- * @see {@link checkAuthorization} for the standalone function equivalent.
239
+ * Common pattern: pass `blockNumber` (and optionally `extrinsicIndex`)
240
+ * from a `store(...).send()` receipt to confirm the upload landed.
241
+ * See {@link verifyOnChain} for details.
162
242
  */
163
- async checkAuthorization(address: string): Promise<AuthorizationStatus> {
164
- return checkAuthorization(this.api, address);
243
+ async verifyOnChain(
244
+ cid: string,
245
+ options: VerifyOnChainOptions,
246
+ ): Promise<ChainStoredEntry | null> {
247
+ return verifyOnChain(this.api, cid, options);
248
+ }
249
+
250
+ /** Tear down the underlying connection. */
251
+ async destroy(): Promise<void> {
252
+ await this.inner.destroy();
165
253
  }
166
254
  }
167
255
 
168
256
  if (import.meta.vitest) {
169
257
  const { describe, test, expect, vi } = import.meta.vitest;
170
258
 
171
- const mockApi = {
172
- tx: {
173
- TransactionStorage: {
174
- store: vi.fn().mockReturnValue({
175
- signSubmitAndWatch: () => ({
176
- subscribe: (handlers: { next: (e: unknown) => void }) => {
177
- queueMicrotask(() => {
178
- handlers.next({ type: "signed", txHash: "0x" });
179
- handlers.next({
180
- type: "txBestBlocksState",
181
- txHash: "0x",
182
- found: true,
183
- ok: true,
184
- block: { hash: "0xblock", number: 1, index: 0 },
185
- events: [],
186
- });
187
- });
188
- return { unsubscribe: vi.fn() };
189
- },
190
- }),
191
- }),
192
- },
193
- },
194
- } as unknown as BulletinApi;
195
-
196
- const GATEWAY = "https://test-gw/ipfs/";
197
-
198
- describe("BulletinClient", () => {
199
- test("from() creates client with given API and gateway", () => {
200
- const client = BulletinClient.from(mockApi, GATEWAY);
201
- expect(client.api).toBe(mockApi);
202
- expect(client.gateway).toBe(GATEWAY);
259
+ describe("BulletinClient.from", () => {
260
+ test("constructs with given inner and api", () => {
261
+ const inner = {
262
+ destroy: vi.fn().mockResolvedValue(undefined),
263
+ } as unknown as AsyncBulletinClient;
264
+ const api = {} as BulletinApi;
265
+ const client = BulletinClient.from(inner, api);
266
+ expect(client.inner).toBe(inner);
267
+ expect(client.api).toBe(api);
203
268
  });
204
269
 
205
- test("computeCid() is static and delegates to standalone", () => {
206
- const data = new TextEncoder().encode("hello");
207
- const cid = BulletinClient.computeCid(data);
208
- expect(cid).toBe(computeCid(data));
270
+ test("destroy delegates to upstream", async () => {
271
+ const destroy = vi.fn().mockResolvedValue(undefined);
272
+ const inner = { destroy } as unknown as AsyncBulletinClient;
273
+ const client = BulletinClient.from(inner, {} as BulletinApi);
274
+ await client.destroy();
275
+ expect(destroy).toHaveBeenCalledOnce();
209
276
  });
210
277
 
211
- test("hashToCid() is static and delegates to standalone", () => {
212
- const data = new TextEncoder().encode("hello");
213
- const cid = computeCid(data);
214
- const key = cidToPreimageKey(cid);
215
- expect(BulletinClient.hashToCid(key)).toBe(cid);
278
+ test("store delegates to inner", () => {
279
+ const builder = {} as StoreBuilder;
280
+ const inner = {
281
+ store: vi.fn().mockReturnValue(builder),
282
+ } as unknown as AsyncBulletinClient;
283
+ const client = BulletinClient.from(inner, {} as BulletinApi);
284
+ const data = new Uint8Array([1, 2, 3]);
285
+ expect(client.store(data)).toBe(builder);
286
+ expect(inner.store).toHaveBeenCalledWith(data);
216
287
  });
288
+ });
217
289
 
218
- test("gatewayUrl() returns gateway + cid", () => {
219
- const client = BulletinClient.from(mockApi, GATEWAY);
220
- expect(client.gatewayUrl("bafyabc")).toBe("https://test-gw/ipfs/bafyabc");
221
- });
290
+ describe("BulletinClient.create (BYOD genesis assertion)", () => {
291
+ // Stand-in descriptor with a known genesis. The full PAPI descriptor
292
+ // type is a `ChainDefinition` with a deep type-level shape; the cast
293
+ // below is fine for the assertion test because we never actually
294
+ // reach createChainClient.
295
+ const stubDescriptor = (
296
+ genesis: `0x${string}`,
297
+ ): (typeof BulletinChain)[BulletinEnvironment]["descriptor"] =>
298
+ ({ genesis }) as unknown as (typeof BulletinChain)[BulletinEnvironment]["descriptor"];
222
299
 
223
- test("upload() passes gateway from client with explicit signer", async () => {
224
- const client = BulletinClient.from(mockApi, GATEWAY);
225
- const data = new TextEncoder().encode("test");
226
- const result = await client.upload(data, {} as PolkadotSigner);
227
- expect(result.gatewayUrl).toContain(GATEWAY);
228
- expect(result.cid).toBeTruthy();
229
- });
300
+ const realPaseo =
301
+ "0x744960c32e3a3df5440e1ecd4d34096f1ce2230d7016a5ada8a765d5a622b4ea" as `0x${string}`;
230
302
 
231
- // Note: fetchBytes and fetchJson tests require e2e testing as they
232
- // depend on the host container environment for strategy resolution.
233
-
234
- test("checkAuthorization delegates to standalone", async () => {
235
- const authMockApi = {
236
- ...mockApi,
237
- query: {
238
- TransactionStorage: {
239
- Authorizations: {
240
- getValue: vi.fn().mockResolvedValue({
241
- extent: { transactions: 5, bytes: 2000n },
242
- expiration: 100,
243
- }),
244
- },
245
- },
246
- },
247
- } as unknown as BulletinApi;
248
- const client = BulletinClient.from(authMockApi, GATEWAY);
249
- const status = await client.checkAuthorization("5GrwvaEF...");
250
- expect(status.authorized).toBe(true);
251
- expect(status.remainingTransactions).toBe(5);
252
- expect(status.remainingBytes).toBe(2000n);
303
+ test("throws when genesisHash and descriptor.genesis disagree", async () => {
304
+ await expect(
305
+ BulletinClient.create({
306
+ genesisHash:
307
+ "0x0000000000000000000000000000000000000000000000000000000000000001",
308
+ descriptor: stubDescriptor(realPaseo),
309
+ signer: {} as PolkadotSigner,
310
+ }),
311
+ ).rejects.toThrow(/does not match descriptor\.genesis/i);
253
312
  });
254
313
  });
255
314
  }