@parity/product-sdk-cloud-storage 0.5.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/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { BulletinTypedApi } from "@parity/bulletin-sdk";
2
+
3
+ /** Typed API for the Cloud Storage (re-export from upstream BulletinTypedApi). */
4
+ export type CloudStorageApi = BulletinTypedApi;
5
+
6
+ /** Re-exported environment string from chain-client. */
7
+ export type { Environment } from "@parity/product-sdk-chain-client";
8
+
9
+ /** //TODO: Come back to this (code docs might need update)
10
+ * Authorization status for a Cloud Storage account.
11
+ *
12
+ * Returned by {@link checkAuthorization} as a pre-flight check before storing
13
+ * data. Consumers can use this to show "not authorized" or "insufficient quota"
14
+ * messages instead of letting the transaction fail mid-execution.
15
+ */
16
+ export interface AuthorizationStatus {
17
+ /** Whether an authorization entry exists for this account. */
18
+ authorized: boolean;
19
+ /** Remaining transactions allowed. 0 if not authorized. */
20
+ remainingTransactions: number;
21
+ /** Remaining bytes allowed. 0n if not authorized. */
22
+ remainingBytes: bigint;
23
+ /** Block number when the authorization expires. 0 if not authorized. */
24
+ expiration: number;
25
+ }
26
+
27
+ /**
28
+ * Options for {@link CloudStorageClient.fetchBytes} / {@link CloudStorageClient.fetchJson}.
29
+ */
30
+ export interface QueryOptions {
31
+ /**
32
+ * Timeout for the host preimage lookup subscription, in ms.
33
+ * Default: 30_000. Applied per lookup — for chunked content (DAG-PB
34
+ * manifest CIDs), the manifest fetch and each child chunk fetch
35
+ * each get this budget.
36
+ */
37
+ lookupTimeoutMs?: number;
38
+ /**
39
+ * When `true`, return the raw bytes for the requested CID without
40
+ * parsing or recursing into a DAG-PB manifest. Default: `false` — the
41
+ * client transparently reassembles chunked content so callers don't
42
+ * need to know whether a CID points at a single chunk or a manifest.
43
+ *
44
+ * Set this if you want to inspect the manifest itself, e.g., to read
45
+ * `unixfs.fileSize()` ahead of fetching, or to drive your own chunk
46
+ * pipeline.
47
+ */
48
+ noReassemble?: boolean;
49
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Chain-storage verification for stored CIDs.
3
+ *
4
+ * The bulletin chain doesn't store content bytes on-chain — `TransactionStorage
5
+ * .Transactions[block]` holds metadata only (`{ chunk_root, content_hash,
6
+ * hashing, cid_codec, size, block_chunks }`), and the bytes themselves live
7
+ * in IPFS. So "read by CID from chain" isn't possible. What *is* possible is
8
+ * proving that a given CID was stored: parse the CID's digest + multihash
9
+ * code, then look it up in `TransactionStorage.Transactions` and confirm a
10
+ * matching `content_hash` + `hashing`.
11
+ *
12
+ * Common use case: just-after-upload UX — `await client.store(data).send()`
13
+ * gives you back `{ cid, blockNumber, extrinsicIndex? }`, and a follow-up
14
+ * `verifyStored(api, cid, { block: blockNumber })` confirms the metadata
15
+ * landed where expected.
16
+ */
17
+ import { CID } from "multiformats/cid";
18
+
19
+ import { HashAlgorithm } from "./cid.js";
20
+ import { CloudStorageCidError } from "./errors.js";
21
+ import type { CloudStorageApi } from "./types.js";
22
+
23
+ /**
24
+ * Match a multihash code in a CID against the chain's `hashing` enum value.
25
+ */
26
+ const HASH_CODE_TO_ENUM_TYPE: Record<number, "Blake2b256" | "Sha2_256" | "Keccak256"> = {
27
+ [HashAlgorithm.Blake2b256]: "Blake2b256",
28
+ [HashAlgorithm.Sha2_256]: "Sha2_256",
29
+ [HashAlgorithm.Keccak256]: "Keccak256",
30
+ };
31
+
32
+ /** A single matched entry from `TransactionStorage.Transactions`. */
33
+ export interface ChainStoredEntry {
34
+ /** Block number where the transaction was included. */
35
+ block: number;
36
+ /** Index of the entry within the block's transactions array. */
37
+ index: number;
38
+ /** Size of the stored data in bytes (from chain metadata). */
39
+ size: number;
40
+ /** Number of chunks (1 for unchunked data, >1 for chunked + manifest). */
41
+ blockChunks: number;
42
+ }
43
+
44
+ /**
45
+ * Verification options for {@link verifyStored}.
46
+ */
47
+ export interface VerifyStoredOptions {
48
+ /**
49
+ * Block number to look up. Pass the `blockNumber` returned from a prior
50
+ * `store(...).send()` for an O(1) lookup.
51
+ *
52
+ * If omitted, throws — full-chain scans are not supported because
53
+ * `RetentionPeriod` can be many days of blocks. Use `getEntries()` on
54
+ * `api.query.TransactionStorage.Transactions` directly if you need that.
55
+ */
56
+ block: number;
57
+ /**
58
+ * Optional: index within the block. When provided, narrows verification
59
+ * to that exact slot. Useful when re-checking a known `(block, index)`
60
+ * tuple from an earlier receipt.
61
+ */
62
+ index?: number;
63
+ }
64
+
65
+ /**
66
+ * Verify that a CID is recorded in the cloud storage (bulletin chain's `Transactions` storage)
67
+ * at the given block.
68
+ *
69
+ * Returns the matched entry (with block + index) when the CID's content
70
+ * hash and hashing algorithm both match a `Transactions[block]` entry.
71
+ * Returns `null` when no match is found at that block.
72
+ *
73
+ * @param api - Typed Cloud Storage API instance.
74
+ * @param cid - CIDv1 string to look up.
75
+ * @param options - Verification target (block number, optional index).
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const receipt = await client.store(data).send();
80
+ * if (receipt.blockNumber !== undefined) {
81
+ * const entry = await verifyStored(client.api, receipt.cid!.toString(), {
82
+ * block: receipt.blockNumber,
83
+ * index: receipt.extrinsicIndex,
84
+ * });
85
+ * if (!entry) console.warn("CID not found in expected block — chain reorg?");
86
+ * }
87
+ * ```
88
+ */
89
+ export async function verifyStored(
90
+ api: CloudStorageApi,
91
+ cid: string,
92
+ options: VerifyStoredOptions,
93
+ ): Promise<ChainStoredEntry | null> {
94
+ const parsed = parseCidForVerify(cid);
95
+
96
+ const queryFn = (api as unknown as TransactionsQueryApi).query?.TransactionStorage?.Transactions
97
+ ?.getValue;
98
+ if (!queryFn) {
99
+ throw new Error(
100
+ "CloudStorage API does not expose query.TransactionStorage.Transactions — " +
101
+ "the typed API may be incomplete or the runtime version doesn't match the descriptor.",
102
+ );
103
+ }
104
+
105
+ const entries = await queryFn(options.block);
106
+ if (!entries || entries.length === 0) return null;
107
+
108
+ // When an explicit index is provided, check that slot directly — no
109
+ // reason to walk the full array just to skip everything else.
110
+ if (options.index !== undefined) {
111
+ const entry = entries[options.index];
112
+ if (entry && matchesEntry(entry, parsed)) {
113
+ return {
114
+ block: options.block,
115
+ index: options.index,
116
+ size: entry.size,
117
+ blockChunks: entry.block_chunks,
118
+ };
119
+ }
120
+ return null;
121
+ }
122
+
123
+ for (let i = 0; i < entries.length; i++) {
124
+ const entry = entries[i]!;
125
+ if (matchesEntry(entry, parsed)) {
126
+ return {
127
+ block: options.block,
128
+ index: i,
129
+ size: entry.size,
130
+ blockChunks: entry.block_chunks,
131
+ };
132
+ }
133
+ }
134
+
135
+ return null;
136
+ }
137
+
138
+ interface ParsedCid {
139
+ digest: Uint8Array;
140
+ hashType: "Blake2b256" | "Sha2_256" | "Keccak256";
141
+ }
142
+
143
+ /**
144
+ * Hand-rolled mirror of `TransactionStorage.Transactions[block][n]` — the
145
+ * shape PAPI returns at runtime when you call `query.TransactionStorage
146
+ * .Transactions.getValue(block)`. Defined here (rather than derived from
147
+ * `BulletinTypedApi`) because the typed API surfaces these values through
148
+ * `Anonymize<I…>` codec aliases that aren't ergonomic to inline.
149
+ *
150
+ * **If the bulletin runtime changes the entry shape, update this here too.**
151
+ * Source of truth: `TransactionInfo` in
152
+ * `packages/descriptors/chains/bulletin/generated/dist/common-types.d.ts`
153
+ * (look for `chunk_root: FixedSizeBinary<32>` to anchor it). When the
154
+ * descriptor regenerates and the fields shift, this interface, the
155
+ * `cid_codec`/`hashing` matching in `matchesEntry`, and the
156
+ * `HASH_CODE_TO_ENUM_TYPE` map above all need to be re-validated together.
157
+ *
158
+ * `Uint8Array | { asBytes(): Uint8Array }` covers both the raw and Binary-
159
+ * wrapped shapes the codec can return depending on configuration.
160
+ */
161
+ interface ChainEntry {
162
+ chunk_root: { asBytes(): Uint8Array } | Uint8Array;
163
+ content_hash: { asBytes(): Uint8Array } | Uint8Array;
164
+ hashing: { type: "Blake2b256" | "Sha2_256" | "Keccak256" };
165
+ cid_codec: bigint;
166
+ size: number;
167
+ block_chunks: number;
168
+ }
169
+
170
+ interface TransactionsQueryApi {
171
+ query?: {
172
+ TransactionStorage?: {
173
+ Transactions?: {
174
+ getValue: (block: number) => Promise<ChainEntry[] | undefined>;
175
+ };
176
+ };
177
+ };
178
+ }
179
+
180
+ function parseCidForVerify(cid: string): ParsedCid {
181
+ let parsed;
182
+ try {
183
+ parsed = CID.parse(cid);
184
+ } catch {
185
+ throw new CloudStorageCidError(`Invalid CID: ${cid}`, cid);
186
+ }
187
+ if (parsed.version !== 1) {
188
+ throw new CloudStorageCidError(`Expected CIDv1, got CIDv${parsed.version}`, cid);
189
+ }
190
+ const hashType = HASH_CODE_TO_ENUM_TYPE[parsed.multihash.code];
191
+ if (!hashType) {
192
+ throw new CloudStorageCidError(
193
+ `Unsupported hash algorithm 0x${parsed.multihash.code.toString(16)}`,
194
+ cid,
195
+ );
196
+ }
197
+ return { digest: parsed.multihash.digest, hashType };
198
+ }
199
+
200
+ function matchesEntry(entry: ChainEntry, target: ParsedCid): boolean {
201
+ if (entry.hashing.type !== target.hashType) return false;
202
+ const onChainBytes =
203
+ entry.content_hash instanceof Uint8Array
204
+ ? entry.content_hash
205
+ : entry.content_hash.asBytes();
206
+ if (onChainBytes.length !== target.digest.length) return false;
207
+ for (let i = 0; i < onChainBytes.length; i++) {
208
+ if (onChainBytes[i] !== target.digest[i]) return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ if (import.meta.vitest) {
214
+ const { describe, test, expect, vi } = import.meta.vitest;
215
+
216
+ function makeMockApi(getValue: (block: number) => Promise<ChainEntry[] | undefined>) {
217
+ return {
218
+ query: {
219
+ TransactionStorage: {
220
+ Transactions: { getValue },
221
+ },
222
+ },
223
+ } as unknown as CloudStorageApi;
224
+ }
225
+
226
+ function makeEntry(
227
+ digest: Uint8Array,
228
+ hashType: "Blake2b256" | "Sha2_256" | "Keccak256" = "Blake2b256",
229
+ size = 100,
230
+ blockChunks = 1,
231
+ ): ChainEntry {
232
+ return {
233
+ chunk_root: digest,
234
+ content_hash: digest,
235
+ hashing: { type: hashType },
236
+ cid_codec: 0x55n,
237
+ size,
238
+ block_chunks: blockChunks,
239
+ };
240
+ }
241
+
242
+ // Build a real CIDv1 (blake2b-256, raw) we can verify against
243
+ async function makeCidWithDigest(digest: Uint8Array, hashCode = 0xb220): Promise<string> {
244
+ const Digest = await import("multiformats/hashes/digest");
245
+ return CID.createV1(0x55, Digest.create(hashCode, digest)).toString();
246
+ }
247
+
248
+ describe("verifyStored", () => {
249
+ test("returns entry when CID matches at given block", async () => {
250
+ const digest = new Uint8Array(32).fill(0xab);
251
+ const cid = await makeCidWithDigest(digest);
252
+ const api = makeMockApi(vi.fn().mockResolvedValue([makeEntry(digest)]));
253
+
254
+ const result = await verifyStored(api, cid, { block: 100 });
255
+ expect(result).toEqual({ block: 100, index: 0, size: 100, blockChunks: 1 });
256
+ });
257
+
258
+ test("returns null when block has no entries", async () => {
259
+ const digest = new Uint8Array(32).fill(0xab);
260
+ const cid = await makeCidWithDigest(digest);
261
+ const api = makeMockApi(vi.fn().mockResolvedValue(undefined));
262
+
263
+ const result = await verifyStored(api, cid, { block: 100 });
264
+ expect(result).toBeNull();
265
+ });
266
+
267
+ test("returns null when block has entries but none match", async () => {
268
+ const targetDigest = new Uint8Array(32).fill(0xab);
269
+ const otherDigest = new Uint8Array(32).fill(0xcd);
270
+ const cid = await makeCidWithDigest(targetDigest);
271
+ const api = makeMockApi(vi.fn().mockResolvedValue([makeEntry(otherDigest)]));
272
+
273
+ const result = await verifyStored(api, cid, { block: 100 });
274
+ expect(result).toBeNull();
275
+ });
276
+
277
+ test("returns null when hashing algorithm differs", async () => {
278
+ const digest = new Uint8Array(32).fill(0xab);
279
+ // CID uses blake2b-256, chain entry says sha2-256 with same digest bytes
280
+ const cid = await makeCidWithDigest(digest, 0xb220);
281
+ const api = makeMockApi(vi.fn().mockResolvedValue([makeEntry(digest, "Sha2_256")]));
282
+
283
+ const result = await verifyStored(api, cid, { block: 100 });
284
+ expect(result).toBeNull();
285
+ });
286
+
287
+ test("finds match at correct index when multiple entries exist", async () => {
288
+ const targetDigest = new Uint8Array(32).fill(0xab);
289
+ const filler = new Uint8Array(32).fill(0xcd);
290
+ const cid = await makeCidWithDigest(targetDigest);
291
+ const api = makeMockApi(
292
+ vi
293
+ .fn()
294
+ .mockResolvedValue([
295
+ makeEntry(filler),
296
+ makeEntry(filler),
297
+ makeEntry(targetDigest),
298
+ ]),
299
+ );
300
+
301
+ const result = await verifyStored(api, cid, { block: 100 });
302
+ expect(result?.index).toBe(2);
303
+ });
304
+
305
+ test("respects explicit index option", async () => {
306
+ const targetDigest = new Uint8Array(32).fill(0xab);
307
+ const filler = new Uint8Array(32).fill(0xcd);
308
+ const cid = await makeCidWithDigest(targetDigest);
309
+ // Target is at index 2, but caller says index 0 — should not match
310
+ const api = makeMockApi(
311
+ vi
312
+ .fn()
313
+ .mockResolvedValue([
314
+ makeEntry(filler),
315
+ makeEntry(filler),
316
+ makeEntry(targetDigest),
317
+ ]),
318
+ );
319
+
320
+ const result = await verifyStored(api, cid, { block: 100, index: 0 });
321
+ expect(result).toBeNull();
322
+ });
323
+
324
+ test("returns the entry when explicit index matches", async () => {
325
+ const targetDigest = new Uint8Array(32).fill(0xab);
326
+ const filler = new Uint8Array(32).fill(0xcd);
327
+ const cid = await makeCidWithDigest(targetDigest);
328
+ const api = makeMockApi(
329
+ vi
330
+ .fn()
331
+ .mockResolvedValue([
332
+ makeEntry(filler),
333
+ makeEntry(filler),
334
+ makeEntry(targetDigest),
335
+ ]),
336
+ );
337
+
338
+ const result = await verifyStored(api, cid, { block: 100, index: 2 });
339
+ expect(result?.index).toBe(2);
340
+ });
341
+
342
+ test("throws CloudStorageCidError on invalid CID", async () => {
343
+ const api = makeMockApi(vi.fn());
344
+ await expect(verifyStored(api, "not-a-cid", { block: 1 })).rejects.toThrow(
345
+ CloudStorageCidError,
346
+ );
347
+ });
348
+
349
+ test("throws when api lacks the expected query path", async () => {
350
+ const api = {} as CloudStorageApi;
351
+ const digest = new Uint8Array(32).fill(0xab);
352
+ const cid = await makeCidWithDigest(digest);
353
+ await expect(verifyStored(api, cid, { block: 1 })).rejects.toThrow(
354
+ /does not expose query/,
355
+ );
356
+ });
357
+
358
+ test("handles content_hash as a Binary-like wrapper", async () => {
359
+ const digest = new Uint8Array(32).fill(0xab);
360
+ const cid = await makeCidWithDigest(digest);
361
+ const wrapper = { asBytes: () => digest };
362
+ const entry: ChainEntry = {
363
+ chunk_root: wrapper,
364
+ content_hash: wrapper,
365
+ hashing: { type: "Blake2b256" },
366
+ cid_codec: 0x55n,
367
+ size: 50,
368
+ block_chunks: 1,
369
+ };
370
+ const api = makeMockApi(vi.fn().mockResolvedValue([entry]));
371
+ const result = await verifyStored(api, cid, { block: 1 });
372
+ expect(result).toEqual({ block: 1, index: 0, size: 50, blockChunks: 1 });
373
+ });
374
+
375
+ test("passes the block number to the storage call", async () => {
376
+ const digest = new Uint8Array(32).fill(0xab);
377
+ const cid = await makeCidWithDigest(digest);
378
+ const getValue = vi.fn().mockResolvedValue([makeEntry(digest)]);
379
+ const api = makeMockApi(getValue);
380
+ await verifyStored(api, cid, { block: 42 });
381
+ expect(getValue).toHaveBeenCalledWith(42);
382
+ });
383
+ });
384
+ }