@shroud-fi/self-host-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 +6 -3
- package/src/dev-entry.ts +91 -0
- package/src/errors.ts +62 -0
- package/src/index.ts +31 -0
- package/src/prod-entry.ts +112 -0
- package/src/relay-eth.ts +122 -0
- package/src/relay.ts +110 -0
- package/src/server.ts +408 -0
- package/src/types.ts +90 -0
- package/tsconfig.json +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shroud-fi/self-host-relayer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Self-hosted gasless sweep relayer service for ShroudFi — Fastify server that broadcasts EIP-7702 / ERC-2771 sweep transactions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"shroudfi",
|
|
@@ -26,13 +26,16 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
-
"dist"
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"tsconfig.json",
|
|
32
|
+
"README.md"
|
|
30
33
|
],
|
|
31
34
|
"dependencies": {
|
|
32
35
|
"fastify": "^5.0.0",
|
|
33
36
|
"@fastify/cors": "^10.0.0",
|
|
34
37
|
"@fastify/rate-limit": "^10.0.0",
|
|
35
|
-
"@shroud-fi/transport": "0.1.
|
|
38
|
+
"@shroud-fi/transport": "0.1.4"
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
41
|
"viem": "^2.21.0"
|
package/src/dev-entry.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev entry: reads config from env vars and starts the server.
|
|
3
|
+
*
|
|
4
|
+
* Required env:
|
|
5
|
+
* SHROUDFI_RELAYER_CONTRACT - deployed ShroudFiRelayer address
|
|
6
|
+
* SHROUDFI_RELAYER_FORWARDER_KEY - 0x-prefixed 32-byte forwarder EOA key
|
|
7
|
+
* SHROUDFI_RELAYER_RPC_URL - chain RPC URL
|
|
8
|
+
*
|
|
9
|
+
* Optional env:
|
|
10
|
+
* SHROUDFI_RELAYER_CHAIN_ID - default 84532
|
|
11
|
+
* SHROUDFI_RELAYER_PORT - default 8787
|
|
12
|
+
* SHROUDFI_RELAYER_ORIGINS - comma-sep allowed origins (default localhost demo)
|
|
13
|
+
* SHROUDFI_RELAYER_RATE_LIMIT - per-IP requests per minute (default 30)
|
|
14
|
+
* SHROUDFI_ETH_RELAYER_CONTRACT - deployed ShroudFiEthRelayer address.
|
|
15
|
+
* When set, /relay-eth becomes active for
|
|
16
|
+
* gasless ETH sweeps via EIP-7702. When
|
|
17
|
+
* unset, /relay-eth returns 501.
|
|
18
|
+
*
|
|
19
|
+
* Privacy: this file is the ONLY place that touches process.env for the
|
|
20
|
+
* forwarder key. Once read, the key flows into a viem account closure inside
|
|
21
|
+
* the server and never resurfaces.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { startServer } from './server.js';
|
|
25
|
+
import { RelayConfigError } from './errors.js';
|
|
26
|
+
import type { Address, Hex } from 'viem';
|
|
27
|
+
import type { ServiceConfig } from './types.js';
|
|
28
|
+
|
|
29
|
+
function envOrThrow(name: string): string {
|
|
30
|
+
const v = process.env[name];
|
|
31
|
+
if (v === undefined || v.trim().length === 0) {
|
|
32
|
+
throw new RelayConfigError(name);
|
|
33
|
+
}
|
|
34
|
+
return v.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadConfig(): ServiceConfig {
|
|
38
|
+
const chainId = Number(
|
|
39
|
+
process.env.SHROUDFI_RELAYER_CHAIN_ID?.trim() ?? '84532',
|
|
40
|
+
);
|
|
41
|
+
const port = Number(process.env.SHROUDFI_RELAYER_PORT?.trim() ?? '8787');
|
|
42
|
+
const rateLimitPerMinute = Number(
|
|
43
|
+
process.env.SHROUDFI_RELAYER_RATE_LIMIT?.trim() ?? '30',
|
|
44
|
+
);
|
|
45
|
+
const originsCsv =
|
|
46
|
+
process.env.SHROUDFI_RELAYER_ORIGINS?.trim() ??
|
|
47
|
+
'http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://localhost:3005,http://localhost:3006,http://localhost:3008,http://localhost:3009,http://127.0.0.1:3005,http://127.0.0.1:3009';
|
|
48
|
+
const allowedOrigins = originsCsv
|
|
49
|
+
.split(',')
|
|
50
|
+
.map((s) => s.trim())
|
|
51
|
+
.filter((s) => s.length > 0);
|
|
52
|
+
|
|
53
|
+
const ethRelayerRaw = process.env.SHROUDFI_ETH_RELAYER_CONTRACT?.trim();
|
|
54
|
+
const ethRelayerContract: Address | undefined =
|
|
55
|
+
ethRelayerRaw !== undefined && ethRelayerRaw.length > 0
|
|
56
|
+
? (ethRelayerRaw as Address)
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
59
|
+
const base = {
|
|
60
|
+
chainId,
|
|
61
|
+
port,
|
|
62
|
+
rateLimitPerMinute,
|
|
63
|
+
allowedOrigins,
|
|
64
|
+
relayerContract: envOrThrow('SHROUDFI_RELAYER_CONTRACT') as Address,
|
|
65
|
+
rpcUrl: envOrThrow('SHROUDFI_RELAYER_RPC_URL'),
|
|
66
|
+
forwarderPrivateKey: envOrThrow(
|
|
67
|
+
'SHROUDFI_RELAYER_FORWARDER_KEY',
|
|
68
|
+
) as Hex,
|
|
69
|
+
};
|
|
70
|
+
return ethRelayerContract === undefined
|
|
71
|
+
? base
|
|
72
|
+
: { ...base, ethRelayerContract };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function main(): Promise<void> {
|
|
76
|
+
const cfg = loadConfig();
|
|
77
|
+
await startServer(cfg);
|
|
78
|
+
// Print a single line so the operator knows the service is up. NO key,
|
|
79
|
+
// NO RPC URL, NO contract address (those are public but we keep the
|
|
80
|
+
// surface minimal — they can be queried via /health).
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.log(
|
|
83
|
+
`self-host-relayer listening on http://127.0.0.1:${cfg.port} (chain ${cfg.chainId})`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
void main().catch((err: unknown) => {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.error(err instanceof Error ? err.name : 'StartupError');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe errors for @shroud-fi/self-host-relayer.
|
|
3
|
+
*
|
|
4
|
+
* Invariants mirrored from other packages:
|
|
5
|
+
* - No key bytes in messages
|
|
6
|
+
* - No amount values in messages
|
|
7
|
+
* - No private signature bytes in messages
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class SelfHostRelayerError extends Error {
|
|
11
|
+
override readonly name: string = 'SelfHostRelayerError';
|
|
12
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class InvalidRelayRequestError extends SelfHostRelayerError {
|
|
18
|
+
override readonly name: string = 'InvalidRelayRequestError';
|
|
19
|
+
constructor() {
|
|
20
|
+
super('Relay request body failed validation');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RelayBroadcastError extends SelfHostRelayerError {
|
|
25
|
+
override readonly name: string = 'RelayBroadcastError';
|
|
26
|
+
constructor(underlyingName?: string, options?: { cause?: unknown }) {
|
|
27
|
+
super(
|
|
28
|
+
underlyingName !== undefined
|
|
29
|
+
? `Broadcast failed (${underlyingName})`
|
|
30
|
+
: 'Broadcast failed',
|
|
31
|
+
options,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RelayConfigError extends SelfHostRelayerError {
|
|
37
|
+
override readonly name: string = 'RelayConfigError';
|
|
38
|
+
constructor(field: string) {
|
|
39
|
+
super(`Relayer config missing required field: ${field}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class InvalidRelayEthRequestError extends SelfHostRelayerError {
|
|
44
|
+
override readonly name: string = 'InvalidRelayEthRequestError';
|
|
45
|
+
constructor() {
|
|
46
|
+
super('Relay ETH request body failed validation');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class EthRelayerNotConfiguredError extends SelfHostRelayerError {
|
|
51
|
+
override readonly name: string = 'EthRelayerNotConfiguredError';
|
|
52
|
+
constructor() {
|
|
53
|
+
super('ETH relayer contract address not configured');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AuthorizationContractMismatchError extends SelfHostRelayerError {
|
|
58
|
+
override readonly name: string = 'AuthorizationContractMismatchError';
|
|
59
|
+
constructor() {
|
|
60
|
+
super('EIP-7702 authorization address does not match configured ETH relayer');
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Public surface — @shroud-fi/self-host-relayer
|
|
2
|
+
//
|
|
3
|
+
// Exports the server builder + the pure relay-logic helpers so the same
|
|
4
|
+
// package can be embedded in another Node process or run standalone via
|
|
5
|
+
// `pnpm dev`.
|
|
6
|
+
|
|
7
|
+
export { buildServer, startServer } from './server.js';
|
|
8
|
+
export { buildRelayCalldata, relayRequest } from './relay.js';
|
|
9
|
+
export type { RelayCtx, RelayResult } from './relay.js';
|
|
10
|
+
export {
|
|
11
|
+
buildEthSweepCalldata,
|
|
12
|
+
relayEthRequest,
|
|
13
|
+
} from './relay-eth.js';
|
|
14
|
+
export type { RelayEthCtx, RelayEthResult } from './relay-eth.js';
|
|
15
|
+
export type {
|
|
16
|
+
RelayRequest,
|
|
17
|
+
RelayResponse,
|
|
18
|
+
RelaySuccess,
|
|
19
|
+
RelayFailure,
|
|
20
|
+
ServiceConfig,
|
|
21
|
+
RelayEthRequest,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
export {
|
|
24
|
+
SelfHostRelayerError,
|
|
25
|
+
InvalidRelayRequestError,
|
|
26
|
+
RelayBroadcastError,
|
|
27
|
+
RelayConfigError,
|
|
28
|
+
InvalidRelayEthRequestError,
|
|
29
|
+
EthRelayerNotConfiguredError,
|
|
30
|
+
AuthorizationContractMismatchError,
|
|
31
|
+
} from './errors.js';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production entry: strict env, fail-fast on missing required values, no
|
|
3
|
+
* permissive defaults that could mask misconfiguration in deployment.
|
|
4
|
+
*
|
|
5
|
+
* Required env (no defaults — startup fails if any are missing):
|
|
6
|
+
* SHROUDFI_RELAYER_CONTRACT - deployed ShroudFiRelayer address
|
|
7
|
+
* SHROUDFI_RELAYER_FORWARDER_KEY - 0x-prefixed 32-byte forwarder EOA key
|
|
8
|
+
* SHROUDFI_RELAYER_RPC_URL - chain RPC URL
|
|
9
|
+
* SHROUDFI_RELAYER_ORIGINS - comma-sep allowed origins (e.g. https://app.shroudfi.live)
|
|
10
|
+
* SHROUDFI_RELAYER_CHAIN_ID - chain id (84532 = Base Sepolia, 8453 = Base)
|
|
11
|
+
* SHROUDFI_RELAYER_PORT - bind port (typically 8789 on droplet)
|
|
12
|
+
*
|
|
13
|
+
* Optional env:
|
|
14
|
+
* SHROUDFI_RELAYER_RATE_LIMIT - per-IP requests per minute (default 30)
|
|
15
|
+
* SHROUDFI_ETH_RELAYER_CONTRACT - deployed ShroudFiEthRelayer address.
|
|
16
|
+
* When set, /relay-eth becomes active for
|
|
17
|
+
* gasless ETH sweeps via EIP-7702. When
|
|
18
|
+
* unset, /relay-eth returns 501.
|
|
19
|
+
* DEBUG_DIAGNOSTICS - "1" to enable per-request preflight + sig recovery logs
|
|
20
|
+
*
|
|
21
|
+
* Differences from dev-entry.ts:
|
|
22
|
+
* - Every required field uses envOrThrow; no fallbacks
|
|
23
|
+
* - SHROUDFI_RELAYER_ORIGINS is REQUIRED (dev defaults to localhost; prod must be explicit)
|
|
24
|
+
* - No startup log line printed to stdout — only journald via PM2 captures fastify's
|
|
25
|
+
* own "Server listening at ..." line
|
|
26
|
+
* - DEBUG_DIAGNOSTICS env opts into the per-request debug logs (off by default)
|
|
27
|
+
*
|
|
28
|
+
* Privacy: this file is the ONLY place that touches process.env for the
|
|
29
|
+
* forwarder key. Once read, the key flows into a viem account closure inside
|
|
30
|
+
* the server and never resurfaces.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { startServer } from './server.js';
|
|
34
|
+
import { RelayConfigError } from './errors.js';
|
|
35
|
+
import type { Address, Hex } from 'viem';
|
|
36
|
+
import type { ServiceConfig } from './types.js';
|
|
37
|
+
|
|
38
|
+
function envOrThrow(name: string): string {
|
|
39
|
+
const v = process.env[name];
|
|
40
|
+
if (v === undefined || v.trim().length === 0) {
|
|
41
|
+
throw new RelayConfigError(name);
|
|
42
|
+
}
|
|
43
|
+
return v.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadConfig(): ServiceConfig {
|
|
47
|
+
const chainId = Number(envOrThrow('SHROUDFI_RELAYER_CHAIN_ID'));
|
|
48
|
+
if (!Number.isFinite(chainId) || chainId <= 0) {
|
|
49
|
+
throw new RelayConfigError('SHROUDFI_RELAYER_CHAIN_ID');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const port = Number(envOrThrow('SHROUDFI_RELAYER_PORT'));
|
|
53
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
54
|
+
throw new RelayConfigError('SHROUDFI_RELAYER_PORT');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rateLimitRaw = process.env.SHROUDFI_RELAYER_RATE_LIMIT?.trim();
|
|
58
|
+
const rateLimitPerMinute =
|
|
59
|
+
rateLimitRaw && rateLimitRaw.length > 0 ? Number(rateLimitRaw) : 30;
|
|
60
|
+
if (!Number.isFinite(rateLimitPerMinute) || rateLimitPerMinute <= 0) {
|
|
61
|
+
throw new RelayConfigError('SHROUDFI_RELAYER_RATE_LIMIT');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const originsCsv = envOrThrow('SHROUDFI_RELAYER_ORIGINS');
|
|
65
|
+
const allowedOrigins = originsCsv
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter((s) => s.length > 0);
|
|
69
|
+
if (allowedOrigins.length === 0) {
|
|
70
|
+
throw new RelayConfigError('SHROUDFI_RELAYER_ORIGINS');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const debugDiagnostics = process.env.DEBUG_DIAGNOSTICS === '1';
|
|
74
|
+
|
|
75
|
+
const ethRelayerRaw = process.env.SHROUDFI_ETH_RELAYER_CONTRACT?.trim();
|
|
76
|
+
const ethRelayerContract: Address | undefined =
|
|
77
|
+
ethRelayerRaw !== undefined && ethRelayerRaw.length > 0
|
|
78
|
+
? (ethRelayerRaw as Address)
|
|
79
|
+
: undefined;
|
|
80
|
+
|
|
81
|
+
const base = {
|
|
82
|
+
chainId,
|
|
83
|
+
port,
|
|
84
|
+
rateLimitPerMinute,
|
|
85
|
+
allowedOrigins,
|
|
86
|
+
relayerContract: envOrThrow('SHROUDFI_RELAYER_CONTRACT') as Address,
|
|
87
|
+
rpcUrl: envOrThrow('SHROUDFI_RELAYER_RPC_URL'),
|
|
88
|
+
forwarderPrivateKey: envOrThrow(
|
|
89
|
+
'SHROUDFI_RELAYER_FORWARDER_KEY',
|
|
90
|
+
) as Hex,
|
|
91
|
+
debugDiagnostics,
|
|
92
|
+
};
|
|
93
|
+
return ethRelayerContract === undefined
|
|
94
|
+
? base
|
|
95
|
+
: { ...base, ethRelayerContract };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main(): Promise<void> {
|
|
99
|
+
const cfg = loadConfig();
|
|
100
|
+
await startServer(cfg);
|
|
101
|
+
// Intentionally no stdout — Fastify's logger already emits a "Server
|
|
102
|
+
// listening at ..." line that PM2/journald captures. Avoid extra surface.
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
void main().catch((err: unknown) => {
|
|
106
|
+
// Startup failure → emit only the error class name to stderr, then exit 1.
|
|
107
|
+
// PM2 will record this and (per ecosystem config) attempt restart up to
|
|
108
|
+
// max_restarts before giving up.
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.error(err instanceof Error ? err.name : 'StartupError');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
});
|
package/src/relay-eth.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ETH gasless sweep relay — broadcasts an EIP-7702 type-0x04 tx that delegates
|
|
3
|
+
* the stealth EOA to ShroudFiEthRelayer and calls sweepETH(destination,
|
|
4
|
+
* deadline, signature).
|
|
5
|
+
*
|
|
6
|
+
* Privacy invariants:
|
|
7
|
+
* - Relayer EOA's private key held in the viem account closure. Never
|
|
8
|
+
* logged or attached to thrown errors.
|
|
9
|
+
* - The EIP-712 signature in the request is a one-time spending
|
|
10
|
+
* authorization bound to (destination, deadline, chainId, stealthEOA).
|
|
11
|
+
* We pass it through verbatim — never store or re-sign.
|
|
12
|
+
* - No amount fields anywhere in the request or response.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
encodeFunctionData,
|
|
17
|
+
type Account,
|
|
18
|
+
type Chain,
|
|
19
|
+
type Hex,
|
|
20
|
+
type PublicClient,
|
|
21
|
+
type SignedAuthorization,
|
|
22
|
+
type Transport,
|
|
23
|
+
type WalletClient,
|
|
24
|
+
} from 'viem';
|
|
25
|
+
import { RelayBroadcastError } from './errors.js';
|
|
26
|
+
import type { RelayEthRequest } from './types.js';
|
|
27
|
+
|
|
28
|
+
const SWEEP_ETH_ABI = [
|
|
29
|
+
{
|
|
30
|
+
type: 'function',
|
|
31
|
+
name: 'sweepETH',
|
|
32
|
+
stateMutability: 'nonpayable',
|
|
33
|
+
inputs: [
|
|
34
|
+
{ name: 'destination', type: 'address' },
|
|
35
|
+
{ name: 'deadline', type: 'uint256' },
|
|
36
|
+
{ name: 'signature', type: 'bytes' },
|
|
37
|
+
],
|
|
38
|
+
outputs: [],
|
|
39
|
+
},
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Same generic-loose shape as RelayCtx. Holds the broadcast-side viem clients
|
|
44
|
+
* plus the relayer EOA. The relayer EOA pays gas for the outer tx.
|
|
45
|
+
*/
|
|
46
|
+
export interface RelayEthCtx {
|
|
47
|
+
readonly publicClient: PublicClient<Transport, Chain | undefined>;
|
|
48
|
+
readonly walletClient: WalletClient<Transport, Chain | undefined, Account>;
|
|
49
|
+
readonly account: Account;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RelayEthResult {
|
|
53
|
+
readonly txHash: Hex;
|
|
54
|
+
readonly blockNumber: bigint;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reconstruct a viem `SignedAuthorization` from the wire-encoded fields. The
|
|
59
|
+
* fields are exactly what the SDK serialises in @shroud-fi/relayer's
|
|
60
|
+
* eth-sweep.ts — `nonce` arrives as a number, `chainId` as a number, `r`/`s`
|
|
61
|
+
* as 0x-prefixed hex, `yParity` as 0 or 1.
|
|
62
|
+
*/
|
|
63
|
+
function reconstructAuthorization(req: RelayEthRequest): SignedAuthorization {
|
|
64
|
+
return {
|
|
65
|
+
address: req.authorization.address,
|
|
66
|
+
chainId: req.authorization.chainId,
|
|
67
|
+
nonce: req.authorization.nonce,
|
|
68
|
+
r: req.authorization.r,
|
|
69
|
+
s: req.authorization.s,
|
|
70
|
+
yParity: req.authorization.yParity,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the inner sweepETH calldata. The OUTER tx is the type-0x04 transaction
|
|
76
|
+
* itself which carries the authorization list and the `to: stealthAddress`;
|
|
77
|
+
* the inner data is what executes once the EOA's code is set to the relayer.
|
|
78
|
+
*/
|
|
79
|
+
export function buildEthSweepCalldata(req: RelayEthRequest): Hex {
|
|
80
|
+
return encodeFunctionData({
|
|
81
|
+
abi: SWEEP_ETH_ABI,
|
|
82
|
+
functionName: 'sweepETH',
|
|
83
|
+
args: [req.destination, BigInt(req.deadline), req.signature],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Submit the type-0x04 tx and wait for receipt.
|
|
89
|
+
*
|
|
90
|
+
* On any viem error we wrap with RelayBroadcastError(name, { cause: err }) so
|
|
91
|
+
* the server can walk the cause chain and log the actual revert reason
|
|
92
|
+
* (public on-chain data) without surfacing it to clients.
|
|
93
|
+
*/
|
|
94
|
+
export async function relayEthRequest(
|
|
95
|
+
ctx: RelayEthCtx,
|
|
96
|
+
req: RelayEthRequest,
|
|
97
|
+
): Promise<RelayEthResult> {
|
|
98
|
+
const data = buildEthSweepCalldata(req);
|
|
99
|
+
const authorization = reconstructAuthorization(req);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const txHash = await ctx.walletClient.sendTransaction({
|
|
103
|
+
account: ctx.account,
|
|
104
|
+
chain: ctx.walletClient.chain ?? null,
|
|
105
|
+
to: req.stealthAddress,
|
|
106
|
+
data,
|
|
107
|
+
value: 0n,
|
|
108
|
+
authorizationList: [authorization],
|
|
109
|
+
});
|
|
110
|
+
const receipt = await ctx.publicClient.waitForTransactionReceipt({
|
|
111
|
+
hash: txHash,
|
|
112
|
+
});
|
|
113
|
+
if (receipt.status !== 'success') {
|
|
114
|
+
throw new RelayBroadcastError('TransactionReverted');
|
|
115
|
+
}
|
|
116
|
+
return { txHash, blockNumber: receipt.blockNumber };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof RelayBroadcastError) throw err;
|
|
119
|
+
const name = err instanceof Error ? err.name : 'UnknownError';
|
|
120
|
+
throw new RelayBroadcastError(name, { cause: err });
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core relay logic. Pure function — given a validated request + viem clients,
|
|
3
|
+
* encodes the ERC-2771 wrapped calldata and broadcasts via the relayer EOA.
|
|
4
|
+
*
|
|
5
|
+
* Privacy invariants:
|
|
6
|
+
* - The relayer EOA's private key is held in a viem account closure. It is
|
|
7
|
+
* never logged, serialized, or attached to thrown errors.
|
|
8
|
+
* - The stealth address is data, not key material.
|
|
9
|
+
* - The permit (v, r, s, deadline) is a one-time authorization for the
|
|
10
|
+
* specific (owner, spender, value, deadline) bound at signing time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
encodeFunctionData,
|
|
15
|
+
type Account,
|
|
16
|
+
type Address,
|
|
17
|
+
type Chain,
|
|
18
|
+
type Hex,
|
|
19
|
+
type PublicClient,
|
|
20
|
+
type Transport,
|
|
21
|
+
type WalletClient,
|
|
22
|
+
} from 'viem';
|
|
23
|
+
import { RelayBroadcastError } from './errors.js';
|
|
24
|
+
import type { RelayRequest } from './types.js';
|
|
25
|
+
|
|
26
|
+
const SWEEP_ABI = [
|
|
27
|
+
{
|
|
28
|
+
type: 'function',
|
|
29
|
+
name: 'sweepERC20WithPermit',
|
|
30
|
+
stateMutability: 'nonpayable',
|
|
31
|
+
inputs: [
|
|
32
|
+
{ name: 'token', type: 'address' },
|
|
33
|
+
{ name: 'destination', type: 'address' },
|
|
34
|
+
{ name: 'deadline', type: 'uint256' },
|
|
35
|
+
{ name: 'v', type: 'uint8' },
|
|
36
|
+
{ name: 'r', type: 'bytes32' },
|
|
37
|
+
{ name: 's', type: 'bytes32' },
|
|
38
|
+
],
|
|
39
|
+
outputs: [],
|
|
40
|
+
},
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
// Generic-loose client types: the relay function only calls a small slice of
|
|
44
|
+
// the viem surface (sendTransaction + waitForTransactionReceipt), so we let
|
|
45
|
+
// callers pass clients keyed to any specific chain (Base, Base Sepolia, etc.)
|
|
46
|
+
// without forcing their chain shape onto our type signature.
|
|
47
|
+
export interface RelayCtx {
|
|
48
|
+
readonly publicClient: PublicClient<Transport, Chain | undefined>;
|
|
49
|
+
readonly walletClient: WalletClient<Transport, Chain | undefined, Account>;
|
|
50
|
+
readonly account: Account;
|
|
51
|
+
readonly relayerContract: Address;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RelayResult {
|
|
55
|
+
readonly txHash: Hex;
|
|
56
|
+
readonly blockNumber: bigint;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the ERC-2771-wrapped calldata: encoded sweepERC20WithPermit(...) with
|
|
61
|
+
* the 20-byte stealth address appended. ShroudFiRelayer's ERC2771Context
|
|
62
|
+
* reads _msgSender() as the appended address when msg.sender is the trusted
|
|
63
|
+
* forwarder (= the relayer EOA = `account` here).
|
|
64
|
+
*/
|
|
65
|
+
export function buildRelayCalldata(req: RelayRequest): Hex {
|
|
66
|
+
const inner = encodeFunctionData({
|
|
67
|
+
abi: SWEEP_ABI,
|
|
68
|
+
functionName: 'sweepERC20WithPermit',
|
|
69
|
+
args: [
|
|
70
|
+
req.token,
|
|
71
|
+
req.destination,
|
|
72
|
+
BigInt(req.deadline),
|
|
73
|
+
req.v,
|
|
74
|
+
req.r,
|
|
75
|
+
req.s,
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
const suffix = req.stealthAddress.slice(2).toLowerCase();
|
|
79
|
+
// Inner is `0x...`; concatenate stealth bytes (20 = 40 hex chars).
|
|
80
|
+
return (inner + suffix) as Hex;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Broadcast the wrapped tx. Waits for the receipt before returning.
|
|
85
|
+
*
|
|
86
|
+
* Throws RelayBroadcastError on any underlying viem error. Errors are wrapped
|
|
87
|
+
* to strip out potentially key-bearing payloads from the visible message.
|
|
88
|
+
*/
|
|
89
|
+
export async function relayRequest(
|
|
90
|
+
ctx: RelayCtx,
|
|
91
|
+
req: RelayRequest,
|
|
92
|
+
): Promise<RelayResult> {
|
|
93
|
+
const data = buildRelayCalldata(req);
|
|
94
|
+
try {
|
|
95
|
+
const txHash = await ctx.walletClient.sendTransaction({
|
|
96
|
+
account: ctx.account,
|
|
97
|
+
chain: ctx.walletClient.chain ?? null,
|
|
98
|
+
to: ctx.relayerContract,
|
|
99
|
+
data,
|
|
100
|
+
value: 0n,
|
|
101
|
+
});
|
|
102
|
+
const receipt = await ctx.publicClient.waitForTransactionReceipt({
|
|
103
|
+
hash: txHash,
|
|
104
|
+
});
|
|
105
|
+
return { txHash, blockNumber: receipt.blockNumber };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const name = err instanceof Error ? err.name : 'UnknownError';
|
|
108
|
+
throw new RelayBroadcastError(name, { cause: err });
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fastify server for @shroud-fi/self-host-relayer.
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* POST /relay - submit a permit-signed sweep request
|
|
6
|
+
* GET /health - liveness probe
|
|
7
|
+
*
|
|
8
|
+
* Privacy invariants:
|
|
9
|
+
* - Request body is validated then discarded — never logged.
|
|
10
|
+
* - Error responses carry only error class names, never inner messages.
|
|
11
|
+
* - The forwarder EOA's private key is read from config once, then held in
|
|
12
|
+
* a viem account closure. Never logged, never serialized to disk.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
16
|
+
import cors from '@fastify/cors';
|
|
17
|
+
import rateLimit from '@fastify/rate-limit';
|
|
18
|
+
import {
|
|
19
|
+
createPublicClient,
|
|
20
|
+
createWalletClient,
|
|
21
|
+
http,
|
|
22
|
+
isAddress,
|
|
23
|
+
recoverTypedDataAddress,
|
|
24
|
+
serializeSignature,
|
|
25
|
+
getAddress,
|
|
26
|
+
type Address,
|
|
27
|
+
type Chain,
|
|
28
|
+
type Hex,
|
|
29
|
+
type PublicClient,
|
|
30
|
+
type Transport,
|
|
31
|
+
type WalletClient,
|
|
32
|
+
} from 'viem';
|
|
33
|
+
import type { Account } from 'viem/accounts';
|
|
34
|
+
import { base, baseSepolia } from 'viem/chains';
|
|
35
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
36
|
+
import {
|
|
37
|
+
AuthorizationContractMismatchError,
|
|
38
|
+
EthRelayerNotConfiguredError,
|
|
39
|
+
InvalidRelayEthRequestError,
|
|
40
|
+
InvalidRelayRequestError,
|
|
41
|
+
} from './errors.js';
|
|
42
|
+
import { relayRequest } from './relay.js';
|
|
43
|
+
import { relayEthRequest } from './relay-eth.js';
|
|
44
|
+
import type {
|
|
45
|
+
RelayEthRequest,
|
|
46
|
+
RelayRequest,
|
|
47
|
+
RelayResponse,
|
|
48
|
+
ServiceConfig,
|
|
49
|
+
} from './types.js';
|
|
50
|
+
|
|
51
|
+
const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
52
|
+
|
|
53
|
+
function isHex32(s: unknown): s is Hex {
|
|
54
|
+
return typeof s === 'string' && HEX32_RE.test(s);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateRequest(body: unknown): RelayRequest {
|
|
58
|
+
if (typeof body !== 'object' || body === null) {
|
|
59
|
+
throw new InvalidRelayRequestError();
|
|
60
|
+
}
|
|
61
|
+
const b = body as Record<string, unknown>;
|
|
62
|
+
if (!isAddress(b.token as string)) throw new InvalidRelayRequestError();
|
|
63
|
+
if (!isAddress(b.destination as string)) throw new InvalidRelayRequestError();
|
|
64
|
+
if (!isAddress(b.stealthAddress as string)) throw new InvalidRelayRequestError();
|
|
65
|
+
if (typeof b.deadline !== 'string' || !/^\d+$/.test(b.deadline)) {
|
|
66
|
+
throw new InvalidRelayRequestError();
|
|
67
|
+
}
|
|
68
|
+
if (typeof b.v !== 'number' || b.v < 0 || b.v > 255) {
|
|
69
|
+
throw new InvalidRelayRequestError();
|
|
70
|
+
}
|
|
71
|
+
if (!isHex32(b.r)) throw new InvalidRelayRequestError();
|
|
72
|
+
if (!isHex32(b.s)) throw new InvalidRelayRequestError();
|
|
73
|
+
return {
|
|
74
|
+
token: b.token as Address,
|
|
75
|
+
destination: b.destination as Address,
|
|
76
|
+
stealthAddress: b.stealthAddress as Address,
|
|
77
|
+
deadline: b.deadline,
|
|
78
|
+
v: b.v,
|
|
79
|
+
r: b.r as Hex,
|
|
80
|
+
s: b.s as Hex,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const HEX_PREFIXED_RE = /^0x[0-9a-fA-F]+$/;
|
|
85
|
+
|
|
86
|
+
function isHex(s: unknown): s is Hex {
|
|
87
|
+
return typeof s === 'string' && HEX_PREFIXED_RE.test(s);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateEthRequest(body: unknown): RelayEthRequest {
|
|
91
|
+
if (typeof body !== 'object' || body === null) {
|
|
92
|
+
throw new InvalidRelayEthRequestError();
|
|
93
|
+
}
|
|
94
|
+
const b = body as Record<string, unknown>;
|
|
95
|
+
if (!isAddress(b.stealthAddress as string)) throw new InvalidRelayEthRequestError();
|
|
96
|
+
if (!isAddress(b.destination as string)) throw new InvalidRelayEthRequestError();
|
|
97
|
+
if (typeof b.chainId !== 'number') throw new InvalidRelayEthRequestError();
|
|
98
|
+
if (typeof b.deadline !== 'string' || !/^\d+$/.test(b.deadline)) {
|
|
99
|
+
throw new InvalidRelayEthRequestError();
|
|
100
|
+
}
|
|
101
|
+
if (!isHex(b.signature)) throw new InvalidRelayEthRequestError();
|
|
102
|
+
const auth = b.authorization as Record<string, unknown> | undefined;
|
|
103
|
+
if (typeof auth !== 'object' || auth === null) throw new InvalidRelayEthRequestError();
|
|
104
|
+
if (!isAddress(auth.address as string)) throw new InvalidRelayEthRequestError();
|
|
105
|
+
if (typeof auth.chainId !== 'number') throw new InvalidRelayEthRequestError();
|
|
106
|
+
if (typeof auth.nonce !== 'number' || auth.nonce < 0) {
|
|
107
|
+
throw new InvalidRelayEthRequestError();
|
|
108
|
+
}
|
|
109
|
+
if (!isHex32(auth.r)) throw new InvalidRelayEthRequestError();
|
|
110
|
+
if (!isHex32(auth.s)) throw new InvalidRelayEthRequestError();
|
|
111
|
+
if (auth.yParity !== 0 && auth.yParity !== 1) throw new InvalidRelayEthRequestError();
|
|
112
|
+
return {
|
|
113
|
+
stealthAddress: b.stealthAddress as Address,
|
|
114
|
+
destination: b.destination as Address,
|
|
115
|
+
chainId: b.chainId,
|
|
116
|
+
deadline: b.deadline,
|
|
117
|
+
signature: b.signature,
|
|
118
|
+
authorization: {
|
|
119
|
+
address: auth.address as Address,
|
|
120
|
+
chainId: auth.chainId,
|
|
121
|
+
nonce: auth.nonce,
|
|
122
|
+
r: auth.r as Hex,
|
|
123
|
+
s: auth.s as Hex,
|
|
124
|
+
yParity: auth.yParity as 0 | 1,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function buildServer(
|
|
130
|
+
cfg: ServiceConfig,
|
|
131
|
+
): Promise<FastifyInstance> {
|
|
132
|
+
const fastify = Fastify({
|
|
133
|
+
// Logging stripped of body — only method, url, status, response time.
|
|
134
|
+
logger: { level: 'info' },
|
|
135
|
+
disableRequestLogging: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await fastify.register(cors, {
|
|
139
|
+
origin: (origin, cb) => {
|
|
140
|
+
// Allow same-host requests (no Origin header) for curl/healthchecks.
|
|
141
|
+
if (origin === undefined) return cb(null, true);
|
|
142
|
+
if (cfg.allowedOrigins.includes(origin)) return cb(null, true);
|
|
143
|
+
return cb(new Error('Origin not allowed'), false);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await fastify.register(rateLimit, {
|
|
148
|
+
max: cfg.rateLimitPerMinute,
|
|
149
|
+
timeWindow: '1 minute',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const chain = cfg.chainId === 8453 ? base : baseSepolia;
|
|
153
|
+
const account = privateKeyToAccount(cfg.forwarderPrivateKey);
|
|
154
|
+
// Viem's OP-stack chain extension widens the tx type set. Our relayRequest
|
|
155
|
+
// signature uses a generic Chain; cast at the boundary so the OP extensions
|
|
156
|
+
// don't bleed into our public types.
|
|
157
|
+
const publicClient = createPublicClient({
|
|
158
|
+
chain,
|
|
159
|
+
transport: http(cfg.rpcUrl, { batch: false }),
|
|
160
|
+
}) as unknown as PublicClient<Transport, Chain | undefined>;
|
|
161
|
+
const walletClient = createWalletClient({
|
|
162
|
+
chain,
|
|
163
|
+
account,
|
|
164
|
+
transport: http(cfg.rpcUrl, { batch: false }),
|
|
165
|
+
}) as unknown as WalletClient<Transport, Chain | undefined, Account>;
|
|
166
|
+
|
|
167
|
+
fastify.get('/health', async () => ({ ok: true, chainId: cfg.chainId }));
|
|
168
|
+
|
|
169
|
+
fastify.post<{ Body: unknown }>('/relay', async (req, reply) => {
|
|
170
|
+
let validated: RelayRequest;
|
|
171
|
+
try {
|
|
172
|
+
validated = validateRequest(req.body);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const name = err instanceof Error ? err.name : 'InvalidRelayRequestError';
|
|
175
|
+
reply.status(400);
|
|
176
|
+
const body: RelayResponse = { ok: false, error: name };
|
|
177
|
+
return body;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Diagnostic pre-flight: read on-chain state for the (stealth, token) pair
|
|
181
|
+
// before broadcasting. NOT key material — all four values (balance,
|
|
182
|
+
// allowance, nonce, name) are public reads. Off in prod by default; flip
|
|
183
|
+
// cfg.debugDiagnostics = true (DEBUG_DIAGNOSTICS=1 env) to enable when
|
|
184
|
+
// troubleshooting permit digest mismatches.
|
|
185
|
+
if (cfg.debugDiagnostics === true) try {
|
|
186
|
+
const tokenAbi = [
|
|
187
|
+
{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'a', type: 'address' }], outputs: [{ type: 'uint256' }] },
|
|
188
|
+
{ type: 'function', name: 'allowance', stateMutability: 'view', inputs: [{ name: 'o', type: 'address' }, { name: 's', type: 'address' }], outputs: [{ type: 'uint256' }] },
|
|
189
|
+
{ type: 'function', name: 'nonces', stateMutability: 'view', inputs: [{ name: 'a', type: 'address' }], outputs: [{ type: 'uint256' }] },
|
|
190
|
+
{ type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
|
|
191
|
+
{ type: 'function', name: 'version', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
|
|
192
|
+
] as const;
|
|
193
|
+
const [bal, allow, nonce, name] = await Promise.all([
|
|
194
|
+
publicClient.readContract({ address: validated.token, abi: tokenAbi, functionName: 'balanceOf', args: [validated.stealthAddress] }) as Promise<bigint>,
|
|
195
|
+
publicClient.readContract({ address: validated.token, abi: tokenAbi, functionName: 'allowance', args: [validated.stealthAddress, cfg.relayerContract] }) as Promise<bigint>,
|
|
196
|
+
publicClient.readContract({ address: validated.token, abi: tokenAbi, functionName: 'nonces', args: [validated.stealthAddress] }) as Promise<bigint>,
|
|
197
|
+
publicClient.readContract({ address: validated.token, abi: tokenAbi, functionName: 'name' }) as Promise<string>,
|
|
198
|
+
]);
|
|
199
|
+
let probedVersion = 'unprobed';
|
|
200
|
+
try {
|
|
201
|
+
probedVersion = (await publicClient.readContract({
|
|
202
|
+
address: validated.token,
|
|
203
|
+
abi: tokenAbi,
|
|
204
|
+
functionName: 'version',
|
|
205
|
+
})) as string;
|
|
206
|
+
} catch {
|
|
207
|
+
probedVersion = 'NOT_EXPOSED(fallback_to_1)';
|
|
208
|
+
}
|
|
209
|
+
const now = Math.floor(Date.now() / 1000);
|
|
210
|
+
fastify.log.info({
|
|
211
|
+
stealth: validated.stealthAddress,
|
|
212
|
+
token: validated.token,
|
|
213
|
+
balance: bal.toString(),
|
|
214
|
+
allowance: allow.toString(),
|
|
215
|
+
nonce: nonce.toString(),
|
|
216
|
+
tokenName: name,
|
|
217
|
+
probedVersion,
|
|
218
|
+
deadline: validated.deadline,
|
|
219
|
+
nowUnix: now,
|
|
220
|
+
deadlineDelta: Number(BigInt(validated.deadline) - BigInt(now)),
|
|
221
|
+
v: validated.v,
|
|
222
|
+
}, 'relay preflight state');
|
|
223
|
+
|
|
224
|
+
// Signature recovery: rebuild the same EIP-712 message the agent SHOULD
|
|
225
|
+
// have signed (under both "1" and probed version) and see which (if any)
|
|
226
|
+
// recovers to the stealth address. Whichever ONE matches tells us the
|
|
227
|
+
// exact domain used. If NEITHER matches, the failure is in value/nonce/
|
|
228
|
+
// chainId/spender — list those candidates too.
|
|
229
|
+
try {
|
|
230
|
+
const fullSig = serializeSignature({ r: validated.r, s: validated.s, v: BigInt(validated.v) });
|
|
231
|
+
const message = {
|
|
232
|
+
owner: validated.stealthAddress,
|
|
233
|
+
spender: cfg.relayerContract,
|
|
234
|
+
value: bal,
|
|
235
|
+
nonce,
|
|
236
|
+
deadline: BigInt(validated.deadline),
|
|
237
|
+
} as const;
|
|
238
|
+
const types = {
|
|
239
|
+
Permit: [
|
|
240
|
+
{ name: 'owner', type: 'address' },
|
|
241
|
+
{ name: 'spender', type: 'address' },
|
|
242
|
+
{ name: 'value', type: 'uint256' },
|
|
243
|
+
{ name: 'nonce', type: 'uint256' },
|
|
244
|
+
{ name: 'deadline', type: 'uint256' },
|
|
245
|
+
],
|
|
246
|
+
} as const;
|
|
247
|
+
const candidates: Array<{ label: string; domain: { name: string; version: string; chainId: number; verifyingContract: Address } }> = [
|
|
248
|
+
{ label: 'name=' + name + ',ver=1,chain=' + cfg.chainId,
|
|
249
|
+
domain: { name, version: '1', chainId: cfg.chainId, verifyingContract: validated.token } },
|
|
250
|
+
];
|
|
251
|
+
if (probedVersion !== 'unprobed' && probedVersion !== 'NOT_EXPOSED(fallback_to_1)') {
|
|
252
|
+
candidates.push({
|
|
253
|
+
label: 'name=' + name + ',ver=' + probedVersion + ',chain=' + cfg.chainId,
|
|
254
|
+
domain: { name, version: probedVersion, chainId: cfg.chainId, verifyingContract: validated.token },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const recoveries: Array<{ label: string; recovered: string; matches: boolean }> = [];
|
|
258
|
+
for (const c of candidates) {
|
|
259
|
+
const recovered = await recoverTypedDataAddress({
|
|
260
|
+
domain: c.domain,
|
|
261
|
+
types,
|
|
262
|
+
primaryType: 'Permit',
|
|
263
|
+
message,
|
|
264
|
+
signature: fullSig,
|
|
265
|
+
});
|
|
266
|
+
recoveries.push({
|
|
267
|
+
label: c.label,
|
|
268
|
+
recovered,
|
|
269
|
+
matches: recovered.toLowerCase() === validated.stealthAddress.toLowerCase(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
fastify.log.info({ recoveries, expectedStealth: validated.stealthAddress }, 'sig recovery probe');
|
|
273
|
+
} catch (sigErr) {
|
|
274
|
+
fastify.log.warn({ name: sigErr instanceof Error ? sigErr.name : 'unknown', msg: sigErr instanceof Error ? sigErr.message.slice(0, 200) : '' }, 'sig recovery threw');
|
|
275
|
+
}
|
|
276
|
+
} catch (probeErr) {
|
|
277
|
+
fastify.log.warn({ name: probeErr instanceof Error ? probeErr.name : 'unknown' }, 'preflight probe failed');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const result = await relayRequest(
|
|
282
|
+
{
|
|
283
|
+
publicClient,
|
|
284
|
+
walletClient,
|
|
285
|
+
account,
|
|
286
|
+
relayerContract: cfg.relayerContract,
|
|
287
|
+
},
|
|
288
|
+
validated,
|
|
289
|
+
);
|
|
290
|
+
const body: RelayResponse = {
|
|
291
|
+
ok: true,
|
|
292
|
+
txHash: result.txHash,
|
|
293
|
+
blockNumber: result.blockNumber.toString(),
|
|
294
|
+
};
|
|
295
|
+
return body;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
const name = err instanceof Error ? err.name : 'RelayBroadcastError';
|
|
298
|
+
// Diagnostic: walk the cause chain and emit each viem error class +
|
|
299
|
+
// shortMessage (revert reason). These are public on-chain values, never
|
|
300
|
+
// key material. Logged via fastify.log.error so the operator can find
|
|
301
|
+
// the actual revert without surfacing it to clients.
|
|
302
|
+
const chain: Array<{ name: string; shortMessage?: string }> = [];
|
|
303
|
+
let cursor: unknown = err;
|
|
304
|
+
let depth = 0;
|
|
305
|
+
while (cursor instanceof Error && depth < 6) {
|
|
306
|
+
const sm = (cursor as { shortMessage?: string }).shortMessage;
|
|
307
|
+
const frame: { name: string; shortMessage?: string } = { name: cursor.name };
|
|
308
|
+
if (sm !== undefined) frame.shortMessage = sm;
|
|
309
|
+
chain.push(frame);
|
|
310
|
+
cursor = (cursor as { cause?: unknown }).cause;
|
|
311
|
+
depth++;
|
|
312
|
+
}
|
|
313
|
+
fastify.log.error({ chain }, 'relay broadcast failed');
|
|
314
|
+
reply.status(502);
|
|
315
|
+
const body: RelayResponse = { ok: false, error: name };
|
|
316
|
+
return body;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── /relay-eth — P5.1 EIP-7702 ETH gasless sweep ─────────────────────
|
|
321
|
+
fastify.post<{ Body: unknown }>('/relay-eth', async (req, reply) => {
|
|
322
|
+
// Refuse early if the operator hasn't pinned the deployed ETH relayer.
|
|
323
|
+
if (cfg.ethRelayerContract === undefined) {
|
|
324
|
+
reply.status(501);
|
|
325
|
+
const body: RelayResponse = {
|
|
326
|
+
ok: false,
|
|
327
|
+
error: new EthRelayerNotConfiguredError().name,
|
|
328
|
+
};
|
|
329
|
+
return body;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let validated: RelayEthRequest;
|
|
333
|
+
try {
|
|
334
|
+
validated = validateEthRequest(req.body);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const name = err instanceof Error ? err.name : 'InvalidRelayEthRequestError';
|
|
337
|
+
reply.status(400);
|
|
338
|
+
const body: RelayResponse = { ok: false, error: name };
|
|
339
|
+
return body;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Defence in depth: the authorization MUST delegate to the configured
|
|
343
|
+
// ETH relayer contract. A mismatch means either (a) the SDK is talking
|
|
344
|
+
// to a different deployment, or (b) someone is trying to delegate to an
|
|
345
|
+
// attacker contract via this relayer. Either way, refuse.
|
|
346
|
+
if (
|
|
347
|
+
getAddress(validated.authorization.address) !==
|
|
348
|
+
getAddress(cfg.ethRelayerContract)
|
|
349
|
+
) {
|
|
350
|
+
reply.status(400);
|
|
351
|
+
const body: RelayResponse = {
|
|
352
|
+
ok: false,
|
|
353
|
+
error: new AuthorizationContractMismatchError().name,
|
|
354
|
+
};
|
|
355
|
+
return body;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Chain ID consistency: the SDK's chainId must match this service's chain.
|
|
359
|
+
if (validated.chainId !== cfg.chainId) {
|
|
360
|
+
reply.status(400);
|
|
361
|
+
const body: RelayResponse = {
|
|
362
|
+
ok: false,
|
|
363
|
+
error: new InvalidRelayEthRequestError().name,
|
|
364
|
+
};
|
|
365
|
+
return body;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const result = await relayEthRequest(
|
|
370
|
+
{ publicClient, walletClient, account },
|
|
371
|
+
validated,
|
|
372
|
+
);
|
|
373
|
+
const body: RelayResponse = {
|
|
374
|
+
ok: true,
|
|
375
|
+
txHash: result.txHash,
|
|
376
|
+
blockNumber: result.blockNumber.toString(),
|
|
377
|
+
};
|
|
378
|
+
return body;
|
|
379
|
+
} catch (err) {
|
|
380
|
+
const name = err instanceof Error ? err.name : 'RelayBroadcastError';
|
|
381
|
+
// Same cause-chain diagnostic as /relay. Public on-chain data only —
|
|
382
|
+
// never signatures or amounts.
|
|
383
|
+
const chain: Array<{ name: string; shortMessage?: string }> = [];
|
|
384
|
+
let cursor: unknown = err;
|
|
385
|
+
let depth = 0;
|
|
386
|
+
while (cursor instanceof Error && depth < 6) {
|
|
387
|
+
const sm = (cursor as { shortMessage?: string }).shortMessage;
|
|
388
|
+
const frame: { name: string; shortMessage?: string } = { name: cursor.name };
|
|
389
|
+
if (sm !== undefined) frame.shortMessage = sm;
|
|
390
|
+
chain.push(frame);
|
|
391
|
+
cursor = (cursor as { cause?: unknown }).cause;
|
|
392
|
+
depth++;
|
|
393
|
+
}
|
|
394
|
+
fastify.log.error({ chain }, 'relay-eth broadcast failed');
|
|
395
|
+
reply.status(502);
|
|
396
|
+
const body: RelayResponse = { ok: false, error: name };
|
|
397
|
+
return body;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return fastify;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function startServer(cfg: ServiceConfig): Promise<FastifyInstance> {
|
|
405
|
+
const fastify = await buildServer(cfg);
|
|
406
|
+
await fastify.listen({ port: cfg.port, host: '127.0.0.1' });
|
|
407
|
+
return fastify;
|
|
408
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Address, Hex } from 'viem';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inbound relay request from a browser/agent.
|
|
5
|
+
*
|
|
6
|
+
* Privacy invariants:
|
|
7
|
+
* - The stealth address's PRIVATE key NEVER appears here. Only the EIP-2612
|
|
8
|
+
* permit signature (v, r, s, deadline) + the public stealth address.
|
|
9
|
+
* - No `amount` field — the contract reads `balanceOf(stealth)` on-chain.
|
|
10
|
+
* - The relayer service NEVER logs the full body.
|
|
11
|
+
*/
|
|
12
|
+
export interface RelayRequest {
|
|
13
|
+
readonly token: Address;
|
|
14
|
+
readonly destination: Address;
|
|
15
|
+
readonly stealthAddress: Address;
|
|
16
|
+
readonly deadline: string;
|
|
17
|
+
readonly v: number;
|
|
18
|
+
readonly r: Hex;
|
|
19
|
+
readonly s: Hex;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RelaySuccess {
|
|
23
|
+
readonly ok: true;
|
|
24
|
+
readonly txHash: Hex;
|
|
25
|
+
readonly blockNumber: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RelayFailure {
|
|
29
|
+
readonly ok: false;
|
|
30
|
+
readonly error: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RelayResponse = RelaySuccess | RelayFailure;
|
|
34
|
+
|
|
35
|
+
export interface ServiceConfig {
|
|
36
|
+
readonly chainId: number;
|
|
37
|
+
readonly rpcUrl: string;
|
|
38
|
+
readonly relayerContract: Address;
|
|
39
|
+
readonly forwarderPrivateKey: Hex;
|
|
40
|
+
readonly allowedOrigins: readonly string[];
|
|
41
|
+
readonly port: number;
|
|
42
|
+
readonly rateLimitPerMinute: number;
|
|
43
|
+
/**
|
|
44
|
+
* When true, the /relay handler emits per-request preflight + EIP-712
|
|
45
|
+
* signature-recovery probe logs. These are valuable for debugging permit
|
|
46
|
+
* digest mismatches but produce one log line per request and surface
|
|
47
|
+
* (public) on-chain state. Off by default; enable only when troubleshooting.
|
|
48
|
+
*/
|
|
49
|
+
readonly debugDiagnostics?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Address of the ShroudFiEthRelayer contract for the P5.1 ETH gasless path.
|
|
52
|
+
* Used by /relay-eth as a defence-in-depth check that the inbound
|
|
53
|
+
* authorization's `address` field matches the deployed contract. When
|
|
54
|
+
* undefined, /relay-eth returns 501 Not Implemented.
|
|
55
|
+
*/
|
|
56
|
+
readonly ethRelayerContract?: Address;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── /relay-eth (P5.1, EIP-7702 ETH gasless sweep) ────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Inbound /relay-eth request. The shape mirrors what
|
|
63
|
+
* @shroud-fi/relayer's relayedSweepETH serialises on the SDK side.
|
|
64
|
+
*
|
|
65
|
+
* Privacy invariants:
|
|
66
|
+
* - The stealth EOA's PRIVATE key NEVER appears here. Only the public
|
|
67
|
+
* stealth address + two signatures (EIP-7702 SET_CODE auth + EIP-712
|
|
68
|
+
* EthSweep).
|
|
69
|
+
* - The signature is a spending-authorisation credential bound to the
|
|
70
|
+
* specific (destination, deadline, chainId, stealthEOA) tuple. Never
|
|
71
|
+
* logged in full; only the class name of any error appears in server
|
|
72
|
+
* logs.
|
|
73
|
+
* - No `amount` field — the contract reads address(this).balance under
|
|
74
|
+
* EIP-7702 delegation.
|
|
75
|
+
*/
|
|
76
|
+
export interface RelayEthRequest {
|
|
77
|
+
readonly stealthAddress: Address;
|
|
78
|
+
readonly destination: Address;
|
|
79
|
+
readonly chainId: number;
|
|
80
|
+
readonly deadline: string;
|
|
81
|
+
readonly signature: Hex;
|
|
82
|
+
readonly authorization: {
|
|
83
|
+
readonly address: Address;
|
|
84
|
+
readonly chainId: number;
|
|
85
|
+
readonly nonce: number;
|
|
86
|
+
readonly r: Hex;
|
|
87
|
+
readonly s: Hex;
|
|
88
|
+
readonly yParity: 0 | 1;
|
|
89
|
+
};
|
|
90
|
+
}
|