@polkadot-apps/tx 0.2.12 → 0.3.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/README.md CHANGED
@@ -29,6 +29,49 @@ const result = await submitAndWatch(tx, signer, {
29
29
  console.log(result.txHash, result.ok);
30
30
  ```
31
31
 
32
+ ## Batch transactions
33
+
34
+ Submit multiple transactions as a single atomic batch. Uses Substrate's `Utility.batch_all` by default (all-or-nothing).
35
+
36
+ ```typescript
37
+ import { batchSubmitAndWatch } from "@polkadot-apps/tx";
38
+
39
+ const tx1 = api.tx.Balances.transfer_keep_alive({ dest: addr1, value: 1_000n });
40
+ const tx2 = api.tx.Balances.transfer_keep_alive({ dest: addr2, value: 2_000n });
41
+ const tx3 = api.tx.System.remark({ remark: Binary.fromText("hello") });
42
+
43
+ const result = await batchSubmitAndWatch([tx1, tx2, tx3], api, signer, {
44
+ onStatus: (status) => console.log(status),
45
+ });
46
+ ```
47
+
48
+ Three batch modes are available:
49
+
50
+ | Mode | Behavior |
51
+ |------|----------|
52
+ | `"batch_all"` (default) | Atomic. Reverts all calls if any single call fails. |
53
+ | `"batch"` | Best-effort. Stops at first failure but earlier successful calls are not reverted. |
54
+ | `"force_batch"` | Like `batch` but continues after failures (never aborts early). |
55
+
56
+ ```typescript
57
+ // Non-atomic: some calls may fail while others succeed
58
+ const result = await batchSubmitAndWatch(calls, api, signer, { mode: "batch" });
59
+ ```
60
+
61
+ > **WARNING:** In `"batch"` and `"force_batch"` modes, `result.ok` is `true` even when individual calls fail. Inspect `result.events` for `Utility.ItemFailed` events to detect individual failures. Only `"batch_all"` guarantees that `result.ok === false` when any call fails.
62
+
63
+ Calls can be PAPI transactions (`.decodedCall` extracted automatically), Ink SDK `AsyncTransaction` wrappers (`.waited` resolved automatically), or raw decoded calls.
64
+
65
+ ```typescript
66
+ // Mix of raw decoded calls and transactions
67
+ const calls = [
68
+ api.tx.Balances.transfer_keep_alive({ dest, value: 1_000n }), // PAPI tx
69
+ extractTransaction(await contract.query("mint", { origin, data })), // Ink SDK
70
+ someDecodedCallObject, // raw
71
+ ];
72
+ const result = await batchSubmitAndWatch(calls, api, signer);
73
+ ```
74
+
32
75
  ## Transaction lifecycle
33
76
 
34
77
  `submitAndWatch` drives a transaction through its full lifecycle: signing, broadcasting, block inclusion, and optional finalization. You choose when to resolve the returned promise with the `waitFor` option.
@@ -77,7 +120,7 @@ const dryRunResult = await contract.query.myMethod(args);
77
120
  const tx = extractTransaction(dryRunResult);
78
121
 
79
122
  const buffered = applyWeightBuffer(dryRunResult.weight_required, {
80
- bufferPercent: 25, // default: 25%
123
+ percent: 25, // default: 25%
81
124
  });
82
125
  ```
83
126
 
@@ -98,7 +141,7 @@ if (!mapped) {
98
141
 
99
142
  ## Retry logic
100
143
 
101
- `withRetry` wraps any async function with exponential backoff and jitter. It does **not** retry `TxDispatchError`, `TxSigningRejectedError`, or `TxTimeoutError` -- these represent terminal conditions that retrying cannot fix.
144
+ `withRetry` wraps any async function with exponential backoff and jitter. It does **not** retry `TxDispatchError`, `TxBatchError`, `TxSigningRejectedError`, or `TxTimeoutError` -- these represent terminal conditions that retrying cannot fix.
102
145
 
103
146
  ```typescript
104
147
  import { withRetry, calculateDelay } from "@polkadot-apps/tx";
@@ -158,6 +201,19 @@ Submit a transaction and watch its lifecycle through to inclusion or finalizatio
158
201
 
159
202
  **Throws**: `TxTimeoutError`, `TxDispatchError`, `TxSigningRejectedError`.
160
203
 
204
+ ### `batchSubmitAndWatch(calls, api, signer, options?): Promise<TxResult>`
205
+
206
+ Batch multiple transactions into a single Substrate Utility batch and submit with lifecycle tracking.
207
+
208
+ | Parameter | Type | Description |
209
+ |-----------|------|-------------|
210
+ | `calls` | `BatchableCall[]` | Transactions, AsyncTransactions, or raw decoded calls. |
211
+ | `api` | `BatchApi` | Typed API with `tx.Utility.batch_all/batch/force_batch`. |
212
+ | `signer` | `PolkadotSigner` | Signer from a wallet, Host API, or `createDevSigner`. |
213
+ | `options` | `BatchSubmitOptions` | Optional. Extends `SubmitOptions` with `mode`. |
214
+
215
+ **Throws**: `TxBatchError` (empty calls), `TxTimeoutError`, `TxDispatchError`, `TxSigningRejectedError`.
216
+
161
217
  ### `createDevSigner(name): PolkadotSigner`
162
218
 
163
219
  Create a signer from the well-known dev mnemonic at `//Name` (sr25519).
@@ -172,7 +228,7 @@ Return the 32-byte public key for a dev account.
172
228
 
173
229
  ### `withRetry<T>(fn, options?): Promise<T>`
174
230
 
175
- Retry an async function with exponential backoff and jitter. Does not retry `TxDispatchError`, `TxSigningRejectedError`, or `TxTimeoutError`.
231
+ Retry an async function with exponential backoff and jitter. Does not retry `TxBatchError`, `TxDispatchError`, `TxSigningRejectedError`, or `TxTimeoutError`.
176
232
 
177
233
  | Parameter | Type | Description |
178
234
  |-----------|------|-------------|
@@ -241,6 +297,22 @@ interface Weight {
241
297
  ref_time: bigint;
242
298
  proof_size: bigint;
243
299
  }
300
+
301
+ type BatchMode = "batch_all" | "batch" | "force_batch";
302
+
303
+ interface BatchSubmitOptions extends SubmitOptions {
304
+ mode?: BatchMode; // default: "batch_all"
305
+ }
306
+
307
+ interface BatchApi {
308
+ tx: {
309
+ Utility: {
310
+ batch(args: { calls: unknown[] }): SubmittableTransaction;
311
+ batch_all(args: { calls: unknown[] }): SubmittableTransaction;
312
+ force_batch(args: { calls: unknown[] }): SubmittableTransaction;
313
+ };
314
+ };
315
+ }
244
316
  ```
245
317
 
246
318
  ### Error classes
@@ -252,6 +324,7 @@ interface Weight {
252
324
  | `TxDispatchError` | `TxError` | `dispatchError: unknown`, `formatted: string` |
253
325
  | `TxSigningRejectedError` | `TxError` | User rejected signing. |
254
326
  | `TxDryRunError` | `TxError` | `raw: unknown`, `formatted: string`, `revertReason?: string` |
327
+ | `TxBatchError` | `TxError` | Batch construction failed (e.g., empty calls). |
255
328
  | `TxAccountMappingError` | `TxError` | Account mapping failed. |
256
329
 
257
330
  ## License
@@ -0,0 +1,38 @@
1
+ import type { PolkadotSigner } from "polkadot-api";
2
+ import type { BatchApi, BatchSubmitOptions, BatchableCall, TxResult } from "./types.js";
3
+ /**
4
+ * Batch multiple transactions into a single Substrate Utility batch and submit.
5
+ *
6
+ * Extracts `.decodedCall` from each transaction (handling Ink SDK `AsyncTransaction`
7
+ * wrappers), wraps them in `Utility.batch_all` (or `batch`/`force_batch` via the
8
+ * `mode` option), and submits via {@link submitAndWatch} with full lifecycle tracking.
9
+ *
10
+ * @param calls - Array of transactions, AsyncTransactions, or raw decoded calls to batch.
11
+ * @param api - A typed API with `tx.Utility.batch_all/batch/force_batch`. Works with any
12
+ * chain that has the Utility pallet — no chain-specific imports required.
13
+ * **All calls must target the same chain as this API.** Do not mix decoded calls
14
+ * from different chains (e.g., Asset Hub and Bulletin) in a single batch.
15
+ * @param signer - The signer to use. Can come from a wallet extension, Host API
16
+ * (`getProductAccountSigner`), or {@link createDevSigner}.
17
+ * @param options - Optional {@link BatchSubmitOptions} (extends `SubmitOptions` with `mode`).
18
+ * @returns The transaction result from the batch submission.
19
+ *
20
+ * @throws {TxBatchError} If `calls` is empty.
21
+ * @throws {TxBatchError} If an AsyncTransaction resolves without a `.decodedCall` property.
22
+ * @throws {TxTimeoutError} If the batch transaction does not reach the target state within `timeoutMs`.
23
+ * @throws {TxDispatchError} If the on-chain dispatch fails.
24
+ * @throws {TxSigningRejectedError} If the user rejects signing in their wallet.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { batchSubmitAndWatch } from "@polkadot-apps/tx";
29
+ *
30
+ * const tx1 = api.tx.Balances.transfer_keep_alive({ dest: addr1, value: 1_000n });
31
+ * const tx2 = api.tx.Balances.transfer_keep_alive({ dest: addr2, value: 2_000n });
32
+ *
33
+ * const result = await batchSubmitAndWatch([tx1, tx2], api, signer, {
34
+ * onStatus: (status) => console.log(status),
35
+ * });
36
+ * ```
37
+ */
38
+ export declare function batchSubmitAndWatch(calls: BatchableCall[], api: BatchApi, signer: PolkadotSigner, options?: BatchSubmitOptions): Promise<TxResult>;
package/dist/batch.js ADDED
@@ -0,0 +1,313 @@
1
+ import { createLogger } from "@polkadot-apps/logger";
2
+ import { TxBatchError } from "./errors.js";
3
+ import { submitAndWatch } from "./submit.js";
4
+ const log = createLogger("tx:batch");
5
+ /**
6
+ * Resolve a single call to its decoded call data.
7
+ *
8
+ * Handles three shapes:
9
+ * 1. Ink SDK AsyncTransaction — has `.waited` Promise that resolves to a tx with `.decodedCall`
10
+ * 2. PAPI transaction or object with `.decodedCall` — extract directly
11
+ * 3. Raw decoded call — pass through as-is
12
+ */
13
+ async function resolveDecodedCall(call) {
14
+ if (call != null && typeof call === "object") {
15
+ const obj = call;
16
+ // Handle Ink SDK AsyncTransaction: resolve .waited first
17
+ if ("waited" in obj &&
18
+ obj.waited &&
19
+ typeof obj.waited.then === "function") {
20
+ log.debug("Resolving Ink SDK AsyncTransaction in batch");
21
+ const resolved = (await obj.waited);
22
+ if (resolved.decodedCall !== undefined)
23
+ return resolved.decodedCall;
24
+ throw new TxBatchError("Resolved AsyncTransaction has no decodedCall property");
25
+ }
26
+ // Handle SubmittableTransaction or object with decodedCall
27
+ if ("decodedCall" in obj && obj.decodedCall !== undefined) {
28
+ return obj.decodedCall;
29
+ }
30
+ }
31
+ // Reject null, undefined, and primitives — they will cause cryptic codec errors on-chain
32
+ if (call == null || typeof call !== "object") {
33
+ throw new TxBatchError(`Invalid batch call: expected a transaction or decoded call object, got ${call === null ? "null" : typeof call}`);
34
+ }
35
+ // Raw decoded call object — pass through
36
+ return call;
37
+ }
38
+ /**
39
+ * Batch multiple transactions into a single Substrate Utility batch and submit.
40
+ *
41
+ * Extracts `.decodedCall` from each transaction (handling Ink SDK `AsyncTransaction`
42
+ * wrappers), wraps them in `Utility.batch_all` (or `batch`/`force_batch` via the
43
+ * `mode` option), and submits via {@link submitAndWatch} with full lifecycle tracking.
44
+ *
45
+ * @param calls - Array of transactions, AsyncTransactions, or raw decoded calls to batch.
46
+ * @param api - A typed API with `tx.Utility.batch_all/batch/force_batch`. Works with any
47
+ * chain that has the Utility pallet — no chain-specific imports required.
48
+ * **All calls must target the same chain as this API.** Do not mix decoded calls
49
+ * from different chains (e.g., Asset Hub and Bulletin) in a single batch.
50
+ * @param signer - The signer to use. Can come from a wallet extension, Host API
51
+ * (`getProductAccountSigner`), or {@link createDevSigner}.
52
+ * @param options - Optional {@link BatchSubmitOptions} (extends `SubmitOptions` with `mode`).
53
+ * @returns The transaction result from the batch submission.
54
+ *
55
+ * @throws {TxBatchError} If `calls` is empty.
56
+ * @throws {TxBatchError} If an AsyncTransaction resolves without a `.decodedCall` property.
57
+ * @throws {TxTimeoutError} If the batch transaction does not reach the target state within `timeoutMs`.
58
+ * @throws {TxDispatchError} If the on-chain dispatch fails.
59
+ * @throws {TxSigningRejectedError} If the user rejects signing in their wallet.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { batchSubmitAndWatch } from "@polkadot-apps/tx";
64
+ *
65
+ * const tx1 = api.tx.Balances.transfer_keep_alive({ dest: addr1, value: 1_000n });
66
+ * const tx2 = api.tx.Balances.transfer_keep_alive({ dest: addr2, value: 2_000n });
67
+ *
68
+ * const result = await batchSubmitAndWatch([tx1, tx2], api, signer, {
69
+ * onStatus: (status) => console.log(status),
70
+ * });
71
+ * ```
72
+ */
73
+ export async function batchSubmitAndWatch(calls, api, signer, options) {
74
+ if (calls.length === 0) {
75
+ throw new TxBatchError("Cannot batch zero calls");
76
+ }
77
+ const mode = options?.mode ?? "batch_all";
78
+ log.info("Resolving batch calls", { count: calls.length, mode });
79
+ const decodedCalls = await Promise.all(calls.map(resolveDecodedCall));
80
+ log.info("Constructing batch transaction", { mode, callCount: decodedCalls.length });
81
+ const batchTx = api.tx.Utility[mode]({ calls: decodedCalls });
82
+ return submitAndWatch(batchTx, signer, options);
83
+ }
84
+ if (import.meta.vitest) {
85
+ const { describe, test, expect, vi, beforeEach } = import.meta.vitest;
86
+ const { configure } = await import("@polkadot-apps/logger");
87
+ const { TxDispatchError, TxSigningRejectedError } = await import("./errors.js");
88
+ // Silence logger during tests
89
+ beforeEach(() => {
90
+ configure({ handler: () => { } });
91
+ });
92
+ function createMockTx(emitFn, decodedCall) {
93
+ return {
94
+ signSubmitAndWatch: (_signer, _options) => ({
95
+ subscribe: (handlers) => {
96
+ const unsub = vi.fn();
97
+ queueMicrotask(() => emitFn(handlers));
98
+ return { unsubscribe: unsub };
99
+ },
100
+ }),
101
+ decodedCall,
102
+ };
103
+ }
104
+ const mockSigner = {};
105
+ const signedEvent = { type: "signed", txHash: "0xbatch" };
106
+ const bestBlockOk = {
107
+ type: "txBestBlocksState",
108
+ txHash: "0xbatch",
109
+ found: true,
110
+ ok: true,
111
+ events: [{ id: 1 }],
112
+ block: { hash: "0xblock1", number: 100, index: 0 },
113
+ };
114
+ function createMockBatchApi(emitFn) {
115
+ const capturedCalls = [];
116
+ const api = {
117
+ tx: {
118
+ Utility: {
119
+ batch: vi.fn((args) => {
120
+ capturedCalls.push(args.calls);
121
+ return createMockTx(emitFn);
122
+ }),
123
+ batch_all: vi.fn((args) => {
124
+ capturedCalls.push(args.calls);
125
+ return createMockTx(emitFn);
126
+ }),
127
+ force_batch: vi.fn((args) => {
128
+ capturedCalls.push(args.calls);
129
+ return createMockTx(emitFn);
130
+ }),
131
+ },
132
+ },
133
+ };
134
+ return { api, getCapturedCalls: () => capturedCalls };
135
+ }
136
+ const successEmit = (h) => {
137
+ h.next(signedEvent);
138
+ h.next(bestBlockOk);
139
+ };
140
+ describe("batchSubmitAndWatch", () => {
141
+ test("batches multiple transactions with decodedCall", async () => {
142
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
143
+ const calls = [
144
+ { decodedCall: { pallet: "Balances", method: "transfer", args: { value: 1 } } },
145
+ { decodedCall: { pallet: "Balances", method: "transfer", args: { value: 2 } } },
146
+ ];
147
+ const result = await batchSubmitAndWatch(calls, api, mockSigner);
148
+ expect(result.ok).toBe(true);
149
+ expect(getCapturedCalls()).toHaveLength(1);
150
+ expect(getCapturedCalls()[0]).toEqual([
151
+ { pallet: "Balances", method: "transfer", args: { value: 1 } },
152
+ { pallet: "Balances", method: "transfer", args: { value: 2 } },
153
+ ]);
154
+ expect(api.tx.Utility.batch_all).toHaveBeenCalledOnce();
155
+ });
156
+ test("handles Ink SDK AsyncTransaction wrappers", async () => {
157
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
158
+ const asyncCall = {
159
+ waited: Promise.resolve({ decodedCall: { pallet: "Contracts", method: "call" } }),
160
+ signSubmitAndWatch: () => {
161
+ throw new Error("Should not be called");
162
+ },
163
+ };
164
+ const result = await batchSubmitAndWatch([asyncCall], api, mockSigner);
165
+ expect(result.ok).toBe(true);
166
+ expect(getCapturedCalls()[0]).toEqual([{ pallet: "Contracts", method: "call" }]);
167
+ });
168
+ test("accepts raw decoded calls (pass-through)", async () => {
169
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
170
+ const rawCall = { pallet: "System", method: "remark" };
171
+ const result = await batchSubmitAndWatch([rawCall], api, mockSigner);
172
+ expect(result.ok).toBe(true);
173
+ expect(getCapturedCalls()[0]).toEqual([{ pallet: "System", method: "remark" }]);
174
+ });
175
+ test("mixes transaction types in a single batch", async () => {
176
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
177
+ const txWithDecoded = { decodedCall: "call1" };
178
+ const asyncTx = {
179
+ waited: Promise.resolve({ decodedCall: "call2" }),
180
+ signSubmitAndWatch: () => {
181
+ throw new Error("Should not be called");
182
+ },
183
+ };
184
+ const rawCall = { pallet: "System", method: "remark" };
185
+ const result = await batchSubmitAndWatch([txWithDecoded, asyncTx, rawCall], api, mockSigner);
186
+ expect(result.ok).toBe(true);
187
+ expect(getCapturedCalls()[0]).toEqual([
188
+ "call1",
189
+ "call2",
190
+ { pallet: "System", method: "remark" },
191
+ ]);
192
+ });
193
+ test("throws TxBatchError for empty calls array", async () => {
194
+ const { api } = createMockBatchApi(successEmit);
195
+ await expect(batchSubmitAndWatch([], api, mockSigner)).rejects.toThrow(TxBatchError);
196
+ await expect(batchSubmitAndWatch([], api, mockSigner)).rejects.toThrow("Cannot batch zero calls");
197
+ });
198
+ test("throws TxBatchError when AsyncTransaction resolves without decodedCall", async () => {
199
+ const { api } = createMockBatchApi(successEmit);
200
+ const badAsync = {
201
+ waited: Promise.resolve({ noDecodedCall: true }),
202
+ signSubmitAndWatch: () => {
203
+ throw new Error("Should not be called");
204
+ },
205
+ };
206
+ await expect(batchSubmitAndWatch([badAsync], api, mockSigner)).rejects.toThrow(TxBatchError);
207
+ });
208
+ test("throws TxBatchError for null call", async () => {
209
+ const { api } = createMockBatchApi(successEmit);
210
+ await expect(batchSubmitAndWatch([null], api, mockSigner)).rejects.toThrow(TxBatchError);
211
+ await expect(batchSubmitAndWatch([null], api, mockSigner)).rejects.toThrow("Invalid batch call");
212
+ });
213
+ test("throws TxBatchError for primitive call", async () => {
214
+ const { api } = createMockBatchApi(successEmit);
215
+ await expect(batchSubmitAndWatch([42], api, mockSigner)).rejects.toThrow(TxBatchError);
216
+ await expect(batchSubmitAndWatch(["oops"], api, mockSigner)).rejects.toThrow("Invalid batch call");
217
+ });
218
+ test("treats { decodedCall: undefined } as raw pass-through object", async () => {
219
+ const { api, getCapturedCalls } = createMockBatchApi(successEmit);
220
+ const edgeCase = { decodedCall: undefined, other: "data" };
221
+ const result = await batchSubmitAndWatch([edgeCase], api, mockSigner);
222
+ expect(result.ok).toBe(true);
223
+ // decodedCall is undefined so it falls through to raw pass-through
224
+ expect(getCapturedCalls()[0]).toEqual([{ decodedCall: undefined, other: "data" }]);
225
+ });
226
+ test("defaults to batch_all mode", async () => {
227
+ const { api } = createMockBatchApi(successEmit);
228
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner);
229
+ expect(api.tx.Utility.batch_all).toHaveBeenCalledOnce();
230
+ expect(api.tx.Utility.batch).not.toHaveBeenCalled();
231
+ expect(api.tx.Utility.force_batch).not.toHaveBeenCalled();
232
+ });
233
+ test("respects mode: batch", async () => {
234
+ const { api } = createMockBatchApi(successEmit);
235
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner, {
236
+ mode: "batch",
237
+ });
238
+ expect(api.tx.Utility.batch).toHaveBeenCalledOnce();
239
+ expect(api.tx.Utility.batch_all).not.toHaveBeenCalled();
240
+ });
241
+ test("respects mode: force_batch", async () => {
242
+ const { api } = createMockBatchApi(successEmit);
243
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner, {
244
+ mode: "force_batch",
245
+ });
246
+ expect(api.tx.Utility.force_batch).toHaveBeenCalledOnce();
247
+ expect(api.tx.Utility.batch_all).not.toHaveBeenCalled();
248
+ });
249
+ test("forwards SubmitOptions to submitAndWatch", async () => {
250
+ const statuses = [];
251
+ const { api } = createMockBatchApi(successEmit);
252
+ await batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner, {
253
+ onStatus: (s) => statuses.push(s),
254
+ });
255
+ expect(statuses).toContain("signing");
256
+ expect(statuses).toContain("in-block");
257
+ });
258
+ test("propagates TxDispatchError", async () => {
259
+ const { api } = createMockBatchApi((h) => {
260
+ h.next(signedEvent);
261
+ h.next({
262
+ type: "txBestBlocksState",
263
+ txHash: "0xbatch",
264
+ found: true,
265
+ ok: false,
266
+ events: [],
267
+ block: { hash: "0xblock1", number: 100, index: 0 },
268
+ dispatchError: {
269
+ type: "Module",
270
+ value: { type: "Utility", value: { type: "TooManyCalls" } },
271
+ },
272
+ });
273
+ });
274
+ await expect(batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner)).rejects.toThrow(TxDispatchError);
275
+ });
276
+ test("propagates TxSigningRejectedError", async () => {
277
+ const { api } = createMockBatchApi((h) => {
278
+ h.error(new Error("User rejected the request"));
279
+ });
280
+ await expect(batchSubmitAndWatch([{ decodedCall: "call1" }], api, mockSigner)).rejects.toThrow(TxSigningRejectedError);
281
+ });
282
+ test("resolves all calls in parallel", async () => {
283
+ const { api } = createMockBatchApi(successEmit);
284
+ const resolveOrder = [];
285
+ const asyncCall1 = {
286
+ waited: new Promise((resolve) => {
287
+ setTimeout(() => {
288
+ resolveOrder.push(1);
289
+ resolve({ decodedCall: "call1" });
290
+ }, 10);
291
+ }),
292
+ signSubmitAndWatch: () => {
293
+ throw new Error("Should not be called");
294
+ },
295
+ };
296
+ const asyncCall2 = {
297
+ waited: new Promise((resolve) => {
298
+ setTimeout(() => {
299
+ resolveOrder.push(2);
300
+ resolve({ decodedCall: "call2" });
301
+ }, 5); // Resolves faster
302
+ }),
303
+ signSubmitAndWatch: () => {
304
+ throw new Error("Should not be called");
305
+ },
306
+ };
307
+ await batchSubmitAndWatch([asyncCall1, asyncCall2], api, mockSigner);
308
+ // Both should have resolved (order depends on timing, but both are present)
309
+ expect(resolveOrder).toContain(1);
310
+ expect(resolveOrder).toContain(2);
311
+ });
312
+ });
313
+ }
package/dist/errors.d.ts CHANGED
@@ -35,6 +35,10 @@ export declare function formatDispatchError(result: {
35
35
  ok: boolean;
36
36
  dispatchError?: unknown;
37
37
  }): string;
38
+ /** Error specific to batch transaction construction (e.g., empty calls array). */
39
+ export declare class TxBatchError extends TxError {
40
+ constructor(message: string);
41
+ }
38
42
  /**
39
43
  * A dry-run simulation failed before the transaction was submitted on-chain.
40
44
  *
package/dist/errors.js CHANGED
@@ -71,6 +71,13 @@ export function formatDispatchError(result) {
71
71
  return "unknown error";
72
72
  }
73
73
  }
74
+ /** Error specific to batch transaction construction (e.g., empty calls array). */
75
+ export class TxBatchError extends TxError {
76
+ constructor(message) {
77
+ super(message);
78
+ this.name = "TxBatchError";
79
+ }
80
+ }
74
81
  /**
75
82
  * A dry-run simulation failed before the transaction was submitted on-chain.
76
83
  *
@@ -238,6 +245,13 @@ if (import.meta.vitest) {
238
245
  expect(err).toBeInstanceOf(TxError);
239
246
  expect(err.name).toBe("TxSigningRejectedError");
240
247
  });
248
+ test("TxBatchError", () => {
249
+ const err = new TxBatchError("Cannot batch zero calls");
250
+ expect(err).toBeInstanceOf(TxError);
251
+ expect(err).toBeInstanceOf(Error);
252
+ expect(err.name).toBe("TxBatchError");
253
+ expect(err.message).toBe("Cannot batch zero calls");
254
+ });
241
255
  });
242
256
  describe("formatDispatchError", () => {
243
257
  test("returns empty string for ok result", () => {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { submitAndWatch } from "./submit.js";
2
+ export { batchSubmitAndWatch } from "./batch.js";
2
3
  export { withRetry, calculateDelay } from "./retry.js";
3
4
  export { createDevSigner, getDevPublicKey } from "./dev-signers.js";
4
5
  export { extractTransaction, applyWeightBuffer } from "./dry-run.js";
5
6
  export { ensureAccountMapped, isAccountMapped, TxAccountMappingError } from "./account-mapping.js";
6
7
  export type { MappingChecker, ReviveApi, EnsureAccountMappedOptions, } from "./account-mapping.js";
7
- export { TxError, TxTimeoutError, TxDispatchError, TxDryRunError, TxSigningRejectedError, formatDispatchError, formatDryRunError, isSigningRejection, } from "./errors.js";
8
- export type { TxStatus, WaitFor, TxResult, SubmitOptions, RetryOptions, DevAccountName, Weight, SubmittableTransaction, TxEvent, } from "./types.js";
8
+ export { TxError, TxTimeoutError, TxDispatchError, TxDryRunError, TxSigningRejectedError, TxBatchError, formatDispatchError, formatDryRunError, isSigningRejection, } from "./errors.js";
9
+ export type { TxStatus, WaitFor, TxResult, SubmitOptions, RetryOptions, DevAccountName, Weight, SubmittableTransaction, TxEvent, BatchMode, BatchableCall, BatchSubmitOptions, BatchApi, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { submitAndWatch } from "./submit.js";
2
+ export { batchSubmitAndWatch } from "./batch.js";
2
3
  export { withRetry, calculateDelay } from "./retry.js";
3
4
  export { createDevSigner, getDevPublicKey } from "./dev-signers.js";
4
5
  export { extractTransaction, applyWeightBuffer } from "./dry-run.js";
5
6
  export { ensureAccountMapped, isAccountMapped, TxAccountMappingError } from "./account-mapping.js";
6
- export { TxError, TxTimeoutError, TxDispatchError, TxDryRunError, TxSigningRejectedError, formatDispatchError, formatDryRunError, isSigningRejection, } from "./errors.js";
7
+ export { TxError, TxTimeoutError, TxDispatchError, TxDryRunError, TxSigningRejectedError, TxBatchError, formatDispatchError, formatDryRunError, isSigningRejection, } from "./errors.js";
package/dist/retry.d.ts CHANGED
@@ -10,9 +10,9 @@ export declare function calculateDelay(attempt: number, baseDelayMs: number, max
10
10
  * Wrap an async function with retry logic and exponential backoff.
11
11
  *
12
12
  * Only retries transient errors (network disconnects, temporary RPC failures).
13
- * Deterministic errors ({@link TxDispatchError}), user rejections
14
- * ({@link TxSigningRejectedError}), and timeouts ({@link TxTimeoutError}) are
15
- * rethrown immediately without retry.
13
+ * Deterministic errors ({@link TxDispatchError}, {@link TxBatchError}), user
14
+ * rejections ({@link TxSigningRejectedError}), and timeouts ({@link TxTimeoutError})
15
+ * are rethrown immediately without retry.
16
16
  *
17
17
  * @param fn - The async function to retry.
18
18
  * @param options - Retry configuration.
package/dist/retry.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createLogger } from "@polkadot-apps/logger";
2
- import { TxDispatchError, TxSigningRejectedError, TxTimeoutError } from "./errors.js";
2
+ import { TxBatchError, TxDispatchError, TxSigningRejectedError, TxTimeoutError } from "./errors.js";
3
3
  const log = createLogger("tx:retry");
4
4
  function sleep(ms) {
5
5
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -7,13 +7,15 @@ function sleep(ms) {
7
7
  /**
8
8
  * Whether an error is deterministic and should not be retried.
9
9
  *
10
+ * - Batch errors are deterministic input validation failures (e.g., empty calls array).
10
11
  * - Dispatch errors are on-chain failures (e.g., insufficient balance) that will
11
12
  * produce the same result on retry.
12
13
  * - Signing rejections are explicit user intent.
13
14
  * - Timeouts mean we already waited the full duration; retrying would double the wait.
14
15
  */
15
16
  function isNonRetryable(error) {
16
- return (error instanceof TxDispatchError ||
17
+ return (error instanceof TxBatchError ||
18
+ error instanceof TxDispatchError ||
17
19
  error instanceof TxSigningRejectedError ||
18
20
  error instanceof TxTimeoutError);
19
21
  }
@@ -32,9 +34,9 @@ export function calculateDelay(attempt, baseDelayMs, maxDelayMs) {
32
34
  * Wrap an async function with retry logic and exponential backoff.
33
35
  *
34
36
  * Only retries transient errors (network disconnects, temporary RPC failures).
35
- * Deterministic errors ({@link TxDispatchError}), user rejections
36
- * ({@link TxSigningRejectedError}), and timeouts ({@link TxTimeoutError}) are
37
- * rethrown immediately without retry.
37
+ * Deterministic errors ({@link TxDispatchError}, {@link TxBatchError}), user
38
+ * rejections ({@link TxSigningRejectedError}), and timeouts ({@link TxTimeoutError})
39
+ * are rethrown immediately without retry.
38
40
  *
39
41
  * @param fn - The async function to retry.
40
42
  * @param options - Retry configuration.
@@ -121,6 +123,14 @@ if (import.meta.vitest) {
121
123
  }, { maxAttempts: 3, baseDelayMs: 1 })).rejects.toThrow(TxSigningRejectedError);
122
124
  expect(calls).toBe(1);
123
125
  });
126
+ test("does NOT retry TxBatchError", async () => {
127
+ let calls = 0;
128
+ await expect(withRetry(() => {
129
+ calls++;
130
+ return Promise.reject(new TxBatchError("Cannot batch zero calls"));
131
+ }, { maxAttempts: 3, baseDelayMs: 1 })).rejects.toThrow(TxBatchError);
132
+ expect(calls).toBe(1);
133
+ });
124
134
  test("does NOT retry TxTimeoutError", async () => {
125
135
  let calls = 0;
126
136
  await expect(withRetry(() => {
package/dist/types.d.ts CHANGED
@@ -75,6 +75,57 @@ export interface SubmittableTransaction {
75
75
  };
76
76
  /** Present on Ink SDK AsyncTransaction wrappers. */
77
77
  waited?: Promise<SubmittableTransaction>;
78
+ /** The decoded call data. Present on PAPI transactions. */
79
+ decodedCall?: unknown;
80
+ }
81
+ /** Batch execution mode corresponding to Substrate's Utility pallet. */
82
+ export type BatchMode = "batch_all" | "batch" | "force_batch";
83
+ /**
84
+ * A transaction or decoded call that can be included in a batch.
85
+ *
86
+ * Accepts:
87
+ * - A {@link SubmittableTransaction} (has `.decodedCall`)
88
+ * - An Ink SDK AsyncTransaction (has `.waited` that resolves to one with `.decodedCall`)
89
+ * - A raw decoded call object (passed through as `Record<string, unknown>`)
90
+ *
91
+ * The `Record<string, unknown>` variant is intentionally broad because PAPI decoded
92
+ * calls are chain-specific enum types that cannot be imported without chain descriptors.
93
+ * Runtime validation in `resolveDecodedCall` rejects null, undefined, and primitives.
94
+ */
95
+ export type BatchableCall = SubmittableTransaction | {
96
+ decodedCall: unknown;
97
+ } | Record<string, unknown>;
98
+ /** Options for {@link batchSubmitAndWatch}. Extends {@link SubmitOptions} with batch mode. */
99
+ export interface BatchSubmitOptions extends SubmitOptions {
100
+ /**
101
+ * Batch execution mode. Default: `"batch_all"` (atomic, all-or-nothing).
102
+ *
103
+ * - `"batch_all"` — Atomic. Reverts all calls if any single call fails.
104
+ * - `"batch"` — Best-effort. Stops at first failure but earlier successful calls are not reverted.
105
+ * - `"force_batch"` — Like `batch` but continues executing remaining calls after failures (never aborts early).
106
+ */
107
+ mode?: BatchMode;
108
+ }
109
+ /**
110
+ * Minimal structural type for a PAPI typed API with the Utility pallet.
111
+ *
112
+ * Structural so it works with any chain that has the Utility pallet, without
113
+ * importing chain-specific descriptors.
114
+ */
115
+ export interface BatchApi {
116
+ tx: {
117
+ Utility: {
118
+ batch(args: {
119
+ calls: unknown[];
120
+ }): SubmittableTransaction;
121
+ batch_all(args: {
122
+ calls: unknown[];
123
+ }): SubmittableTransaction;
124
+ force_batch(args: {
125
+ calls: unknown[];
126
+ }): SubmittableTransaction;
127
+ };
128
+ };
78
129
  }
79
130
  /** PAPI transaction event (discriminated union). */
80
131
  export type TxEvent = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@polkadot-apps/tx",
3
3
  "description": "Transaction submission, lifecycle watching, and dev signers for Polkadot chains",
4
- "version": "0.2.12",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",