@parity/product-sdk-bulletin 0.1.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/cid.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { blake2b256, bytesToHex, hexToBytes } from "@parity/product-sdk-crypto";
2
+ import { createLogger } from "@parity/product-sdk-logger";
3
+ import { CID } from "multiformats/cid";
4
+ import * as Digest from "multiformats/hashes/digest";
5
+
6
+ import { BulletinCidError } from "./errors.js";
7
+
8
+ const log = createLogger("bulletin");
9
+
10
+ /**
11
+ * Hash algorithms supported by the Bulletin Chain.
12
+ *
13
+ * Values are multihash codes as defined in the
14
+ * {@link https://github.com/multiformats/multicodec multicodec table}.
15
+ */
16
+ export const HashAlgorithm = {
17
+ /** BLAKE2b-256 — default for product-sdk and the chain SDK. */
18
+ Blake2b256: 0xb220,
19
+ /** SHA2-256 — default for bulletin-deploy. */
20
+ Sha2_256: 0x12,
21
+ /** Keccak-256 — Ethereum compatibility. */
22
+ Keccak256: 0x1b,
23
+ } as const;
24
+
25
+ /** A multihash code supported by the Bulletin Chain. */
26
+ export type HashAlgorithm = (typeof HashAlgorithm)[keyof typeof HashAlgorithm];
27
+
28
+ /**
29
+ * CID codecs supported by the Bulletin Chain.
30
+ *
31
+ * Values are multicodec codes.
32
+ */
33
+ export const CidCodec = {
34
+ /** Raw binary — default for single-chunk data. */
35
+ Raw: 0x55,
36
+ /** DAG-PB — used for multi-chunk manifests / directory structures. */
37
+ DagPb: 0x70,
38
+ /** DAG-CBOR — alternative DAG encoding. */
39
+ DagCbor: 0x71,
40
+ } as const;
41
+
42
+ /** A multicodec code supported by the Bulletin Chain. */
43
+ export type CidCodec = (typeof CidCodec)[keyof typeof CidCodec];
44
+
45
+ const SUPPORTED_HASH_CODES = new Set<number>(Object.values(HashAlgorithm));
46
+ const SUPPORTED_CODEC_CODES = new Set<number>(Object.values(CidCodec));
47
+ const EXPECTED_HEX_LENGTH = 66; // "0x" + 64 hex chars = 32 bytes
48
+
49
+ /**
50
+ * Compute the CIDv1 (blake2b-256, raw codec) for arbitrary data.
51
+ * Deterministic: same input always produces the same CID.
52
+ */
53
+ export function computeCid(data: Uint8Array): string {
54
+ const hash = blake2b256(data);
55
+ return CID.createV1(CidCodec.Raw, Digest.create(HashAlgorithm.Blake2b256, hash)).toString();
56
+ }
57
+
58
+ /**
59
+ * Extract the content hash digest from a CIDv1 string and return it as a
60
+ * `0x`-prefixed hex string — the preimage key format used by the host API.
61
+ *
62
+ * Accepts CIDv1 with any hash algorithm supported by the Bulletin Chain
63
+ * (blake2b-256, sha2-256, keccak-256).
64
+ *
65
+ * @param cid - CIDv1 base32 string (as produced by {@link computeCid} or {@link hashToCid}).
66
+ * @returns `0x`-prefixed hex string of the 32-byte hash digest.
67
+ * @throws If the CID is not CIDv1 or uses an unsupported hash algorithm.
68
+ */
69
+ export function cidToPreimageKey(cid: string): `0x${string}` {
70
+ const parsed = CID.parse(cid);
71
+ if (parsed.version !== 1) {
72
+ throw new BulletinCidError(`Expected CIDv1, got CIDv${parsed.version}`, cid);
73
+ }
74
+ if (!SUPPORTED_HASH_CODES.has(parsed.multihash.code)) {
75
+ throw new BulletinCidError(
76
+ `Unsupported hash algorithm 0x${parsed.multihash.code.toString(16)}; ` +
77
+ `expected one of: ${[...SUPPORTED_HASH_CODES].map((c) => `0x${c.toString(16)}`).join(", ")}`,
78
+ cid,
79
+ );
80
+ }
81
+ return `0x${bytesToHex(parsed.multihash.digest)}`;
82
+ }
83
+
84
+ /**
85
+ * Reconstruct a CIDv1 from a `0x`-prefixed hex hash stored on-chain.
86
+ *
87
+ * This is the inverse of {@link cidToPreimageKey}: given a 32-byte content hash
88
+ * and the CID configuration used when the data was stored, it rebuilds the
89
+ * original CIDv1 so you can construct IPFS gateway URLs.
90
+ *
91
+ * The Bulletin Chain supports multiple hash algorithms and codecs — pass the
92
+ * values that match the on-chain `TransactionInfo` to get the correct CID.
93
+ * When omitted, defaults match {@link computeCid} (blake2b-256, raw).
94
+ *
95
+ * @param hexHash - `0x`-prefixed hex string of a 32-byte hash digest
96
+ * (66 characters total: `"0x"` + 64 hex chars).
97
+ * @param hashCode - Multihash code of the hashing algorithm (default: blake2b-256 `0xb220`).
98
+ * Use {@link HashAlgorithm} for the supported values.
99
+ * @param codec - Multicodec code of the CID codec (default: raw `0x55`).
100
+ * Use {@link CidCodec} for the supported values.
101
+ * @returns Base32-lower CIDv1 string.
102
+ * @throws If `hexHash` is not exactly 66 characters, or if the hash/codec is unsupported.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * import { hashToCid, HashAlgorithm, CidCodec, gatewayUrl, getGateway } from "@parity/product-sdk-bulletin";
107
+ *
108
+ * // Default (blake2b-256, raw) — matches computeCid output
109
+ * const cid = hashToCid(onChainHash);
110
+ *
111
+ * // SHA2-256 content stored via bulletin-deploy
112
+ * const cid2 = hashToCid(onChainHash, HashAlgorithm.Sha2_256);
113
+ *
114
+ * // DAG-PB manifest with blake2b-256
115
+ * const cid3 = hashToCid(manifestHash, HashAlgorithm.Blake2b256, CidCodec.DagPb);
116
+ *
117
+ * const url = gatewayUrl(cid, getGateway("paseo"));
118
+ * ```
119
+ *
120
+ * @see {@link cidToPreimageKey} for the reverse direction (CID → hex hash).
121
+ * @see {@link computeCid} for computing a CID from raw data.
122
+ * @see {@link HashAlgorithm} for supported hash algorithms.
123
+ * @see {@link CidCodec} for supported CID codecs.
124
+ */
125
+ export function hashToCid(
126
+ hexHash: `0x${string}`,
127
+ hashCode: HashAlgorithm = HashAlgorithm.Blake2b256,
128
+ codec: CidCodec = CidCodec.Raw,
129
+ ): string {
130
+ if (hexHash.length !== EXPECTED_HEX_LENGTH) {
131
+ throw new BulletinCidError(
132
+ `Expected a 0x-prefixed 32-byte hex hash (${EXPECTED_HEX_LENGTH} chars), ` +
133
+ `got ${hexHash.length} chars`,
134
+ );
135
+ }
136
+ if (!SUPPORTED_HASH_CODES.has(hashCode)) {
137
+ throw new BulletinCidError(
138
+ `Unsupported hash algorithm 0x${hashCode.toString(16)}; ` +
139
+ `expected one of: ${[...SUPPORTED_HASH_CODES].map((c) => `0x${c.toString(16)}`).join(", ")}`,
140
+ );
141
+ }
142
+ if (!SUPPORTED_CODEC_CODES.has(codec)) {
143
+ throw new BulletinCidError(
144
+ `Unsupported CID codec 0x${codec.toString(16)}; ` +
145
+ `expected one of: ${[...SUPPORTED_CODEC_CODES].map((c) => `0x${c.toString(16)}`).join(", ")}`,
146
+ );
147
+ }
148
+ const digest = hexToBytes(hexHash.slice(2));
149
+ const cid = CID.createV1(codec, Digest.create(hashCode, digest)).toString();
150
+ log.debug("hashToCid", { hexHash, hashCode, codec, cid });
151
+ return cid;
152
+ }
153
+
154
+ if (import.meta.vitest) {
155
+ const { describe, test, expect } = import.meta.vitest;
156
+
157
+ describe("computeCid", () => {
158
+ test("produces known CID for known input", () => {
159
+ const data = new TextEncoder().encode("hello bulletin");
160
+ const cid = computeCid(data);
161
+ expect(cid).toBe(computeCid(new TextEncoder().encode("hello bulletin")));
162
+ expect(cid).toMatch(/^b[a-z2-7]+$/);
163
+ });
164
+
165
+ test("deterministic — same input, same output", () => {
166
+ const data = new Uint8Array([1, 2, 3, 4, 5]);
167
+ expect(computeCid(data)).toBe(computeCid(data));
168
+ });
169
+
170
+ test("different inputs produce different CIDs", () => {
171
+ const a = computeCid(new Uint8Array([1]));
172
+ const b = computeCid(new Uint8Array([2]));
173
+ expect(a).not.toBe(b);
174
+ });
175
+
176
+ test("empty input produces valid CID", () => {
177
+ const cid = computeCid(new Uint8Array(0));
178
+ expect(cid).toMatch(/^b[a-z2-7]+$/);
179
+ });
180
+
181
+ test("matches reference implementation (manual varint)", () => {
182
+ const data = new TextEncoder().encode("test");
183
+ const cid = computeCid(data);
184
+ expect(cid[0]).toBe("b");
185
+ const parsed = CID.parse(cid);
186
+ expect(parsed.version).toBe(1);
187
+ expect(parsed.code).toBe(CidCodec.Raw);
188
+ });
189
+ });
190
+
191
+ describe("cidToPreimageKey", () => {
192
+ test("round-trips with computeCid — returns 0x-prefixed 64-char hex", () => {
193
+ const data = new TextEncoder().encode("hello bulletin");
194
+ const cid = computeCid(data);
195
+ const key = cidToPreimageKey(cid);
196
+ expect(key).toMatch(/^0x[0-9a-f]{64}$/);
197
+ });
198
+
199
+ test("deterministic — same CID always yields same key", () => {
200
+ const cid = computeCid(new Uint8Array([1, 2, 3]));
201
+ expect(cidToPreimageKey(cid)).toBe(cidToPreimageKey(cid));
202
+ });
203
+
204
+ test("matches raw blake2b-256 hash", () => {
205
+ const data = new TextEncoder().encode("test");
206
+ const cid = computeCid(data);
207
+ const key = cidToPreimageKey(cid);
208
+ const hash = blake2b256(data);
209
+ const expected = `0x${bytesToHex(hash)}`;
210
+ expect(key).toBe(expected);
211
+ });
212
+
213
+ test("accepts CIDv1 with sha2-256", () => {
214
+ const hash = new Uint8Array(32).fill(0xcd);
215
+ const cidV1 = CID.createV1(CidCodec.Raw, Digest.create(HashAlgorithm.Sha2_256, hash));
216
+ const key = cidToPreimageKey(cidV1.toString());
217
+ expect(key).toMatch(/^0x[0-9a-f]{64}$/);
218
+ expect(key).toBe(`0x${bytesToHex(hash)}`);
219
+ });
220
+
221
+ test("accepts CIDv1 with keccak-256", () => {
222
+ const hash = new Uint8Array(32).fill(0xef);
223
+ const cidV1 = CID.createV1(CidCodec.Raw, Digest.create(HashAlgorithm.Keccak256, hash));
224
+ const key = cidToPreimageKey(cidV1.toString());
225
+ expect(key).toBe(`0x${bytesToHex(hash)}`);
226
+ });
227
+
228
+ test("throws BulletinCidError for CIDv0 input", () => {
229
+ const hash = new Uint8Array(32).fill(0xab);
230
+ const cidV0 = CID.create(0, 0x70, Digest.create(HashAlgorithm.Sha2_256, hash));
231
+ expect(() => cidToPreimageKey(cidV0.toString())).toThrow(BulletinCidError);
232
+ });
233
+
234
+ test("throws BulletinCidError for CIDv1 with unsupported hash algorithm", () => {
235
+ const unsupportedCode = 0x99;
236
+ const hash = new Uint8Array(32).fill(0xab);
237
+ const cidV1 = CID.createV1(CidCodec.Raw, Digest.create(unsupportedCode, hash));
238
+ expect(() => cidToPreimageKey(cidV1.toString())).toThrow(BulletinCidError);
239
+ });
240
+ });
241
+
242
+ describe("hashToCid", () => {
243
+ test("round-trips with cidToPreimageKey — hex → CID → hex", () => {
244
+ const data = new TextEncoder().encode("hello bulletin");
245
+ const originalCid = computeCid(data);
246
+ const hex = cidToPreimageKey(originalCid);
247
+ const reconstructed = hashToCid(hex);
248
+ expect(reconstructed).toBe(originalCid);
249
+ });
250
+
251
+ test("full cycle: data → CID → hex → CID", () => {
252
+ const data = new Uint8Array([10, 20, 30, 40, 50]);
253
+ const cid1 = computeCid(data);
254
+ const hex = cidToPreimageKey(cid1);
255
+ const cid2 = hashToCid(hex);
256
+ expect(cid2).toBe(cid1);
257
+ });
258
+
259
+ test("deterministic — same hex always yields same CID", () => {
260
+ const hex = cidToPreimageKey(computeCid(new Uint8Array([1, 2, 3])));
261
+ expect(hashToCid(hex)).toBe(hashToCid(hex));
262
+ });
263
+
264
+ test("produces valid base32-lower CIDv1 (default: blake2b-256, raw)", () => {
265
+ const hex = cidToPreimageKey(computeCid(new TextEncoder().encode("test")));
266
+ const cid = hashToCid(hex);
267
+ expect(cid).toMatch(/^b[a-z2-7]+$/);
268
+ const parsed = CID.parse(cid);
269
+ expect(parsed.version).toBe(1);
270
+ expect(parsed.code).toBe(CidCodec.Raw);
271
+ expect(parsed.multihash.code).toBe(HashAlgorithm.Blake2b256);
272
+ });
273
+
274
+ test("sha2-256 produces different CID from blake2b-256 for same hash", () => {
275
+ const hex = `0x${"ab".repeat(32)}` as `0x${string}`;
276
+ const blake = hashToCid(hex, HashAlgorithm.Blake2b256);
277
+ const sha = hashToCid(hex, HashAlgorithm.Sha2_256);
278
+ expect(blake).not.toBe(sha);
279
+ // Both should be valid CIDv1
280
+ expect(CID.parse(blake).version).toBe(1);
281
+ expect(CID.parse(sha).version).toBe(1);
282
+ });
283
+
284
+ test("sha2-256 round-trips through cidToPreimageKey", () => {
285
+ const hex = `0x${"cd".repeat(32)}` as `0x${string}`;
286
+ const cid = hashToCid(hex, HashAlgorithm.Sha2_256);
287
+ const extracted = cidToPreimageKey(cid);
288
+ expect(extracted).toBe(hex);
289
+ });
290
+
291
+ test("keccak-256 round-trips through cidToPreimageKey", () => {
292
+ const hex = `0x${"ef".repeat(32)}` as `0x${string}`;
293
+ const cid = hashToCid(hex, HashAlgorithm.Keccak256);
294
+ const extracted = cidToPreimageKey(cid);
295
+ expect(extracted).toBe(hex);
296
+ });
297
+
298
+ test("dag-pb codec produces different CID from raw for same hash", () => {
299
+ const hex = `0x${"ab".repeat(32)}` as `0x${string}`;
300
+ const rawCid = hashToCid(hex, HashAlgorithm.Blake2b256, CidCodec.Raw);
301
+ const dagPbCid = hashToCid(hex, HashAlgorithm.Blake2b256, CidCodec.DagPb);
302
+ expect(rawCid).not.toBe(dagPbCid);
303
+ expect(CID.parse(dagPbCid).code).toBe(CidCodec.DagPb);
304
+ });
305
+
306
+ test("dag-cbor codec works", () => {
307
+ const hex = `0x${"ab".repeat(32)}` as `0x${string}`;
308
+ const cid = hashToCid(hex, HashAlgorithm.Blake2b256, CidCodec.DagCbor);
309
+ expect(CID.parse(cid).code).toBe(CidCodec.DagCbor);
310
+ });
311
+
312
+ test("throws BulletinCidError for hex that is too short", () => {
313
+ expect(() => hashToCid("0xabcd" as `0x${string}`)).toThrow(BulletinCidError);
314
+ });
315
+
316
+ test("throws BulletinCidError for hex that is too long", () => {
317
+ const tooLong = `0x${"aa".repeat(33)}` as `0x${string}`;
318
+ expect(() => hashToCid(tooLong)).toThrow(BulletinCidError);
319
+ });
320
+
321
+ test("throws for non-hex characters", () => {
322
+ const bad = `0x${"zz".repeat(32)}` as `0x${string}`;
323
+ expect(() => hashToCid(bad)).toThrow();
324
+ });
325
+
326
+ test("throws BulletinCidError for unsupported hash algorithm", () => {
327
+ const hex = `0x${"ab".repeat(32)}` as `0x${string}`;
328
+ expect(() => hashToCid(hex, 0x99 as HashAlgorithm)).toThrow(BulletinCidError);
329
+ });
330
+
331
+ test("throws BulletinCidError for unsupported codec", () => {
332
+ const hex = `0x${"ab".repeat(32)}` as `0x${string}`;
333
+ expect(() => hashToCid(hex, HashAlgorithm.Blake2b256, 0x99 as CidCodec)).toThrow(
334
+ BulletinCidError,
335
+ );
336
+ });
337
+ });
338
+ }
package/src/client.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { getChainAPI } from "@parity/product-sdk-chain-client";
2
+ import type { PolkadotSigner } from "polkadot-api";
3
+
4
+ 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";
13
+ import { executeQuery } from "./query.js";
14
+ 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";
27
+
28
+ /**
29
+ * Ergonomic entry point for Bulletin Chain operations.
30
+ *
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.
33
+ *
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.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const bulletin = await BulletinClient.create("paseo");
41
+ * const result = await bulletin.upload(fileBytes);
42
+ * const metadata = await bulletin.fetchJson<Metadata>(result.cid);
43
+ * ```
44
+ */
45
+ export class BulletinClient {
46
+ readonly api: BulletinApi;
47
+ readonly gateway: string;
48
+
49
+ private queryStrategyPromise: Promise<QueryStrategy> | null = null;
50
+
51
+ private constructor(api: BulletinApi, gateway: string) {
52
+ this.api = api;
53
+ this.gateway = gateway;
54
+ }
55
+
56
+ /** Lazily resolve and cache the query strategy for the client lifetime. */
57
+ private resolveQuery(): Promise<QueryStrategy> {
58
+ if (!this.queryStrategyPromise) {
59
+ this.queryStrategyPromise = resolveQueryStrategy();
60
+ }
61
+ return this.queryStrategyPromise;
62
+ }
63
+
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
+ /**
81
+ * Reconstruct a CID from a `0x`-prefixed hex hash. Static — no instance needed.
82
+ *
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.
85
+ *
86
+ * @see {@link hashToCid} for full documentation.
87
+ */
88
+ static hashToCid(hexHash: `0x${string}`, hashCode?: HashAlgorithm, codec?: CidCodec): string {
89
+ return hashToCid(hexHash, hashCode, codec);
90
+ }
91
+
92
+ /**
93
+ * Upload data to the Bulletin Chain.
94
+ *
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).
98
+ */
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 });
105
+ }
106
+
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 });
120
+ }
121
+
122
+ /**
123
+ * Fetch raw bytes by CID.
124
+ *
125
+ * Uses host preimage lookup with caching.
126
+ */
127
+ async fetchBytes(cid: string, options?: QueryOptions): Promise<Uint8Array> {
128
+ const strategy = await this.resolveQuery();
129
+ return executeQuery(strategy, cid, options);
130
+ }
131
+
132
+ /**
133
+ * Fetch and parse JSON by CID.
134
+ *
135
+ * Auto-resolves query path (same as {@link fetchBytes}).
136
+ */
137
+ async fetchJson<T>(cid: string, options?: QueryOptions): Promise<T> {
138
+ const bytes = await this.fetchBytes(cid, options);
139
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
140
+ }
141
+
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);
150
+ }
151
+
152
+ /**
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.
160
+ *
161
+ * @see {@link checkAuthorization} for the standalone function equivalent.
162
+ */
163
+ async checkAuthorization(address: string): Promise<AuthorizationStatus> {
164
+ return checkAuthorization(this.api, address);
165
+ }
166
+ }
167
+
168
+ if (import.meta.vitest) {
169
+ const { describe, test, expect, vi } = import.meta.vitest;
170
+
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);
203
+ });
204
+
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));
209
+ });
210
+
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);
216
+ });
217
+
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
+ });
222
+
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
+ });
230
+
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);
253
+ });
254
+ });
255
+ }