@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/dry-run.ts ADDED
@@ -0,0 +1,263 @@
1
+ import { TxDryRunError, formatDryRunError } from "./errors.js";
2
+ import type { SubmittableTransaction, Weight } from "./types.js";
3
+
4
+ /**
5
+ * Validate an Ink SDK dry-run result and extract the submittable transaction.
6
+ *
7
+ * Replaces the 5-10 line boilerplate that every contract interaction repeats:
8
+ * check `success`, parse the error, verify `send()` exists, and call it.
9
+ *
10
+ * Works with any object whose shape matches the Ink SDK contract query result
11
+ * (typed structurally — no Ink SDK import required):
12
+ *
13
+ * - `contract.query("method", { origin, data })` (Ink SDK)
14
+ * - `contract.write("method", args, origin)` (patched SDK wrappers)
15
+ * - Any object with `{ success: boolean; value?: { send?(): ... } }`
16
+ *
17
+ * @param result - The dry-run result from a contract query or write simulation.
18
+ * @returns The submittable transaction, ready to pass to {@link submitAndWatch}.
19
+ * @throws {TxDryRunError} If the dry run failed or the result has no `send()`.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { extractTransaction, submitAndWatch, createDevSigner } from "@parity/product-sdk-tx";
24
+ *
25
+ * const dryRun = await contract.query("createItem", { origin, data: { name, price } });
26
+ * const tx = extractTransaction(dryRun);
27
+ * const result = await submitAndWatch(tx, createDevSigner("Alice"));
28
+ * ```
29
+ *
30
+ * @example Composing with retry logic:
31
+ * ```ts
32
+ * const tx = extractTransaction(await contract.query("transfer", { origin, data }));
33
+ * const result = await withRetry(() => submitAndWatch(tx, signer));
34
+ * ```
35
+ */
36
+ export function extractTransaction(result: {
37
+ success: boolean;
38
+ value?: unknown;
39
+ error?: unknown;
40
+ }): SubmittableTransaction {
41
+ if (!result.success) {
42
+ const formatted = formatDryRunError(result);
43
+ const revertReason = extractRevertReason(result.value);
44
+ throw new TxDryRunError(result, formatted, revertReason);
45
+ }
46
+
47
+ const value = result.value;
48
+ if (value == null || typeof value !== "object") {
49
+ throw new TxDryRunError(result, "dry run returned no value");
50
+ }
51
+
52
+ const v = value as Record<string, unknown>;
53
+ if (typeof v.send !== "function") {
54
+ throw new TxDryRunError(result, "not a write query (no send())");
55
+ }
56
+
57
+ return v.send() as SubmittableTransaction;
58
+ }
59
+
60
+ /**
61
+ * Try to extract a revert reason string from a dry-run result value.
62
+ * Returns `undefined` if no revert reason is available.
63
+ */
64
+ function extractRevertReason(value: unknown): string | undefined {
65
+ if (value == null || typeof value !== "object") return undefined;
66
+ const v = value as Record<string, unknown>;
67
+
68
+ if (typeof v.revertReason === "string" && v.revertReason) {
69
+ return v.revertReason;
70
+ }
71
+
72
+ // Wrapped raw value (patched SDK)
73
+ if ("raw" in v && v.raw != null && typeof v.raw === "object") {
74
+ return extractRevertReason(v.raw);
75
+ }
76
+
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Apply a safety buffer to weight estimates from a dry-run result.
82
+ *
83
+ * Dry-run weight estimates reflect the exact execution cost at the time of
84
+ * simulation. On-chain conditions can change between dry-run and actual
85
+ * submission (storage growth, state changes by other transactions), so a
86
+ * buffer prevents unexpected `OutOfGas` failures.
87
+ *
88
+ * The default 25% buffer matches the convention used across Polkadot
89
+ * ecosystem tooling.
90
+ *
91
+ * @param weight - The `weight_required` from a `ReviveApi.call` or `ReviveApi.eth_transact` dry-run.
92
+ * @param options - Override the buffer percentage (default: 25%).
93
+ * @returns A new weight with both components scaled up.
94
+ *
95
+ * @example Basic usage with ReviveApi dry-run:
96
+ * ```ts
97
+ * const dryRun = await api.apis.ReviveApi.call(origin, dest, value, undefined, undefined, data);
98
+ *
99
+ * const tx = api.tx.Revive.call({
100
+ * dest, value, data,
101
+ * weight_limit: applyWeightBuffer(dryRun.weight_required),
102
+ * storage_deposit_limit: dryRun.storage_deposit.value,
103
+ * });
104
+ * ```
105
+ *
106
+ * @example Custom buffer for latency-sensitive operations:
107
+ * ```ts
108
+ * applyWeightBuffer(dryRun.weight_required, { percent: 50 });
109
+ * ```
110
+ */
111
+ export function applyWeightBuffer(weight: Weight, options?: { percent?: number }): Weight {
112
+ const percent = options?.percent ?? 25;
113
+ const multiplier = 100n + BigInt(percent);
114
+ return {
115
+ ref_time: (weight.ref_time * multiplier) / 100n,
116
+ proof_size: (weight.proof_size * multiplier) / 100n,
117
+ };
118
+ }
119
+
120
+ if (import.meta.vitest) {
121
+ const { describe, test, expect } = import.meta.vitest;
122
+
123
+ describe("extractTransaction", () => {
124
+ test("returns tx from successful dry-run with send()", () => {
125
+ const mockTx = {
126
+ signSubmitAndWatch: () => ({ subscribe: () => ({ unsubscribe: () => {} }) }),
127
+ };
128
+ const result = {
129
+ success: true,
130
+ value: { response: "ok", send: () => mockTx },
131
+ };
132
+ expect(extractTransaction(result)).toBe(mockTx);
133
+ });
134
+
135
+ test("throws TxDryRunError on failed dry-run", () => {
136
+ const result = {
137
+ success: false,
138
+ value: { revertReason: "InsufficientBalance" },
139
+ };
140
+ try {
141
+ extractTransaction(result);
142
+ expect.unreachable("should have thrown");
143
+ } catch (e) {
144
+ expect(e).toBeInstanceOf(TxDryRunError);
145
+ const err = e as TxDryRunError;
146
+ expect(err.revertReason).toBe("InsufficientBalance");
147
+ expect(err.formatted).toBe("InsufficientBalance");
148
+ expect(err.message).toContain("InsufficientBalance");
149
+ expect(err.raw).toBe(result);
150
+ }
151
+ });
152
+
153
+ test("throws TxDryRunError with Module error formatting", () => {
154
+ const result = {
155
+ success: false,
156
+ value: {
157
+ type: "Module",
158
+ value: { type: "Revive", value: { type: "StorageDepositNotEnoughFunds" } },
159
+ },
160
+ };
161
+ try {
162
+ extractTransaction(result);
163
+ expect.unreachable("should have thrown");
164
+ } catch (e) {
165
+ const err = e as TxDryRunError;
166
+ expect(err.formatted).toBe("Revive.StorageDepositNotEnoughFunds");
167
+ expect(err.revertReason).toBeUndefined();
168
+ }
169
+ });
170
+
171
+ test("throws TxDryRunError with error field", () => {
172
+ const result = {
173
+ success: false,
174
+ value: {},
175
+ error: { type: "ContractTrapped" },
176
+ };
177
+ try {
178
+ extractTransaction(result);
179
+ expect.unreachable("should have thrown");
180
+ } catch (e) {
181
+ const err = e as TxDryRunError;
182
+ expect(err.formatted).toBe("ContractTrapped");
183
+ }
184
+ });
185
+
186
+ test("throws when value is missing", () => {
187
+ const result = { success: true };
188
+ expect(() => extractTransaction(result)).toThrow(TxDryRunError);
189
+ });
190
+
191
+ test("throws when send is not a function", () => {
192
+ const result = { success: true, value: { response: "ok" } };
193
+ expect(() => extractTransaction(result)).toThrow("not a write query");
194
+ });
195
+
196
+ test("throws with revertReason from nested raw (patched SDK)", () => {
197
+ const result = {
198
+ success: false,
199
+ value: { raw: { revertReason: "Unauthorized" } },
200
+ };
201
+ try {
202
+ extractTransaction(result);
203
+ expect.unreachable("should have thrown");
204
+ } catch (e) {
205
+ const err = e as TxDryRunError;
206
+ expect(err.revertReason).toBe("Unauthorized");
207
+ }
208
+ });
209
+
210
+ test("throws with ReviveApi Message error", () => {
211
+ const result = {
212
+ success: false,
213
+ value: { type: "Message", value: "Insufficient balance for gas * price + value" },
214
+ };
215
+ try {
216
+ extractTransaction(result);
217
+ expect.unreachable("should have thrown");
218
+ } catch (e) {
219
+ const err = e as TxDryRunError;
220
+ expect(err.formatted).toBe("Insufficient balance for gas * price + value");
221
+ }
222
+ });
223
+ });
224
+
225
+ describe("applyWeightBuffer", () => {
226
+ test("applies default 25% buffer", () => {
227
+ const weight: Weight = { ref_time: 1000n, proof_size: 500n };
228
+ const buffered = applyWeightBuffer(weight);
229
+ expect(buffered.ref_time).toBe(1250n);
230
+ expect(buffered.proof_size).toBe(625n);
231
+ });
232
+
233
+ test("applies custom buffer percentage", () => {
234
+ const weight: Weight = { ref_time: 1000n, proof_size: 1000n };
235
+ const buffered = applyWeightBuffer(weight, { percent: 50 });
236
+ expect(buffered.ref_time).toBe(1500n);
237
+ expect(buffered.proof_size).toBe(1500n);
238
+ });
239
+
240
+ test("zero buffer returns same values", () => {
241
+ const weight: Weight = { ref_time: 1000n, proof_size: 500n };
242
+ const buffered = applyWeightBuffer(weight, { percent: 0 });
243
+ expect(buffered.ref_time).toBe(1000n);
244
+ expect(buffered.proof_size).toBe(500n);
245
+ });
246
+
247
+ test("does not mutate original weight", () => {
248
+ const weight: Weight = { ref_time: 1000n, proof_size: 500n };
249
+ const buffered = applyWeightBuffer(weight);
250
+ expect(weight.ref_time).toBe(1000n);
251
+ expect(weight.proof_size).toBe(500n);
252
+ expect(buffered).not.toBe(weight);
253
+ });
254
+
255
+ test("works with realistic weight values", () => {
256
+ // Values from tick3t reference repo
257
+ const weight: Weight = { ref_time: 4_500_000_000n, proof_size: 1_000_000n };
258
+ const buffered = applyWeightBuffer(weight);
259
+ expect(buffered.ref_time).toBe(5_625_000_000n);
260
+ expect(buffered.proof_size).toBe(1_250_000n);
261
+ });
262
+ });
263
+ }