@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/LICENSE +201 -0
- package/dist/index.d.ts +555 -0
- package/dist/index.js +1344 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/authorization.ts +191 -0
- package/src/cid.ts +338 -0
- package/src/client.ts +255 -0
- package/src/errors.ts +235 -0
- package/src/gateway.ts +209 -0
- package/src/index.ts +38 -0
- package/src/query.ts +82 -0
- package/src/resolve-query.ts +192 -0
- package/src/resolve-signer.ts +66 -0
- package/src/types.ts +140 -0
- package/src/upload.ts +344 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { getPreimageManager, type PreimageManager } from "@parity/product-sdk-host";
|
|
2
|
+
import { createLogger } from "@parity/product-sdk-logger";
|
|
3
|
+
|
|
4
|
+
import { cidToPreimageKey, computeCid } from "./cid.js";
|
|
5
|
+
import {
|
|
6
|
+
BulletinHostUnavailableError,
|
|
7
|
+
BulletinLookupInterruptedError,
|
|
8
|
+
BulletinLookupTimeoutError,
|
|
9
|
+
} from "./errors.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("bulletin");
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LOOKUP_TIMEOUT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Query strategy for the Bulletin Chain.
|
|
17
|
+
*
|
|
18
|
+
* The host manages the lookup via its preimage subscription API,
|
|
19
|
+
* which includes local caching and managed IPFS polling.
|
|
20
|
+
*/
|
|
21
|
+
export interface QueryStrategy {
|
|
22
|
+
kind: "host-lookup";
|
|
23
|
+
lookup: (cid: string, timeoutMs?: number) => Promise<Uint8Array>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determine the query strategy for the Bulletin Chain.
|
|
28
|
+
*
|
|
29
|
+
* Uses the host preimage lookup API which caches results and manages
|
|
30
|
+
* IPFS polling automatically.
|
|
31
|
+
*
|
|
32
|
+
* @returns The resolved query strategy.
|
|
33
|
+
* @throws {BulletinHostUnavailableError} If the host preimage manager is unavailable.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveQueryStrategy(): Promise<QueryStrategy> {
|
|
36
|
+
const preimageManager = await getPreimageManager();
|
|
37
|
+
if (preimageManager) {
|
|
38
|
+
log.info("using host preimage lookup for bulletin queries");
|
|
39
|
+
return {
|
|
40
|
+
kind: "host-lookup",
|
|
41
|
+
lookup: (cid, timeoutMs) => lookupViaHost(preimageManager, cid, timeoutMs),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new BulletinHostUnavailableError("query");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wrap `preimageManager.lookup` (subscription-based) into a one-shot Promise.
|
|
50
|
+
*
|
|
51
|
+
* Converts the CID to a hex preimage key, subscribes, and resolves on the
|
|
52
|
+
* first non-null callback. Rejects on timeout or if the host interrupts the
|
|
53
|
+
* subscription (e.g. after repeated failures). Always unsubscribes on settlement.
|
|
54
|
+
*
|
|
55
|
+
* @param manager - The product-sdk preimage manager.
|
|
56
|
+
* @param cid - CIDv1 string to look up.
|
|
57
|
+
* @param timeoutMs - Maximum wait time. Default: 30_000ms.
|
|
58
|
+
* @returns The raw bytes of the preimage.
|
|
59
|
+
*/
|
|
60
|
+
export function lookupViaHost(
|
|
61
|
+
manager: PreimageManager,
|
|
62
|
+
cid: string,
|
|
63
|
+
timeoutMs: number = DEFAULT_LOOKUP_TIMEOUT_MS,
|
|
64
|
+
): Promise<Uint8Array> {
|
|
65
|
+
const key = cidToPreimageKey(cid);
|
|
66
|
+
|
|
67
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
68
|
+
const cleanup = () => {
|
|
69
|
+
cancelInterrupt();
|
|
70
|
+
sub.unsubscribe();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const settle = (fn: () => void) => {
|
|
74
|
+
if (timer === null) return;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
timer = null;
|
|
77
|
+
cleanup();
|
|
78
|
+
fn();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
|
82
|
+
settle(() => {
|
|
83
|
+
reject(new BulletinLookupTimeoutError(cid, timeoutMs));
|
|
84
|
+
});
|
|
85
|
+
}, timeoutMs);
|
|
86
|
+
|
|
87
|
+
const sub = manager.lookup(key, (preimage) => {
|
|
88
|
+
if (preimage !== null) {
|
|
89
|
+
settle(() => resolve(preimage));
|
|
90
|
+
}
|
|
91
|
+
// null means "not found yet" — host will keep polling
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const cancelInterrupt = sub.onInterrupt(() => {
|
|
95
|
+
settle(() => {
|
|
96
|
+
reject(new BulletinLookupInterruptedError(cid));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (import.meta.vitest) {
|
|
103
|
+
const { describe, test, expect, vi } = import.meta.vitest;
|
|
104
|
+
|
|
105
|
+
// Note: resolveQueryStrategy tests require e2e testing as they
|
|
106
|
+
// depend on the host container environment.
|
|
107
|
+
|
|
108
|
+
describe("lookupViaHost", () => {
|
|
109
|
+
function createMockManager(
|
|
110
|
+
behavior: "resolve" | "null-then-resolve" | "hang" | "interrupt",
|
|
111
|
+
) {
|
|
112
|
+
const unsubscribe = vi.fn();
|
|
113
|
+
const cancelInterrupt = vi.fn();
|
|
114
|
+
let interruptCb: VoidFunction | undefined;
|
|
115
|
+
|
|
116
|
+
const lookup = vi.fn((_key: string, callback: (p: Uint8Array | null) => void) => {
|
|
117
|
+
const data = new Uint8Array([10, 20, 30]);
|
|
118
|
+
queueMicrotask(() => {
|
|
119
|
+
if (behavior === "resolve") {
|
|
120
|
+
callback(data);
|
|
121
|
+
} else if (behavior === "null-then-resolve") {
|
|
122
|
+
callback(null);
|
|
123
|
+
queueMicrotask(() => callback(data));
|
|
124
|
+
} else if (behavior === "interrupt") {
|
|
125
|
+
interruptCb?.();
|
|
126
|
+
}
|
|
127
|
+
// "hang" does nothing
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
unsubscribe,
|
|
131
|
+
onInterrupt: (cb: VoidFunction) => {
|
|
132
|
+
interruptCb = cb;
|
|
133
|
+
return cancelInterrupt;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return { lookup, unsubscribe, cancelInterrupt, submit: vi.fn() };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const testCid = computeCid(new TextEncoder().encode("test"));
|
|
142
|
+
|
|
143
|
+
test("resolves on first non-null callback", async () => {
|
|
144
|
+
const manager = createMockManager("resolve");
|
|
145
|
+
const result = await lookupViaHost(manager, testCid);
|
|
146
|
+
expect(result).toEqual(new Uint8Array([10, 20, 30]));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("ignores null callbacks and resolves on subsequent data", async () => {
|
|
150
|
+
const manager = createMockManager("null-then-resolve");
|
|
151
|
+
const result = await lookupViaHost(manager, testCid);
|
|
152
|
+
expect(result).toEqual(new Uint8Array([10, 20, 30]));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("rejects with BulletinLookupTimeoutError on timeout", async () => {
|
|
156
|
+
const { BulletinLookupTimeoutError } = await import("./errors.js");
|
|
157
|
+
const manager = createMockManager("hang");
|
|
158
|
+
const err = await lookupViaHost(manager, testCid, 50).catch((e) => e);
|
|
159
|
+
expect(err).toBeInstanceOf(BulletinLookupTimeoutError);
|
|
160
|
+
expect(err.cid).toBe(testCid);
|
|
161
|
+
expect(err.timeoutMs).toBe(50);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("rejects with BulletinLookupInterruptedError on interrupt", async () => {
|
|
165
|
+
const { BulletinLookupInterruptedError } = await import("./errors.js");
|
|
166
|
+
const manager = createMockManager("interrupt");
|
|
167
|
+
const err = await lookupViaHost(manager, testCid).catch((e) => e);
|
|
168
|
+
expect(err).toBeInstanceOf(BulletinLookupInterruptedError);
|
|
169
|
+
expect(err.cid).toBe(testCid);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("calls unsubscribe and cancelInterrupt on resolution", async () => {
|
|
173
|
+
const manager = createMockManager("resolve");
|
|
174
|
+
await lookupViaHost(manager, testCid);
|
|
175
|
+
expect(manager.unsubscribe).toHaveBeenCalledOnce();
|
|
176
|
+
expect(manager.cancelInterrupt).toHaveBeenCalledOnce();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("calls unsubscribe on interrupt", async () => {
|
|
180
|
+
const manager = createMockManager("interrupt");
|
|
181
|
+
await lookupViaHost(manager, testCid).catch(() => {});
|
|
182
|
+
expect(manager.unsubscribe).toHaveBeenCalledOnce();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("passes correct hex key to manager", async () => {
|
|
186
|
+
const expectedKey = cidToPreimageKey(testCid);
|
|
187
|
+
const manager = createMockManager("resolve");
|
|
188
|
+
await lookupViaHost(manager, testCid);
|
|
189
|
+
expect(manager.lookup).toHaveBeenCalledWith(expectedKey, expect.any(Function));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { TypedApi } from "polkadot-api";
|
|
2
|
+
import type { TxStatus, WaitFor } from "@parity/product-sdk-tx";
|
|
3
|
+
|
|
4
|
+
/** Typed API for the Bulletin Chain, derived from PAPI descriptors. */
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
export type BulletinApi = TypedApi<any>;
|
|
7
|
+
|
|
8
|
+
export type { Environment } from "@parity/product-sdk-chain-client";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for {@link upload}.
|
|
12
|
+
*
|
|
13
|
+
* Note: `waitFor`, `timeoutMs`, and `onStatus` only apply to the **transaction**
|
|
14
|
+
* upload path (when an explicit signer is used or the dev signer fallback is active).
|
|
15
|
+
* The preimage path delegates to the host which controls its own submission
|
|
16
|
+
* lifecycle — these options are ignored in that case.
|
|
17
|
+
*/
|
|
18
|
+
export interface UploadOptions {
|
|
19
|
+
/** IPFS gateway base URL (e.g., from `getGateway("paseo")`). If provided, result includes gatewayUrl. */
|
|
20
|
+
gateway?: string;
|
|
21
|
+
/** When to resolve: `"best-block"` (default) or `"finalized"`. Transaction path only. */
|
|
22
|
+
waitFor?: WaitFor;
|
|
23
|
+
/** Timeout in ms. Default: 300_000 (5 min). Transaction path only. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
/** Lifecycle status callback for UI progress. Transaction path only. */
|
|
26
|
+
onStatus?: (status: TxStatus) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Fields common to all upload results. */
|
|
30
|
+
interface UploadResultBase {
|
|
31
|
+
/** CIDv1 string (blake2b-256, raw codec). */
|
|
32
|
+
cid: string;
|
|
33
|
+
/** Gateway URL. Present only if `gateway` was provided in options. */
|
|
34
|
+
gatewayUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Result of a successful upload to the Bulletin Chain.
|
|
39
|
+
*
|
|
40
|
+
* Discriminated on `kind`:
|
|
41
|
+
* - `"transaction"` — uploaded via a signed `TransactionStorage.store` extrinsic.
|
|
42
|
+
* - `"preimage"` — uploaded via the host preimage API (no user signing).
|
|
43
|
+
*
|
|
44
|
+
* Use `result.kind` to narrow the type and access path-specific fields.
|
|
45
|
+
*/
|
|
46
|
+
export type UploadResult =
|
|
47
|
+
| (UploadResultBase & {
|
|
48
|
+
/** Upload was performed via a signed transaction. */
|
|
49
|
+
kind: "transaction";
|
|
50
|
+
/** Block hash where the store transaction was included. */
|
|
51
|
+
blockHash: string;
|
|
52
|
+
})
|
|
53
|
+
| (UploadResultBase & {
|
|
54
|
+
/** Upload was performed via the host preimage API. */
|
|
55
|
+
kind: "preimage";
|
|
56
|
+
/** Hex key returned by the host preimage API. */
|
|
57
|
+
preimageKey: string;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** A single item in a batch upload. */
|
|
61
|
+
export interface BatchUploadItem {
|
|
62
|
+
/** Raw bytes to upload. */
|
|
63
|
+
data: Uint8Array;
|
|
64
|
+
/** Label for progress tracking (e.g., filename). */
|
|
65
|
+
label: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fields common to all batch upload results. */
|
|
69
|
+
interface BatchUploadResultBase {
|
|
70
|
+
label: string;
|
|
71
|
+
cid: string;
|
|
72
|
+
gatewayUrl?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Result for one item in a batch upload.
|
|
77
|
+
*
|
|
78
|
+
* Discriminated on `kind` (upload path) and `success` (outcome).
|
|
79
|
+
* Use `result.success` to check for errors, then `result.kind` to access
|
|
80
|
+
* path-specific fields like `blockHash` or `preimageKey`.
|
|
81
|
+
*/
|
|
82
|
+
export type BatchUploadResult =
|
|
83
|
+
| (BatchUploadResultBase & {
|
|
84
|
+
kind: "transaction";
|
|
85
|
+
success: true;
|
|
86
|
+
/** Block hash where the store transaction was included. */
|
|
87
|
+
blockHash: string;
|
|
88
|
+
})
|
|
89
|
+
| (BatchUploadResultBase & {
|
|
90
|
+
kind: "preimage";
|
|
91
|
+
success: true;
|
|
92
|
+
/** Hex key returned by the host preimage API. */
|
|
93
|
+
preimageKey: string;
|
|
94
|
+
})
|
|
95
|
+
| (BatchUploadResultBase & {
|
|
96
|
+
kind: "transaction" | "preimage";
|
|
97
|
+
success: false;
|
|
98
|
+
/** Error message describing the failure. */
|
|
99
|
+
error: string;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** Options for {@link batchUpload}. */
|
|
103
|
+
export interface BatchUploadOptions extends UploadOptions {
|
|
104
|
+
/** Called after each item completes (success or failure). */
|
|
105
|
+
onProgress?: (completed: number, total: number, current: BatchUploadResult) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Authorization status for a Bulletin Chain account.
|
|
110
|
+
*
|
|
111
|
+
* Returned by {@link checkAuthorization} to enable pre-flight checks before
|
|
112
|
+
* uploading. Consumers can use this to show "not authorized" or "insufficient
|
|
113
|
+
* quota" messages instead of letting the transaction fail.
|
|
114
|
+
*/
|
|
115
|
+
export interface AuthorizationStatus {
|
|
116
|
+
/** Whether an authorization entry exists for this account. */
|
|
117
|
+
authorized: boolean;
|
|
118
|
+
/** Remaining transactions allowed. 0 if not authorized. */
|
|
119
|
+
remainingTransactions: number;
|
|
120
|
+
/** Remaining bytes allowed. 0n if not authorized. */
|
|
121
|
+
remainingBytes: bigint;
|
|
122
|
+
/** Block number when the authorization expires. 0 if not authorized. */
|
|
123
|
+
expiration: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Options for gateway fetch operations. */
|
|
127
|
+
export interface FetchOptions {
|
|
128
|
+
/** Timeout in ms. Default: 30_000. */
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Options for query operations that support host lookup auto-resolution. */
|
|
133
|
+
export interface QueryOptions extends FetchOptions {
|
|
134
|
+
/**
|
|
135
|
+
* Timeout for the host preimage lookup subscription in ms.
|
|
136
|
+
* Only applies when the query resolves through the host path.
|
|
137
|
+
* Default: 30_000.
|
|
138
|
+
*/
|
|
139
|
+
lookupTimeoutMs?: number;
|
|
140
|
+
}
|