@parity/product-sdk-bulletin 0.1.0 → 0.2.1

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.
@@ -1,7 +1,9 @@
1
1
  import { createLogger } from "@parity/product-sdk-logger";
2
+ import { submitAndWatch, type TxStatus, type WaitFor } from "@parity/product-sdk-tx";
3
+ import type { PolkadotSigner } from "polkadot-api";
2
4
  import { Enum } from "polkadot-api";
3
5
 
4
- import { BulletinAuthorizationError } from "./errors.js";
6
+ import { BulletinAuthorizationError, ProductBulletinError } from "./errors.js";
5
7
  import type { AuthorizationStatus, BulletinApi } from "./types.js";
6
8
 
7
9
  const log = createLogger("bulletin");
@@ -63,10 +65,18 @@ export async function checkAuthorization(
63
65
  return NOT_AUTHORIZED;
64
66
  }
65
67
 
68
+ // After polkadot-bulletin-chain PR #448 (e543696, 2026-04-30), AuthorizationExtent's
69
+ // `transactions` and `bytes` fields are *consumed counters*; the granted allowance moved
70
+ // to `transactions_allowance` / `bytes_allowance`. Compute remaining = allowance − consumed
71
+ // so the public `remainingTransactions` / `remainingBytes` contract stays semantically
72
+ // honest.
66
73
  const status: AuthorizationStatus = {
67
74
  authorized: true,
68
- remainingTransactions: auth.extent.transactions,
69
- remainingBytes: auth.extent.bytes,
75
+ remainingTransactions: auth.extent.transactions_allowance - auth.extent.transactions,
76
+ // `auth` is `any` (TypedApi<any> upstream — see line 53). TS narrows
77
+ // `any - any` to `number`, so cast each u64 operand to bigint to keep
78
+ // the subtraction in bigint space. Runtime values are bigints from PAPI.
79
+ remainingBytes: (auth.extent.bytes_allowance as bigint) - (auth.extent.bytes as bigint),
70
80
  expiration: auth.expiration,
71
81
  };
72
82
 
@@ -80,6 +90,144 @@ export async function checkAuthorization(
80
90
  return status;
81
91
  }
82
92
 
93
+ /**
94
+ * Options for {@link authorizeAccount}.
95
+ */
96
+ export interface AuthorizeAccountOptions {
97
+ /**
98
+ * Wrap the extrinsic in `Sudo.sudo(...)` before submission. Default: `false`.
99
+ *
100
+ * Use `true` on networks where granting bulletin storage authorization
101
+ * requires sudo permissions (most production / managed test networks).
102
+ * Use `false` (default) when the account self-authorizes — typical for
103
+ * local development chains.
104
+ */
105
+ viaSudo?: boolean;
106
+ /** When to resolve: `"best-block"` (default) or `"finalized"`. */
107
+ waitFor?: WaitFor;
108
+ /** Timeout in ms. Default: 300_000 (5 min). */
109
+ timeoutMs?: number;
110
+ /** Lifecycle status callback for UI progress. */
111
+ onStatus?: (status: TxStatus) => void;
112
+ }
113
+
114
+ /**
115
+ * Grant an account authorization to store data on the Bulletin Chain.
116
+ *
117
+ * Submits a `TransactionStorage.authorize_account` extrinsic, optionally
118
+ * wrapped in `Sudo.sudo(...)` for networks that require sudo to grant
119
+ * authorization. Mirrors the call shape of {@link upload} — top-level
120
+ * function, takes an explicit signer, returns a block hash on success.
121
+ *
122
+ * Pair with {@link checkAuthorization} for a typical "check, grant if
123
+ * insufficient, then upload" flow.
124
+ *
125
+ * ## Additive semantics — call once per authorization need
126
+ *
127
+ * `authorize_account` is **additive** within an unexpired authorization window
128
+ * for `AuthorizationScope::Account` (see `pallet-bulletin-transaction-storage`,
129
+ * `fn authorize`). Each successful call **adds** to the existing
130
+ * `transactions_allowance` and `bytes_allowance` rather than overwriting them.
131
+ *
132
+ * Implications for callers:
133
+ *
134
+ * - Calling this function twice with `(100, 1MB)` while the previous
135
+ * authorization is still active leaves the account with quota for `200`
136
+ * transactions and `2MB` — likely unintended.
137
+ * - **This function does NOT use `withRetry`.** Retrying a successful-but-
138
+ * acknowledgment-lost submission would double-grant the quota. Callers
139
+ * needing retry should wrap this function and use {@link checkAuthorization}
140
+ * to verify the post-state before retrying.
141
+ * - To "reset" a quota, let the existing authorization expire
142
+ * (`AuthorizationPeriod` blocks). The next call after expiry creates a fresh
143
+ * authorization rather than adding.
144
+ *
145
+ * Note: `AuthorizationScope::Preimage` uses `set` semantics in the same
146
+ * pallet. This helper is for account-scope authorization only.
147
+ *
148
+ * @param api - Typed Bulletin Chain API instance.
149
+ * @param who - SS58-encoded account to authorize.
150
+ * @param transactions - Number of transactions to **add** to the account's allowance.
151
+ * @param bytes - Byte budget to **add** to the account's allowance.
152
+ * @param signer - Signer for the extrinsic. On `viaSudo: true` this must be the sudo key.
153
+ * @param options - Optional `viaSudo` flag plus standard submission controls.
154
+ * @returns Block hash where the extrinsic was included.
155
+ * @throws {ProductBulletinError} If `viaSudo: true` is requested but the chain has no `Sudo` pallet.
156
+ *
157
+ * @example Direct (account self-authorizes — local dev)
158
+ * ```ts
159
+ * import { authorizeAccount } from "@parity/product-sdk-bulletin";
160
+ *
161
+ * await authorizeAccount(api, address, 100, 100n * 1024n * 1024n, signer);
162
+ * ```
163
+ *
164
+ * @example Sudo-wrapped (managed test network)
165
+ * ```ts
166
+ * await authorizeAccount(api, userAddress, 100, 1_000_000n, sudoSigner, {
167
+ * viaSudo: true,
168
+ * });
169
+ * ```
170
+ *
171
+ * @see {@link checkAuthorization} for the read counterpart.
172
+ * @see {@link BulletinClient.authorizeAccount} for the client method equivalent.
173
+ */
174
+ export async function authorizeAccount(
175
+ api: BulletinApi,
176
+ who: string,
177
+ transactions: number,
178
+ bytes: bigint,
179
+ signer: PolkadotSigner,
180
+ options: AuthorizeAccountOptions = {},
181
+ ): Promise<{ blockHash: string }> {
182
+ const { viaSudo = false, waitFor, timeoutMs, onStatus } = options;
183
+
184
+ // Single `as any` cast for the whole function. `BulletinApi` is upstream-
185
+ // typed as `TypedApi<any>` (see types.ts), so member access is loose by
186
+ // design. Same pattern as upload.ts:57 — narrowing it requires retyping
187
+ // BulletinApi against a bundled descriptor (out of scope here).
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ const apiTx = (api as any).tx;
190
+
191
+ log.info("authorizeAccount: building extrinsic", {
192
+ who,
193
+ transactions,
194
+ bytes: bytes.toString(),
195
+ viaSudo,
196
+ });
197
+
198
+ if (viaSudo && !apiTx.Sudo?.sudo) {
199
+ throw new ProductBulletinError(
200
+ "viaSudo: true requires the Sudo pallet, which is not available on this network. " +
201
+ "On production networks (Polkadot, Kusama), authorize_account requires governance or a different mechanism.",
202
+ );
203
+ }
204
+
205
+ const authorizeTx = apiTx.TransactionStorage.authorize_account({
206
+ who,
207
+ transactions,
208
+ bytes,
209
+ });
210
+
211
+ const txToSubmit = viaSudo ? apiTx.Sudo.sudo({ call: authorizeTx.decodedCall }) : authorizeTx;
212
+
213
+ // NOTE: Intentionally NOT using `withRetry` here. `authorize_account` is
214
+ // additive (see JSDoc above), so a retry after a successful-but-lost
215
+ // submission would double-grant the quota. Caller-side retry must verify
216
+ // post-state via `checkAuthorization` first.
217
+ const result = await submitAndWatch(txToSubmit, signer, {
218
+ waitFor,
219
+ timeoutMs,
220
+ onStatus,
221
+ });
222
+
223
+ log.info("authorizeAccount: included in block", {
224
+ who,
225
+ blockHash: result.block.hash,
226
+ });
227
+
228
+ return { blockHash: result.block.hash };
229
+ }
230
+
83
231
  if (import.meta.vitest) {
84
232
  const { describe, test, expect, vi } = import.meta.vitest;
85
233
 
@@ -106,9 +254,15 @@ if (import.meta.vitest) {
106
254
  expect(status.expiration).toBe(0);
107
255
  });
108
256
 
109
- test("returns authorization with full quota", async () => {
257
+ test("returns authorization with full quota (fresh authorize, nothing consumed)", async () => {
110
258
  const api = createMockApi({
111
- extent: { transactions: 10, bytes: 1_000_000n },
259
+ extent: {
260
+ transactions: 0,
261
+ transactions_allowance: 10,
262
+ bytes: 0n,
263
+ bytes_permanent: 0n,
264
+ bytes_allowance: 1_000_000n,
265
+ },
112
266
  expiration: 999,
113
267
  });
114
268
  const status = await checkAuthorization(api, "5GrwvaEF...");
@@ -119,9 +273,15 @@ if (import.meta.vitest) {
119
273
  expect(status.expiration).toBe(999);
120
274
  });
121
275
 
122
- test("returns authorization with zero transactions remaining", async () => {
276
+ test("returns authorization with zero transactions remaining (fully consumed)", async () => {
123
277
  const api = createMockApi({
124
- extent: { transactions: 0, bytes: 1_000_000n },
278
+ extent: {
279
+ transactions: 5,
280
+ transactions_allowance: 5,
281
+ bytes: 0n,
282
+ bytes_permanent: 0n,
283
+ bytes_allowance: 1_000_000n,
284
+ },
125
285
  expiration: 999,
126
286
  });
127
287
  const status = await checkAuthorization(api, "5GrwvaEF...");
@@ -130,9 +290,15 @@ if (import.meta.vitest) {
130
290
  expect(status.remainingTransactions).toBe(0);
131
291
  });
132
292
 
133
- test("returns authorization with zero bytes remaining", async () => {
293
+ test("returns authorization with zero bytes remaining (fully consumed)", async () => {
134
294
  const api = createMockApi({
135
- extent: { transactions: 5, bytes: 0n },
295
+ extent: {
296
+ transactions: 0,
297
+ transactions_allowance: 5,
298
+ bytes: 1_000n,
299
+ bytes_permanent: 0n,
300
+ bytes_allowance: 1_000n,
301
+ },
136
302
  expiration: 999,
137
303
  });
138
304
  const status = await checkAuthorization(api, "5GrwvaEF...");
@@ -141,9 +307,33 @@ if (import.meta.vitest) {
141
307
  expect(status.remainingBytes).toBe(0n);
142
308
  });
143
309
 
310
+ test("returns remaining = allowance − consumed when partially used", async () => {
311
+ const api = createMockApi({
312
+ extent: {
313
+ transactions: 3,
314
+ transactions_allowance: 10,
315
+ bytes: 250_000n,
316
+ bytes_permanent: 0n,
317
+ bytes_allowance: 1_000_000n,
318
+ },
319
+ expiration: 999,
320
+ });
321
+ const status = await checkAuthorization(api, "5GrwvaEF...");
322
+
323
+ expect(status.authorized).toBe(true);
324
+ expect(status.remainingTransactions).toBe(7); // 10 − 3
325
+ expect(status.remainingBytes).toBe(750_000n); // 1M − 250K
326
+ });
327
+
144
328
  test("preserves expiration block number", async () => {
145
329
  const api = createMockApi({
146
- extent: { transactions: 1, bytes: 500n },
330
+ extent: {
331
+ transactions: 0,
332
+ transactions_allowance: 1,
333
+ bytes: 0n,
334
+ bytes_permanent: 0n,
335
+ bytes_allowance: 500n,
336
+ },
147
337
  expiration: 12345,
148
338
  });
149
339
  const status = await checkAuthorization(api, "5GrwvaEF...");
@@ -188,4 +378,163 @@ if (import.meta.vitest) {
188
378
  expect(arg.value).toBe("5GrwvaEF...");
189
379
  });
190
380
  });
381
+
382
+ /**
383
+ * Mock factory for `authorizeAccount` tests.
384
+ *
385
+ * Mirrors the mocking style used in `upload.ts` — we don't mock
386
+ * `submitAndWatch` (the SDK helper) directly. Instead we let it call
387
+ * through to a fake `signSubmitAndWatch` on the api, which emits the
388
+ * lifecycle events `submitAndWatch` listens for.
389
+ */
390
+ function createMockApiForAuthorize(blockHash = "0xblockhash") {
391
+ const fakeTx = {
392
+ decodedCall: { fakeCall: true } as unknown,
393
+ signSubmitAndWatch: vi.fn().mockReturnValue({
394
+ subscribe: (handlers: { next: (e: unknown) => void }) => {
395
+ queueMicrotask(() => {
396
+ handlers.next({ type: "signed", txHash: "0xtxhash" });
397
+ handlers.next({
398
+ type: "txBestBlocksState",
399
+ txHash: "0xtxhash",
400
+ found: true,
401
+ ok: true,
402
+ block: { hash: blockHash, number: 1, index: 0 },
403
+ events: [],
404
+ });
405
+ });
406
+ return { unsubscribe: vi.fn() };
407
+ },
408
+ }),
409
+ };
410
+
411
+ return {
412
+ api: {
413
+ tx: {
414
+ TransactionStorage: {
415
+ authorize_account: vi.fn().mockReturnValue(fakeTx),
416
+ },
417
+ Sudo: {
418
+ sudo: vi.fn().mockReturnValue(fakeTx),
419
+ },
420
+ },
421
+ },
422
+ fakeTx,
423
+ };
424
+ }
425
+
426
+ const mockSigner = {} as PolkadotSigner;
427
+
428
+ describe("authorizeAccount", () => {
429
+ test("direct path: calls TransactionStorage.authorize_account with the right params", async () => {
430
+ const { api } = createMockApiForAuthorize();
431
+
432
+ await authorizeAccount(
433
+ api as unknown as BulletinApi,
434
+ "5GrwvaEF...",
435
+ 100,
436
+ 1_000_000n,
437
+ mockSigner,
438
+ );
439
+
440
+ expect(api.tx.TransactionStorage.authorize_account).toHaveBeenCalledOnce();
441
+ const arg = api.tx.TransactionStorage.authorize_account.mock.calls[0][0];
442
+ expect(arg.who).toBe("5GrwvaEF...");
443
+ expect(arg.transactions).toBe(100);
444
+ expect(arg.bytes).toBe(1_000_000n);
445
+ });
446
+
447
+ test("direct path: does NOT call Sudo.sudo when viaSudo is false (default)", async () => {
448
+ const { api } = createMockApiForAuthorize();
449
+
450
+ await authorizeAccount(
451
+ api as unknown as BulletinApi,
452
+ "5GrwvaEF...",
453
+ 10,
454
+ 100n,
455
+ mockSigner,
456
+ );
457
+
458
+ expect(api.tx.Sudo.sudo).not.toHaveBeenCalled();
459
+ });
460
+
461
+ test("direct path: returns the block hash from submission", async () => {
462
+ const { api } = createMockApiForAuthorize("0xdeadbeef");
463
+
464
+ const result = await authorizeAccount(
465
+ api as unknown as BulletinApi,
466
+ "5GrwvaEF...",
467
+ 10,
468
+ 100n,
469
+ mockSigner,
470
+ );
471
+
472
+ expect(result).toEqual({ blockHash: "0xdeadbeef" });
473
+ });
474
+
475
+ test("sudo path: wraps the authorize_account call inside Sudo.sudo", async () => {
476
+ const { api, fakeTx } = createMockApiForAuthorize();
477
+
478
+ await authorizeAccount(
479
+ api as unknown as BulletinApi,
480
+ "5GrwvaEF...",
481
+ 10,
482
+ 100n,
483
+ mockSigner,
484
+ { viaSudo: true },
485
+ );
486
+
487
+ expect(api.tx.Sudo.sudo).toHaveBeenCalledOnce();
488
+ const sudoArg = api.tx.Sudo.sudo.mock.calls[0][0];
489
+ expect(sudoArg.call).toBe(fakeTx.decodedCall);
490
+ });
491
+
492
+ test("sudo path: still returns the block hash from the sudo extrinsic", async () => {
493
+ const { api } = createMockApiForAuthorize("0xsudoblock");
494
+
495
+ const result = await authorizeAccount(
496
+ api as unknown as BulletinApi,
497
+ "5GrwvaEF...",
498
+ 10,
499
+ 100n,
500
+ mockSigner,
501
+ { viaSudo: true },
502
+ );
503
+
504
+ expect(result).toEqual({ blockHash: "0xsudoblock" });
505
+ });
506
+
507
+ test("throws ProductBulletinError when viaSudo is true but the chain lacks a Sudo pallet", async () => {
508
+ const apiWithoutSudo = {
509
+ tx: {
510
+ TransactionStorage: {
511
+ authorize_account: vi.fn().mockReturnValue({
512
+ decodedCall: {} as unknown,
513
+ signSubmitAndWatch: vi.fn(),
514
+ }),
515
+ },
516
+ // Sudo intentionally absent — represents production Polkadot/Kusama
517
+ },
518
+ };
519
+
520
+ const err = await authorizeAccount(
521
+ apiWithoutSudo as unknown as BulletinApi,
522
+ "5GrwvaEF...",
523
+ 10,
524
+ 100n,
525
+ mockSigner,
526
+ { viaSudo: true },
527
+ ).catch((e: unknown) => e);
528
+
529
+ expect(err).toBeInstanceOf(ProductBulletinError);
530
+ expect((err as Error).message).toMatch(/Sudo pallet/i);
531
+ // Verify we did NOT proceed to submit anything
532
+ expect(apiWithoutSudo.tx.TransactionStorage.authorize_account).not.toHaveBeenCalled();
533
+ });
534
+
535
+ // Note: submission-failure propagation is tested upstream in
536
+ // @parity/product-sdk-tx's own suite. Re-testing it here would just
537
+ // re-test submitAndWatch's error path through a brittle mock, which
538
+ // depends on internal event-shape details outside this file's contract.
539
+ });
191
540
  }