@parity/product-sdk-tx 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/batch.ts ADDED
@@ -0,0 +1,419 @@
1
+ import type { PolkadotSigner } from "polkadot-api";
2
+ import { createLogger } from "@parity/product-sdk-logger";
3
+
4
+ import { TxBatchError } from "./errors.js";
5
+ import { submitAndWatch } from "./submit.js";
6
+ import type {
7
+ BatchApi,
8
+ BatchSubmitOptions,
9
+ BatchableCall,
10
+ SubmittableTransaction,
11
+ TxResult,
12
+ } from "./types.js";
13
+
14
+ const log = createLogger("tx:batch");
15
+
16
+ /**
17
+ * Resolve a single call to its decoded call data.
18
+ *
19
+ * Handles three shapes:
20
+ * 1. Ink SDK AsyncTransaction — has `.waited` Promise that resolves to a tx with `.decodedCall`
21
+ * 2. PAPI transaction or object with `.decodedCall` — extract directly
22
+ * 3. Raw decoded call — pass through as-is
23
+ */
24
+ async function resolveDecodedCall(call: BatchableCall): Promise<unknown> {
25
+ if (call != null && typeof call === "object") {
26
+ const obj = call as Record<string, unknown>;
27
+
28
+ // Handle Ink SDK AsyncTransaction: resolve .waited first
29
+ if (
30
+ "waited" in obj &&
31
+ obj.waited &&
32
+ typeof (obj.waited as Promise<unknown>).then === "function"
33
+ ) {
34
+ log.debug("Resolving Ink SDK AsyncTransaction in batch");
35
+ const resolved = (await obj.waited) as Record<string, unknown>;
36
+ if (resolved.decodedCall !== undefined) return resolved.decodedCall;
37
+ throw new TxBatchError("Resolved AsyncTransaction has no decodedCall property");
38
+ }
39
+
40
+ // Handle SubmittableTransaction or object with decodedCall
41
+ if ("decodedCall" in obj && obj.decodedCall !== undefined) {
42
+ return obj.decodedCall;
43
+ }
44
+ }
45
+
46
+ // Reject null, undefined, and primitives — they will cause cryptic codec errors on-chain
47
+ if (call == null || typeof call !== "object") {
48
+ throw new TxBatchError(
49
+ `Invalid batch call: expected a transaction or decoded call object, got ${call === null ? "null" : typeof call}`,
50
+ );
51
+ }
52
+
53
+ // Raw decoded call object — pass through
54
+ return call;
55
+ }
56
+
57
+ /**
58
+ * Batch multiple transactions into a single Substrate Utility batch and submit.
59
+ *
60
+ * Extracts `.decodedCall` from each transaction (handling Ink SDK `AsyncTransaction`
61
+ * wrappers), wraps them in `Utility.batch_all` (or `batch`/`force_batch` via the
62
+ * `mode` option), and submits via {@link submitAndWatch} with full lifecycle tracking.
63
+ *
64
+ * @param calls - Array of transactions, AsyncTransactions, or raw decoded calls to batch.
65
+ * @param api - A typed API with `tx.Utility.batch_all/batch/force_batch`. Works with any
66
+ * chain that has the Utility pallet — no chain-specific imports required.
67
+ * **All calls must target the same chain as this API.** Do not mix decoded calls
68
+ * from different chains (e.g., Asset Hub and Bulletin) in a single batch.
69
+ * @param signer - The signer to use. Can come from a wallet extension, Host API
70
+ * (`getProductAccountSigner`), or {@link createDevSigner}.
71
+ * @param options - Optional {@link BatchSubmitOptions} (extends `SubmitOptions` with `mode`).
72
+ * @returns The transaction result from the batch submission.
73
+ *
74
+ * @throws {TxBatchError} If `calls` is empty.
75
+ * @throws {TxBatchError} If an AsyncTransaction resolves without a `.decodedCall` property.
76
+ * @throws {TxTimeoutError} If the batch transaction does not reach the target state within `timeoutMs`.
77
+ * @throws {TxDispatchError} If the on-chain dispatch fails.
78
+ * @throws {TxSigningRejectedError} If the user rejects signing in their wallet.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * import { batchSubmitAndWatch } from "@parity/product-sdk-tx";
83
+ *
84
+ * const tx1 = api.tx.Balances.transfer_keep_alive({ dest: addr1, value: 1_000n });
85
+ * const tx2 = api.tx.Balances.transfer_keep_alive({ dest: addr2, value: 2_000n });
86
+ *
87
+ * const result = await batchSubmitAndWatch([tx1, tx2], api, signer, {
88
+ * onStatus: (status) => console.log(status),
89
+ * });
90
+ * ```
91
+ */
92
+ export async function batchSubmitAndWatch(
93
+ calls: BatchableCall[],
94
+ api: BatchApi,
95
+ signer: PolkadotSigner,
96
+ options?: BatchSubmitOptions,
97
+ ): Promise<TxResult> {
98
+ if (calls.length === 0) {
99
+ throw new TxBatchError("Cannot batch zero calls");
100
+ }
101
+
102
+ const mode = options?.mode ?? "batch_all";
103
+
104
+ log.info("Resolving batch calls", { count: calls.length, mode });
105
+ const decodedCalls = await Promise.all(calls.map(resolveDecodedCall));
106
+
107
+ log.info("Constructing batch transaction", { mode, callCount: decodedCalls.length });
108
+ const batchTx = api.tx.Utility[mode]({ calls: decodedCalls });
109
+
110
+ return submitAndWatch(batchTx, signer, options);
111
+ }
112
+
113
+ if (import.meta.vitest) {
114
+ const { describe, test, expect, vi, beforeEach } = import.meta.vitest;
115
+ const { configure } = await import("@parity/product-sdk-logger");
116
+ const { TxDispatchError, TxSigningRejectedError } = await import("./errors.js");
117
+ type TxEvent = import("./types.js").TxEvent;
118
+
119
+ // Silence logger during tests
120
+ beforeEach(() => {
121
+ configure({ handler: () => {} });
122
+ });
123
+
124
+ type MockSubscribeHandlers = {
125
+ next: (event: TxEvent) => void;
126
+ error: (error: Error) => void;
127
+ };
128
+
129
+ function createMockTx(
130
+ emitFn: (handlers: MockSubscribeHandlers) => void,
131
+ decodedCall?: unknown,
132
+ ): SubmittableTransaction {
133
+ return {
134
+ signSubmitAndWatch: (_signer: PolkadotSigner, _options?: unknown) => ({
135
+ subscribe: (handlers: MockSubscribeHandlers) => {
136
+ const unsub = vi.fn();
137
+ queueMicrotask(() => emitFn(handlers));
138
+ return { unsubscribe: unsub };
139
+ },
140
+ }),
141
+ decodedCall,
142
+ };
143
+ }
144
+
145
+ const mockSigner = {} as PolkadotSigner;
146
+
147
+ const signedEvent: TxEvent = { type: "signed", txHash: "0xbatch" };
148
+ const bestBlockOk: TxEvent = {
149
+ type: "txBestBlocksState",
150
+ txHash: "0xbatch",
151
+ found: true,
152
+ ok: true,
153
+ events: [{ id: 1 }],
154
+ block: { hash: "0xblock1", number: 100, index: 0 },
155
+ };
156
+
157
+ function createMockBatchApi(emitFn: (handlers: MockSubscribeHandlers) => void): {
158
+ api: BatchApi;
159
+ getCapturedCalls: () => unknown[][];
160
+ } {
161
+ const capturedCalls: unknown[][] = [];
162
+
163
+ const api: BatchApi = {
164
+ tx: {
165
+ Utility: {
166
+ batch: vi.fn((args: { calls: unknown[] }) => {
167
+ capturedCalls.push(args.calls);
168
+ return createMockTx(emitFn);
169
+ }) as BatchApi["tx"]["Utility"]["batch"],
170
+ batch_all: vi.fn((args: { calls: unknown[] }) => {
171
+ capturedCalls.push(args.calls);
172
+ return createMockTx(emitFn);
173
+ }) as BatchApi["tx"]["Utility"]["batch_all"],
174
+ force_batch: vi.fn((args: { calls: unknown[] }) => {
175
+ capturedCalls.push(args.calls);
176
+ return createMockTx(emitFn);
177
+ }) as BatchApi["tx"]["Utility"]["force_batch"],
178
+ },
179
+ },
180
+ };
181
+
182
+ return { api, getCapturedCalls: () => capturedCalls };
183
+ }
184
+
185
+ const successEmit = (h: MockSubscribeHandlers) => {
186
+ h.next(signedEvent);
187
+ h.next(bestBlockOk);
188
+ };
189
+
190
+ describe("batchSubmitAndWatch", () => {
191
+ test("batches multiple transactions with decodedCall", async () => {
192
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
193
+ const calls = [
194
+ { decodedCall: { pallet: "Balances", method: "transfer", args: { value: 1 } } },
195
+ { decodedCall: { pallet: "Balances", method: "transfer", args: { value: 2 } } },
196
+ ];
197
+
198
+ const result = await batchSubmitAndWatch(calls, api, mockSigner);
199
+
200
+ expect(result.ok).toBe(true);
201
+ expect(getCapturedCalls()).toHaveLength(1);
202
+ expect(getCapturedCalls()[0]).toEqual([
203
+ { pallet: "Balances", method: "transfer", args: { value: 1 } },
204
+ { pallet: "Balances", method: "transfer", args: { value: 2 } },
205
+ ]);
206
+ expect(api.tx.Utility.batch_all).toHaveBeenCalledOnce();
207
+ });
208
+
209
+ test("handles Ink SDK AsyncTransaction wrappers", async () => {
210
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
211
+ const asyncCall = {
212
+ waited: Promise.resolve({ decodedCall: { pallet: "Contracts", method: "call" } }),
213
+ signSubmitAndWatch: () => {
214
+ throw new Error("Should not be called");
215
+ },
216
+ };
217
+
218
+ const result = await batchSubmitAndWatch([asyncCall], api, mockSigner);
219
+
220
+ expect(result.ok).toBe(true);
221
+ expect(getCapturedCalls()[0]).toEqual([{ pallet: "Contracts", method: "call" }]);
222
+ });
223
+
224
+ test("accepts raw decoded calls (pass-through)", async () => {
225
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
226
+ const rawCall = { pallet: "System", method: "remark" };
227
+
228
+ const result = await batchSubmitAndWatch([rawCall], api, mockSigner);
229
+
230
+ expect(result.ok).toBe(true);
231
+ expect(getCapturedCalls()[0]).toEqual([{ pallet: "System", method: "remark" }]);
232
+ });
233
+
234
+ test("mixes transaction types in a single batch", async () => {
235
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
236
+ const txWithDecoded = { decodedCall: "call1" };
237
+ const asyncTx = {
238
+ waited: Promise.resolve({ decodedCall: "call2" }),
239
+ signSubmitAndWatch: () => {
240
+ throw new Error("Should not be called");
241
+ },
242
+ };
243
+ const rawCall = { pallet: "System", method: "remark" };
244
+
245
+ const result = await batchSubmitAndWatch(
246
+ [txWithDecoded, asyncTx, rawCall],
247
+ api,
248
+ mockSigner,
249
+ );
250
+
251
+ expect(result.ok).toBe(true);
252
+ expect(getCapturedCalls()[0]).toEqual([
253
+ "call1",
254
+ "call2",
255
+ { pallet: "System", method: "remark" },
256
+ ]);
257
+ });
258
+
259
+ test("throws TxBatchError for empty calls array", async () => {
260
+ const { api } = createMockBatchApi(successEmit);
261
+ await expect(batchSubmitAndWatch([], api, mockSigner)).rejects.toThrow(TxBatchError);
262
+ await expect(batchSubmitAndWatch([], api, mockSigner)).rejects.toThrow(
263
+ "Cannot batch zero calls",
264
+ );
265
+ });
266
+
267
+ test("throws TxBatchError when AsyncTransaction resolves without decodedCall", async () => {
268
+ const { api } = createMockBatchApi(successEmit);
269
+ const badAsync = {
270
+ waited: Promise.resolve({ noDecodedCall: true }),
271
+ signSubmitAndWatch: () => {
272
+ throw new Error("Should not be called");
273
+ },
274
+ };
275
+
276
+ await expect(
277
+ batchSubmitAndWatch([badAsync as unknown as BatchableCall], api, mockSigner),
278
+ ).rejects.toThrow(TxBatchError);
279
+ });
280
+
281
+ test("throws TxBatchError for null call", async () => {
282
+ const { api } = createMockBatchApi(successEmit);
283
+ await expect(
284
+ batchSubmitAndWatch([null as unknown as BatchableCall], api, mockSigner),
285
+ ).rejects.toThrow(TxBatchError);
286
+ await expect(
287
+ batchSubmitAndWatch([null as unknown as BatchableCall], api, mockSigner),
288
+ ).rejects.toThrow("Invalid batch call");
289
+ });
290
+
291
+ test("throws TxBatchError for primitive call", async () => {
292
+ const { api } = createMockBatchApi(successEmit);
293
+ await expect(
294
+ batchSubmitAndWatch([42 as unknown as BatchableCall], api, mockSigner),
295
+ ).rejects.toThrow(TxBatchError);
296
+ await expect(
297
+ batchSubmitAndWatch(["oops" as unknown as BatchableCall], api, mockSigner),
298
+ ).rejects.toThrow("Invalid batch call");
299
+ });
300
+
301
+ test("treats { decodedCall: undefined } as raw pass-through object", async () => {
302
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
303
+ const edgeCase = { decodedCall: undefined, other: "data" };
304
+
305
+ const result = await batchSubmitAndWatch([edgeCase], api, mockSigner);
306
+
307
+ expect(result.ok).toBe(true);
308
+ // decodedCall is undefined so it falls through to raw pass-through
309
+ expect(getCapturedCalls()[0]).toEqual([{ decodedCall: undefined, other: "data" }]);
310
+ });
311
+
312
+ test("defaults to batch_all mode", async () => {
313
+ const { api } = createMockBatchApi(successEmit);
314
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner);
315
+
316
+ expect(api.tx.Utility.batch_all).toHaveBeenCalledOnce();
317
+ expect(api.tx.Utility.batch).not.toHaveBeenCalled();
318
+ expect(api.tx.Utility.force_batch).not.toHaveBeenCalled();
319
+ });
320
+
321
+ test("respects mode: batch", async () => {
322
+ const { api } = createMockBatchApi(successEmit);
323
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner, {
324
+ mode: "batch",
325
+ });
326
+
327
+ expect(api.tx.Utility.batch).toHaveBeenCalledOnce();
328
+ expect(api.tx.Utility.batch_all).not.toHaveBeenCalled();
329
+ });
330
+
331
+ test("respects mode: force_batch", async () => {
332
+ const { api } = createMockBatchApi(successEmit);
333
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner, {
334
+ mode: "force_batch",
335
+ });
336
+
337
+ expect(api.tx.Utility.force_batch).toHaveBeenCalledOnce();
338
+ expect(api.tx.Utility.batch_all).not.toHaveBeenCalled();
339
+ });
340
+
341
+ test("forwards SubmitOptions to submitAndWatch", async () => {
342
+ const statuses: string[] = [];
343
+ const { api } = createMockBatchApi(successEmit);
344
+
345
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner, {
346
+ onStatus: (s) => statuses.push(s),
347
+ });
348
+
349
+ expect(statuses).toContain("signing");
350
+ expect(statuses).toContain("in-block");
351
+ });
352
+
353
+ test("propagates TxDispatchError", async () => {
354
+ const { api } = createMockBatchApi((h) => {
355
+ h.next(signedEvent);
356
+ h.next({
357
+ type: "txBestBlocksState",
358
+ txHash: "0xbatch",
359
+ found: true,
360
+ ok: false,
361
+ events: [],
362
+ block: { hash: "0xblock1", number: 100, index: 0 },
363
+ dispatchError: {
364
+ type: "Module",
365
+ value: { type: "Utility", value: { type: "TooManyCalls" } },
366
+ },
367
+ });
368
+ });
369
+
370
+ await expect(
371
+ batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner),
372
+ ).rejects.toThrow(TxDispatchError);
373
+ });
374
+
375
+ test("propagates TxSigningRejectedError", async () => {
376
+ const { api } = createMockBatchApi((h) => {
377
+ h.error(new Error("User rejected the request"));
378
+ });
379
+
380
+ await expect(
381
+ batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner),
382
+ ).rejects.toThrow(TxSigningRejectedError);
383
+ });
384
+
385
+ test("resolves all calls in parallel", async () => {
386
+ const { api } = createMockBatchApi(successEmit);
387
+ const resolveOrder: number[] = [];
388
+
389
+ const asyncCall1 = {
390
+ waited: new Promise<{ decodedCall: string }>((resolve) => {
391
+ setTimeout(() => {
392
+ resolveOrder.push(1);
393
+ resolve({ decodedCall: "call1" });
394
+ }, 10);
395
+ }),
396
+ signSubmitAndWatch: () => {
397
+ throw new Error("Should not be called");
398
+ },
399
+ };
400
+ const asyncCall2 = {
401
+ waited: new Promise<{ decodedCall: string }>((resolve) => {
402
+ setTimeout(() => {
403
+ resolveOrder.push(2);
404
+ resolve({ decodedCall: "call2" });
405
+ }, 5); // Resolves faster
406
+ }),
407
+ signSubmitAndWatch: () => {
408
+ throw new Error("Should not be called");
409
+ },
410
+ };
411
+
412
+ await batchSubmitAndWatch([asyncCall1, asyncCall2], api, mockSigner);
413
+
414
+ // Both should have resolved (order depends on timing, but both are present)
415
+ expect(resolveOrder).toContain(1);
416
+ expect(resolveOrder).toContain(2);
417
+ });
418
+ });
419
+ }
@@ -0,0 +1,97 @@
1
+ import { DEV_PHRASE } from "@polkadot-labs/hdkd-helpers";
2
+ import { seedToAccount } from "@parity/product-sdk-keys";
3
+ import type { PolkadotSigner } from "polkadot-api";
4
+
5
+ import type { DevAccountName } from "./types.js";
6
+
7
+ /**
8
+ * Create a PolkadotSigner for a standard Substrate dev account.
9
+ *
10
+ * Dev accounts use the well-known Substrate dev mnemonic (`DEV_PHRASE`) with
11
+ * Sr25519 key derivation at the path `//{Name}`. These accounts have known
12
+ * private keys and are pre-funded on dev/test chains.
13
+ *
14
+ * Only for local development, scripts, and testing. Never use in production.
15
+ *
16
+ * @param name - Dev account name ("Alice", "Bob", "Charlie", "Dave", "Eve", or "Ferdie").
17
+ * @returns A PolkadotSigner that can sign transactions.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { createDevSigner } from "@parity/product-sdk-tx";
22
+ *
23
+ * const alice = createDevSigner("Alice");
24
+ * const result = await submitAndWatch(tx, alice);
25
+ * ```
26
+ */
27
+ export function createDevSigner(name: DevAccountName): PolkadotSigner {
28
+ return seedToAccount(DEV_PHRASE, `//${name}`).signer;
29
+ }
30
+
31
+ /**
32
+ * Get the public key bytes for a dev account.
33
+ *
34
+ * Useful for address derivation or identity checks in tests without
35
+ * needing the full signer.
36
+ *
37
+ * @param name - Dev account name.
38
+ * @returns 32-byte Sr25519 public key.
39
+ */
40
+ export function getDevPublicKey(name: DevAccountName): Uint8Array {
41
+ return seedToAccount(DEV_PHRASE, `//${name}`).publicKey;
42
+ }
43
+
44
+ if (import.meta.vitest) {
45
+ const { describe, test, expect } = import.meta.vitest;
46
+
47
+ // Alice's well-known sr25519 public key
48
+ const ALICE_PUBKEY = new Uint8Array([
49
+ 0xd4, 0x35, 0x93, 0xc7, 0x15, 0xfd, 0xd3, 0x1c, 0x61, 0x14, 0x1a, 0xbd, 0x04, 0xa9, 0x9f,
50
+ 0xd6, 0x82, 0x2c, 0x85, 0x58, 0x85, 0x4c, 0xcd, 0xe3, 0x9a, 0x56, 0x84, 0xe7, 0xa5, 0x6d,
51
+ 0xa2, 0x7d,
52
+ ]);
53
+
54
+ describe("createDevSigner", () => {
55
+ test("creates a signer for Alice with known public key", () => {
56
+ const signer = createDevSigner("Alice");
57
+ expect(signer).toBeDefined();
58
+ expect(signer.publicKey).toEqual(ALICE_PUBKEY);
59
+ });
60
+
61
+ test("different names produce different signers", () => {
62
+ const alice = createDevSigner("Alice");
63
+ const bob = createDevSigner("Bob");
64
+ expect(alice.publicKey).not.toEqual(bob.publicKey);
65
+ });
66
+
67
+ test("all dev account names produce valid signers", () => {
68
+ const names: DevAccountName[] = ["Alice", "Bob", "Charlie", "Dave", "Eve", "Ferdie"];
69
+ const keys = new Set<string>();
70
+
71
+ for (const name of names) {
72
+ const signer = createDevSigner(name);
73
+ expect(signer).toBeDefined();
74
+ expect(signer.publicKey).toBeInstanceOf(Uint8Array);
75
+ expect(signer.publicKey.length).toBe(32);
76
+ // All keys should be unique
77
+ const hex = Array.from(signer.publicKey)
78
+ .map((b) => b.toString(16).padStart(2, "0"))
79
+ .join("");
80
+ expect(keys.has(hex)).toBe(false);
81
+ keys.add(hex);
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("getDevPublicKey", () => {
87
+ test("returns Alice's known public key", () => {
88
+ expect(getDevPublicKey("Alice")).toEqual(ALICE_PUBKEY);
89
+ });
90
+
91
+ test("matches the signer's public key", () => {
92
+ const signer = createDevSigner("Bob");
93
+ const pubkey = getDevPublicKey("Bob");
94
+ expect(pubkey).toEqual(signer.publicKey);
95
+ });
96
+ });
97
+ }