@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.
- package/package.json +8 -5
- package/src/constants.ts +56 -0
- package/src/errors.ts +82 -0
- package/src/eth-sweep.ts +233 -0
- package/src/gelato-adapter.ts +295 -0
- package/src/index.ts +33 -0
- package/src/relayer.ts +211 -0
- package/src/signing.ts +165 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +9 -0
- package/dist/cjs/constants.d.ts.map +0 -1
- package/dist/cjs/constants.js.map +0 -1
- package/dist/cjs/errors.d.ts.map +0 -1
- package/dist/cjs/errors.js.map +0 -1
- package/dist/cjs/eth-sweep.d.ts.map +0 -1
- package/dist/cjs/eth-sweep.js.map +0 -1
- package/dist/cjs/gelato-adapter.d.ts.map +0 -1
- package/dist/cjs/gelato-adapter.js.map +0 -1
- package/dist/cjs/index.d.ts.map +0 -1
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/relayer.d.ts.map +0 -1
- package/dist/cjs/relayer.js.map +0 -1
- package/dist/cjs/signing.d.ts.map +0 -1
- package/dist/cjs/signing.js.map +0 -1
- package/dist/cjs/types.d.ts.map +0 -1
- package/dist/cjs/types.js.map +0 -1
- package/dist/esm/constants.d.ts.map +0 -1
- package/dist/esm/constants.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/eth-sweep.d.ts.map +0 -1
- package/dist/esm/eth-sweep.js.map +0 -1
- package/dist/esm/gelato-adapter.d.ts.map +0 -1
- package/dist/esm/gelato-adapter.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/relayer.d.ts.map +0 -1
- package/dist/esm/relayer.js.map +0 -1
- package/dist/esm/signing.d.ts.map +0 -1
- package/dist/esm/signing.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.esm.tsbuildinfo +0 -1
- 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.
|
|
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.
|
|
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.
|
|
44
|
-
"@shroud-fi/self-host-relayer": "0.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"
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|
package/src/eth-sweep.ts
ADDED
|
@@ -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
|
+
}
|