@switch-win/sdk 1.2.9 → 1.3.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/GASLESS.md +434 -0
  3. package/LIMIT-ORDERS.md +213 -205
  4. package/README.md +134 -105
  5. package/ROBINHOOD.md +201 -198
  6. package/abi/GaslessSwitchV2ABI.json +1095 -0
  7. package/dist/constants.d.ts +276 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +403 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/gasless-client.d.ts +82 -0
  12. package/dist/gasless-client.d.ts.map +1 -0
  13. package/dist/gasless-client.js +467 -0
  14. package/dist/gasless-client.js.map +1 -0
  15. package/dist/gasless-index.d.ts +5 -0
  16. package/dist/gasless-index.d.ts.map +1 -0
  17. package/dist/gasless-index.js +5 -0
  18. package/dist/gasless-index.js.map +1 -0
  19. package/dist/gasless-operators.d.ts +32 -0
  20. package/dist/gasless-operators.d.ts.map +1 -0
  21. package/dist/gasless-operators.js +132 -0
  22. package/dist/gasless-operators.js.map +1 -0
  23. package/dist/gasless-store.d.ts +18 -0
  24. package/dist/gasless-store.d.ts.map +1 -0
  25. package/dist/gasless-store.js +83 -0
  26. package/dist/gasless-store.js.map +1 -0
  27. package/dist/gasless-types.d.ts +222 -0
  28. package/dist/gasless-types.d.ts.map +1 -0
  29. package/dist/gasless-types.js +2 -0
  30. package/dist/gasless-types.js.map +1 -0
  31. package/dist/gasless.d.ts +135 -0
  32. package/dist/gasless.d.ts.map +1 -0
  33. package/dist/gasless.js +513 -0
  34. package/dist/gasless.js.map +1 -0
  35. package/dist/index.d.ts +29 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +30 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/limit-orders.d.ts +440 -0
  40. package/dist/limit-orders.d.ts.map +1 -0
  41. package/dist/limit-orders.js +387 -0
  42. package/dist/limit-orders.js.map +1 -0
  43. package/dist/networks/index.d.ts +3 -0
  44. package/dist/networks/index.d.ts.map +1 -0
  45. package/dist/networks/index.js +2 -0
  46. package/dist/networks/index.js.map +1 -0
  47. package/dist/networks/robinhood.d.ts +536 -0
  48. package/dist/networks/robinhood.d.ts.map +1 -0
  49. package/dist/networks/robinhood.js +489 -0
  50. package/dist/networks/robinhood.js.map +1 -0
  51. package/dist/types.d.ts +436 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +19 -0
  54. package/dist/types.js.map +1 -0
  55. package/examples/gasless-nextjs-proxy.ts +87 -0
  56. package/examples/gasless-operator.ts +76 -0
  57. package/examples/gasless-swap.ts +173 -0
  58. package/examples/limit-order-ethers.ts +195 -0
  59. package/examples/nextjs-proxy.ts +82 -0
  60. package/examples/react-hooks.tsx +396 -0
  61. package/examples/swap-ethers.ts +209 -0
  62. package/examples/swap-web3py.py +267 -0
  63. package/package.json +53 -12
  64. package/src/constants.ts +184 -182
  65. package/src/gasless-client.ts +824 -0
  66. package/src/gasless-index.ts +4 -0
  67. package/src/gasless-operators.ts +227 -0
  68. package/src/gasless-store.ts +137 -0
  69. package/src/gasless-types.ts +262 -0
  70. package/src/gasless.ts +756 -0
  71. package/src/index.ts +69 -63
  72. package/src/limit-orders.ts +206 -195
  73. package/src/types.ts +80 -55
@@ -0,0 +1,824 @@
1
+ import {
2
+ Contract,
3
+ Interface,
4
+ MaxUint256,
5
+ verifyTypedData,
6
+ type Provider,
7
+ type Signer,
8
+ type TransactionRequest,
9
+ type TransactionResponse,
10
+ } from "ethers";
11
+ import { API_BASE, ERC20_ABI, NATIVE_PLS } from "./constants.js";
12
+ import type { LimitOrderConfigResponse, LimitOrderRecord } from "./types.js";
13
+ import type { LimitOrderNetwork } from "./limit-orders.js";
14
+ import {
15
+ GASLESS_DEPLOYMENTS,
16
+ GASLESS_SWITCH_ABI,
17
+ GaslessError,
18
+ assessGaslessQuote,
19
+ buildGaslessOrder,
20
+ gaslessAddress,
21
+ gaslessCeil,
22
+ gaslessUint,
23
+ getGaslessSigningParams,
24
+ normalizeGaslessTrade,
25
+ readGaslessChainState,
26
+ sameAddress,
27
+ selectGaslessFeeOnOutput,
28
+ validateGaslessConfig,
29
+ validateGaslessTaxInfo,
30
+ } from "./gasless.js";
31
+ import type {
32
+ GaslessDeployment,
33
+ GaslessOrder,
34
+ GaslessOrderOutcome,
35
+ GaslessPendingStore,
36
+ GaslessQuoteDecision,
37
+ GaslessQuoteResponse,
38
+ GaslessTaxInfo,
39
+ GaslessTrade,
40
+ PendingGaslessOrder,
41
+ PreparedGaslessSwap,
42
+ SignedGaslessOrder,
43
+ } from "./gasless-types.js";
44
+
45
+ export interface GaslessClientOptions {
46
+ network: LimitOrderNetwork;
47
+ provider: Provider;
48
+ /** Your own backend proxy can expose the same paths under a different base. */
49
+ apiBaseUrl?: string;
50
+ quoteEndpoint?: string;
51
+ ordersEndpoint?: string;
52
+ taxEndpoint?: string;
53
+ /** Attach credentials server-side. Never embed an API key in browser code. */
54
+ fetch?: typeof globalThis.fetch;
55
+ /** Explicitly trusted migration override; never derive it blindly from an API. */
56
+ deployment?: GaslessDeployment;
57
+ }
58
+
59
+ function endpoint(url: string, query: Record<string, string>): string {
60
+ const suffix = new URLSearchParams(query).toString();
61
+ return `${url}${url.includes("?") ? "&" : "?"}${suffix}`;
62
+ }
63
+
64
+ /** Abort bounded reads; a timed-out POST must instead retain its uncertain order lock. */
65
+ export async function withGaslessDeadline<T>(
66
+ work: () => Promise<T>,
67
+ timeoutMs: number,
68
+ signal?: AbortSignal,
69
+ ): Promise<T> {
70
+ signal?.throwIfAborted();
71
+ let timer: ReturnType<typeof setTimeout>;
72
+ let onAbort: () => void;
73
+ const timeout = new Promise<never>((_, reject) => {
74
+ timer = setTimeout(
75
+ () =>
76
+ reject(
77
+ new GaslessError(
78
+ "REQUEST_TIMEOUT",
79
+ "Gasless request timed out.",
80
+ true,
81
+ ),
82
+ ),
83
+ timeoutMs,
84
+ );
85
+ onAbort = () =>
86
+ reject(
87
+ signal?.reason ?? new GaslessError("ABORTED", "Request cancelled."),
88
+ );
89
+ signal?.addEventListener("abort", onAbort, { once: true });
90
+ });
91
+ try {
92
+ return await Promise.race([work(), timeout]);
93
+ } finally {
94
+ clearTimeout(timer!);
95
+ signal?.removeEventListener("abort", onAbort!);
96
+ }
97
+ }
98
+
99
+ /** Complete field comparison prevents a duplicate-nonce response matching a different order. */
100
+ export function gaslessRecordMatches(
101
+ pending: PendingGaslessOrder,
102
+ record: LimitOrderRecord,
103
+ ): boolean {
104
+ const o = pending.order;
105
+ return (
106
+ sameAddress(record.limitOrderContract, pending.contract) &&
107
+ ["maker", "tokenIn", "tokenOut", "recipient", "partnerAddress"].every(
108
+ (key) =>
109
+ sameAddress(
110
+ record[key as keyof LimitOrderRecord],
111
+ o[key as keyof GaslessOrder] as string,
112
+ ),
113
+ ) &&
114
+ record.nonce === o.nonce &&
115
+ record.deadline === o.deadline &&
116
+ record.amountIn === o.amountIn &&
117
+ record.minAmountOut === o.minAmountOut &&
118
+ record.feeOnOutput === o.feeOnOutput &&
119
+ record.unwrapOutput === o.unwrapOutput
120
+ );
121
+ }
122
+
123
+ export class GaslessSubmissionError extends GaslessError {
124
+ constructor(
125
+ message: string,
126
+ public readonly pending: PendingGaslessOrder,
127
+ ) {
128
+ super("SUBMISSION_UNCERTAIN", message, true);
129
+ this.name = "GaslessSubmissionError";
130
+ }
131
+ }
132
+
133
+ /** Gasless settlements charge a fixed 0.1% protocol fee, plus an operator payout
134
+ * capped at 0.05% of the after-fee base including gas. Integrators cannot override
135
+ * the protocol fee; offer regular swaps only if this fee policy is unsuitable.
136
+ * No background quote polling. Each method is explicit; status checks never request a quote. */
137
+ export class GaslessSwapClient {
138
+ readonly deployment: Readonly<GaslessDeployment>;
139
+ private readonly provider: Provider;
140
+ private readonly fetcher: typeof globalThis.fetch;
141
+ private readonly quoteUrl: string;
142
+ private readonly ordersUrl: string;
143
+ private readonly taxUrl: string;
144
+
145
+ constructor(options: GaslessClientOptions) {
146
+ const deployment = {
147
+ ...(options.deployment ?? GASLESS_DEPLOYMENTS[options.network]),
148
+ };
149
+ if (
150
+ deployment.network !== options.network ||
151
+ deployment.chainId !== GASLESS_DEPLOYMENTS[options.network].chainId
152
+ )
153
+ throw new GaslessError(
154
+ "WRONG_CHAIN",
155
+ "Deployment and requested network differ.",
156
+ );
157
+ this.deployment = Object.freeze({
158
+ ...deployment,
159
+ contract: gaslessAddress(deployment.contract),
160
+ router: gaslessAddress(deployment.router),
161
+ wrappedNative: gaslessAddress(deployment.wrappedNative),
162
+ });
163
+ this.provider = options.provider;
164
+ this.fetcher = options.fetch ?? globalThis.fetch;
165
+ const base = (options.apiBaseUrl ?? API_BASE).replace(/\/$/, "");
166
+ this.quoteUrl = options.quoteEndpoint ?? `${base}/swap/quote`;
167
+ this.ordersUrl = options.ordersEndpoint ?? `${base}/limit-orders`;
168
+ this.taxUrl = options.taxEndpoint ?? `${base}/swap/checkTax`;
169
+ }
170
+
171
+ private async request<T>(
172
+ url: string,
173
+ init: RequestInit = {},
174
+ signal?: AbortSignal,
175
+ timeoutMs = 15_000,
176
+ ): Promise<T> {
177
+ const timeout = AbortSignal.timeout(timeoutMs);
178
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
179
+ const response = await this.fetcher(url, {
180
+ ...init,
181
+ cache: "no-store",
182
+ signal: combined,
183
+ });
184
+ const body: unknown = await response.json().catch(() => null);
185
+ const error =
186
+ body && typeof body === "object" && "error" in body
187
+ ? String(body.error)
188
+ : undefined;
189
+ if (!response.ok || error || !body)
190
+ throw new GaslessError(
191
+ "API_ERROR",
192
+ error ?? `Switch API returned HTTP ${response.status}.`,
193
+ response.status >= 500 || response.status === 429,
194
+ );
195
+ return body as T;
196
+ }
197
+
198
+ async getConfig(signal?: AbortSignal): Promise<LimitOrderConfigResponse> {
199
+ const config = await this.request<LimitOrderConfigResponse>(
200
+ endpoint(`${this.ordersUrl}/config`, {
201
+ network: this.deployment.network,
202
+ }),
203
+ {},
204
+ signal,
205
+ );
206
+ validateGaslessConfig(config, this.deployment);
207
+ return config;
208
+ }
209
+
210
+ private async tax(
211
+ token: string,
212
+ maker: string,
213
+ signal?: AbortSignal,
214
+ ): Promise<GaslessTaxInfo> {
215
+ if (
216
+ sameAddress(token, NATIVE_PLS) ||
217
+ sameAddress(token, this.deployment.wrappedNative)
218
+ )
219
+ return { isTaxToken: false, sellTaxBps: 0, buyTaxBps: 0 };
220
+ const tax = await this.request<GaslessTaxInfo>(
221
+ endpoint(this.taxUrl, {
222
+ network: this.deployment.network,
223
+ token,
224
+ sender: maker,
225
+ }),
226
+ {},
227
+ signal,
228
+ );
229
+ validateGaslessTaxInfo(tax);
230
+ return tax;
231
+ }
232
+
233
+ private async checkQuoteBlock(
234
+ quote: GaslessQuoteResponse,
235
+ signal?: AbortSignal,
236
+ ): Promise<void> {
237
+ if (
238
+ !Number.isSafeInteger(quote.quoteBlockNumber) ||
239
+ quote.quoteBlockNumber <= 0
240
+ )
241
+ throw new GaslessError("INVALID_PREVIEW", "Missing quote block.");
242
+ const block = await withGaslessDeadline(
243
+ () => this.provider.getBlock(quote.quoteBlockNumber),
244
+ 10_000,
245
+ signal,
246
+ );
247
+ const now = Date.now() / 1000;
248
+ if (!block || now - block.timestamp > 90 || block.timestamp > now + 15)
249
+ throw new GaslessError(
250
+ "STALE_QUOTE",
251
+ "The quote uses an old or unavailable chain snapshot.",
252
+ true,
253
+ );
254
+ }
255
+
256
+ /** Connected-wallet quote. Missing gas simulation or unknown taxes never enable gasless. */
257
+ async quote(
258
+ tradeInput: GaslessTrade,
259
+ signal?: AbortSignal,
260
+ ): Promise<GaslessQuoteDecision> {
261
+ const trade = normalizeGaslessTrade(tradeInput);
262
+ if (sameAddress(trade.tokenIn, NATIVE_PLS))
263
+ return {
264
+ eligible: false,
265
+ code: "NATIVE_INPUT_REQUIRES_WRAP",
266
+ reason:
267
+ "Wrap native currency first (wrapping requires gas), or use Regular.",
268
+ retryable: false,
269
+ };
270
+ try {
271
+ const [network, , inputTax, outputTax] = await Promise.all([
272
+ withGaslessDeadline(() => this.provider.getNetwork(), 10_000, signal),
273
+ this.getConfig(signal),
274
+ this.tax(trade.tokenIn, trade.maker, signal),
275
+ this.tax(trade.tokenOut, trade.maker, signal),
276
+ ]);
277
+ if (network.chainId !== BigInt(this.deployment.chainId))
278
+ throw new GaslessError("WRONG_CHAIN", "RPC is on another network.");
279
+ const feeOnOutput = selectGaslessFeeOnOutput(
280
+ this.deployment.network,
281
+ trade.tokenIn,
282
+ trade.tokenOut,
283
+ inputTax,
284
+ outputTax,
285
+ );
286
+ const quote = await this.request<GaslessQuoteResponse>(
287
+ endpoint(this.quoteUrl, {
288
+ network: this.deployment.network,
289
+ from: trade.tokenIn,
290
+ to: trade.tokenOut,
291
+ amount: trade.amountIn,
292
+ sender: trade.maker,
293
+ receiver: trade.recipient,
294
+ partnerAddress: trade.partnerAddress,
295
+ slippage: String(trade.slippageBps),
296
+ fee: "10",
297
+ feeOnOutput: String(feeOnOutput),
298
+ gasless: "true",
299
+ // Do not send fee=15: the preview reserves the capped payout itself.
300
+ // Do not restrict adapters: public operators need the same route universe.
301
+ }),
302
+ {},
303
+ signal,
304
+ 45_000,
305
+ );
306
+ if (quote.feeOnOutput !== feeOnOutput)
307
+ throw new GaslessError(
308
+ "INVALID_PREVIEW",
309
+ "API changed the requested fee side.",
310
+ );
311
+ // Read gas again after routing: a long quote must not retain an old gas price.
312
+ const [current] = await Promise.all([
313
+ withGaslessDeadline(
314
+ () => readGaslessChainState(this.provider, this.deployment),
315
+ 15_000,
316
+ signal,
317
+ ),
318
+ this.checkQuoteBlock(quote, signal),
319
+ ]);
320
+ return assessGaslessQuote(quote, trade, this.deployment, current);
321
+ } catch (error) {
322
+ if (signal?.aborted) throw signal.reason;
323
+ const code =
324
+ error instanceof GaslessError &&
325
+ ["CONFIGURATION_CHANGED", "TAX_CHECK_UNAVAILABLE"].includes(error.code)
326
+ ? (error.code as "CONFIGURATION_CHANGED" | "TAX_CHECK_UNAVAILABLE")
327
+ : "QUOTE_UNAVAILABLE";
328
+ return {
329
+ eligible: false,
330
+ code,
331
+ reason:
332
+ error instanceof Error
333
+ ? error.message
334
+ : "Gasless execution is unavailable. Use Regular.",
335
+ retryable: error instanceof GaslessError ? error.retryable : true,
336
+ };
337
+ }
338
+ }
339
+
340
+ /** A signing click can await a refresh and continue to the wallet in the same call. */
341
+ async prepare(
342
+ trade: GaslessTrade,
343
+ displayed?: PreparedGaslessSwap,
344
+ signal?: AbortSignal,
345
+ ): Promise<PreparedGaslessSwap> {
346
+ const checkedAt =
347
+ displayed?.quote?.gaslessExecution?.status === "ready"
348
+ ? displayed.quote.gaslessExecution.checkedAt
349
+ : 0;
350
+ if (
351
+ displayed &&
352
+ Date.now() >= checkedAt &&
353
+ Date.now() - checkedAt <= 30_000
354
+ ) {
355
+ await this.getConfig(signal);
356
+ const [state] = await Promise.all([
357
+ withGaslessDeadline(
358
+ () => readGaslessChainState(this.provider, this.deployment),
359
+ 15_000,
360
+ signal,
361
+ ),
362
+ this.checkQuoteBlock(displayed.quote, signal),
363
+ ]);
364
+ const checked = assessGaslessQuote(
365
+ displayed.quote,
366
+ trade,
367
+ this.deployment,
368
+ state,
369
+ );
370
+ if (checked.eligible) return checked.prepared;
371
+ }
372
+ const refreshed = await this.quote(trade, signal);
373
+ if (!refreshed.eligible)
374
+ throw new GaslessError(
375
+ refreshed.code,
376
+ refreshed.reason,
377
+ refreshed.retryable,
378
+ );
379
+ return refreshed.prepared;
380
+ }
381
+
382
+ private async checkSigner(signer: Signer, maker: string): Promise<void> {
383
+ if (
384
+ !signer.provider ||
385
+ !sameAddress(await signer.getAddress(), maker) ||
386
+ (await signer.provider.getNetwork()).chainId !==
387
+ BigInt(this.deployment.chainId)
388
+ )
389
+ throw new GaslessError(
390
+ "WALLET_CHANGED",
391
+ "Wallet account or network changed. Review the swap.",
392
+ );
393
+ }
394
+
395
+ async checkFunds(
396
+ maker: string,
397
+ tokenIn: string,
398
+ amountIn: string,
399
+ signal?: AbortSignal,
400
+ ) {
401
+ const token = new Contract(
402
+ gaslessAddress(tokenIn),
403
+ ERC20_ABI,
404
+ this.provider,
405
+ );
406
+ const [balance, allowance] = await withGaslessDeadline(
407
+ () =>
408
+ Promise.all([
409
+ token.balanceOf(gaslessAddress(maker)),
410
+ token.allowance(maker, this.deployment.contract),
411
+ ]) as Promise<[bigint, bigint]>,
412
+ 15_000,
413
+ signal,
414
+ );
415
+ return {
416
+ balance: balance.toString(),
417
+ allowance: allowance.toString(),
418
+ hasBalance: balance >= gaslessUint(amountIn),
419
+ approved: allowance >= gaslessUint(amountIn),
420
+ spender: this.deployment.contract,
421
+ };
422
+ }
423
+
424
+ /** Build approval with a fresh API-compatible gas boost. User still sends/confirms it. */
425
+ async approvalRequest(
426
+ tradeInput: GaslessTrade,
427
+ signer: Signer,
428
+ options: {
429
+ unlimited?: boolean;
430
+ resetToZero?: boolean;
431
+ quoteGasPriceWei?: string;
432
+ signal?: AbortSignal;
433
+ } = {},
434
+ ): Promise<TransactionRequest> {
435
+ const trade = normalizeGaslessTrade(tradeInput);
436
+ if (sameAddress(trade.tokenIn, NATIVE_PLS))
437
+ throw new GaslessError(
438
+ "NATIVE_INPUT_REQUIRES_WRAP",
439
+ "Native input must be wrapped before approval.",
440
+ );
441
+ await this.checkSigner(signer, trade.maker);
442
+ await this.getConfig(options.signal);
443
+ const state = await withGaslessDeadline(
444
+ () => readGaslessChainState(this.provider, this.deployment),
445
+ 15_000,
446
+ options.signal,
447
+ );
448
+ const boosted = gaslessCeil(
449
+ gaslessUint(state.rawGasPriceWei) * 13000n,
450
+ 10000n,
451
+ );
452
+ const quoted = options.quoteGasPriceWei
453
+ ? gaslessUint(options.quoteGasPriceWei)
454
+ : boosted;
455
+ const amount = options.resetToZero
456
+ ? 0n
457
+ : options.unlimited === false
458
+ ? gaslessUint(trade.amountIn)
459
+ : MaxUint256;
460
+ const request: TransactionRequest = {
461
+ chainId: this.deployment.chainId,
462
+ from: trade.maker,
463
+ to: trade.tokenIn,
464
+ value: 0n,
465
+ data: new Interface(ERC20_ABI).encodeFunctionData("approve", [
466
+ this.deployment.contract,
467
+ amount,
468
+ ]),
469
+ gasPrice: boosted > quoted ? boosted : quoted,
470
+ };
471
+ request.gasLimit = gaslessCeil(
472
+ (await withGaslessDeadline(
473
+ () => this.provider.estimateGas(request),
474
+ 15_000,
475
+ options.signal,
476
+ )) * 13000n,
477
+ 10000n,
478
+ );
479
+ return request;
480
+ }
481
+
482
+ async approve(
483
+ trade: GaslessTrade,
484
+ signer: Signer,
485
+ options: Parameters<GaslessSwapClient["approvalRequest"]>[2] = {},
486
+ ): Promise<TransactionResponse> {
487
+ const request = await this.approvalRequest(trade, signer, options);
488
+ options.signal?.throwIfAborted();
489
+ await this.checkSigner(signer, trade.maker);
490
+ return signer.sendTransaction(request);
491
+ }
492
+
493
+ /** Off-chain signatures are not indexed until POST succeeds. */
494
+ async signAndSubmit(input: {
495
+ trade: GaslessTrade;
496
+ signer: Signer;
497
+ store: GaslessPendingStore;
498
+ displayed?: PreparedGaslessSwap;
499
+ signal?: AbortSignal;
500
+ /** Before the wallet prompt, update the main expectedOutput estimate and
501
+ * separate minAmountOut display. Signing uses minAmountOut, not expectedOutput. */
502
+ onPrepared?: (prepared: PreparedGaslessSwap) => void | Promise<void>;
503
+ onPending?: (pending: PendingGaslessOrder) => void;
504
+ }): Promise<LimitOrderRecord> {
505
+ if (await input.store.load())
506
+ throw new GaslessError(
507
+ "ORDER_PENDING",
508
+ "An existing gasless order can still execute. Reconcile it before another swap.",
509
+ );
510
+ await this.checkSigner(input.signer, input.trade.maker);
511
+ let prepared = await this.prepare(
512
+ input.trade,
513
+ input.displayed,
514
+ input.signal,
515
+ );
516
+ let order: GaslessOrder | undefined;
517
+ for (let attempt = 0; attempt < 2; attempt++) {
518
+ const funds = await this.checkFunds(
519
+ prepared.trade.maker,
520
+ prepared.trade.tokenIn,
521
+ prepared.trade.amountIn,
522
+ input.signal,
523
+ );
524
+ if (!funds.hasBalance)
525
+ throw new GaslessError(
526
+ "INSUFFICIENT_BALANCE",
527
+ "The input token balance is insufficient.",
528
+ );
529
+ if (!funds.approved)
530
+ throw new GaslessError(
531
+ "APPROVAL_REQUIRED",
532
+ "Approve GaslessSwitch first. Approval requires native gas.",
533
+ );
534
+ try {
535
+ order = buildGaslessOrder(prepared);
536
+ break;
537
+ } catch (error) {
538
+ if (
539
+ !(error instanceof GaslessError) ||
540
+ error.code !== "STALE_QUOTE" ||
541
+ attempt === 1
542
+ )
543
+ throw error;
544
+ prepared = await this.prepare(input.trade, undefined, input.signal);
545
+ }
546
+ }
547
+ if (!order)
548
+ throw new GaslessError("STALE_QUOTE", "Refresh the gasless quote.", true);
549
+ Object.freeze(order);
550
+ const contract = new Contract(
551
+ this.deployment.contract,
552
+ GASLESS_SWITCH_ABI,
553
+ this.provider,
554
+ );
555
+ if (
556
+ await withGaslessDeadline(
557
+ () => contract.isNonceUsed(order!.maker, order!.nonce),
558
+ 10_000,
559
+ input.signal,
560
+ )
561
+ )
562
+ throw new GaslessError(
563
+ "NONCE_USED",
564
+ "Generated nonce is already used; request another preparation.",
565
+ true,
566
+ );
567
+ await input.onPrepared?.(prepared);
568
+ input.signal?.throwIfAborted();
569
+ await this.checkSigner(input.signer, order.maker);
570
+ const checked = assessGaslessQuote(
571
+ prepared.quote,
572
+ input.trade,
573
+ this.deployment,
574
+ prepared.chainState,
575
+ );
576
+ if (!checked.eligible)
577
+ throw new GaslessError(checked.code, checked.reason, checked.retryable);
578
+ let pending: PendingGaslessOrder = {
579
+ network: this.deployment.network,
580
+ chainId: this.deployment.chainId,
581
+ contract: this.deployment.contract,
582
+ order,
583
+ state: "signing",
584
+ };
585
+ await input.store.reserve(pending);
586
+ let signature: string | undefined;
587
+ try {
588
+ input.onPending?.(pending);
589
+ const { domain, types } = getGaslessSigningParams(this.deployment);
590
+ signature = await input.signer.signTypedData(domain, types, order);
591
+ // Persist the signed payload before the request; never replace its nonce on retry.
592
+ pending = { ...pending, signature, state: "submitting" };
593
+ await input.store.update(pending);
594
+ if (
595
+ !sameAddress(
596
+ verifyTypedData(domain, types, order, signature),
597
+ order.maker,
598
+ )
599
+ )
600
+ throw new GaslessError(
601
+ "INVALID_SIGNATURE",
602
+ "Signature does not match the maker. Hosted submission currently requires an EOA signature.",
603
+ );
604
+ await this.checkSigner(input.signer, order.maker);
605
+ input.signal?.throwIfAborted();
606
+ await this.getConfig(input.signal);
607
+ if (
608
+ JSON.stringify(normalizeGaslessTrade(input.trade)) !==
609
+ JSON.stringify(prepared.trade)
610
+ )
611
+ throw new GaslessError(
612
+ "TRADE_CHANGED",
613
+ "Trade changed after signing. Keep the signed order locked until expiry.",
614
+ );
615
+ await this.checkSigner(input.signer, order.maker);
616
+ if (order.deadline <= Math.floor(Date.now() / 1000) + 30)
617
+ throw new GaslessError(
618
+ "ORDER_EXPIRING",
619
+ "Order is too close to expiry to submit. Keep its lock until chain expiry.",
620
+ );
621
+ const record = await this.submitExact(pending, input.signal);
622
+ pending = { ...pending, state: "submitted" };
623
+ await input.store.update(pending);
624
+ input.onPending?.(pending);
625
+ return record;
626
+ } catch (error) {
627
+ if (!signature) await input.store.release(pending);
628
+ else {
629
+ pending = { ...pending, signature, state: "uncertain" };
630
+ await input.store.update(pending);
631
+ input.onPending?.(pending);
632
+ throw new GaslessSubmissionError(
633
+ error instanceof Error
634
+ ? error.message
635
+ : "Submission is uncertain. Check this exact order before retrying.",
636
+ pending,
637
+ );
638
+ }
639
+ throw error;
640
+ }
641
+ }
642
+
643
+ private async submitExact(
644
+ pending: PendingGaslessOrder,
645
+ signal?: AbortSignal,
646
+ ): Promise<LimitOrderRecord> {
647
+ if (
648
+ !pending.signature ||
649
+ pending.network !== this.deployment.network ||
650
+ pending.chainId !== this.deployment.chainId ||
651
+ !sameAddress(pending.contract, this.deployment.contract)
652
+ )
653
+ throw new GaslessError(
654
+ "INVALID_SUBMISSION",
655
+ "Signed order does not target this gasless deployment.",
656
+ );
657
+ const payload: SignedGaslessOrder = {
658
+ ...pending.order,
659
+ signature: pending.signature,
660
+ limitOrderContract: pending.contract,
661
+ };
662
+ try {
663
+ const result = await this.request<{
664
+ success: boolean;
665
+ order: LimitOrderRecord;
666
+ }>(
667
+ endpoint(this.ordersUrl, { network: pending.network }),
668
+ {
669
+ method: "POST",
670
+ headers: { "Content-Type": "application/json" },
671
+ body: JSON.stringify(payload),
672
+ },
673
+ signal,
674
+ 20_000,
675
+ );
676
+ if (
677
+ result.success !== true ||
678
+ !result.order ||
679
+ !gaslessRecordMatches(pending, result.order)
680
+ )
681
+ throw new GaslessError(
682
+ "INVALID_RESPONSE",
683
+ "API did not confirm this exact gasless order.",
684
+ );
685
+ return result.order;
686
+ } catch (error) {
687
+ if (!signal?.aborted) {
688
+ // Hosted duplicates return an error, not an idempotent success response.
689
+ const existing = await this.getOrder(pending, signal).catch(() => null);
690
+ if (existing && gaslessRecordMatches(pending, existing))
691
+ return existing;
692
+ }
693
+ throw error;
694
+ }
695
+ }
696
+
697
+ /** Resend only the original signed payload after reconciling a transport/duplicate error. */
698
+ async retrySubmission(
699
+ store: GaslessPendingStore,
700
+ signal?: AbortSignal,
701
+ ): Promise<LimitOrderRecord> {
702
+ const pending = await store.load();
703
+ if (!pending?.signature)
704
+ throw new GaslessError(
705
+ "NO_SIGNED_ORDER",
706
+ "There is no saved signed order to retry.",
707
+ );
708
+ const existing = await this.getOrder(pending, signal);
709
+ if (existing) {
710
+ await store.update({ ...pending, state: "submitted" });
711
+ return existing;
712
+ }
713
+ await this.getConfig(signal);
714
+ if (pending.order.deadline <= Math.floor(Date.now() / 1000) + 30)
715
+ throw new GaslessError(
716
+ "ORDER_EXPIRING",
717
+ "Wait for chain expiry instead of resubmitting an expiring order.",
718
+ );
719
+ const record = await this.submitExact(pending, signal);
720
+ await store.update({ ...pending, state: "submitted" });
721
+ return record;
722
+ }
723
+
724
+ async getOrder(
725
+ pending: PendingGaslessOrder,
726
+ signal?: AbortSignal,
727
+ ): Promise<LimitOrderRecord | null> {
728
+ if (
729
+ pending.network !== this.deployment.network ||
730
+ pending.chainId !== this.deployment.chainId
731
+ )
732
+ throw new GaslessError(
733
+ "WRONG_CHAIN",
734
+ "Pending order belongs to another network.",
735
+ );
736
+ try {
737
+ const record = await this.request<LimitOrderRecord>(
738
+ endpoint(
739
+ `${this.ordersUrl}/${gaslessAddress(pending.order.maker)}/${pending.order.nonce}`,
740
+ { network: pending.network },
741
+ ),
742
+ {},
743
+ signal,
744
+ 10_000,
745
+ );
746
+ if (!gaslessRecordMatches(pending, record))
747
+ throw new GaslessError(
748
+ "ORDER_IDENTITY_MISMATCH",
749
+ "The API record does not match the saved signed order.",
750
+ );
751
+ return record;
752
+ } catch (error) {
753
+ if (
754
+ error instanceof GaslessError &&
755
+ error.code === "API_ERROR" &&
756
+ error.message === "Order not found"
757
+ )
758
+ return null;
759
+ throw error;
760
+ }
761
+ }
762
+
763
+ /** Poll at most once per 5 seconds. An API outage alone cannot release the pending lock. */
764
+ async reconcile(
765
+ store: GaslessPendingStore,
766
+ signal?: AbortSignal,
767
+ ): Promise<GaslessOrderOutcome | null> {
768
+ const pending = await store.load();
769
+ if (!pending) return null;
770
+ let record: LimitOrderRecord | null = null;
771
+ let readError: unknown;
772
+ try {
773
+ record = await this.getOrder(pending, signal);
774
+ } catch (error) {
775
+ if (
776
+ signal?.aborted ||
777
+ (error instanceof GaslessError &&
778
+ ["ORDER_IDENTITY_MISMATCH", "WRONG_CHAIN"].includes(error.code))
779
+ )
780
+ throw error;
781
+ readError = error;
782
+ }
783
+ if (record?.status === "FILLED") {
784
+ await store.release(pending);
785
+ return {
786
+ status: "filled",
787
+ pending,
788
+ record,
789
+ settlementTxUrl:
790
+ typeof record.filledTxHash === "string" &&
791
+ /^0x[\da-f]{64}$/i.test(record.filledTxHash)
792
+ ? `${this.deployment.explorer}/tx/${record.filledTxHash}`
793
+ : undefined,
794
+ };
795
+ }
796
+ if (record?.status === "CANCELLED") {
797
+ await store.release(pending);
798
+ return { status: "cancelled", pending, record };
799
+ }
800
+ // API EXPIRED or the local clock alone cannot unlock a still-executable signature.
801
+ const [network, block] = await withGaslessDeadline(
802
+ () =>
803
+ Promise.all([
804
+ this.provider.getNetwork(),
805
+ this.provider.getBlock("latest"),
806
+ ]),
807
+ 10_000,
808
+ signal,
809
+ );
810
+ if (network.chainId !== BigInt(pending.chainId))
811
+ throw new GaslessError("WRONG_CHAIN", "RPC is on another network.");
812
+ if (block && block.timestamp > pending.order.deadline) {
813
+ await store.release(pending);
814
+ return {
815
+ status: "closed",
816
+ pending,
817
+ reason:
818
+ "The chain has passed the signed deadline. Check order history for the final settlement result.",
819
+ };
820
+ }
821
+ if (readError) throw readError;
822
+ return { status: "pending", pending };
823
+ }
824
+ }