@1llet.xyz/erc4337-gasless-sdk 0.1.0

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.
@@ -0,0 +1,529 @@
1
+ import {
2
+ createPublicClient,
3
+ http,
4
+ encodeFunctionData,
5
+ encodeAbiParameters,
6
+ keccak256,
7
+ type Address,
8
+ type Hash,
9
+ type Hex,
10
+ type PublicClient
11
+ } from "viem";
12
+ import {
13
+ factoryAbi,
14
+ entryPointAbi,
15
+ smartAccountAbi,
16
+ erc20Abi,
17
+ } from "./constants";
18
+ import {
19
+ type ChainConfig,
20
+ type UserOperation,
21
+ type GasEstimate,
22
+ type UserOpReceipt,
23
+ type ApprovalSupportResult
24
+ } from "./types";
25
+
26
+ /**
27
+ * ERC-4337 Account Abstraction Client
28
+ */
29
+ export class AccountAbstraction {
30
+ private owner: Address | null = null;
31
+ private smartAccountAddress: Address | null = null;
32
+ private chainConfig: ChainConfig;
33
+ private publicClient: PublicClient;
34
+
35
+ constructor(chainConfig: ChainConfig) {
36
+ this.chainConfig = chainConfig;
37
+ this.publicClient = createPublicClient({
38
+ chain: chainConfig.chain,
39
+ transport: http(chainConfig.rpcUrl),
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Connect to MetaMask and get the owner address
45
+ */
46
+ async connect(): Promise<{ owner: Address; smartAccount: Address }> {
47
+ if (typeof window === "undefined" || !window.ethereum) {
48
+ throw new Error("MetaMask is not installed");
49
+ }
50
+
51
+ // Request account access
52
+ const accounts = (await window.ethereum.request({
53
+ method: "eth_requestAccounts",
54
+ })) as string[];
55
+
56
+ if (!accounts || accounts.length === 0) {
57
+ throw new Error("No accounts found");
58
+ }
59
+
60
+ // Check network
61
+ const chainId = (await window.ethereum.request({
62
+ method: "eth_chainId",
63
+ })) as string;
64
+
65
+ const targetChainId = this.chainConfig.chain.id;
66
+
67
+ if (parseInt(chainId, 16) !== targetChainId) {
68
+ // Switch to configured chain
69
+ try {
70
+ await window.ethereum.request({
71
+ method: "wallet_switchEthereumChain",
72
+ params: [{ chainId: "0x" + targetChainId.toString(16) }],
73
+ });
74
+ } catch (switchError: unknown) {
75
+ const error = switchError as { code?: number };
76
+ // Chain not added, add it
77
+ if (error.code === 4902) {
78
+ await window.ethereum.request({
79
+ method: "wallet_addEthereumChain",
80
+ params: [
81
+ {
82
+ chainId: "0x" + targetChainId.toString(16),
83
+ chainName: this.chainConfig.chain.name,
84
+ nativeCurrency: this.chainConfig.chain.nativeCurrency,
85
+ rpcUrls: [this.chainConfig.rpcUrl],
86
+ blockExplorerUrls: this.chainConfig.chain.blockExplorers?.default?.url
87
+ ? [this.chainConfig.chain.blockExplorers.default.url]
88
+ : [],
89
+ },
90
+ ],
91
+ });
92
+ } else {
93
+ throw switchError;
94
+ }
95
+ }
96
+ }
97
+
98
+ this.owner = accounts[0] as Address;
99
+ this.smartAccountAddress = await this.getSmartAccountAddress(this.owner);
100
+
101
+ return {
102
+ owner: this.owner,
103
+ smartAccount: this.smartAccountAddress,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Get the Smart Account address for an owner (counterfactual)
109
+ */
110
+ async getSmartAccountAddress(owner: Address): Promise<Address> {
111
+ const address = await this.publicClient.readContract({
112
+ address: this.chainConfig.factoryAddress,
113
+ abi: factoryAbi,
114
+ functionName: "getAccountAddress",
115
+ args: [owner, 0n], // salt = 0
116
+ }) as Address;
117
+ return address;
118
+ }
119
+
120
+ /**
121
+ * Check if the Smart Account is deployed
122
+ */
123
+ async isAccountDeployed(): Promise<boolean> {
124
+ if (!this.smartAccountAddress) {
125
+ throw new Error("Not connected");
126
+ }
127
+
128
+ const code = await this.publicClient.getCode({
129
+ address: this.smartAccountAddress,
130
+ });
131
+ return code !== undefined && code !== "0x";
132
+ }
133
+
134
+
135
+ /**
136
+ * Get the USDC balance of the Smart Account
137
+ */
138
+ async getUsdcBalance(): Promise<bigint> {
139
+ if (!this.smartAccountAddress) {
140
+ throw new Error("Not connected");
141
+ }
142
+
143
+ return await this.publicClient.readContract({
144
+ address: this.chainConfig.usdcAddress,
145
+ abi: erc20Abi,
146
+ functionName: "balanceOf",
147
+ args: [this.smartAccountAddress],
148
+ }) as bigint;
149
+ }
150
+
151
+
152
+ /**
153
+ * Get the EOA's USDC balance
154
+ */
155
+ async getEoaUsdcBalance(): Promise<bigint> {
156
+ if (!this.owner) {
157
+ throw new Error("Not connected");
158
+ }
159
+
160
+ return await this.publicClient.readContract({
161
+ address: this.chainConfig.usdcAddress,
162
+ abi: erc20Abi,
163
+ functionName: "balanceOf",
164
+ args: [this.owner],
165
+ }) as bigint;
166
+ }
167
+
168
+ /**
169
+ * Get the allowance of the Smart Account to spend the EOA's USDC
170
+ */
171
+ async getAllowance(): Promise<bigint> {
172
+ if (!this.owner || !this.smartAccountAddress) {
173
+ throw new Error("Not connected");
174
+ }
175
+
176
+ return await this.publicClient.readContract({
177
+ address: this.chainConfig.usdcAddress,
178
+ abi: erc20Abi,
179
+ functionName: "allowance",
180
+ args: [this.owner, this.smartAccountAddress],
181
+ }) as bigint;
182
+ }
183
+
184
+ /**
185
+ * Get the nonce for the Smart Account
186
+ */
187
+ async getNonce(): Promise<bigint> {
188
+ if (!this.smartAccountAddress) {
189
+ throw new Error("Not connected");
190
+ }
191
+
192
+ return await this.publicClient.readContract({
193
+ address: this.chainConfig.entryPointAddress,
194
+ abi: entryPointAbi,
195
+ functionName: "getNonce",
196
+ args: [this.smartAccountAddress, 0n],
197
+ }) as bigint;
198
+ }
199
+
200
+ /**
201
+ * Build initCode for account deployment
202
+ */
203
+ buildInitCode(): Hex {
204
+ if (!this.owner) {
205
+ throw new Error("Not connected");
206
+ }
207
+
208
+ const createAccountData = encodeFunctionData({
209
+ abi: factoryAbi,
210
+ functionName: "createAccount",
211
+ args: [this.owner, 0n],
212
+ });
213
+
214
+ return `${this.chainConfig.factoryAddress}${createAccountData.slice(2)}` as Hex;
215
+ }
216
+
217
+
218
+ /**
219
+ * Estimate gas for a UserOperation
220
+ */
221
+ async estimateGas(userOp: Partial<UserOperation>): Promise<GasEstimate> {
222
+ const response = await fetch(this.chainConfig.bundlerUrl, {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify({
226
+ jsonrpc: "2.0",
227
+ id: 1,
228
+ method: "eth_estimateUserOperationGas",
229
+ params: [
230
+ {
231
+ sender: userOp.sender,
232
+ nonce: userOp.nonce ? "0x" + userOp.nonce.toString(16) : "0x0",
233
+ initCode: userOp.initCode || "0x",
234
+ callData: userOp.callData || "0x",
235
+ paymasterAndData: userOp.paymasterAndData || "0x",
236
+ signature: "0x",
237
+ },
238
+ this.chainConfig.entryPointAddress,
239
+ ],
240
+ }),
241
+ });
242
+
243
+ const result = await response.json();
244
+ if (result.error) {
245
+ throw new Error(result.error.message);
246
+ }
247
+
248
+ return result.result;
249
+ }
250
+
251
+
252
+ /**
253
+ * Build a UserOperation for Batched Execution (e.g. USDC Transfer + Fee)
254
+ */
255
+ async buildUserOperationBatch(
256
+ transactions: { target: Address; value: bigint; data: Hex }[]
257
+ ): Promise<UserOperation> {
258
+ if (!this.owner || !this.smartAccountAddress) {
259
+ throw new Error("Not connected");
260
+ }
261
+
262
+ const isDeployed = await this.isAccountDeployed();
263
+ const initCode = isDeployed ? "0x" : this.buildInitCode();
264
+
265
+ // Prepare arrays for executeBatch
266
+ const targets = transactions.map((tx) => tx.target);
267
+ const values = transactions.map((tx) => tx.value);
268
+ const datas = transactions.map((tx) => tx.data);
269
+
270
+ // Encode callData for executeBatch
271
+ const callData = encodeFunctionData({
272
+ abi: smartAccountAbi,
273
+ functionName: "executeBatch",
274
+ args: [targets, values, datas],
275
+ });
276
+
277
+ const nonce = await this.getNonce();
278
+
279
+ // Estimate gas
280
+ const gasEstimate = await this.estimateGas({
281
+ sender: this.smartAccountAddress,
282
+ nonce,
283
+ initCode: initCode as Hex,
284
+ callData,
285
+ paymasterAndData: this.chainConfig.paymasterAddress as Hex,
286
+ });
287
+
288
+ return {
289
+ sender: this.smartAccountAddress,
290
+ nonce,
291
+ initCode: initCode as Hex,
292
+ callData,
293
+ callGasLimit: BigInt(gasEstimate.callGasLimit),
294
+ verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
295
+ preVerificationGas: BigInt(gasEstimate.preVerificationGas),
296
+ maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
297
+ maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
298
+ paymasterAndData: this.chainConfig.paymasterAddress as Hex,
299
+ signature: "0x",
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Build a UserOperation to ONLY deploy the account (empty callData)
305
+ */
306
+ async buildDeployUserOperation(): Promise<UserOperation> {
307
+ if (!this.owner || !this.smartAccountAddress) {
308
+ throw new Error("Not connected");
309
+ }
310
+
311
+ const isDeployed = await this.isAccountDeployed();
312
+ if (isDeployed) {
313
+ throw new Error("Account is already deployed");
314
+ }
315
+
316
+ const initCode = this.buildInitCode();
317
+ const callData = "0x"; // Empty callData for deployment only
318
+ const nonce = await this.getNonce();
319
+
320
+ // Estimate gas
321
+ const gasEstimate = await this.estimateGas({
322
+ sender: this.smartAccountAddress,
323
+ nonce,
324
+ initCode: initCode as Hex,
325
+ callData,
326
+ paymasterAndData: this.chainConfig.paymasterAddress as Hex,
327
+ });
328
+
329
+ return {
330
+ sender: this.smartAccountAddress,
331
+ nonce,
332
+ initCode: initCode as Hex,
333
+ callData,
334
+ callGasLimit: BigInt(gasEstimate.callGasLimit),
335
+ verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
336
+ preVerificationGas: BigInt(gasEstimate.preVerificationGas),
337
+ maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
338
+ maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
339
+ paymasterAndData: this.chainConfig.paymasterAddress as Hex,
340
+ signature: "0x",
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Calculate the UserOperation hash
346
+ */
347
+ getUserOpHash(userOp: UserOperation): Hex {
348
+ const packed = encodeAbiParameters(
349
+ [
350
+ { type: "address" },
351
+ { type: "uint256" },
352
+ { type: "bytes32" },
353
+ { type: "bytes32" },
354
+ { type: "uint256" },
355
+ { type: "uint256" },
356
+ { type: "uint256" },
357
+ { type: "uint256" },
358
+ { type: "uint256" },
359
+ { type: "bytes32" },
360
+ ],
361
+ [
362
+ userOp.sender,
363
+ userOp.nonce,
364
+ keccak256(userOp.initCode),
365
+ keccak256(userOp.callData),
366
+ userOp.callGasLimit,
367
+ userOp.verificationGasLimit,
368
+ userOp.preVerificationGas,
369
+ userOp.maxFeePerGas,
370
+ userOp.maxPriorityFeePerGas,
371
+ keccak256(userOp.paymasterAndData),
372
+ ]
373
+ );
374
+
375
+ const packedHash = keccak256(packed);
376
+
377
+ return keccak256(
378
+ encodeAbiParameters(
379
+ [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }],
380
+ [packedHash, this.chainConfig.entryPointAddress, BigInt(this.chainConfig.chain.id)]
381
+ )
382
+ );
383
+ }
384
+
385
+ /**
386
+ * Sign a UserOperation with MetaMask
387
+ */
388
+ async signUserOperation(userOp: UserOperation): Promise<UserOperation> {
389
+ if (!this.owner) {
390
+ throw new Error("Not connected");
391
+ }
392
+
393
+ const userOpHash = this.getUserOpHash(userOp);
394
+
395
+ // Sign with MetaMask using personal_sign (EIP-191)
396
+ const signature = (await window.ethereum!.request({
397
+ method: "personal_sign",
398
+ params: [userOpHash, this.owner],
399
+ })) as Hex;
400
+
401
+ return {
402
+ ...userOp,
403
+ signature,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Send a signed UserOperation to the bundler
409
+ */
410
+ async sendUserOperation(userOp: UserOperation): Promise<Hash> {
411
+ const response = await fetch(this.chainConfig.bundlerUrl, {
412
+ method: "POST",
413
+ headers: { "Content-Type": "application/json" },
414
+ body: JSON.stringify({
415
+ jsonrpc: "2.0",
416
+ id: 1,
417
+ method: "eth_sendUserOperation",
418
+ params: [
419
+ {
420
+ sender: userOp.sender,
421
+ nonce: "0x" + userOp.nonce.toString(16),
422
+ initCode: userOp.initCode,
423
+ callData: userOp.callData,
424
+ callGasLimit: "0x" + userOp.callGasLimit.toString(16),
425
+ verificationGasLimit: "0x" + userOp.verificationGasLimit.toString(16),
426
+ preVerificationGas: "0x" + userOp.preVerificationGas.toString(16),
427
+ maxFeePerGas: "0x" + userOp.maxFeePerGas.toString(16),
428
+ maxPriorityFeePerGas: "0x" + userOp.maxPriorityFeePerGas.toString(16),
429
+ paymasterAndData: userOp.paymasterAndData,
430
+ signature: userOp.signature,
431
+ },
432
+ this.chainConfig.entryPointAddress,
433
+ ],
434
+ }),
435
+ });
436
+
437
+ const result = await response.json();
438
+ if (result.error) {
439
+ throw new Error(result.error.message);
440
+ }
441
+
442
+ return result.result as Hash;
443
+ }
444
+
445
+ /**
446
+ * Wait for a UserOperation to be confirmed
447
+ */
448
+ async waitForUserOperation(
449
+ userOpHash: Hash,
450
+ timeout = 60000
451
+ ): Promise<UserOpReceipt> {
452
+ const startTime = Date.now();
453
+
454
+ while (Date.now() - startTime < timeout) {
455
+ const response = await fetch(this.chainConfig.bundlerUrl, {
456
+ method: "POST",
457
+ headers: { "Content-Type": "application/json" },
458
+ body: JSON.stringify({
459
+ jsonrpc: "2.0",
460
+ id: 1,
461
+ method: "eth_getUserOperationReceipt",
462
+ params: [userOpHash],
463
+ }),
464
+ });
465
+
466
+ const result = await response.json();
467
+ if (result.result) {
468
+ return result.result as UserOpReceipt;
469
+ }
470
+
471
+ // Wait 2 seconds before polling again
472
+ await new Promise((resolve) => setTimeout(resolve, 2000));
473
+ }
474
+
475
+ throw new Error("Timeout waiting for UserOperation");
476
+ }
477
+
478
+
479
+ /**
480
+ * Request support for token approval (fund if needed)
481
+ */
482
+ async requestApprovalSupport(
483
+ token: Address,
484
+ spender: Address,
485
+ amount: bigint
486
+ ): Promise<ApprovalSupportResult> {
487
+ if (!this.owner) {
488
+ throw new Error("Not connected");
489
+ }
490
+
491
+ const response = await fetch(this.chainConfig.bundlerUrl, {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/json" },
494
+ body: JSON.stringify({
495
+ jsonrpc: "2.0",
496
+ id: 1,
497
+ method: "pm_requestApprovalSupport",
498
+ params: [token, this.owner, spender, amount.toString()],
499
+ }),
500
+ });
501
+
502
+ const result = await response.json();
503
+ if (result.error) {
504
+ throw new Error(result.error.message);
505
+ }
506
+
507
+ return result.result;
508
+ }
509
+
510
+ // Getters
511
+ getOwner(): Address | null {
512
+ return this.owner;
513
+ }
514
+
515
+ getSmartAccount(): Address | null {
516
+ return this.smartAccountAddress;
517
+ }
518
+ }
519
+
520
+ // Global window types for MetaMask
521
+ declare global {
522
+ interface Window {
523
+ ethereum?: {
524
+ request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
525
+ on: (event: string, callback: (args: unknown) => void) => void;
526
+ removeListener: (event: string, callback: (args: unknown) => void) => void;
527
+ };
528
+ }
529
+ }
@@ -0,0 +1,131 @@
1
+ export const DEFAULT_ENTRYPOINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";
2
+ export const DEFAULT_FACTORY = "0x9406Cc6185a346906296840746125a0E44976454"; // SimpleAccountFactory v0.6
3
+
4
+ export const factoryAbi = [
5
+ {
6
+ inputs: [
7
+ { name: "owner", type: "address" },
8
+ { name: "salt", type: "uint256" },
9
+ ],
10
+ name: "getAccountAddress",
11
+ outputs: [{ name: "", type: "address" }],
12
+ stateMutability: "view",
13
+ type: "function",
14
+ },
15
+ {
16
+ inputs: [
17
+ { name: "owner", type: "address" },
18
+ { name: "salt", type: "uint256" },
19
+ ],
20
+ name: "isAccountDeployed",
21
+ outputs: [{ name: "", type: "bool" }],
22
+ stateMutability: "view",
23
+ type: "function",
24
+ },
25
+ {
26
+ inputs: [
27
+ { name: "owner", type: "address" },
28
+ { name: "salt", type: "uint256" },
29
+ ],
30
+ name: "createAccount",
31
+ outputs: [{ name: "account", type: "address" }],
32
+ stateMutability: "nonpayable",
33
+ type: "function",
34
+ },
35
+ ] as const;
36
+
37
+ export const entryPointAbi = [
38
+ {
39
+ inputs: [
40
+ { name: "sender", type: "address" },
41
+ { name: "key", type: "uint192" },
42
+ ],
43
+ name: "getNonce",
44
+ outputs: [{ name: "nonce", type: "uint256" }],
45
+ stateMutability: "view",
46
+ type: "function",
47
+ },
48
+ ] as const;
49
+
50
+ export const smartAccountAbi = [
51
+ {
52
+ inputs: [
53
+ { name: "target", type: "address" },
54
+ { name: "value", type: "uint256" },
55
+ { name: "data", type: "bytes" },
56
+ ],
57
+ name: "execute",
58
+ outputs: [],
59
+ stateMutability: "nonpayable",
60
+ type: "function",
61
+ },
62
+ {
63
+ inputs: [
64
+ { name: "targets", type: "address[]" },
65
+ { name: "values", type: "uint256[]" },
66
+ { name: "datas", type: "bytes[]" },
67
+ ],
68
+ name: "executeBatch",
69
+ outputs: [],
70
+ stateMutability: "nonpayable",
71
+ type: "function",
72
+ },
73
+ ] as const;
74
+
75
+ export const erc20Abi = [
76
+ {
77
+ inputs: [{ name: "account", type: "address" }],
78
+ name: "balanceOf",
79
+ outputs: [{ name: "", type: "uint256" }],
80
+ stateMutability: "view",
81
+ type: "function",
82
+ },
83
+ {
84
+ inputs: [
85
+ { name: "to", type: "address" },
86
+ { name: "amount", type: "uint256" },
87
+ ],
88
+ name: "transfer",
89
+ outputs: [{ name: "", type: "bool" }],
90
+ stateMutability: "nonpayable",
91
+ type: "function",
92
+ },
93
+ {
94
+ inputs: [
95
+ { name: "spender", type: "address" },
96
+ { name: "amount", type: "uint256" },
97
+ ],
98
+ name: "approve",
99
+ outputs: [{ name: "", type: "bool" }],
100
+ stateMutability: "nonpayable",
101
+ type: "function",
102
+ },
103
+ {
104
+ inputs: [
105
+ { name: "owner", type: "address" },
106
+ { name: "spender", type: "address" },
107
+ ],
108
+ name: "allowance",
109
+ outputs: [{ name: "", type: "uint256" }],
110
+ stateMutability: "view",
111
+ type: "function",
112
+ },
113
+ {
114
+ inputs: [
115
+ { name: "from", type: "address" },
116
+ { name: "to", type: "address" },
117
+ { name: "amount", type: "uint256" },
118
+ ],
119
+ name: "transferFrom",
120
+ outputs: [{ name: "", type: "bool" }],
121
+ stateMutability: "nonpayable",
122
+ type: "function",
123
+ },
124
+ {
125
+ inputs: [],
126
+ name: "decimals",
127
+ outputs: [{ name: "", type: "uint8" }],
128
+ stateMutability: "view",
129
+ type: "function",
130
+ },
131
+ ] as const;
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./AccountAbstraction";
2
+ export * from "./types";
3
+ export * from "./constants";