@shroud-fi/relayer 0.1.1 → 0.1.3

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 (45) hide show
  1. package/package.json +8 -5
  2. package/src/constants.ts +56 -0
  3. package/src/errors.ts +82 -0
  4. package/src/eth-sweep.ts +233 -0
  5. package/src/gelato-adapter.ts +295 -0
  6. package/src/index.ts +33 -0
  7. package/src/relayer.ts +211 -0
  8. package/src/signing.ts +165 -0
  9. package/src/types.ts +101 -0
  10. package/tsconfig.json +9 -0
  11. package/dist/cjs/constants.d.ts.map +0 -1
  12. package/dist/cjs/constants.js.map +0 -1
  13. package/dist/cjs/errors.d.ts.map +0 -1
  14. package/dist/cjs/errors.js.map +0 -1
  15. package/dist/cjs/eth-sweep.d.ts.map +0 -1
  16. package/dist/cjs/eth-sweep.js.map +0 -1
  17. package/dist/cjs/gelato-adapter.d.ts.map +0 -1
  18. package/dist/cjs/gelato-adapter.js.map +0 -1
  19. package/dist/cjs/index.d.ts.map +0 -1
  20. package/dist/cjs/index.js.map +0 -1
  21. package/dist/cjs/relayer.d.ts.map +0 -1
  22. package/dist/cjs/relayer.js.map +0 -1
  23. package/dist/cjs/signing.d.ts.map +0 -1
  24. package/dist/cjs/signing.js.map +0 -1
  25. package/dist/cjs/types.d.ts.map +0 -1
  26. package/dist/cjs/types.js.map +0 -1
  27. package/dist/esm/constants.d.ts.map +0 -1
  28. package/dist/esm/constants.js.map +0 -1
  29. package/dist/esm/errors.d.ts.map +0 -1
  30. package/dist/esm/errors.js.map +0 -1
  31. package/dist/esm/eth-sweep.d.ts.map +0 -1
  32. package/dist/esm/eth-sweep.js.map +0 -1
  33. package/dist/esm/gelato-adapter.d.ts.map +0 -1
  34. package/dist/esm/gelato-adapter.js.map +0 -1
  35. package/dist/esm/index.d.ts.map +0 -1
  36. package/dist/esm/index.js.map +0 -1
  37. package/dist/esm/relayer.d.ts.map +0 -1
  38. package/dist/esm/relayer.js.map +0 -1
  39. package/dist/esm/signing.d.ts.map +0 -1
  40. package/dist/esm/signing.js.map +0 -1
  41. package/dist/esm/types.d.ts.map +0 -1
  42. package/dist/esm/types.js.map +0 -1
  43. package/dist/tsconfig.cjs.tsbuildinfo +0 -1
  44. package/dist/tsconfig.esm.tsbuildinfo +0 -1
  45. package/dist/tsconfig.tsbuildinfo +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shroud-fi/relayer",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Gasless sweep client for ShroudFi. ERC-20 via Gelato 1Balance + EIP-2612 permit, native ETH via EIP-7702 delegated relayer.",
5
5
  "keywords": [
6
6
  "shroudfi",
@@ -27,11 +27,14 @@
27
27
  }
28
28
  },
29
29
  "files": [
30
- "dist"
30
+ "dist",
31
+ "src",
32
+ "tsconfig.json",
33
+ "README.md"
31
34
  ],
32
35
  "dependencies": {
33
36
  "@gelatonetwork/relay-sdk-viem": "^1.4.0",
34
- "@shroud-fi/transport": "0.1.2"
37
+ "@shroud-fi/transport": "0.1.4"
35
38
  },
36
39
  "peerDependencies": {
37
40
  "viem": "^2.21.0"
@@ -40,8 +43,8 @@
40
43
  "viem": "^2.21.0",
41
44
  "vitest": "^2.0.0",
42
45
  "typescript": "^5.6.0",
43
- "@shroud-fi/payments": "0.1.1",
44
- "@shroud-fi/self-host-relayer": "0.1.1"
46
+ "@shroud-fi/payments": "0.1.3",
47
+ "@shroud-fi/self-host-relayer": "0.1.3"
45
48
  },
46
49
  "publishConfig": {
47
50
  "access": "public"
@@ -0,0 +1,56 @@
1
+ import type { Address } from 'viem';
2
+
3
+ /**
4
+ * Default EIP-2612 permit deadline lifetime (in seconds).
5
+ *
6
+ * 30 minutes balances:
7
+ * - long enough for Gelato task scheduling + L2 inclusion under congestion
8
+ * - short enough to keep the signed permit window tight (smaller front-run /
9
+ * replay attack window if a permit signature leaks)
10
+ */
11
+ export const DEFAULT_PERMIT_DEADLINE_SECS: bigint = 1800n;
12
+
13
+ /** Poll Gelato task status every 2s by default. */
14
+ export const DEFAULT_POLL_INTERVAL_MS: number = 2000;
15
+
16
+ /** Abort polling after 90s by default — well past Base L2 inclusion p99. */
17
+ export const DEFAULT_POLL_TIMEOUT_MS: number = 90_000;
18
+
19
+ /**
20
+ * Gelato Trusted Forwarder addresses ("Group A") for ShroudFi-supported chains.
21
+ *
22
+ * Both Base Sepolia (84532) and Base Mainnet (8453) share the same
23
+ * forwarder address per Gelato's Group A deployment.
24
+ *
25
+ * Defense-in-depth: at runtime the SDK reads `ShroudFiRelayer.trustedForwarder()`
26
+ * and asserts equality against this table before submitting any metaTx.
27
+ */
28
+ export const GELATO_FORWARDER_BY_CHAIN: Readonly<Record<number, Address>> =
29
+ Object.freeze({
30
+ 8453: '0xd8253782c45a12053594b9deB72d8e8aB2Fca54c' as Address,
31
+ 84532: '0xd8253782c45a12053594b9deB72d8e8aB2Fca54c' as Address,
32
+ });
33
+
34
+ /**
35
+ * Gelato public task-status endpoint. No auth required.
36
+ * Used as a fallback if the SDK's `getTaskStatus` is unavailable.
37
+ */
38
+ export const GELATO_TASK_STATUS_URL: string =
39
+ 'https://api.gelato.digital/tasks/status/';
40
+
41
+ // ─── ETH sweep (P5.1) ─────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Default EIP-712 EthSweep deadline lifetime (seconds). Matches the ERC-20
45
+ * permit deadline — 30 minutes balances inclusion headroom against a tight
46
+ * replay window if the signature leaks.
47
+ */
48
+ export const DEFAULT_ETH_SWEEP_DEADLINE_SECS: bigint = 1800n;
49
+
50
+ /**
51
+ * Chains where the ShroudFiEthRelayer is deployed AND EIP-7702 is active.
52
+ * Base Sepolia (84532) + Base mainnet (8453). Pectra activated on both.
53
+ */
54
+ export const ETH_RELAYER_SUPPORTED_CHAINS: ReadonlySet<number> = new Set([
55
+ 8453, 84532,
56
+ ]);
package/src/errors.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Base class for all relayer-layer failures. Carries no key or amount
3
+ * material. Mirrors the shape of `@shroud-fi/payments` errors.
4
+ */
5
+ export class RelayerError extends Error {
6
+ override readonly name: string = 'RelayerError';
7
+ constructor(message: string) {
8
+ super(message);
9
+ }
10
+ }
11
+
12
+ /** EIP-2612 permit could not be signed (typed-data path failed). */
13
+ export class RelayerSignatureError extends RelayerError {
14
+ override readonly name: string = 'RelayerSignatureError';
15
+ constructor() {
16
+ super('Failed to sign EIP-2612 permit');
17
+ }
18
+ }
19
+
20
+ /** Gelato accepted/rejected the metaTx submission — no taskId obtained. */
21
+ export class RelayerSubmissionError extends RelayerError {
22
+ override readonly name: string = 'RelayerSubmissionError';
23
+ constructor(tag?: string) {
24
+ super(
25
+ tag === undefined
26
+ ? 'Failed to submit sponsored call to relayer network'
27
+ : `Failed to submit sponsored call to relayer network (${tag})`,
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Gelato accepted the task but on-chain execution reverted (or was blacklisted).
34
+ * Carries the lifecycle state but no signature / amount / address bytes.
35
+ */
36
+ export class RelayerExecutionFailedError extends RelayerError {
37
+ override readonly name: string = 'RelayerExecutionFailedError';
38
+ constructor(state: string) {
39
+ super(`Relayed sweep execution did not succeed (state=${state})`);
40
+ }
41
+ }
42
+
43
+ /** Polling exceeded the configured timeout. */
44
+ export class RelayerTimeoutError extends RelayerError {
45
+ override readonly name: string = 'RelayerTimeoutError';
46
+ constructor() {
47
+ super('Timed out waiting for relayer task to reach a terminal state');
48
+ }
49
+ }
50
+
51
+ /**
52
+ * On-chain `ShroudFiRelayer.trustedForwarder()` did not match the Gelato
53
+ * forwarder we expected for this chain. Defense-in-depth against deploying
54
+ * against the wrong forwarder.
55
+ */
56
+ export class RelayerForwarderMismatchError extends RelayerError {
57
+ override readonly name: string = 'RelayerForwarderMismatchError';
58
+ constructor() {
59
+ super('Relayer contract trustedForwarder does not match expected Gelato forwarder');
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Stealth address held zero balance of the target token at the time the
65
+ * relayer was asked to sweep it. Distinct from a wallet "insufficient funds"
66
+ * condition — this means the inbound stealth has already been swept (or never
67
+ * funded), so there's nothing left to relay.
68
+ */
69
+ export class StealthAddressEmptyError extends RelayerError {
70
+ override readonly name: string = 'StealthAddressEmptyError';
71
+ constructor() {
72
+ super('Stealth address has zero balance of the target token');
73
+ }
74
+ }
75
+
76
+ /** Transport chain is not in the supported Gelato forwarder table. */
77
+ export class RelayerInvalidChainError extends RelayerError {
78
+ override readonly name: string = 'RelayerInvalidChainError';
79
+ constructor() {
80
+ super('Transport chain is not supported by the Gelato relayer');
81
+ }
82
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * relayedSweepETH — gasless ETH sweep via EIP-7702 + self-host relayer service.
3
+ *
4
+ * Flow:
5
+ * 1. Validate chain is in the supported ETH-relayer table.
6
+ * 2. Read stealth EOA balance; refuse if zero.
7
+ * 3. Read stealth EOA nonce (required for the EIP-7702 auth tuple).
8
+ * 4. Sign an EIP-7702 SET_CODE authorization delegating the stealth EOA to
9
+ * `ethRelayerContract`.
10
+ * 5. Sign an EIP-712 `EthSweep(destination, deadline)` message — the
11
+ * contract verifies this binds the specific destination + deadline so a
12
+ * compromised relayer cannot redirect funds.
13
+ * 6. POST both signatures to the self-host relayer's /relay-eth endpoint.
14
+ * 7. Wait for the on-chain receipt and return.
15
+ *
16
+ * Privacy invariants:
17
+ * - `stealthPrivateKey` never logged, never serialized to any persistent
18
+ * surface. Held in a closure during this call only.
19
+ * - No amount fields in the returned receipt. Callers wanting amount can
20
+ * compare pre/post balances themselves.
21
+ * - Error messages contain no addresses, signature bytes, or chain ids.
22
+ * - The fetch request body is constructed once and discarded. No fetch
23
+ * library that caches request bodies is used (native `fetch` only).
24
+ *
25
+ * Why self-host instead of Gelato:
26
+ * Gelato's 7702 path is bundled into their Smart Wallets product (passkeys,
27
+ * wallet abstraction). It doesn't expose a "submit my pre-signed 7702 auth"
28
+ * endpoint that fits our threat model. Our self-host relayer at
29
+ * api.shroudfi.live already controls its own EOA and can build arbitrary
30
+ * tx types — fewer vendor dependencies, full control of the broadcast path.
31
+ */
32
+
33
+ import { type Address, type Hex } from 'viem';
34
+ import { privateKeyToAccount } from 'viem/accounts';
35
+ import type { ShroudFiTransport } from '@shroud-fi/transport';
36
+ import {
37
+ DEFAULT_ETH_SWEEP_DEADLINE_SECS,
38
+ DEFAULT_POLL_TIMEOUT_MS,
39
+ ETH_RELAYER_SUPPORTED_CHAINS,
40
+ } from './constants.js';
41
+ import type {
42
+ RelayedEthSweepOptions,
43
+ RelayedEthSweepReceipt,
44
+ } from './types.js';
45
+ import {
46
+ RelayerExecutionFailedError,
47
+ StealthAddressEmptyError,
48
+ RelayerInvalidChainError,
49
+ RelayerSignatureError,
50
+ RelayerSubmissionError,
51
+ RelayerTimeoutError,
52
+ } from './errors.js';
53
+
54
+ /**
55
+ * EIP-712 typed-data spec for the EthSweep struct. Matches
56
+ * `bytes32 SWEEP_TYPEHASH = keccak256("EthSweep(address destination,uint256 deadline)")`
57
+ * in ShroudFiEthRelayer.sol.
58
+ */
59
+ const ETH_SWEEP_TYPES = {
60
+ EthSweep: [
61
+ { name: 'destination', type: 'address' },
62
+ { name: 'deadline', type: 'uint256' },
63
+ ],
64
+ } as const;
65
+
66
+ const ETH_SWEEP_DOMAIN_NAME = 'ShroudFiEthRelayer';
67
+ const ETH_SWEEP_DOMAIN_VERSION = '1';
68
+
69
+ interface RelayServiceResponse {
70
+ readonly ok?: boolean;
71
+ readonly txHash?: string;
72
+ readonly blockNumber?: string;
73
+ readonly error?: string;
74
+ }
75
+
76
+ function isHex(s: unknown): s is Hex {
77
+ return typeof s === 'string' && /^0x[0-9a-fA-F]+$/.test(s);
78
+ }
79
+
80
+ export async function relayedSweepETH(
81
+ transport: ShroudFiTransport,
82
+ stealthPrivateKey: Hex,
83
+ destination: Address,
84
+ ethRelayerContract: Address,
85
+ relayerServiceUrl: string,
86
+ options?: RelayedEthSweepOptions,
87
+ ): Promise<RelayedEthSweepReceipt> {
88
+ const chainId = transport.chain.id;
89
+
90
+ // 1. Chain support gate.
91
+ if (!ETH_RELAYER_SUPPORTED_CHAINS.has(chainId)) {
92
+ throw new RelayerInvalidChainError();
93
+ }
94
+
95
+ const account = privateKeyToAccount(stealthPrivateKey);
96
+ const stealthAddress = account.address;
97
+
98
+ // 2. Balance pre-flight.
99
+ const balance = await transport.publicClient.getBalance({
100
+ address: stealthAddress,
101
+ });
102
+ if (balance === 0n) {
103
+ throw new StealthAddressEmptyError();
104
+ }
105
+
106
+ // 3. Read stealth EOA's nonce. EIP-7702 auth tuples include the EOA's
107
+ // nonce; mismatches at submission time cause invalid_auth_signature reverts.
108
+ const nonce = await transport.publicClient.getTransactionCount({
109
+ address: stealthAddress,
110
+ });
111
+
112
+ // 4. Compute deadline against on-chain block timestamp.
113
+ const lifetime = options?.deadlineSecs ?? DEFAULT_ETH_SWEEP_DEADLINE_SECS;
114
+ const block = await transport.publicClient.getBlock();
115
+ const deadline = block.timestamp + lifetime;
116
+
117
+ // 5a. Sign EIP-7702 SET_CODE authorization.
118
+ let authorization;
119
+ try {
120
+ authorization = await account.signAuthorization({
121
+ contractAddress: ethRelayerContract,
122
+ chainId,
123
+ nonce,
124
+ });
125
+ } catch {
126
+ throw new RelayerSignatureError();
127
+ }
128
+
129
+ // 5b. Sign EIP-712 EthSweep. verifyingContract = stealthAddress because the
130
+ // contract runs as delegated code at the stealth EOA's address. OZ EIP712
131
+ // rebuilds the domain at runtime when address(this) != _cachedThis.
132
+ let signature: Hex;
133
+ try {
134
+ signature = await account.signTypedData({
135
+ domain: {
136
+ name: ETH_SWEEP_DOMAIN_NAME,
137
+ version: ETH_SWEEP_DOMAIN_VERSION,
138
+ chainId,
139
+ verifyingContract: stealthAddress,
140
+ },
141
+ types: ETH_SWEEP_TYPES,
142
+ primaryType: 'EthSweep',
143
+ message: { destination, deadline },
144
+ });
145
+ } catch {
146
+ throw new RelayerSignatureError();
147
+ }
148
+
149
+ // 6. POST to self-host relayer. The relayer reconstructs the 7702
150
+ // SignedAuthorization, builds a type-0x04 transaction, and broadcasts.
151
+ const url = `${relayerServiceUrl.replace(/\/$/, '')}/relay-eth`;
152
+ const body = {
153
+ stealthAddress,
154
+ destination,
155
+ chainId,
156
+ deadline: deadline.toString(),
157
+ signature,
158
+ authorization: {
159
+ address: authorization.address,
160
+ chainId: authorization.chainId,
161
+ nonce: authorization.nonce,
162
+ r: authorization.r,
163
+ s: authorization.s,
164
+ yParity: authorization.yParity,
165
+ },
166
+ };
167
+
168
+ let response: Response;
169
+ try {
170
+ const init: RequestInit = {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify(body),
174
+ };
175
+ if (options?.signal !== undefined) {
176
+ init.signal = options.signal;
177
+ }
178
+ response = await fetch(url, init);
179
+ } catch {
180
+ throw new RelayerSubmissionError();
181
+ }
182
+
183
+ if (!response.ok) {
184
+ let tag: string | undefined;
185
+ try {
186
+ const parsed = (await response.json()) as RelayServiceResponse;
187
+ if (typeof parsed.error === 'string') tag = parsed.error;
188
+ } catch {
189
+ // body wasn't JSON — fall through with no tag.
190
+ }
191
+ throw new RelayerSubmissionError(tag);
192
+ }
193
+
194
+ let parsed: RelayServiceResponse;
195
+ try {
196
+ parsed = (await response.json()) as RelayServiceResponse;
197
+ } catch {
198
+ throw new RelayerSubmissionError();
199
+ }
200
+
201
+ if (parsed.ok !== true || !isHex(parsed.txHash)) {
202
+ const tag = typeof parsed.error === 'string' ? parsed.error : 'unknown';
203
+ throw new RelayerExecutionFailedError(tag);
204
+ }
205
+ const txHash = parsed.txHash;
206
+
207
+ // 7. Wait for on-chain confirmation. Relayer responded with a tx hash; the
208
+ // tx is broadcasting but not necessarily mined. Use viem's standard
209
+ // wait-for-receipt with the configured timeout.
210
+ const timeoutMs = options?.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
211
+ let receipt;
212
+ try {
213
+ receipt = await transport.publicClient.waitForTransactionReceipt({
214
+ hash: txHash,
215
+ timeout: timeoutMs,
216
+ });
217
+ } catch {
218
+ throw new RelayerTimeoutError();
219
+ }
220
+
221
+ if (receipt.status !== 'success') {
222
+ throw new RelayerExecutionFailedError('reverted');
223
+ }
224
+
225
+ return {
226
+ txHash,
227
+ status: 'success',
228
+ blockNumber: receipt.blockNumber,
229
+ ethRelayerContract,
230
+ destination,
231
+ stealthAddress,
232
+ };
233
+ }
@@ -0,0 +1,295 @@
1
+ import type { Address, Hex, Chain } from 'viem';
2
+ import { createWalletClient, http } from 'viem';
3
+ import { privateKeyToAccount } from 'viem/accounts';
4
+ import { GelatoRelay, TaskState } from '@gelatonetwork/relay-sdk-viem';
5
+ import {
6
+ GELATO_TASK_STATUS_URL,
7
+ } from './constants.js';
8
+ import type { GelatoTaskId, RelayerSweepStatus } from './types.js';
9
+ import {
10
+ RelayerExecutionFailedError,
11
+ RelayerSubmissionError,
12
+ RelayerTimeoutError,
13
+ } from './errors.js';
14
+
15
+ /**
16
+ * Parameters for submitting a sponsored (1Balance) ERC-2771 metaTx.
17
+ *
18
+ * `data` must already be ABI-encoded for the target function on `target`.
19
+ * The Gelato relay layer appends the `user` address to the calldata so the
20
+ * `_msgSender()` inside the target contract returns the stealth address.
21
+ *
22
+ * Privacy: `userPrivateKey` is consumed once to construct an ephemeral
23
+ * `walletClient`, which is then handed to the Gelato SDK. It is never logged,
24
+ * stored on `this`, or attached to thrown errors.
25
+ */
26
+ export interface SubmitMetaTxParams {
27
+ readonly chainId: number;
28
+ readonly target: Address;
29
+ readonly data: Hex;
30
+ readonly user: Address;
31
+ readonly userPrivateKey: Hex;
32
+ /**
33
+ * Optional Gelato 1Balance sponsor API key.
34
+ * The SDK requires a string; if absent we forward an empty string and let
35
+ * Gelato reject with a structured error.
36
+ */
37
+ readonly apiKey?: string;
38
+ readonly signal?: AbortSignal;
39
+ /**
40
+ * viem `Chain` to attach to the ephemeral walletClient. Required so viem
41
+ * has a chainId/rpc context for typed-data signing.
42
+ */
43
+ readonly chain: Chain;
44
+ /** RPC URL forwarded to the ephemeral walletClient. */
45
+ readonly rpcUrl?: string;
46
+ }
47
+
48
+ /**
49
+ * Normalized Gelato task status surfaced to the SDK caller. Strips PII /
50
+ * un-needed metadata (creation date, gas used, etc.) from the upstream type.
51
+ */
52
+ export interface GelatoTaskStatus {
53
+ readonly taskId: GelatoTaskId;
54
+ readonly state: RelayerSweepStatus;
55
+ readonly txHash?: Hex;
56
+ readonly blockNumber?: bigint;
57
+ readonly lastErrorMsg?: string;
58
+ }
59
+
60
+ // Lazily instantiate a single relay client per process. Stateless wrt
61
+ // chain/key so safe to share.
62
+ let _relay: GelatoRelay | undefined;
63
+ function relay(): GelatoRelay {
64
+ if (_relay === undefined) {
65
+ _relay = new GelatoRelay();
66
+ }
67
+ return _relay;
68
+ }
69
+
70
+ /**
71
+ * Submit a sponsored ERC-2771 metaTx via Gelato 1Balance.
72
+ *
73
+ * Returns the Gelato `taskId`. Caller is responsible for polling.
74
+ */
75
+ export async function submitSponsoredCallErc2771(
76
+ p: SubmitMetaTxParams,
77
+ ): Promise<GelatoTaskId> {
78
+ const account = privateKeyToAccount(p.userPrivateKey);
79
+
80
+ // Build an ephemeral walletClient. The Gelato SDK uses it to sign the
81
+ // ERC-2771 typed data. We deliberately avoid persisting it.
82
+ const wallet = createWalletClient({
83
+ account,
84
+ chain: p.chain,
85
+ transport: p.rpcUrl !== undefined ? http(p.rpcUrl) : http(),
86
+ });
87
+
88
+ const request = {
89
+ chainId: BigInt(p.chainId),
90
+ target: p.target,
91
+ data: p.data,
92
+ user: p.user,
93
+ isConcurrent: false as const,
94
+ };
95
+
96
+ try {
97
+ const response = await relay().sponsoredCallERC2771(
98
+ request,
99
+ wallet,
100
+ p.apiKey ?? '',
101
+ );
102
+ return response.taskId;
103
+ } catch (err) {
104
+ // Surface only the upstream error class name; never the message
105
+ // (may contain calldata bytes, addresses, or RPC quoting).
106
+ const tag = err instanceof Error ? err.name : 'unknown';
107
+ throw new RelayerSubmissionError(tag);
108
+ }
109
+ }
110
+
111
+ function mapTaskState(state: TaskState | string): RelayerSweepStatus {
112
+ switch (state) {
113
+ case TaskState.ExecSuccess:
114
+ case 'ExecSuccess':
115
+ return 'success';
116
+ case TaskState.ExecReverted:
117
+ case 'ExecReverted':
118
+ return 'failed';
119
+ case TaskState.Cancelled:
120
+ case 'Cancelled':
121
+ return 'cancelled';
122
+ case TaskState.CheckPending:
123
+ case TaskState.ExecPending:
124
+ case TaskState.WaitingForConfirmation:
125
+ case 'CheckPending':
126
+ case 'ExecPending':
127
+ case 'WaitingForConfirmation':
128
+ return 'pending';
129
+ default:
130
+ // Treat unknown lifecycle states (e.g. "Blacklisted") as failure.
131
+ return 'failed';
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Fetch a single task-status snapshot via the public Gelato HTTP endpoint.
137
+ * Used as fallback if the SDK's getTaskStatus returns undefined.
138
+ */
139
+ async function fetchTaskStatusFallback(
140
+ taskId: GelatoTaskId,
141
+ signal?: AbortSignal,
142
+ ): Promise<GelatoTaskStatus | undefined> {
143
+ // `fetch` is globally available in Node 18+.
144
+ let resp: Response;
145
+ try {
146
+ const init: RequestInit = signal !== undefined ? { signal } : {};
147
+ resp = await fetch(`${GELATO_TASK_STATUS_URL}${taskId}`, init);
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ if (!resp.ok) return undefined;
152
+
153
+ let body: unknown;
154
+ try {
155
+ body = await resp.json();
156
+ } catch {
157
+ return undefined;
158
+ }
159
+
160
+ // Endpoint returns either { task: {...} } or the bare task object.
161
+ const taskUnknown =
162
+ body !== null && typeof body === 'object' && 'task' in body
163
+ ? (body as { task: unknown }).task
164
+ : body;
165
+
166
+ if (taskUnknown === null || typeof taskUnknown !== 'object')
167
+ return undefined;
168
+ const task = taskUnknown as Record<string, unknown>;
169
+
170
+ const rawState = task['taskState'];
171
+ if (typeof rawState !== 'string') return undefined;
172
+
173
+ const state = mapTaskState(rawState);
174
+
175
+ const result: { -readonly [K in keyof GelatoTaskStatus]: GelatoTaskStatus[K] } = {
176
+ taskId,
177
+ state,
178
+ };
179
+
180
+ const txHash = task['transactionHash'];
181
+ if (typeof txHash === 'string' && txHash.startsWith('0x')) {
182
+ result.txHash = txHash as Hex;
183
+ }
184
+
185
+ const blockNumber = task['blockNumber'];
186
+ if (typeof blockNumber === 'number') {
187
+ result.blockNumber = BigInt(blockNumber);
188
+ } else if (typeof blockNumber === 'string' && /^\d+$/.test(blockNumber)) {
189
+ result.blockNumber = BigInt(blockNumber);
190
+ }
191
+
192
+ const lastErr = task['lastCheckMessage'];
193
+ if (typeof lastErr === 'string' && lastErr.length > 0) {
194
+ result.lastErrorMsg = lastErr;
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Poll Gelato task status until terminal (success / failed / cancelled) or
202
+ * until the timeout expires / the abort signal fires.
203
+ *
204
+ * Strategy:
205
+ * 1. Try the SDK's `getTaskStatus` first (it shares websocket cache).
206
+ * 2. Fall back to direct HTTP if the SDK returns undefined.
207
+ *
208
+ * Errors:
209
+ * - RelayerTimeoutError on timeout / abort
210
+ * - RelayerExecutionFailedError on `failed`/`cancelled` terminal state
211
+ */
212
+ export async function pollGelatoTaskUntilTerminal(
213
+ taskId: GelatoTaskId,
214
+ opts: {
215
+ pollIntervalMs: number;
216
+ pollTimeoutMs: number;
217
+ signal?: AbortSignal;
218
+ },
219
+ ): Promise<GelatoTaskStatus> {
220
+ const deadline = Date.now() + opts.pollTimeoutMs;
221
+ const r = relay();
222
+
223
+ while (Date.now() < deadline) {
224
+ if (opts.signal?.aborted === true) {
225
+ throw new RelayerTimeoutError();
226
+ }
227
+
228
+ let status: GelatoTaskStatus | undefined;
229
+ try {
230
+ const upstream = await r.getTaskStatus(taskId);
231
+ if (upstream !== undefined) {
232
+ const built: { -readonly [K in keyof GelatoTaskStatus]: GelatoTaskStatus[K] } = {
233
+ taskId,
234
+ state: mapTaskState(upstream.taskState),
235
+ };
236
+ if (
237
+ typeof upstream.transactionHash === 'string' &&
238
+ upstream.transactionHash.startsWith('0x')
239
+ ) {
240
+ built.txHash = upstream.transactionHash as Hex;
241
+ }
242
+ if (typeof upstream.blockNumber === 'number') {
243
+ built.blockNumber = BigInt(upstream.blockNumber);
244
+ }
245
+ if (
246
+ typeof upstream.lastCheckMessage === 'string' &&
247
+ upstream.lastCheckMessage.length > 0
248
+ ) {
249
+ built.lastErrorMsg = upstream.lastCheckMessage;
250
+ }
251
+ status = built;
252
+ }
253
+ } catch {
254
+ // swallow; we'll try the HTTP fallback or retry next tick
255
+ }
256
+
257
+ if (status === undefined) {
258
+ status = await fetchTaskStatusFallback(taskId, opts.signal);
259
+ }
260
+
261
+ if (status !== undefined) {
262
+ if (status.state === 'success') return status;
263
+ if (status.state === 'failed' || status.state === 'cancelled') {
264
+ throw new RelayerExecutionFailedError(status.state);
265
+ }
266
+ // pending / submitted: keep polling
267
+ }
268
+
269
+ await sleep(opts.pollIntervalMs, opts.signal);
270
+ }
271
+
272
+ throw new RelayerTimeoutError();
273
+ }
274
+
275
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
276
+ return new Promise<void>((resolve, reject) => {
277
+ if (signal?.aborted === true) {
278
+ reject(new RelayerTimeoutError());
279
+ return;
280
+ }
281
+ const t = setTimeout(() => {
282
+ if (signal !== undefined) {
283
+ signal.removeEventListener('abort', onAbort);
284
+ }
285
+ resolve();
286
+ }, ms);
287
+ const onAbort = () => {
288
+ clearTimeout(t);
289
+ reject(new RelayerTimeoutError());
290
+ };
291
+ if (signal !== undefined) {
292
+ signal.addEventListener('abort', onAbort, { once: true });
293
+ }
294
+ });
295
+ }