@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/submit.ts ADDED
@@ -0,0 +1,492 @@
1
+ import type { PolkadotSigner } from "polkadot-api";
2
+ import { createLogger } from "@parity/product-sdk-logger";
3
+
4
+ import {
5
+ TxDispatchError,
6
+ TxSigningRejectedError,
7
+ TxTimeoutError,
8
+ formatDispatchError,
9
+ isSigningRejection,
10
+ } from "./errors.js";
11
+ import type { SubmitOptions, SubmittableTransaction, TxEvent, TxResult } from "./types.js";
12
+
13
+ const DEFAULT_TIMEOUT_MS = 300_000;
14
+ const DEFAULT_MORTALITY_PERIOD = 256;
15
+
16
+ const log = createLogger("tx");
17
+
18
+ /**
19
+ * Resolve Ink SDK AsyncTransaction wrappers.
20
+ *
21
+ * Ink SDK's `contract.send()` returns an object with a `.waited` Promise that
22
+ * resolves to the actual transaction. This handles that transparently.
23
+ */
24
+ async function resolveTransaction(tx: SubmittableTransaction): Promise<SubmittableTransaction> {
25
+ if (tx.waited && typeof tx.waited.then === "function") {
26
+ log.debug("Resolving Ink SDK AsyncTransaction");
27
+ return tx.waited;
28
+ }
29
+ return tx;
30
+ }
31
+
32
+ function buildTxResult(
33
+ event: TxEvent & { ok: boolean; block: TxResult["block"]; events: unknown[] },
34
+ ): TxResult {
35
+ return {
36
+ txHash: event.txHash,
37
+ ok: event.ok,
38
+ block: event.block,
39
+ events: event.events,
40
+ dispatchError: "dispatchError" in event ? event.dispatchError : undefined,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Submit a transaction and watch its lifecycle through signing, broadcasting,
46
+ * block inclusion, and (optionally) finalization.
47
+ *
48
+ * @param tx - A transaction object with `signSubmitAndWatch`. Works with raw PAPI
49
+ * transactions and Ink SDK `AsyncTransaction` wrappers (resolved automatically).
50
+ * @param signer - The signer to use. Can come from a wallet extension, Host API
51
+ * (`getProductAccountSigner`), or {@link createDevSigner}.
52
+ * @param options - Submission options (waitFor, timeout, mortality, status callback).
53
+ * @returns The transaction result once included/finalized.
54
+ *
55
+ * @throws {TxTimeoutError} If the transaction does not reach the target state within `timeoutMs`.
56
+ * @throws {TxDispatchError} If the on-chain dispatch fails (e.g., insufficient balance, contract revert).
57
+ * @throws {TxSigningRejectedError} If the user rejects signing in their wallet.
58
+ */
59
+ export async function submitAndWatch(
60
+ tx: SubmittableTransaction,
61
+ signer: PolkadotSigner,
62
+ options?: SubmitOptions,
63
+ ): Promise<TxResult> {
64
+ const waitFor = options?.waitFor ?? "best-block";
65
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
66
+ const mortalityPeriod = options?.mortalityPeriod ?? DEFAULT_MORTALITY_PERIOD;
67
+ const onStatus = options?.onStatus;
68
+
69
+ const resolvedTx = await resolveTransaction(tx);
70
+
71
+ return new Promise<TxResult>((resolve, reject) => {
72
+ let settled = false;
73
+ let subscription: { unsubscribe: () => void } | null = null;
74
+
75
+ const timer = setTimeout(() => {
76
+ subscription?.unsubscribe();
77
+ if (!settled) {
78
+ settled = true;
79
+ onStatus?.("error");
80
+ reject(new TxTimeoutError(timeoutMs));
81
+ }
82
+ }, timeoutMs);
83
+
84
+ function teardown(): void {
85
+ clearTimeout(timer);
86
+ subscription?.unsubscribe();
87
+ }
88
+
89
+ function settleReject(error: Error): void {
90
+ if (settled) return;
91
+ settled = true;
92
+ teardown();
93
+ onStatus?.("error");
94
+ reject(error);
95
+ }
96
+
97
+ try {
98
+ const observable = resolvedTx.signSubmitAndWatch(signer, {
99
+ mortality: { mortal: true, period: mortalityPeriod },
100
+ });
101
+
102
+ subscription = observable.subscribe({
103
+ next: (event: TxEvent) => {
104
+ switch (event.type) {
105
+ case "signed": {
106
+ log.info("Transaction signed", { txHash: event.txHash });
107
+ onStatus?.("signing");
108
+ break;
109
+ }
110
+ case "broadcasted": {
111
+ log.info("Transaction broadcasted", { txHash: event.txHash });
112
+ onStatus?.("broadcasting");
113
+ break;
114
+ }
115
+ case "txBestBlocksState": {
116
+ if (!event.found) break;
117
+
118
+ if (event.ok === false) {
119
+ const formatted = formatDispatchError({
120
+ ok: false,
121
+ dispatchError: event.dispatchError,
122
+ });
123
+ log.error("Transaction failed in best block", {
124
+ formatted,
125
+ block: event.block,
126
+ });
127
+ settleReject(new TxDispatchError(event.dispatchError, formatted));
128
+ return;
129
+ }
130
+
131
+ log.info("Transaction in best block", { block: event.block });
132
+ onStatus?.("in-block");
133
+
134
+ if (
135
+ waitFor === "best-block" &&
136
+ event.ok === true &&
137
+ event.block &&
138
+ event.events
139
+ ) {
140
+ // Resolve the Promise but keep the subscription alive so we can
141
+ // detect reorgs (finalized event with ok=false after best-block ok=true).
142
+ // Only clear the timer since the consumer has their result.
143
+ settled = true;
144
+ clearTimeout(timer);
145
+ resolve(
146
+ buildTxResult(
147
+ event as TxEvent & {
148
+ ok: boolean;
149
+ block: TxResult["block"];
150
+ events: unknown[];
151
+ },
152
+ ),
153
+ );
154
+ }
155
+ break;
156
+ }
157
+ case "finalized": {
158
+ log.info("Transaction finalized", { ok: event.ok, block: event.block });
159
+
160
+ if (!event.ok) {
161
+ const formatted = formatDispatchError({
162
+ ok: false,
163
+ dispatchError: event.dispatchError,
164
+ });
165
+
166
+ if (settled) {
167
+ // Already resolved at best-block but finalized shows failure
168
+ // due to a chain reorganization. We can only log since the
169
+ // Promise is already resolved.
170
+ log.warn(
171
+ "Transaction failed after being in best block (reorg). " +
172
+ "The consumer received a success result that is no longer valid.",
173
+ { formatted, block: event.block },
174
+ );
175
+ } else {
176
+ settleReject(
177
+ new TxDispatchError(event.dispatchError, formatted),
178
+ );
179
+ }
180
+ subscription?.unsubscribe();
181
+ return;
182
+ }
183
+
184
+ onStatus?.("finalized");
185
+
186
+ if (!settled) {
187
+ settled = true;
188
+ teardown();
189
+ resolve(buildTxResult(event));
190
+ } else {
191
+ // Already resolved at best-block, finalization confirmed success.
192
+ subscription?.unsubscribe();
193
+ }
194
+ break;
195
+ }
196
+ }
197
+ },
198
+ error: (err: Error) => {
199
+ log.error("Transaction subscription error", { error: err.message });
200
+
201
+ if (isSigningRejection(err)) {
202
+ settleReject(new TxSigningRejectedError());
203
+ } else {
204
+ settleReject(err);
205
+ }
206
+ },
207
+ });
208
+ } catch (err) {
209
+ log.error("Failed to start transaction", { error: (err as Error).message });
210
+ teardown();
211
+
212
+ if (isSigningRejection(err)) {
213
+ settleReject(new TxSigningRejectedError());
214
+ } else {
215
+ settleReject(err as Error);
216
+ }
217
+ }
218
+ });
219
+ }
220
+
221
+ if (import.meta.vitest) {
222
+ const { describe, test, expect, vi, beforeEach } = import.meta.vitest;
223
+ const { configure } = await import("@parity/product-sdk-logger");
224
+
225
+ // Silence logger during tests
226
+ beforeEach(() => {
227
+ configure({ handler: () => {} });
228
+ });
229
+
230
+ type MockSubscribeHandlers = {
231
+ next: (event: TxEvent) => void;
232
+ error: (error: Error) => void;
233
+ };
234
+
235
+ function createMockTx(
236
+ emitFn: (handlers: MockSubscribeHandlers) => void,
237
+ ): SubmittableTransaction {
238
+ return {
239
+ signSubmitAndWatch: (_signer: PolkadotSigner, _options?: unknown) => ({
240
+ subscribe: (handlers: MockSubscribeHandlers) => {
241
+ const unsub = vi.fn();
242
+ // Emit events asynchronously so the subscription is returned first
243
+ queueMicrotask(() => emitFn(handlers));
244
+ return { unsubscribe: unsub };
245
+ },
246
+ }),
247
+ };
248
+ }
249
+
250
+ const mockSigner = {} as PolkadotSigner;
251
+
252
+ const signedEvent: TxEvent = { type: "signed", txHash: "0xabc" };
253
+ const broadcastedEvent: TxEvent = { type: "broadcasted", txHash: "0xabc" };
254
+ const bestBlockOk: TxEvent = {
255
+ type: "txBestBlocksState",
256
+ txHash: "0xabc",
257
+ found: true,
258
+ ok: true,
259
+ events: [{ id: 1 }],
260
+ block: { hash: "0xblock1", number: 100, index: 0 },
261
+ };
262
+ const bestBlockFail: TxEvent = {
263
+ type: "txBestBlocksState",
264
+ txHash: "0xabc",
265
+ found: true,
266
+ ok: false,
267
+ events: [],
268
+ block: { hash: "0xblock1", number: 100, index: 0 },
269
+ dispatchError: {
270
+ type: "Module",
271
+ value: { type: "Balances", value: { type: "InsufficientBalance" } },
272
+ },
273
+ };
274
+ const finalizedOk: TxEvent = {
275
+ type: "finalized",
276
+ txHash: "0xabc",
277
+ ok: true,
278
+ events: [{ id: 1 }],
279
+ block: { hash: "0xblock2", number: 101, index: 0 },
280
+ };
281
+ const finalizedFail: TxEvent = {
282
+ type: "finalized",
283
+ txHash: "0xabc",
284
+ ok: false,
285
+ events: [],
286
+ block: { hash: "0xblock2", number: 101, index: 0 },
287
+ dispatchError: { type: "BadOrigin" },
288
+ };
289
+
290
+ describe("submitAndWatch", () => {
291
+ test("resolves at best-block by default", async () => {
292
+ const tx = createMockTx((h) => {
293
+ h.next(signedEvent);
294
+ h.next(broadcastedEvent);
295
+ h.next(bestBlockOk);
296
+ h.next(finalizedOk);
297
+ });
298
+ const result = await submitAndWatch(tx, mockSigner);
299
+ expect(result.ok).toBe(true);
300
+ expect(result.block.number).toBe(100);
301
+ });
302
+
303
+ test("resolves at finalized when configured", async () => {
304
+ const tx = createMockTx((h) => {
305
+ h.next(signedEvent);
306
+ h.next(bestBlockOk);
307
+ h.next(finalizedOk);
308
+ });
309
+ const result = await submitAndWatch(tx, mockSigner, { waitFor: "finalized" });
310
+ expect(result.ok).toBe(true);
311
+ expect(result.block.number).toBe(101);
312
+ });
313
+
314
+ test("rejects with TxDispatchError on best-block failure", async () => {
315
+ const tx = createMockTx((h) => {
316
+ h.next(signedEvent);
317
+ h.next(bestBlockFail);
318
+ });
319
+ await expect(submitAndWatch(tx, mockSigner)).rejects.toThrow(TxDispatchError);
320
+ });
321
+
322
+ test("rejects with TxDispatchError on finalized failure", async () => {
323
+ const tx = createMockTx((h) => {
324
+ h.next(signedEvent);
325
+ h.next(finalizedFail);
326
+ });
327
+ await expect(submitAndWatch(tx, mockSigner, { waitFor: "finalized" })).rejects.toThrow(
328
+ TxDispatchError,
329
+ );
330
+ });
331
+
332
+ test("rejects with TxTimeoutError after timeout", async () => {
333
+ const tx = createMockTx(() => {
334
+ // Never emits any events - tx hangs forever
335
+ });
336
+ const error = await submitAndWatch(tx, mockSigner, { timeoutMs: 50 }).catch(
337
+ (e: unknown) => e,
338
+ );
339
+ expect(error).toBeInstanceOf(TxTimeoutError);
340
+ expect((error as TxTimeoutError).timeoutMs).toBe(50);
341
+ });
342
+
343
+ test("calls onStatus callbacks in order", async () => {
344
+ const statuses: string[] = [];
345
+ const tx = createMockTx((h) => {
346
+ h.next(signedEvent);
347
+ h.next(broadcastedEvent);
348
+ h.next(bestBlockOk);
349
+ });
350
+ await submitAndWatch(tx, mockSigner, {
351
+ onStatus: (s) => statuses.push(s),
352
+ });
353
+ expect(statuses).toEqual(["signing", "broadcasting", "in-block"]);
354
+ });
355
+
356
+ test("resolves Ink SDK AsyncTransaction", async () => {
357
+ const innerTx = createMockTx((h) => {
358
+ h.next(signedEvent);
359
+ h.next(bestBlockOk);
360
+ });
361
+ const wrappedTx: SubmittableTransaction = {
362
+ signSubmitAndWatch: () => {
363
+ throw new Error("Should not be called on outer tx");
364
+ },
365
+ waited: Promise.resolve(innerTx),
366
+ };
367
+ const result = await submitAndWatch(wrappedTx, mockSigner);
368
+ expect(result.ok).toBe(true);
369
+ });
370
+
371
+ test("passes mortality options", async () => {
372
+ let capturedOptions: unknown;
373
+ const tx: SubmittableTransaction = {
374
+ signSubmitAndWatch: (_signer: PolkadotSigner, options?: unknown) => {
375
+ capturedOptions = options;
376
+ return {
377
+ subscribe: (handlers: MockSubscribeHandlers) => {
378
+ queueMicrotask(() => {
379
+ handlers.next(signedEvent);
380
+ handlers.next(bestBlockOk);
381
+ });
382
+ return { unsubscribe: vi.fn() };
383
+ },
384
+ };
385
+ },
386
+ };
387
+ await submitAndWatch(tx, mockSigner, { mortalityPeriod: 512 });
388
+ expect(capturedOptions).toEqual({ mortality: { mortal: true, period: 512 } });
389
+ });
390
+
391
+ test("wraps signing rejection in TxSigningRejectedError", async () => {
392
+ const tx = createMockTx((h) => {
393
+ h.error(new Error("User rejected the request"));
394
+ });
395
+ await expect(submitAndWatch(tx, mockSigner)).rejects.toThrow(TxSigningRejectedError);
396
+ });
397
+
398
+ test("skips txBestBlocksState with found=false", async () => {
399
+ const tx = createMockTx((h) => {
400
+ h.next(signedEvent);
401
+ h.next({
402
+ type: "txBestBlocksState",
403
+ txHash: "0xabc",
404
+ found: false,
405
+ });
406
+ h.next(bestBlockOk);
407
+ });
408
+ const result = await submitAndWatch(tx, mockSigner);
409
+ expect(result.ok).toBe(true);
410
+ });
411
+
412
+ test("rejects with original error for non-rejection Observable errors", async () => {
413
+ const tx = createMockTx((h) => {
414
+ h.error(new Error("WebSocket disconnected"));
415
+ });
416
+ const err = await submitAndWatch(tx, mockSigner).catch((e) => e);
417
+ expect(err.message).toBe("WebSocket disconnected");
418
+ expect(err).not.toBeInstanceOf(TxSigningRejectedError);
419
+ });
420
+
421
+ test("handles synchronous throw from signSubmitAndWatch", async () => {
422
+ const tx: SubmittableTransaction = {
423
+ signSubmitAndWatch: () => {
424
+ throw new Error("Signer not available");
425
+ },
426
+ };
427
+ await expect(submitAndWatch(tx, mockSigner)).rejects.toThrow("Signer not available");
428
+ });
429
+
430
+ test("calls onStatus error on dispatch failure", async () => {
431
+ const statuses: string[] = [];
432
+ const tx = createMockTx((h) => {
433
+ h.next(bestBlockFail);
434
+ });
435
+ await submitAndWatch(tx, mockSigner, {
436
+ onStatus: (s) => statuses.push(s),
437
+ }).catch(() => {});
438
+ expect(statuses).toContain("error");
439
+ });
440
+
441
+ test("logs warning on reorg (best-block ok, finalized fail)", async () => {
442
+ const warnings: unknown[] = [];
443
+ const { configure: configureLogs } = await import("@parity/product-sdk-logger");
444
+ configureLogs({
445
+ level: "debug",
446
+ handler: (entry) => {
447
+ if (entry.level === "warn") warnings.push(entry.message);
448
+ },
449
+ });
450
+
451
+ const tx = createMockTx((h) => {
452
+ h.next(signedEvent);
453
+ h.next(bestBlockOk);
454
+ // Finalized says the tx actually failed (reorg)
455
+ h.next(finalizedFail);
456
+ });
457
+
458
+ // Should resolve at best-block (success)
459
+ const result = await submitAndWatch(tx, mockSigner);
460
+ expect(result.ok).toBe(true);
461
+
462
+ // Give the finalized event time to fire and log
463
+ await new Promise((r) => setTimeout(r, 10));
464
+
465
+ expect(warnings.some((w) => typeof w === "string" && w.includes("reorg"))).toBe(true);
466
+
467
+ // Restore silent handler
468
+ configureLogs({ handler: () => {} });
469
+ });
470
+
471
+ test("does not resolve when txBestBlocksState ok is undefined", async () => {
472
+ const tx = createMockTx((h) => {
473
+ h.next(signedEvent);
474
+ // ok is undefined (not explicitly true or false)
475
+ h.next({
476
+ type: "txBestBlocksState",
477
+ txHash: "0xabc",
478
+ found: true,
479
+ events: [{ id: 1 }],
480
+ block: { hash: "0xblock1", number: 100, index: 0 },
481
+ // ok intentionally omitted
482
+ } as TxEvent);
483
+ // Should only resolve when finalized
484
+ h.next(finalizedOk);
485
+ });
486
+
487
+ const result = await submitAndWatch(tx, mockSigner);
488
+ // Should resolve from finalized, not best-block (since ok was undefined)
489
+ expect(result.block.number).toBe(101);
490
+ });
491
+ });
492
+ }
package/src/types.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { PolkadotSigner } from "polkadot-api";
2
+
3
+ /** Transaction lifecycle status for UI callbacks. */
4
+ export type TxStatus = "signing" | "broadcasting" | "in-block" | "finalized" | "error";
5
+
6
+ /** When to resolve the submission promise. */
7
+ export type WaitFor = "best-block" | "finalized";
8
+
9
+ /** Successful transaction result. */
10
+ export interface TxResult {
11
+ /** Transaction hash. */
12
+ txHash: string;
13
+ /** Whether the on-chain dispatch succeeded. */
14
+ ok: boolean;
15
+ /** Block where the transaction was included. */
16
+ block: { hash: string; number: number; index: number };
17
+ /** Raw events emitted by the transaction. */
18
+ events: unknown[];
19
+ /** Dispatch error details when ok is false. */
20
+ dispatchError?: unknown;
21
+ }
22
+
23
+ /** Options for {@link submitAndWatch}. */
24
+ export interface SubmitOptions {
25
+ /** When to resolve the promise. Default: `"best-block"`. */
26
+ waitFor?: WaitFor;
27
+ /** Timeout in milliseconds. Default: `300_000` (5 minutes). */
28
+ timeoutMs?: number;
29
+ /** Mortality period in blocks. Default: `256` (~43 minutes on Polkadot). */
30
+ mortalityPeriod?: number;
31
+ /** Called on each lifecycle transition for UI progress indicators. */
32
+ onStatus?: (status: TxStatus) => void;
33
+ }
34
+
35
+ /** Options for {@link withRetry}. */
36
+ export interface RetryOptions {
37
+ /** Total attempts including the first. Default: `3`. */
38
+ maxAttempts?: number;
39
+ /** Base delay in ms for exponential backoff. Default: `1_000`. */
40
+ baseDelayMs?: number;
41
+ /** Maximum delay in ms. Default: `15_000`. */
42
+ maxDelayMs?: number;
43
+ }
44
+
45
+ /**
46
+ * Substrate weight representing computational and storage resources.
47
+ *
48
+ * Matches the shape returned by `ReviveApi.call` and `ReviveApi.eth_transact`
49
+ * dry-run results in the `weight_required` field.
50
+ */
51
+ export interface Weight {
52
+ /** Reference time component in picoseconds. */
53
+ ref_time: bigint;
54
+ /** Proof size component in bytes. */
55
+ proof_size: bigint;
56
+ }
57
+
58
+ /** Standard Substrate dev account names. */
59
+ export type DevAccountName = "Alice" | "Bob" | "Charlie" | "Dave" | "Eve" | "Ferdie";
60
+
61
+ /**
62
+ * Structural type for any transaction object that supports Observable-based
63
+ * sign-submit-and-watch. Works with raw PAPI transactions and Ink SDK
64
+ * resolved transactions.
65
+ */
66
+ export interface SubmittableTransaction {
67
+ signSubmitAndWatch: (
68
+ signer: PolkadotSigner,
69
+ options?: { mortality?: { mortal: boolean; period: number } },
70
+ ) => {
71
+ subscribe: (handlers: {
72
+ next: (event: TxEvent) => void;
73
+ error: (error: Error) => void;
74
+ }) => { unsubscribe: () => void };
75
+ };
76
+ /** Present on Ink SDK AsyncTransaction wrappers. */
77
+ waited?: Promise<SubmittableTransaction>;
78
+ /** The decoded call data. Present on PAPI transactions. */
79
+ decodedCall?: unknown;
80
+ }
81
+
82
+ /** Batch execution mode corresponding to Substrate's Utility pallet. */
83
+ export type BatchMode = "batch_all" | "batch" | "force_batch";
84
+
85
+ /**
86
+ * A transaction or decoded call that can be included in a batch.
87
+ *
88
+ * Accepts:
89
+ * - A {@link SubmittableTransaction} (has `.decodedCall`)
90
+ * - An Ink SDK AsyncTransaction (has `.waited` that resolves to one with `.decodedCall`)
91
+ * - A raw decoded call object (passed through as `Record<string, unknown>`)
92
+ *
93
+ * The `Record<string, unknown>` variant is intentionally broad because PAPI decoded
94
+ * calls are chain-specific enum types that cannot be imported without chain descriptors.
95
+ * Runtime validation in `resolveDecodedCall` rejects null, undefined, and primitives.
96
+ */
97
+ export type BatchableCall =
98
+ | SubmittableTransaction
99
+ | { decodedCall: unknown }
100
+ | Record<string, unknown>;
101
+
102
+ /** Options for {@link batchSubmitAndWatch}. Extends {@link SubmitOptions} with batch mode. */
103
+ export interface BatchSubmitOptions extends SubmitOptions {
104
+ /**
105
+ * Batch execution mode. Default: `"batch_all"` (atomic, all-or-nothing).
106
+ *
107
+ * - `"batch_all"` — Atomic. Reverts all calls if any single call fails.
108
+ * - `"batch"` — Best-effort. Stops at first failure but earlier successful calls are not reverted.
109
+ * - `"force_batch"` — Like `batch` but continues executing remaining calls after failures (never aborts early).
110
+ */
111
+ mode?: BatchMode;
112
+ }
113
+
114
+ /**
115
+ * Minimal structural type for a PAPI typed API with the Utility pallet.
116
+ *
117
+ * Structural so it works with any chain that has the Utility pallet, without
118
+ * importing chain-specific descriptors.
119
+ */
120
+ export interface BatchApi {
121
+ tx: {
122
+ Utility: {
123
+ batch(args: { calls: unknown[] }): SubmittableTransaction;
124
+ batch_all(args: { calls: unknown[] }): SubmittableTransaction;
125
+ force_batch(args: { calls: unknown[] }): SubmittableTransaction;
126
+ };
127
+ };
128
+ }
129
+
130
+ /** PAPI transaction event (discriminated union). */
131
+ export type TxEvent =
132
+ | { type: "signed"; txHash: string }
133
+ | { type: "broadcasted"; txHash: string }
134
+ | {
135
+ type: "txBestBlocksState";
136
+ txHash: string;
137
+ found: boolean;
138
+ ok?: boolean;
139
+ events?: unknown[];
140
+ block?: { hash: string; number: number; index: number };
141
+ dispatchError?: unknown;
142
+ }
143
+ | {
144
+ type: "finalized";
145
+ txHash: string;
146
+ ok: boolean;
147
+ events: unknown[];
148
+ block: { hash: string; number: number; index: number };
149
+ dispatchError?: unknown;
150
+ };