@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/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
+ * `verifyOnChain(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 { BulletinCidError } from "./errors.js";
21
+ import type { BulletinApi } 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 verifyOnChain}.
46
+ */
47
+ export interface VerifyOnChainOptions {
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 bulletin chain's `Transactions`
67
+ * storage 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 bulletin API.
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 verifyOnChain(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 verifyOnChain(
90
+ api: BulletinApi,
91
+ cid: string,
92
+ options: VerifyOnChainOptions,
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
+ "Bulletin 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 BulletinCidError(`Invalid CID: ${cid}`, cid);
186
+ }
187
+ if (parsed.version !== 1) {
188
+ throw new BulletinCidError(`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 BulletinCidError(
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 BulletinApi;
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("verifyOnChain", () => {
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 verifyOnChain(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 verifyOnChain(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 verifyOnChain(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 verifyOnChain(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 verifyOnChain(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 verifyOnChain(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 verifyOnChain(api, cid, { block: 100, index: 2 });
339
+ expect(result?.index).toBe(2);
340
+ });
341
+
342
+ test("throws BulletinCidError on invalid CID", async () => {
343
+ const api = makeMockApi(vi.fn());
344
+ await expect(verifyOnChain(api, "not-a-cid", { block: 1 })).rejects.toThrow(
345
+ BulletinCidError,
346
+ );
347
+ });
348
+
349
+ test("throws when api lacks the expected query path", async () => {
350
+ const api = {} as BulletinApi;
351
+ const digest = new Uint8Array(32).fill(0xab);
352
+ const cid = await makeCidWithDigest(digest);
353
+ await expect(verifyOnChain(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 verifyOnChain(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 verifyOnChain(api, cid, { block: 42 });
381
+ expect(getValue).toHaveBeenCalledWith(42);
382
+ });
383
+ });
384
+ }
package/src/gateway.ts DELETED
@@ -1,209 +0,0 @@
1
- import { BulletinGatewayFetchError, BulletinGatewayUnavailableError } from "./errors.js";
2
- import type { Environment, FetchOptions } from "./types.js";
3
-
4
- /** Add entries here as bulletin gateways go live on each network. */
5
- const GATEWAYS: Partial<Record<Environment, string>> = {
6
- paseo: "https://paseo-ipfs.polkadot.io/ipfs/",
7
- };
8
-
9
- const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
10
-
11
- /**
12
- * Get the IPFS gateway URL for an environment.
13
- * @throws {BulletinGatewayUnavailableError} If the network doesn't have a live gateway yet.
14
- */
15
- export function getGateway(env: Environment): string {
16
- const gw = GATEWAYS[env];
17
- if (!gw) {
18
- throw new BulletinGatewayUnavailableError(env);
19
- }
20
- return gw;
21
- }
22
-
23
- /** Build the full gateway URL for a CID. */
24
- export function gatewayUrl(cid: string, gateway: string): string {
25
- return `${gateway}${cid}`;
26
- }
27
-
28
- /** Check if a CID exists on the gateway (HEAD request). Returns false on any error or timeout. */
29
- export async function cidExists(
30
- cid: string,
31
- gateway: string,
32
- options?: FetchOptions,
33
- ): Promise<boolean> {
34
- const timeoutMs = options?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
35
- const controller = new AbortController();
36
- const timer = setTimeout(() => controller.abort(), timeoutMs);
37
- try {
38
- const response = await fetch(gatewayUrl(cid, gateway), {
39
- method: "HEAD",
40
- signal: controller.signal,
41
- });
42
- return response.ok;
43
- } catch {
44
- return false;
45
- } finally {
46
- clearTimeout(timer);
47
- }
48
- }
49
-
50
- /**
51
- * Fetch raw bytes from the gateway.
52
- * @throws {BulletinGatewayFetchError} If the gateway returns a non-OK response.
53
- */
54
- export async function fetchBytes(
55
- cid: string,
56
- gateway: string,
57
- options?: FetchOptions,
58
- ): Promise<Uint8Array> {
59
- const timeoutMs = options?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
60
- const controller = new AbortController();
61
- const timer = setTimeout(() => controller.abort(), timeoutMs);
62
-
63
- try {
64
- const response = await fetch(gatewayUrl(cid, gateway), { signal: controller.signal });
65
- if (!response.ok) {
66
- throw new BulletinGatewayFetchError(cid, response.status, response.statusText);
67
- }
68
- return new Uint8Array(await response.arrayBuffer());
69
- } finally {
70
- clearTimeout(timer);
71
- }
72
- }
73
-
74
- /** Fetch and parse JSON from the gateway. */
75
- export async function fetchJson<T>(
76
- cid: string,
77
- gateway: string,
78
- options?: FetchOptions,
79
- ): Promise<T> {
80
- const bytes = await fetchBytes(cid, gateway, options);
81
- return JSON.parse(new TextDecoder().decode(bytes)) as T;
82
- }
83
-
84
- if (import.meta.vitest) {
85
- const { describe, test, expect, vi, afterEach } = import.meta.vitest;
86
-
87
- afterEach(() => {
88
- vi.restoreAllMocks();
89
- });
90
-
91
- describe("getGateway", () => {
92
- test("returns known URL for paseo", () => {
93
- const gw = getGateway("paseo");
94
- expect(gw).toMatch(/^https:\/\//);
95
- expect(gw).toMatch(/\/ipfs\/$/);
96
- });
97
-
98
- test("throws BulletinGatewayUnavailableError for environments without a live gateway", () => {
99
- expect(() => getGateway("polkadot")).toThrow(BulletinGatewayUnavailableError);
100
- expect(() => getGateway("kusama")).toThrow(BulletinGatewayUnavailableError);
101
- });
102
- });
103
-
104
- describe("gatewayUrl", () => {
105
- test("concatenates gateway and CID", () => {
106
- expect(gatewayUrl("bafyabc", "https://gw.example/ipfs/")).toBe(
107
- "https://gw.example/ipfs/bafyabc",
108
- );
109
- });
110
- });
111
-
112
- describe("cidExists", () => {
113
- test("returns true for 200 response", async () => {
114
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
115
- expect(await cidExists("bafyabc", "https://gw/ipfs/")).toBe(true);
116
- });
117
-
118
- test("returns false for 404 response", async () => {
119
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
120
- expect(await cidExists("bafyabc", "https://gw/ipfs/")).toBe(false);
121
- });
122
-
123
- test("returns false on network error", async () => {
124
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
125
- expect(await cidExists("bafyabc", "https://gw/ipfs/")).toBe(false);
126
- });
127
-
128
- test("returns false on timeout", async () => {
129
- vi.stubGlobal(
130
- "fetch",
131
- vi.fn().mockImplementation(
132
- (_url: string, init: { signal: AbortSignal }) =>
133
- new Promise((_resolve, reject) => {
134
- init.signal.addEventListener("abort", () =>
135
- reject(new DOMException("aborted", "AbortError")),
136
- );
137
- }),
138
- ),
139
- );
140
- expect(await cidExists("bafyabc", "https://gw/ipfs/", { timeoutMs: 10 })).toBe(false);
141
- });
142
- });
143
-
144
- describe("fetchBytes", () => {
145
- test("returns bytes from response", async () => {
146
- const payload = new Uint8Array([1, 2, 3]);
147
- vi.stubGlobal(
148
- "fetch",
149
- vi.fn().mockResolvedValue({
150
- ok: true,
151
- arrayBuffer: () => Promise.resolve(payload.buffer),
152
- }),
153
- );
154
- const result = await fetchBytes("bafyabc", "https://gw/ipfs/");
155
- expect(result).toEqual(payload);
156
- });
157
-
158
- test("throws BulletinGatewayFetchError on non-ok response", async () => {
159
- vi.stubGlobal(
160
- "fetch",
161
- vi.fn().mockResolvedValue({
162
- ok: false,
163
- status: 500,
164
- statusText: "Internal Server Error",
165
- }),
166
- );
167
- const err = await fetchBytes("bafyabc", "https://gw/ipfs/").catch((e) => e);
168
- expect(err).toBeInstanceOf(BulletinGatewayFetchError);
169
- expect(err.cid).toBe("bafyabc");
170
- expect(err.status).toBe(500);
171
- });
172
-
173
- test("throws on timeout", async () => {
174
- vi.stubGlobal(
175
- "fetch",
176
- vi.fn().mockImplementation(
177
- (_url: string, init: { signal: AbortSignal }) =>
178
- new Promise((_resolve, reject) => {
179
- init.signal.addEventListener("abort", () =>
180
- reject(new DOMException("aborted", "AbortError")),
181
- );
182
- }),
183
- ),
184
- );
185
- await expect(
186
- fetchBytes("bafyabc", "https://gw/ipfs/", { timeoutMs: 10 }),
187
- ).rejects.toThrow();
188
- });
189
- });
190
-
191
- describe("fetchJson", () => {
192
- test("parses JSON from response", async () => {
193
- const obj = { name: "test", value: 42 };
194
- const bytes = new TextEncoder().encode(JSON.stringify(obj));
195
- vi.stubGlobal(
196
- "fetch",
197
- vi.fn().mockResolvedValue({
198
- ok: true,
199
- arrayBuffer: () => Promise.resolve(bytes.buffer),
200
- }),
201
- );
202
- const result = await fetchJson<{ name: string; value: number }>(
203
- "bafyabc",
204
- "https://gw/ipfs/",
205
- );
206
- expect(result).toEqual(obj);
207
- });
208
- });
209
- }
@@ -1,66 +0,0 @@
1
- import { getPreimageManager } from "@parity/product-sdk-host";
2
- import { createLogger } from "@parity/product-sdk-logger";
3
- import type { PolkadotSigner } from "polkadot-api";
4
-
5
- import { BulletinHostUnavailableError } from "./errors.js";
6
-
7
- const log = createLogger("bulletin");
8
-
9
- /**
10
- * Discriminated union describing how data will be uploaded to the Bulletin Chain.
11
- *
12
- * - `"preimage"` — the host handles signing and chain submission via its preimage API.
13
- * - `"signer"` — a `TransactionStorage.store` transaction is signed and submitted directly.
14
- */
15
- export type UploadStrategy =
16
- | { kind: "preimage"; submit: (data: Uint8Array) => Promise<string> }
17
- | { kind: "signer"; signer: PolkadotSigner };
18
-
19
- /**
20
- * Determine the upload strategy for the Bulletin Chain.
21
- *
22
- * Resolution order:
23
- * 1. If an explicit signer is provided, use it directly.
24
- * 2. Otherwise, use the host preimage API (the SDK is designed to run inside a container).
25
- *
26
- * @param explicitSigner - Optional signer provided by the caller. When present,
27
- * skips host auto-detection entirely.
28
- * @returns The resolved upload strategy.
29
- * @throws {Error} If no signer is provided and the host preimage API is unavailable.
30
- */
31
- export async function resolveUploadStrategy(
32
- explicitSigner?: PolkadotSigner,
33
- ): Promise<UploadStrategy> {
34
- if (explicitSigner) {
35
- log.debug("using explicit signer provided by caller");
36
- return { kind: "signer", signer: explicitSigner };
37
- }
38
-
39
- // Use the host preimage API (inside container)
40
- const preimageManager = await getPreimageManager();
41
- if (preimageManager) {
42
- log.info("using host preimage API for bulletin upload");
43
- return { kind: "preimage", submit: (data) => preimageManager.submit(data) };
44
- }
45
-
46
- throw new BulletinHostUnavailableError("upload");
47
- }
48
-
49
- if (import.meta.vitest) {
50
- const { describe, test, expect, vi } = import.meta.vitest;
51
-
52
- describe("resolveUploadStrategy", () => {
53
- test("returns explicit signer when provided", async () => {
54
- const signer = { publicKey: new Uint8Array(32) } as PolkadotSigner;
55
- const strategy = await resolveUploadStrategy(signer);
56
- expect(strategy.kind).toBe("signer");
57
- if (strategy.kind === "signer") {
58
- expect(strategy.signer).toBe(signer);
59
- }
60
- });
61
-
62
- // Note: Tests for host preimage manager integration require e2e testing
63
- // as they depend on the actual host container environment.
64
- // The explicit signer path above validates the core logic.
65
- });
66
- }