@towns-labs/relayer-client 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +752 -0
- package/dist/actions/checkHealth.d.ts +17 -0
- package/dist/actions/checkHealth.d.ts.map +1 -0
- package/dist/actions/checkHealth.js +45 -0
- package/dist/actions/checkHealth.js.map +1 -0
- package/dist/actions/createAccount.d.ts +151 -0
- package/dist/actions/createAccount.d.ts.map +1 -0
- package/dist/actions/createAccount.js +203 -0
- package/dist/actions/createAccount.js.map +1 -0
- package/dist/actions/executeIntent.d.ts +119 -0
- package/dist/actions/executeIntent.d.ts.map +1 -0
- package/dist/actions/executeIntent.js +118 -0
- package/dist/actions/executeIntent.js.map +1 -0
- package/dist/actions/getBundleStatus.d.ts +21 -0
- package/dist/actions/getBundleStatus.d.ts.map +1 -0
- package/dist/actions/getBundleStatus.js +73 -0
- package/dist/actions/getBundleStatus.js.map +1 -0
- package/dist/actions/getCapabilities.d.ts +24 -0
- package/dist/actions/getCapabilities.d.ts.map +1 -0
- package/dist/actions/getCapabilities.js +72 -0
- package/dist/actions/getCapabilities.js.map +1 -0
- package/dist/actions/index.d.ts +17 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +17 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/prepareIntent.d.ts +59 -0
- package/dist/actions/prepareIntent.d.ts.map +1 -0
- package/dist/actions/prepareIntent.js +87 -0
- package/dist/actions/prepareIntent.js.map +1 -0
- package/dist/actions/signIntent.d.ts +244 -0
- package/dist/actions/signIntent.d.ts.map +1 -0
- package/dist/actions/signIntent.js +319 -0
- package/dist/actions/signIntent.js.map +1 -0
- package/dist/actions/signPayment.d.ts +71 -0
- package/dist/actions/signPayment.d.ts.map +1 -0
- package/dist/actions/signPayment.js +81 -0
- package/dist/actions/signPayment.js.map +1 -0
- package/dist/actions/submitIntent.d.ts +53 -0
- package/dist/actions/submitIntent.d.ts.map +1 -0
- package/dist/actions/submitIntent.js +122 -0
- package/dist/actions/submitIntent.js.map +1 -0
- package/dist/actions/waitForBundle.d.ts +36 -0
- package/dist/actions/waitForBundle.d.ts.map +1 -0
- package/dist/actions/waitForBundle.js +55 -0
- package/dist/actions/waitForBundle.js.map +1 -0
- package/dist/chains.d.ts +24 -0
- package/dist/chains.d.ts.map +1 -0
- package/dist/chains.js +68 -0
- package/dist/chains.js.map +1 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +28 -0
- package/dist/client.js.map +1 -0
- package/dist/decorators/index.d.ts +2 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +2 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/decorators/relayer.d.ts +80 -0
- package/dist/decorators/relayer.d.ts.map +1 -0
- package/dist/decorators/relayer.js +66 -0
- package/dist/decorators/relayer.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/transport.d.ts +81 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +95 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +359 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +42 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/constants.d.ts +47 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +46 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/erc1271.d.ts +43 -0
- package/dist/utils/erc1271.d.ts.map +1 -0
- package/dist/utils/erc1271.js +50 -0
- package/dist/utils/erc1271.js.map +1 -0
- package/dist/utils/errors.d.ts +54 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +60 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/keyHash.d.ts +50 -0
- package/dist/utils/keyHash.d.ts.map +1 -0
- package/dist/utils/keyHash.js +52 -0
- package/dist/utils/keyHash.js.map +1 -0
- package/dist/utils/serialize.d.ts +51 -0
- package/dist/utils/serialize.d.ts.map +1 -0
- package/dist/utils/serialize.js +52 -0
- package/dist/utils/serialize.js.map +1 -0
- package/dist/utils/wallet.d.ts +9 -0
- package/dist/utils/wallet.d.ts.map +1 -0
- package/dist/utils/wallet.js +25 -0
- package/dist/utils/wallet.js.map +1 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
# @towns-labs/relayer-client
|
|
2
|
+
|
|
3
|
+
A slim, viem-style SDK for interacting with the EIP-7702 Relayer Orchestrator system. This SDK enables gasless transactions through account delegation and intent-based execution.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @towns-labs/relayer-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createPublicClient, http, encodeFunctionData, erc20Abi } from "viem";
|
|
15
|
+
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
16
|
+
import { base } from "viem/chains";
|
|
17
|
+
import { relayerActions } from "@towns-labs/relayer-client";
|
|
18
|
+
|
|
19
|
+
// 1. Create the relayer client (just needs relayerUrl!)
|
|
20
|
+
const client = createPublicClient({
|
|
21
|
+
chain: base,
|
|
22
|
+
transport: http("https://mainnet.base.org"),
|
|
23
|
+
}).extend(
|
|
24
|
+
relayerActions({
|
|
25
|
+
relayerUrl: "https://your-relayer.example.com",
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// 2. Generate a new account
|
|
30
|
+
const privateKey = generatePrivateKey();
|
|
31
|
+
const account = privateKeyToAccount(privateKey);
|
|
32
|
+
|
|
33
|
+
// 3. Create the delegated account via the relayer (two-step flow handled internally)
|
|
34
|
+
const createResult = await client.createAccount({
|
|
35
|
+
accountAddress: account.address,
|
|
36
|
+
signerKey: privateKey,
|
|
37
|
+
delegation: "0x...TownsAccountAddress", // Get from checkHealth() or getCapabilities()
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// 4. Execute gasless transactions via intents
|
|
41
|
+
const signedIntent = await client.signIntent({
|
|
42
|
+
accountAddress: account.address,
|
|
43
|
+
signerKey: privateKey,
|
|
44
|
+
calls: [
|
|
45
|
+
{
|
|
46
|
+
target: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
|
|
47
|
+
value: 0n,
|
|
48
|
+
data: encodeFunctionData({
|
|
49
|
+
abi: erc20Abi,
|
|
50
|
+
functionName: "transfer",
|
|
51
|
+
args: ["0xrecipient...", 1000000n], // 1 USDC
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const result = await client.submitIntent({ intent: signedIntent.intent });
|
|
58
|
+
console.log("Bundle ID:", result.bundleId);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Gas Payment Models
|
|
62
|
+
|
|
63
|
+
The SDK supports three gas payment models:
|
|
64
|
+
|
|
65
|
+
### 1. Fully Sponsored (Gasless)
|
|
66
|
+
|
|
67
|
+
The relayer pays for gas. User pays nothing.
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const signedIntent = await client.signIntent({
|
|
71
|
+
accountAddress: account.address,
|
|
72
|
+
signerKey: privateKey,
|
|
73
|
+
calls: [...],
|
|
74
|
+
// No payer specified = fully sponsored
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await client.submitIntent({ intent: signedIntent.intent });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. User Pays Gas (Non-Sponsored)
|
|
81
|
+
|
|
82
|
+
The user reimburses the relayer for gas costs.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { zeroAddress, parseEther } from "viem";
|
|
86
|
+
|
|
87
|
+
const signedIntent = await client.signIntent({
|
|
88
|
+
accountAddress: account.address,
|
|
89
|
+
signerKey: privateKey,
|
|
90
|
+
calls: [...],
|
|
91
|
+
payer: account.address, // User pays
|
|
92
|
+
paymentToken: zeroAddress, // Native ETH (or ERC20 address)
|
|
93
|
+
paymentMaxAmount: parseEther("0.01"), // Max willing to pay
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await client.submitIntent({ intent: signedIntent.intent });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Third-Party Sponsor
|
|
100
|
+
|
|
101
|
+
A separate account (sponsor) pays for the user's gas. The sponsor must sign a payment authorization.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { zeroAddress, parseEther } from "viem";
|
|
105
|
+
|
|
106
|
+
// User signs intent specifying sponsor as payer
|
|
107
|
+
const signedIntent = await client.signIntent({
|
|
108
|
+
accountAddress: userAccount.address,
|
|
109
|
+
signerKey: userPrivateKey,
|
|
110
|
+
calls: [...],
|
|
111
|
+
payer: sponsorAccount.address, // Sponsor pays
|
|
112
|
+
paymentToken: zeroAddress,
|
|
113
|
+
paymentMaxAmount: parseEther("0.01"),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Sponsor signs payment authorization
|
|
117
|
+
const paymentSignature = await client.signPayment({
|
|
118
|
+
signedIntent,
|
|
119
|
+
sponsorKey: sponsorPrivateKey,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Submit with both signatures
|
|
123
|
+
await client.submitIntent({
|
|
124
|
+
intent: { ...signedIntent.intent, paymentSignature },
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Delegated Signers (Bot/Agent Authorization)
|
|
129
|
+
|
|
130
|
+
A powerful pattern is authorizing a bot or agent to act on behalf of a user's account with limited permissions. This enables automated actions while maintaining security through spend limits and call restrictions.
|
|
131
|
+
|
|
132
|
+
### Setup: User Authorizes Bot
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
import {
|
|
136
|
+
encodeSecp256k1Key,
|
|
137
|
+
computeKeyHash,
|
|
138
|
+
ANY_TARGET,
|
|
139
|
+
EMPTY_CALLDATA_SELECTOR,
|
|
140
|
+
} from "@towns-labs/relayer-client";
|
|
141
|
+
import { zeroAddress, parseEther } from "viem";
|
|
142
|
+
|
|
143
|
+
// Bot is a separate delegated account
|
|
144
|
+
await client.createAccount({
|
|
145
|
+
accountAddress: botAccount.address,
|
|
146
|
+
signerKey: botPrivateKey,
|
|
147
|
+
delegation: townsAccountAddress,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// User creates account and authorizes bot with limited permissions
|
|
151
|
+
const botPublicKey = encodeSecp256k1Key(botAccount.address);
|
|
152
|
+
|
|
153
|
+
await client.createAccount({
|
|
154
|
+
accountAddress: userAccount.address,
|
|
155
|
+
signerKey: userPrivateKey,
|
|
156
|
+
delegation: townsAccountAddress,
|
|
157
|
+
authorizeKeys: [
|
|
158
|
+
{
|
|
159
|
+
expiry: "0",
|
|
160
|
+
type: "secp256k1",
|
|
161
|
+
role: "normal", // Limited permissions, not admin
|
|
162
|
+
publicKey: botPublicKey,
|
|
163
|
+
permissions: [
|
|
164
|
+
{
|
|
165
|
+
type: "spend",
|
|
166
|
+
token: zeroAddress, // ETH
|
|
167
|
+
limit: parseEther("0.1").toString(), // Max 0.1 ETH per day
|
|
168
|
+
period: "day",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: "call",
|
|
172
|
+
to: ANY_TARGET,
|
|
173
|
+
selector: EMPTY_CALLDATA_SELECTOR, // ETH transfers only
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Bot Signs on Behalf of User
|
|
182
|
+
|
|
183
|
+
Since the bot is a delegated account (has bytecode), use `signIntentAsDelegate`:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
const botKeyHash = computeKeyHash("secp256k1", botPublicKey);
|
|
187
|
+
|
|
188
|
+
// Bot signs intent to transfer from user's account
|
|
189
|
+
const signedIntent = await client.signIntentAsDelegate({
|
|
190
|
+
accountAddress: userAccount.address,
|
|
191
|
+
signerAddress: botAccount.address,
|
|
192
|
+
signerKey: botPrivateKey,
|
|
193
|
+
keyHash: botKeyHash,
|
|
194
|
+
calls: [
|
|
195
|
+
{
|
|
196
|
+
target: recipientAddress,
|
|
197
|
+
value: parseEther("0.05"), // Within daily limit
|
|
198
|
+
data: "0x",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await client.submitIntent({ intent: signedIntent.intent });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## API Reference
|
|
207
|
+
|
|
208
|
+
### Client Creation
|
|
209
|
+
|
|
210
|
+
#### `relayerActions(config)`
|
|
211
|
+
|
|
212
|
+
Extends a viem PublicClient with relayer actions:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { createPublicClient, http } from "viem";
|
|
216
|
+
import { base } from "viem/chains";
|
|
217
|
+
import { relayerActions } from "@towns-labs/relayer-client";
|
|
218
|
+
|
|
219
|
+
const client = createPublicClient({
|
|
220
|
+
chain: base,
|
|
221
|
+
transport: http("https://mainnet.base.org"),
|
|
222
|
+
}).extend(
|
|
223
|
+
relayerActions({
|
|
224
|
+
relayerUrl: "https://your-relayer.example.com",
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Client Actions
|
|
230
|
+
|
|
231
|
+
#### `checkHealth()`
|
|
232
|
+
|
|
233
|
+
Checks the relayer's health status and returns contract addresses.
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
const health = await client.checkHealth();
|
|
237
|
+
// {
|
|
238
|
+
// status: 'ok',
|
|
239
|
+
// chainId: 8453,
|
|
240
|
+
// relayerAddress: '0x...',
|
|
241
|
+
// contracts: { orchestrator, simulator, townsAccount, accountProxy, simpleFunder }
|
|
242
|
+
// }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### `getCapabilities()`
|
|
246
|
+
|
|
247
|
+
Gets the relayer's capabilities.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const caps = await client.getCapabilities();
|
|
251
|
+
// { capabilities: { accountCreation, intentExecution, simulation, ... } }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
#### `createAccount(params)`
|
|
255
|
+
|
|
256
|
+
Creates a new EIP-7702 delegated account. Uses a two-step JSON-RPC flow internally.
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
const result = await client.createAccount({
|
|
260
|
+
accountAddress: Address, // EOA to delegate
|
|
261
|
+
signerKey: Hex, // Private key for signing the authorization
|
|
262
|
+
delegation: Address, // TownsAccount implementation address
|
|
263
|
+
authorizeKeys?: AuthorizeKey[], // Optional keys to authorize during upgrade
|
|
264
|
+
});
|
|
265
|
+
// { success, accountAddress, bundleIds }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Authorizing Keys as SuperAdmin:**
|
|
269
|
+
|
|
270
|
+
When creating an account, you can authorize additional keys with admin or normal roles:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
import { encodeAbiParameters } from "viem";
|
|
274
|
+
|
|
275
|
+
const result = await client.createAccount({
|
|
276
|
+
accountAddress: account.address,
|
|
277
|
+
signerKey: privateKey,
|
|
278
|
+
delegation: townsAccountAddress,
|
|
279
|
+
authorizeKeys: [
|
|
280
|
+
{
|
|
281
|
+
expiry: "0", // 0 = never expires
|
|
282
|
+
type: "secp256k1", // or 'external' for ISigner contracts
|
|
283
|
+
role: "admin", // 'admin' = superadmin, 'normal' = limited permissions
|
|
284
|
+
publicKey: encodeAbiParameters([{ type: "address" }], [signerAddress]),
|
|
285
|
+
permissions: [], // Empty for admin, or specify CallPermission/SpendPermission
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Key Types:**
|
|
292
|
+
|
|
293
|
+
| Type | Description |
|
|
294
|
+
| ----------- | ------------------------------------------------------------------- |
|
|
295
|
+
| `secp256k1` | Standard Ethereum EOA keys (same curve as EOAs) |
|
|
296
|
+
| `external` | Delegated to an external `ISigner` contract for custom verification |
|
|
297
|
+
|
|
298
|
+
**Roles:**
|
|
299
|
+
|
|
300
|
+
| Role | Description |
|
|
301
|
+
| -------- | ----------------------------------------------------------------------- |
|
|
302
|
+
| `admin` | SuperAdmin - can call `authorize()` and `revoke()` to manage other keys |
|
|
303
|
+
| `normal` | Limited to specified permissions only |
|
|
304
|
+
|
|
305
|
+
#### `signIntent(params)`
|
|
306
|
+
|
|
307
|
+
Signs an intent for execution. Returns a `SignedIntent` containing the intent, digest, and typed data (for third-party sponsorship).
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
const signedIntent = await client.signIntent({
|
|
311
|
+
accountAddress: Address, // The delegated account address
|
|
312
|
+
signerKey: Hex, // Private key for signing
|
|
313
|
+
calls: Call[], // Array of calls to execute
|
|
314
|
+
// Optional:
|
|
315
|
+
nonce?: bigint, // Override nonce
|
|
316
|
+
seqKey?: bigint, // Sequence key for parallel execution (2D nonce)
|
|
317
|
+
combinedGas?: bigint, // Override gas limit
|
|
318
|
+
expiry?: bigint, // Override expiry timestamp
|
|
319
|
+
// Payment options (see Gas Payment Models):
|
|
320
|
+
payer?: Address, // Who pays for gas
|
|
321
|
+
paymentToken?: Address, // zeroAddress for ETH, or ERC20 address
|
|
322
|
+
paymentMaxAmount?: bigint, // Maximum willing to pay
|
|
323
|
+
});
|
|
324
|
+
// Returns: { intent, digest, typedData }
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
#### `signIntentWithWallet(params)`
|
|
328
|
+
|
|
329
|
+
Signs an intent using a viem WalletClient instead of a raw private key. Useful for browser wallets and hardware wallets.
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
import { createWalletClient, custom } from "viem";
|
|
333
|
+
|
|
334
|
+
const walletClient = createWalletClient({
|
|
335
|
+
chain: base,
|
|
336
|
+
transport: custom(window.ethereum),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const signedIntent = await client.signIntentWithWallet({
|
|
340
|
+
accountAddress: Address,
|
|
341
|
+
walletClient: WalletClient,
|
|
342
|
+
calls: Call[],
|
|
343
|
+
});
|
|
344
|
+
// Returns: { intent, digest, typedData }
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
#### `signIntentAsDelegate(params)`
|
|
348
|
+
|
|
349
|
+
Signs an intent when the signer is itself a delegated account (smart contract). This handles the ERC-1271 replay-safe digest transformation required for smart contract signatures.
|
|
350
|
+
|
|
351
|
+
Use this when:
|
|
352
|
+
|
|
353
|
+
- A bot account (delegated) signs on behalf of a user account
|
|
354
|
+
- An agent with limited permissions acts on behalf of another delegated account
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
import { encodeSecp256k1Key, computeKeyHash } from "@towns-labs/relayer-client";
|
|
358
|
+
|
|
359
|
+
// Bot is a delegated account that was authorized on user's account
|
|
360
|
+
const botPublicKey = encodeSecp256k1Key(botAccount.address);
|
|
361
|
+
const botKeyHash = computeKeyHash("secp256k1", botPublicKey);
|
|
362
|
+
|
|
363
|
+
const signedIntent = await client.signIntentAsDelegate({
|
|
364
|
+
accountAddress: userAccount.address, // Account to execute on
|
|
365
|
+
signerAddress: botAccount.address, // Delegated signer's address
|
|
366
|
+
signerKey: botPrivateKey, // Delegated signer's private key
|
|
367
|
+
keyHash: botKeyHash, // Key hash in the target account
|
|
368
|
+
calls: [
|
|
369
|
+
{
|
|
370
|
+
target: recipientAddress,
|
|
371
|
+
value: parseEther("0.01"),
|
|
372
|
+
data: "0x",
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await client.submitIntent({ intent: signedIntent.intent });
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Why is this needed?**
|
|
381
|
+
|
|
382
|
+
When a signer has bytecode (is a smart contract/delegated account), signature verification follows the ERC-1271 standard. The digest must be transformed to include the signer's address for replay protection. This method handles that transformation automatically.
|
|
383
|
+
|
|
384
|
+
#### `signPayment(params)`
|
|
385
|
+
|
|
386
|
+
Signs a payment authorization for third-party gas sponsorship. The sponsor calls this to authorize paying for someone else's transaction.
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
const paymentSignature = await client.signPayment({
|
|
390
|
+
signedIntent: SignedIntent, // From signIntent()
|
|
391
|
+
sponsorKey: Hex, // Sponsor's private key
|
|
392
|
+
});
|
|
393
|
+
// Returns: Hex (the payment signature)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
#### `signPaymentWithWallet(params)`
|
|
397
|
+
|
|
398
|
+
Signs a payment authorization using a WalletClient instead of a raw private key.
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
const paymentSignature = await client.signPaymentWithWallet({
|
|
402
|
+
signedIntent: SignedIntent,
|
|
403
|
+
walletClient: WalletClient, // Sponsor's wallet client
|
|
404
|
+
});
|
|
405
|
+
// Returns: Hex (the payment signature)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
#### `submitIntent(params)`
|
|
409
|
+
|
|
410
|
+
Submits a signed intent for execution.
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
const result = await client.submitIntent({ intent: signedIntent.intent });
|
|
414
|
+
// { success, bundleId }
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
#### `submitBatch(params)`
|
|
418
|
+
|
|
419
|
+
Submits multiple intents as a single batch for gas-efficient execution. The relayer optimizes homogeneous batches into a single on-chain transaction, while each intent gets its own bundleId for status tracking. All intents execute atomically (all succeed or all fail).
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
// Sign multiple intents (can be different accounts)
|
|
423
|
+
const signedIntents = await Promise.all([
|
|
424
|
+
client.signIntent({ accountAddress: addr1, signerKey: key1, calls: [...] }),
|
|
425
|
+
client.signIntent({ accountAddress: addr2, signerKey: key2, calls: [...] }),
|
|
426
|
+
]);
|
|
427
|
+
|
|
428
|
+
// Submit as batch - executes in single on-chain transaction
|
|
429
|
+
const result = await client.submitBatch({
|
|
430
|
+
intents: signedIntents.map((s) => s.intent),
|
|
431
|
+
});
|
|
432
|
+
// { success, bundleIds, succeeded, failed }
|
|
433
|
+
|
|
434
|
+
// Poll status for each bundle
|
|
435
|
+
for (const bundleId of result.bundleIds) {
|
|
436
|
+
const status = await client.getBundleStatus({ bundleId });
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
For parallel intents from the same account, use different `seqKey` values:
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
const signedIntents = await Promise.all([
|
|
444
|
+
client.signIntent({ accountAddress, signerKey, seqKey: 0n, calls: [...] }),
|
|
445
|
+
client.signIntent({ accountAddress, signerKey, seqKey: 1n, calls: [...] }),
|
|
446
|
+
]);
|
|
447
|
+
await client.submitBatch({ intents: signedIntents.map((s) => s.intent) });
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
#### `prepareIntent(params)`
|
|
451
|
+
|
|
452
|
+
Prepares an intent for signing (advanced use). Returns EIP-712 typed data for manual signing.
|
|
453
|
+
Also provides gas estimation via `combinedGas` - use this instead of `simulateIntent`.
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
const prepared = await client.prepareIntent({
|
|
457
|
+
accountAddress: Address,
|
|
458
|
+
calls: Call[],
|
|
459
|
+
});
|
|
460
|
+
// { success, typedData, nonce, combinedGas, expiry, digest }
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
#### `getBundleStatus(params)`
|
|
464
|
+
|
|
465
|
+
Gets the status of a submitted bundle.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
const status = await client.getBundleStatus({ bundleId });
|
|
469
|
+
// { status: 'pending' | 'confirmed' | 'failed' | 'reverted', statusCode, receipt }
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**Status Codes:**
|
|
473
|
+
|
|
474
|
+
| Code | Status | Meaning |
|
|
475
|
+
| ---- | ----------- | ---------------------------------------------------- |
|
|
476
|
+
| 100 | `pending` | Still waiting for confirmation |
|
|
477
|
+
| 200 | `confirmed` | Intent executed successfully |
|
|
478
|
+
| 300 | `failed` | Transaction never submitted/mined (offchain failure) |
|
|
479
|
+
| 400 | `reverted` | Transaction mined but intent failed on-chain |
|
|
480
|
+
| 500 | `reverted` | Some intents in bundle failed (partial revert) |
|
|
481
|
+
|
|
482
|
+
#### `waitForBundle(params)`
|
|
483
|
+
|
|
484
|
+
Waits for a bundle to reach a final status (confirmed, failed, or reverted).
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const status = await client.waitForBundle({
|
|
488
|
+
bundleId: result.bundleId!,
|
|
489
|
+
timeoutMs: 60_000, // Optional, default 30s
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
if (status.status === "confirmed") {
|
|
493
|
+
console.log("Success!", status.receipt?.transactionHash);
|
|
494
|
+
} else if (status.status === "reverted") {
|
|
495
|
+
console.log("Intent failed on-chain:", status.receipt?.intentError);
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
#### `executeIntent(params)`
|
|
500
|
+
|
|
501
|
+
Convenience method that combines `signIntent`, `submitIntent`, and `waitForBundle` into a single call.
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
// Simple execution with waiting (default)
|
|
505
|
+
const result = await client.executeIntent({
|
|
506
|
+
accountAddress: account.address,
|
|
507
|
+
signerKey: privateKey,
|
|
508
|
+
calls: [
|
|
509
|
+
{
|
|
510
|
+
target: recipientAddress,
|
|
511
|
+
value: parseEther("0.1"),
|
|
512
|
+
data: "0x",
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (result.success) {
|
|
518
|
+
console.log("Transaction hash:", result.txHash);
|
|
519
|
+
} else {
|
|
520
|
+
console.log("Failed:", result.error);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Fire and forget (don't wait for confirmation)
|
|
524
|
+
const result = await client.executeIntent({
|
|
525
|
+
accountAddress: account.address,
|
|
526
|
+
signerKey: privateKey,
|
|
527
|
+
calls: [...],
|
|
528
|
+
waitForConfirmation: false,
|
|
529
|
+
});
|
|
530
|
+
console.log("Bundle submitted:", result.bundleId);
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Utilities
|
|
534
|
+
|
|
535
|
+
#### `encodeSecp256k1Key(address)`
|
|
536
|
+
|
|
537
|
+
Encodes an address as a secp256k1 public key for TownsAccount. This is a convenience wrapper for the common pattern of ABI-encoding an address.
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
import { encodeSecp256k1Key, computeKeyHash } from "@towns-labs/relayer-client";
|
|
541
|
+
|
|
542
|
+
const publicKey = encodeSecp256k1Key(signerAddress);
|
|
543
|
+
const keyHash = computeKeyHash("secp256k1", publicKey);
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
#### `computeKeyHash(keyType, publicKey)`
|
|
547
|
+
|
|
548
|
+
Computes the key hash for an authorized key. This matches TownsAccount's key hash computation.
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
import { encodeSecp256k1Key, computeKeyHash } from "@towns-labs/relayer-client";
|
|
552
|
+
|
|
553
|
+
// For secp256k1 keys
|
|
554
|
+
const publicKey = encodeSecp256k1Key(signerAddress);
|
|
555
|
+
const keyHash = computeKeyHash("secp256k1", publicKey);
|
|
556
|
+
|
|
557
|
+
// For external signer contracts
|
|
558
|
+
const externalPublicKey = concat([signerContract, `0x${"00".repeat(12)}`]);
|
|
559
|
+
const externalKeyHash = computeKeyHash("external", externalPublicKey);
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
#### `wrapSignature(signature, keyHash, prehash?)`
|
|
563
|
+
|
|
564
|
+
Wraps a signature with keyHash and prehash flag for TownsAccount validation. This is the format expected by `TownsAccount.unwrapAndValidateSignature`.
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
import { wrapSignature } from "@towns-labs/relayer-client";
|
|
568
|
+
|
|
569
|
+
// Wrap a raw signature with key hash
|
|
570
|
+
const wrappedSignature = wrapSignature(rawSignature, keyHash);
|
|
571
|
+
|
|
572
|
+
// With prehash flag (for certain signing scenarios)
|
|
573
|
+
const wrappedSignature = wrapSignature(rawSignature, keyHash, true);
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
#### `computeErc1271Digest(digest, accountAddress)`
|
|
577
|
+
|
|
578
|
+
Computes the ERC-1271 replay-safe digest for smart contract signatures. Use this when manually signing as a delegated account.
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
import { computeErc1271Digest } from "@towns-labs/relayer-client";
|
|
582
|
+
|
|
583
|
+
// Transform digest for ERC-1271 signing
|
|
584
|
+
const erc1271Digest = computeErc1271Digest(originalDigest, signerAddress);
|
|
585
|
+
|
|
586
|
+
// Sign the transformed digest
|
|
587
|
+
const signature = await sign({ hash: erc1271Digest, privateKey });
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
#### `decodeIntentError(selector)`
|
|
591
|
+
|
|
592
|
+
Decodes an intent error selector (bytes4) to a human-readable name.
|
|
593
|
+
|
|
594
|
+
```typescript
|
|
595
|
+
import { decodeIntentError } from "@towns-labs/relayer-client";
|
|
596
|
+
|
|
597
|
+
const status = await client.getBundleStatus({ bundleId });
|
|
598
|
+
if (status.receipt?.intentError) {
|
|
599
|
+
const errorName = decodeIntentError(status.receipt.intentError);
|
|
600
|
+
console.log("Intent failed:", errorName); // e.g., 'ExceededSpendLimit'
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
#### Permission Constants
|
|
605
|
+
|
|
606
|
+
Constants for configuring session key permissions:
|
|
607
|
+
|
|
608
|
+
```typescript
|
|
609
|
+
import {
|
|
610
|
+
ANY_TARGET,
|
|
611
|
+
EMPTY_CALLDATA_SELECTOR,
|
|
612
|
+
ERC20_SELECTORS,
|
|
613
|
+
} from "@towns-labs/relayer-client";
|
|
614
|
+
|
|
615
|
+
// Allow ETH transfers to any address
|
|
616
|
+
const ethTransferPermission: CallPermission = {
|
|
617
|
+
type: "call",
|
|
618
|
+
to: ANY_TARGET,
|
|
619
|
+
selector: EMPTY_CALLDATA_SELECTOR,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// Allow ERC20 transfers to any address
|
|
623
|
+
const erc20TransferPermission: CallPermission = {
|
|
624
|
+
type: "call",
|
|
625
|
+
to: ANY_TARGET,
|
|
626
|
+
selector: ERC20_SELECTORS.TRANSFER,
|
|
627
|
+
};
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
| Constant | Value | Description |
|
|
631
|
+
| ------------------------------- | --------------- | ------------------------------------------------- |
|
|
632
|
+
| `ANY_TARGET` | `0x3232...3232` | Matches any contract address |
|
|
633
|
+
| `EMPTY_CALLDATA_SELECTOR` | `0xe0e0e0e0` | Matches calls with empty calldata (ETH transfers) |
|
|
634
|
+
| `ERC20_SELECTORS.TRANSFER` | `0xa9059cbb` | ERC20 `transfer(address,uint256)` |
|
|
635
|
+
| `ERC20_SELECTORS.APPROVE` | `0x095ea7b3` | ERC20 `approve(address,uint256)` |
|
|
636
|
+
| `ERC20_SELECTORS.TRANSFER_FROM` | `0x23b872dd` | ERC20 `transferFrom(address,address,uint256)` |
|
|
637
|
+
|
|
638
|
+
### Types
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
interface Call {
|
|
642
|
+
target: Address; // Contract to call
|
|
643
|
+
value: bigint; // ETH value to send
|
|
644
|
+
data: Hex; // Calldata
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
interface Intent {
|
|
648
|
+
eoa: Address;
|
|
649
|
+
calls: Call[];
|
|
650
|
+
nonce: bigint;
|
|
651
|
+
combinedGas: bigint;
|
|
652
|
+
expiry: bigint;
|
|
653
|
+
signature: Hex;
|
|
654
|
+
// Payment fields (optional)
|
|
655
|
+
payer?: Address;
|
|
656
|
+
paymentToken?: Address;
|
|
657
|
+
paymentMaxAmount?: bigint;
|
|
658
|
+
paymentAmount?: bigint;
|
|
659
|
+
paymentSignature?: Hex;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
interface SignedIntent {
|
|
663
|
+
intent: Intent;
|
|
664
|
+
digest: Hex; // EIP-712 digest (for third-party sponsorship)
|
|
665
|
+
typedData: {
|
|
666
|
+
domain: EIP712Domain;
|
|
667
|
+
types: typeof INTENT_TYPES;
|
|
668
|
+
primaryType: "Intent";
|
|
669
|
+
message: Record<string, unknown>;
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
interface SubmitBatchParams {
|
|
674
|
+
intents: Intent[];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
interface BatchIntentsResponse {
|
|
678
|
+
success: boolean;
|
|
679
|
+
bundleIds?: string[]; // One per intent for status tracking
|
|
680
|
+
succeeded?: number;
|
|
681
|
+
failed?: number;
|
|
682
|
+
error?: string;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Key authorization types
|
|
686
|
+
type KeyType = "secp256k1" | "external";
|
|
687
|
+
|
|
688
|
+
interface AuthorizeKey {
|
|
689
|
+
expiry: string; // Unix timestamp, "0" = never expires
|
|
690
|
+
type: KeyType;
|
|
691
|
+
role: "admin" | "normal"; // admin = superadmin
|
|
692
|
+
publicKey: Hex; // For secp256k1: encodeAbiParameters([{type:'address'}], [addr])
|
|
693
|
+
permissions: Permission[];
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
interface CallPermission {
|
|
697
|
+
type: "call";
|
|
698
|
+
to: Address;
|
|
699
|
+
selector: Hex; // 4-byte function selector
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
interface SpendPermission {
|
|
703
|
+
type: "spend";
|
|
704
|
+
token: Address;
|
|
705
|
+
limit: string;
|
|
706
|
+
period: "minute" | "hour" | "day" | "week" | "month" | "year";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
type Permission = CallPermission | SpendPermission;
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
## Running Tests
|
|
713
|
+
|
|
714
|
+
The integration tests serve as comprehensive examples of SDK usage.
|
|
715
|
+
|
|
716
|
+
### Prerequisites
|
|
717
|
+
|
|
718
|
+
- [Foundry](https://getfoundry.sh) (for Anvil)
|
|
719
|
+
- [Bun](https://bun.sh)
|
|
720
|
+
- A Base mainnet RPC URL (e.g., from Alchemy)
|
|
721
|
+
|
|
722
|
+
### Run Tests
|
|
723
|
+
|
|
724
|
+
From the `packages/relayer-client` directory:
|
|
725
|
+
|
|
726
|
+
```bash
|
|
727
|
+
# Start services and run tests (recommended)
|
|
728
|
+
FORK_RPC_URL=https://your-rpc-url ./scripts/local-dev.sh --test
|
|
729
|
+
|
|
730
|
+
# Or start services in dev mode, then run tests separately
|
|
731
|
+
FORK_RPC_URL=https://your-rpc-url ./scripts/local-dev.sh
|
|
732
|
+
# In another terminal:
|
|
733
|
+
bun run test:integration
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### Test Scenarios
|
|
737
|
+
|
|
738
|
+
The tests in `test/scenarios/` demonstrate:
|
|
739
|
+
|
|
740
|
+
1. **Account Delegation** - Creating delegated EIP-7702 accounts
|
|
741
|
+
2. **ERC20 Gasless Transfers** - Transferring tokens without paying gas
|
|
742
|
+
3. **ETH Gasless Transfers** - Sending ETH without paying gas, user-paid gas, and third-party sponsorship
|
|
743
|
+
4. **Nonce Management** - Sequential and parallel intent execution
|
|
744
|
+
5. **Batch Execution** - Submitting multiple intents in a single transaction
|
|
745
|
+
|
|
746
|
+
## Supported Chains
|
|
747
|
+
|
|
748
|
+
| Chain | Chain ID | Status |
|
|
749
|
+
| ------------ | -------- | --------- |
|
|
750
|
+
| Base Mainnet | 8453 | Supported |
|
|
751
|
+
| Base Sepolia | 84532 | Supported |
|
|
752
|
+
| Local Anvil | 31337 | Supported |
|