@shroud-fi/relayer 0.1.2 → 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/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/
|
|
44
|
-
"@shroud-fi/
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export { relayedSweepERC20 } from './relayer.js';
|
|
2
|
+
export { relayedSweepETH } from './eth-sweep.js';
|
|
3
|
+
export { signEip2612Permit } from './signing.js';
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
RelayerSweepOptions,
|
|
7
|
+
RelayerSweepReceipt,
|
|
8
|
+
RelayerSweepStatus,
|
|
9
|
+
GelatoTaskId,
|
|
10
|
+
Eip2612PermitSignature,
|
|
11
|
+
RelayedEthSweepOptions,
|
|
12
|
+
RelayedEthSweepReceipt,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
RelayerError,
|
|
17
|
+
RelayerSignatureError,
|
|
18
|
+
RelayerSubmissionError,
|
|
19
|
+
RelayerExecutionFailedError,
|
|
20
|
+
RelayerTimeoutError,
|
|
21
|
+
RelayerForwarderMismatchError,
|
|
22
|
+
StealthAddressEmptyError,
|
|
23
|
+
RelayerInvalidChainError,
|
|
24
|
+
} from './errors.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
DEFAULT_PERMIT_DEADLINE_SECS,
|
|
28
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
29
|
+
DEFAULT_POLL_TIMEOUT_MS,
|
|
30
|
+
GELATO_FORWARDER_BY_CHAIN,
|
|
31
|
+
DEFAULT_ETH_SWEEP_DEADLINE_SECS,
|
|
32
|
+
ETH_RELAYER_SUPPORTED_CHAINS,
|
|
33
|
+
} from './constants.js';
|
package/src/relayer.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Address, Hex } from 'viem';
|
|
2
|
+
import { encodeFunctionData, getAddress } from 'viem';
|
|
3
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
4
|
+
import type { ShroudFiTransport } from '@shroud-fi/transport';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_PERMIT_DEADLINE_SECS,
|
|
7
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
8
|
+
DEFAULT_POLL_TIMEOUT_MS,
|
|
9
|
+
GELATO_FORWARDER_BY_CHAIN,
|
|
10
|
+
} from './constants.js';
|
|
11
|
+
import type {
|
|
12
|
+
RelayerSweepOptions,
|
|
13
|
+
RelayerSweepReceipt,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
import {
|
|
16
|
+
RelayerForwarderMismatchError,
|
|
17
|
+
StealthAddressEmptyError,
|
|
18
|
+
RelayerInvalidChainError,
|
|
19
|
+
} from './errors.js';
|
|
20
|
+
import { signEip2612Permit } from './signing.js';
|
|
21
|
+
import {
|
|
22
|
+
submitSponsoredCallErc2771,
|
|
23
|
+
pollGelatoTaskUntilTerminal,
|
|
24
|
+
} from './gelato-adapter.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimal inline ABI for `ShroudFiRelayer` — only the surface we call from
|
|
28
|
+
* the SDK. Kept here (not in a shared `abi.ts`) because no other module needs
|
|
29
|
+
* these entries today.
|
|
30
|
+
*/
|
|
31
|
+
const SHROUDFI_RELAYER_ABI = [
|
|
32
|
+
{
|
|
33
|
+
type: 'function',
|
|
34
|
+
name: 'sweepERC20WithPermit',
|
|
35
|
+
stateMutability: 'nonpayable',
|
|
36
|
+
inputs: [
|
|
37
|
+
{ name: 'token', type: 'address' },
|
|
38
|
+
{ name: 'destination', type: 'address' },
|
|
39
|
+
{ name: 'deadline', type: 'uint256' },
|
|
40
|
+
{ name: 'v', type: 'uint8' },
|
|
41
|
+
{ name: 'r', type: 'bytes32' },
|
|
42
|
+
{ name: 's', type: 'bytes32' },
|
|
43
|
+
],
|
|
44
|
+
outputs: [],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'function',
|
|
48
|
+
name: 'trustedForwarder',
|
|
49
|
+
stateMutability: 'view',
|
|
50
|
+
inputs: [],
|
|
51
|
+
outputs: [{ name: '', type: 'address' }],
|
|
52
|
+
},
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Minimal balanceOf ABI for the pre-flight balance read. Inlined so this file
|
|
57
|
+
* has no dependency on `@shroud-fi/transport`'s ERC20Abi shape.
|
|
58
|
+
*/
|
|
59
|
+
const ERC20_BALANCE_OF_ABI = [
|
|
60
|
+
{
|
|
61
|
+
type: 'function',
|
|
62
|
+
name: 'balanceOf',
|
|
63
|
+
stateMutability: 'view',
|
|
64
|
+
inputs: [{ name: 'account', type: 'address' }],
|
|
65
|
+
outputs: [{ name: '', type: 'uint256' }],
|
|
66
|
+
},
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gasless ERC-20 sweep via the deployed `ShroudFiRelayer` + Gelato.
|
|
71
|
+
*
|
|
72
|
+
* Flow:
|
|
73
|
+
* 1. Validate chain is in the supported Gelato forwarder table.
|
|
74
|
+
* 2. Read the relayer's on-chain trustedForwarder and assert match.
|
|
75
|
+
* 3. Read the stealth address ERC-20 balance (= permit `value`).
|
|
76
|
+
* 4. Sign an EIP-2612 permit { owner=stealth, spender=relayer, value, deadline }.
|
|
77
|
+
* 5. Encode `sweepERC20WithPermit(token, destination, deadline, v, r, s)`.
|
|
78
|
+
* 6. Submit via `sponsoredCallERC2771` (1Balance sponsored).
|
|
79
|
+
* 7. Poll until terminal — return receipt with txHash + blockNumber.
|
|
80
|
+
*
|
|
81
|
+
* Privacy invariants enforced here:
|
|
82
|
+
* - `stealthPrivateKey` is never logged, serialized, or attached to errors.
|
|
83
|
+
* - The receipt contains NO amount fields (balance is read but discarded
|
|
84
|
+
* after permit signing).
|
|
85
|
+
* - Error messages contain no addresses, amounts, signature bytes, or chain ids.
|
|
86
|
+
*/
|
|
87
|
+
export async function relayedSweepERC20(
|
|
88
|
+
transport: ShroudFiTransport,
|
|
89
|
+
stealthPrivateKey: Hex,
|
|
90
|
+
token: Address,
|
|
91
|
+
destination: Address,
|
|
92
|
+
relayerContract: Address,
|
|
93
|
+
options?: RelayerSweepOptions,
|
|
94
|
+
): Promise<RelayerSweepReceipt> {
|
|
95
|
+
const chainId = transport.chain.id;
|
|
96
|
+
|
|
97
|
+
// 1. Chain support check.
|
|
98
|
+
const expectedForwarder = GELATO_FORWARDER_BY_CHAIN[chainId];
|
|
99
|
+
if (expectedForwarder === undefined) {
|
|
100
|
+
throw new RelayerInvalidChainError();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Forwarder match check (defense-in-depth).
|
|
104
|
+
const onChainForwarder = (await transport.publicClient.readContract({
|
|
105
|
+
address: relayerContract,
|
|
106
|
+
abi: SHROUDFI_RELAYER_ABI,
|
|
107
|
+
functionName: 'trustedForwarder',
|
|
108
|
+
})) as Address;
|
|
109
|
+
if (
|
|
110
|
+
getAddress(onChainForwarder) !== getAddress(expectedForwarder)
|
|
111
|
+
) {
|
|
112
|
+
throw new RelayerForwarderMismatchError();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Balance read. Note: `account` is derived inside a tight scope; we
|
|
116
|
+
// never persist or log the privateKey-derived account beyond this.
|
|
117
|
+
const stealthAddress = privateKeyToAccount(stealthPrivateKey).address;
|
|
118
|
+
|
|
119
|
+
const balance = (await transport.publicClient.readContract({
|
|
120
|
+
address: token,
|
|
121
|
+
abi: ERC20_BALANCE_OF_ABI,
|
|
122
|
+
functionName: 'balanceOf',
|
|
123
|
+
args: [stealthAddress],
|
|
124
|
+
})) as bigint;
|
|
125
|
+
|
|
126
|
+
if (balance === 0n) {
|
|
127
|
+
throw new StealthAddressEmptyError();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Compute permit deadline. Use block.timestamp + lifetime so the
|
|
131
|
+
// deadline aligns with the chain's clock rather than the host's.
|
|
132
|
+
const lifetime = options?.deadlineSecs ?? DEFAULT_PERMIT_DEADLINE_SECS;
|
|
133
|
+
const latestBlock = await transport.publicClient.getBlock();
|
|
134
|
+
const deadline = latestBlock.timestamp + lifetime;
|
|
135
|
+
|
|
136
|
+
// 5. Sign the EIP-2612 permit.
|
|
137
|
+
const permit = await signEip2612Permit(
|
|
138
|
+
transport,
|
|
139
|
+
stealthPrivateKey,
|
|
140
|
+
token,
|
|
141
|
+
relayerContract,
|
|
142
|
+
balance,
|
|
143
|
+
deadline,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// 6. Encode sweepERC20WithPermit calldata.
|
|
147
|
+
const calldata = encodeFunctionData({
|
|
148
|
+
abi: SHROUDFI_RELAYER_ABI,
|
|
149
|
+
functionName: 'sweepERC20WithPermit',
|
|
150
|
+
args: [token, destination, permit.deadline, permit.v, permit.r, permit.s],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// 7. Submit via Gelato 1Balance sponsored call.
|
|
154
|
+
const submitParams: {
|
|
155
|
+
chainId: number;
|
|
156
|
+
target: Address;
|
|
157
|
+
data: Hex;
|
|
158
|
+
user: Address;
|
|
159
|
+
userPrivateKey: Hex;
|
|
160
|
+
chain: ShroudFiTransport['chain'];
|
|
161
|
+
apiKey?: string;
|
|
162
|
+
rpcUrl?: string;
|
|
163
|
+
signal?: AbortSignal;
|
|
164
|
+
} = {
|
|
165
|
+
chainId,
|
|
166
|
+
target: relayerContract,
|
|
167
|
+
data: calldata,
|
|
168
|
+
user: stealthAddress,
|
|
169
|
+
userPrivateKey: stealthPrivateKey,
|
|
170
|
+
chain: transport.chain,
|
|
171
|
+
};
|
|
172
|
+
if (options?.gelatoApiKey !== undefined) {
|
|
173
|
+
submitParams.apiKey = options.gelatoApiKey;
|
|
174
|
+
}
|
|
175
|
+
if (options?.signal !== undefined) {
|
|
176
|
+
submitParams.signal = options.signal;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const taskId = await submitSponsoredCallErc2771(submitParams);
|
|
180
|
+
|
|
181
|
+
// 8. Poll until terminal.
|
|
182
|
+
const pollOpts: {
|
|
183
|
+
pollIntervalMs: number;
|
|
184
|
+
pollTimeoutMs: number;
|
|
185
|
+
signal?: AbortSignal;
|
|
186
|
+
} = {
|
|
187
|
+
pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
188
|
+
pollTimeoutMs: options?.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS,
|
|
189
|
+
};
|
|
190
|
+
if (options?.signal !== undefined) {
|
|
191
|
+
pollOpts.signal = options.signal;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const status = await pollGelatoTaskUntilTerminal(taskId, pollOpts);
|
|
195
|
+
|
|
196
|
+
// Build the receipt. NOTE: no amount fields by design.
|
|
197
|
+
const receipt: {
|
|
198
|
+
-readonly [K in keyof RelayerSweepReceipt]: RelayerSweepReceipt[K];
|
|
199
|
+
} = {
|
|
200
|
+
taskId,
|
|
201
|
+
txHash: status.txHash ?? ('0x' as Hex),
|
|
202
|
+
status: status.state,
|
|
203
|
+
relayerContract,
|
|
204
|
+
token,
|
|
205
|
+
destination,
|
|
206
|
+
};
|
|
207
|
+
if (status.blockNumber !== undefined) {
|
|
208
|
+
receipt.blockNumber = status.blockNumber;
|
|
209
|
+
}
|
|
210
|
+
return receipt;
|
|
211
|
+
}
|
package/src/signing.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Address, Hex } from 'viem';
|
|
2
|
+
import { parseSignature } from 'viem';
|
|
3
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
4
|
+
import type { ShroudFiTransport } from '@shroud-fi/transport';
|
|
5
|
+
import type { Eip2612PermitSignature } from './types.js';
|
|
6
|
+
import { RelayerSignatureError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal ABI for the read paths we use. Inlined rather than imported from
|
|
10
|
+
* `@shroud-fi/transport`'s ERC20Abi so this file has zero coupling to
|
|
11
|
+
* non-permit fields. Direct two-call reads (no multicall3 dependency) so the
|
|
12
|
+
* SDK runs against forks/local Anvils where multicall may not be configured.
|
|
13
|
+
*/
|
|
14
|
+
const PERMIT_READ_ABI = [
|
|
15
|
+
{
|
|
16
|
+
type: 'function',
|
|
17
|
+
name: 'name',
|
|
18
|
+
stateMutability: 'view',
|
|
19
|
+
inputs: [],
|
|
20
|
+
outputs: [{ name: '', type: 'string' }],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'function',
|
|
24
|
+
name: 'version',
|
|
25
|
+
stateMutability: 'view',
|
|
26
|
+
inputs: [],
|
|
27
|
+
outputs: [{ name: '', type: 'string' }],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: 'function',
|
|
31
|
+
name: 'nonces',
|
|
32
|
+
stateMutability: 'view',
|
|
33
|
+
inputs: [{ name: 'owner', type: 'address' }],
|
|
34
|
+
outputs: [{ name: '', type: 'uint256' }],
|
|
35
|
+
},
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const PERMIT_TYPES = {
|
|
39
|
+
Permit: [
|
|
40
|
+
{ name: 'owner', type: 'address' },
|
|
41
|
+
{ name: 'spender', type: 'address' },
|
|
42
|
+
{ name: 'value', type: 'uint256' },
|
|
43
|
+
{ name: 'nonce', type: 'uint256' },
|
|
44
|
+
{ name: 'deadline', type: 'uint256' },
|
|
45
|
+
],
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sign an EIP-2612 permit for {token, spender, value, deadline} from the
|
|
50
|
+
* stealth address keyed by `stealthPrivateKey`.
|
|
51
|
+
*
|
|
52
|
+
* Privacy:
|
|
53
|
+
* - The privateKey is consumed once to derive a viem `account` and is not
|
|
54
|
+
* stored, logged, serialized, or attached to thrown errors.
|
|
55
|
+
* - On failure we throw a structured `RelayerSignatureError` with no key
|
|
56
|
+
* material, no signature bytes, no nonce.
|
|
57
|
+
* - Token `version()` is probed with a try/catch fallback to "1" — the
|
|
58
|
+
* dominant default across permit-compatible ERC-20s.
|
|
59
|
+
*
|
|
60
|
+
* @param transport - ShroudFi transport (publicClient for reads)
|
|
61
|
+
* @param stealthPrivateKey - 0x-prefixed 32-byte private key (treated as secret)
|
|
62
|
+
* @param token - ERC-20 contract
|
|
63
|
+
* @param spender - relayer contract address (= `ShroudFiRelayer`)
|
|
64
|
+
* @param value - permit value (= full balance at time of sign)
|
|
65
|
+
* @param deadline - unix-seconds deadline (caller computes from `block.timestamp + lifetime`)
|
|
66
|
+
*/
|
|
67
|
+
export async function signEip2612Permit(
|
|
68
|
+
transport: ShroudFiTransport,
|
|
69
|
+
stealthPrivateKey: Hex,
|
|
70
|
+
token: Address,
|
|
71
|
+
spender: Address,
|
|
72
|
+
value: bigint,
|
|
73
|
+
deadline: bigint,
|
|
74
|
+
): Promise<Eip2612PermitSignature> {
|
|
75
|
+
const account = privateKeyToAccount(stealthPrivateKey);
|
|
76
|
+
const owner = account.address;
|
|
77
|
+
const chainId = transport.chain.id;
|
|
78
|
+
|
|
79
|
+
// Read token name (required for the domain separator). If a token doesn't
|
|
80
|
+
// expose `name()` it isn't ERC-2612 compatible — propagate as signature error.
|
|
81
|
+
let tokenName: string;
|
|
82
|
+
try {
|
|
83
|
+
tokenName = (await transport.publicClient.readContract({
|
|
84
|
+
address: token,
|
|
85
|
+
abi: PERMIT_READ_ABI,
|
|
86
|
+
functionName: 'name',
|
|
87
|
+
})) as string;
|
|
88
|
+
} catch {
|
|
89
|
+
throw new RelayerSignatureError();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Token version: most permit ERC-20s use "1". Probe with a try/catch.
|
|
93
|
+
let tokenVersion = '1';
|
|
94
|
+
try {
|
|
95
|
+
const v = (await transport.publicClient.readContract({
|
|
96
|
+
address: token,
|
|
97
|
+
abi: PERMIT_READ_ABI,
|
|
98
|
+
functionName: 'version',
|
|
99
|
+
})) as string;
|
|
100
|
+
if (typeof v === 'string' && v.length > 0) {
|
|
101
|
+
tokenVersion = v;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// intentional: fall back to "1"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read the current permit nonce for `owner`.
|
|
108
|
+
let nonce: bigint;
|
|
109
|
+
try {
|
|
110
|
+
nonce = (await transport.publicClient.readContract({
|
|
111
|
+
address: token,
|
|
112
|
+
abi: PERMIT_READ_ABI,
|
|
113
|
+
functionName: 'nonces',
|
|
114
|
+
args: [owner],
|
|
115
|
+
})) as bigint;
|
|
116
|
+
} catch {
|
|
117
|
+
throw new RelayerSignatureError();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const domain = {
|
|
121
|
+
name: tokenName,
|
|
122
|
+
version: tokenVersion,
|
|
123
|
+
chainId,
|
|
124
|
+
verifyingContract: token,
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
127
|
+
let signatureHex: Hex;
|
|
128
|
+
try {
|
|
129
|
+
signatureHex = await account.signTypedData({
|
|
130
|
+
domain,
|
|
131
|
+
types: PERMIT_TYPES,
|
|
132
|
+
primaryType: 'Permit',
|
|
133
|
+
message: {
|
|
134
|
+
owner,
|
|
135
|
+
spender,
|
|
136
|
+
value,
|
|
137
|
+
nonce,
|
|
138
|
+
deadline,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
throw new RelayerSignatureError();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// viem returns the 65-byte signature; split into v/r/s.
|
|
146
|
+
// `parseSignature` returns either {r,s,v,yParity} or {r,s,yParity} (v?: never).
|
|
147
|
+
// Standard EIP-2612 needs `v ∈ {27, 28}`: derive from yParity if v is absent.
|
|
148
|
+
let v: number;
|
|
149
|
+
let r: Hex;
|
|
150
|
+
let s: Hex;
|
|
151
|
+
try {
|
|
152
|
+
const split = parseSignature(signatureHex);
|
|
153
|
+
r = split.r;
|
|
154
|
+
s = split.s;
|
|
155
|
+
if (split.v !== undefined) {
|
|
156
|
+
v = Number(split.v);
|
|
157
|
+
} else {
|
|
158
|
+
v = split.yParity === 0 ? 27 : 28;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
throw new RelayerSignatureError();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { v, r, s, deadline };
|
|
165
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Address, Hex } from 'viem';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gelato task identifier returned by `sponsoredCallERC2771`.
|
|
5
|
+
* Opaque string the SDK uses to poll status.
|
|
6
|
+
*/
|
|
7
|
+
export type GelatoTaskId = string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Terminal + in-flight states the SDK surfaces to the caller.
|
|
11
|
+
* Mapped from Gelato's `TaskState` plus our own `'submitted'` initial state.
|
|
12
|
+
*/
|
|
13
|
+
export type RelayerSweepStatus =
|
|
14
|
+
| 'submitted'
|
|
15
|
+
| 'pending'
|
|
16
|
+
| 'success'
|
|
17
|
+
| 'failed'
|
|
18
|
+
| 'cancelled';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* EIP-2612 permit signature components plus deadline.
|
|
22
|
+
* `v / r / s` are passed verbatim to `sweepERC20WithPermit`.
|
|
23
|
+
*
|
|
24
|
+
* Privacy: never log or serialize this struct — the signature is a
|
|
25
|
+
* spending-authorization credential equivalent to a one-time approval.
|
|
26
|
+
*/
|
|
27
|
+
export interface Eip2612PermitSignature {
|
|
28
|
+
readonly v: number;
|
|
29
|
+
readonly r: Hex;
|
|
30
|
+
readonly s: Hex;
|
|
31
|
+
readonly deadline: bigint;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional runtime overrides for {@link relayedSweepERC20}.
|
|
36
|
+
*
|
|
37
|
+
* All fields are optional; omitted fields use defaults from `constants.ts`.
|
|
38
|
+
*
|
|
39
|
+
* Privacy: `gelatoApiKey` is forwarded only to the Gelato adapter and never
|
|
40
|
+
* logged. The caller is responsible for sourcing it securely.
|
|
41
|
+
*/
|
|
42
|
+
export interface RelayerSweepOptions {
|
|
43
|
+
/** Override the EIP-2612 permit deadline lifetime in seconds. */
|
|
44
|
+
readonly deadlineSecs?: bigint;
|
|
45
|
+
/** Override poll interval (ms) for Gelato task status. */
|
|
46
|
+
readonly pollIntervalMs?: number;
|
|
47
|
+
/** Override hard timeout (ms) for the polling loop. */
|
|
48
|
+
readonly pollTimeoutMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Optional Gelato 1Balance sponsor API key. The Gelato SDK requires a
|
|
51
|
+
* non-empty string for sponsored calls; pass it here for production.
|
|
52
|
+
*/
|
|
53
|
+
readonly gelatoApiKey?: string;
|
|
54
|
+
/** Cancellation token. Aborts polling early. */
|
|
55
|
+
readonly signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Receipt for a completed relayed sweep.
|
|
60
|
+
*
|
|
61
|
+
* Privacy invariant: NO `amount` / `feeAmount` / `netAmount` fields.
|
|
62
|
+
* The caller can read pre-/post-sweep balances independently if it needs
|
|
63
|
+
* those values; the SDK never returns plaintext amounts.
|
|
64
|
+
*/
|
|
65
|
+
export interface RelayerSweepReceipt {
|
|
66
|
+
readonly taskId: GelatoTaskId;
|
|
67
|
+
readonly txHash: Hex;
|
|
68
|
+
readonly status: RelayerSweepStatus;
|
|
69
|
+
readonly relayerContract: Address;
|
|
70
|
+
readonly token: Address;
|
|
71
|
+
readonly destination: Address;
|
|
72
|
+
readonly blockNumber?: bigint;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── ETH sweep (P5.1, EIP-7702 via self-host relayer) ─────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Optional runtime overrides for {@link relayedSweepETH}.
|
|
79
|
+
*/
|
|
80
|
+
export interface RelayedEthSweepOptions {
|
|
81
|
+
/** Override the EIP-712 EthSweep deadline lifetime in seconds. */
|
|
82
|
+
readonly deadlineSecs?: bigint;
|
|
83
|
+
/** Override hard timeout (ms) for waitForTransactionReceipt. */
|
|
84
|
+
readonly pollTimeoutMs?: number;
|
|
85
|
+
/** Cancellation token. Forwarded to fetch + receipt polling. */
|
|
86
|
+
readonly signal?: AbortSignal;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Receipt for a completed ETH gasless sweep.
|
|
91
|
+
*
|
|
92
|
+
* Same privacy invariant as {@link RelayerSweepReceipt}: NO amount fields.
|
|
93
|
+
*/
|
|
94
|
+
export interface RelayedEthSweepReceipt {
|
|
95
|
+
readonly txHash: Hex;
|
|
96
|
+
readonly status: 'success';
|
|
97
|
+
readonly blockNumber: bigint;
|
|
98
|
+
readonly ethRelayerContract: Address;
|
|
99
|
+
readonly destination: Address;
|
|
100
|
+
readonly stealthAddress: Address;
|
|
101
|
+
}
|