@1llet.xyz/erc4337-gasless-sdk 0.2.0 → 0.4.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.
@@ -1,613 +0,0 @@
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
- decodeErrorResult
12
- } from "viem";
13
- import {
14
- factoryAbi,
15
- entryPointAbi,
16
- smartAccountAbi,
17
- erc20Abi,
18
- } from "./constants";
19
- import {
20
- type ChainConfig,
21
- type UserOperation,
22
- type GasEstimate,
23
- type UserOpReceipt,
24
- type ApprovalSupportResult
25
- } from "./types";
26
- import { DEPLOYMENTS } from "./deployments";
27
- import { BundlerClient } from "./BundlerClient";
28
-
29
- /**
30
- * ERC-4337 Account Abstraction Client
31
- */
32
- export class AccountAbstraction {
33
- private owner: Address | null = null;
34
- private smartAccountAddress: Address | null = null;
35
- private chainConfig: ChainConfig;
36
- private publicClient: PublicClient;
37
- private bundlerClient: BundlerClient;
38
-
39
- // Resolved addresses
40
- private entryPointAddress: Address;
41
- private factoryAddress: Address;
42
- private paymasterAddress?: Address;
43
- private usdcAddress: Address;
44
-
45
- constructor(chainConfig: ChainConfig) {
46
- this.chainConfig = chainConfig;
47
- const chainId = chainConfig.chain.id;
48
- const defaults = DEPLOYMENTS[chainId];
49
-
50
- // Resolve addresses (Config > Defaults > Error)
51
- const entryPoint = chainConfig.entryPointAddress || defaults?.entryPoint;
52
- if (!entryPoint) throw new Error(`EntryPoint address not found for chain ${chainId}`);
53
- this.entryPointAddress = entryPoint;
54
-
55
- const factory = chainConfig.factoryAddress || defaults?.factory;
56
- if (!factory) throw new Error(`Factory address not found for chain ${chainId}`);
57
- this.factoryAddress = factory;
58
-
59
- const usdc = chainConfig.usdcAddress || defaults?.usdc;
60
- if (!usdc) throw new Error(`USDC address not found for chain ${chainId}`);
61
- this.usdcAddress = usdc;
62
-
63
- this.paymasterAddress = chainConfig.paymasterAddress || defaults?.paymaster;
64
-
65
- // Use provided RPC or default from chain
66
- const rpcUrl = chainConfig.rpcUrl || chainConfig.chain.rpcUrls.default.http[0];
67
-
68
- this.publicClient = createPublicClient({
69
- chain: chainConfig.chain,
70
- transport: http(rpcUrl),
71
- });
72
-
73
- this.bundlerClient = new BundlerClient(chainConfig, this.entryPointAddress);
74
- }
75
-
76
- /**
77
- * Connect to MetaMask and get the owner address
78
- */
79
- async connect(): Promise<{ owner: Address; smartAccount: Address }> {
80
- if (typeof window === "undefined" || !window.ethereum) {
81
- throw new Error("MetaMask is not installed");
82
- }
83
-
84
- // Request account access
85
- const accounts = (await window.ethereum.request({
86
- method: "eth_requestAccounts",
87
- })) as string[];
88
-
89
- if (!accounts || accounts.length === 0) {
90
- throw new Error("No accounts found");
91
- }
92
-
93
- // Check network
94
- const chainId = (await window.ethereum.request({
95
- method: "eth_chainId",
96
- })) as string;
97
-
98
- const targetChainId = this.chainConfig.chain.id;
99
-
100
- if (parseInt(chainId, 16) !== targetChainId) {
101
- // Switch to configured chain
102
- try {
103
- await window.ethereum.request({
104
- method: "wallet_switchEthereumChain",
105
- params: [{ chainId: "0x" + targetChainId.toString(16) }],
106
- });
107
- } catch (switchError: unknown) {
108
- const error = switchError as { code?: number };
109
- // Chain not added, add it
110
- if (error.code === 4902) {
111
- await window.ethereum.request({
112
- method: "wallet_addEthereumChain",
113
- params: [
114
- {
115
- chainId: "0x" + targetChainId.toString(16),
116
- chainName: this.chainConfig.chain.name,
117
- nativeCurrency: this.chainConfig.chain.nativeCurrency,
118
- rpcUrls: [this.chainConfig.rpcUrl],
119
- blockExplorerUrls: this.chainConfig.chain.blockExplorers?.default?.url
120
- ? [this.chainConfig.chain.blockExplorers.default.url]
121
- : [],
122
- },
123
- ],
124
- });
125
- } else {
126
- throw switchError;
127
- }
128
- }
129
- }
130
-
131
- this.owner = accounts[0] as Address;
132
- this.smartAccountAddress = await this.getSmartAccountAddress(this.owner);
133
-
134
- return {
135
- owner: this.owner,
136
- smartAccount: this.smartAccountAddress,
137
- };
138
- }
139
-
140
- /**
141
- * Get the Smart Account address for an owner (counterfactual)
142
- */
143
- async getSmartAccountAddress(owner: Address): Promise<Address> {
144
- const address = await this.publicClient.readContract({
145
- address: this.factoryAddress,
146
- abi: factoryAbi,
147
- functionName: "getAccountAddress",
148
- args: [owner, 0n], // salt = 0
149
- }) as Address;
150
- return address;
151
- }
152
-
153
- /**
154
- * Check if the Smart Account is deployed
155
- */
156
- async isAccountDeployed(): Promise<boolean> {
157
- if (!this.smartAccountAddress) {
158
- throw new Error("Not connected");
159
- }
160
-
161
- const code = await this.publicClient.getCode({
162
- address: this.smartAccountAddress,
163
- });
164
- return code !== undefined && code !== "0x";
165
- }
166
-
167
-
168
- /**
169
- * Get the USDC balance of the Smart Account
170
- */
171
- async getUsdcBalance(): Promise<bigint> {
172
- if (!this.smartAccountAddress) {
173
- throw new Error("Not connected");
174
- }
175
-
176
- return await this.publicClient.readContract({
177
- address: this.usdcAddress,
178
- abi: erc20Abi,
179
- functionName: "balanceOf",
180
- args: [this.smartAccountAddress],
181
- }) as bigint;
182
- }
183
-
184
-
185
- /**
186
- * Get the EOA's USDC balance
187
- */
188
- async getEoaUsdcBalance(): Promise<bigint> {
189
- if (!this.owner) {
190
- throw new Error("Not connected");
191
- }
192
-
193
- return await this.publicClient.readContract({
194
- address: this.usdcAddress,
195
- abi: erc20Abi,
196
- functionName: "balanceOf",
197
- args: [this.owner],
198
- }) as bigint;
199
- }
200
-
201
- /**
202
- * Get the allowance of the Smart Account to spend the EOA's USDC
203
- */
204
- async getAllowance(): Promise<bigint> {
205
- if (!this.owner || !this.smartAccountAddress) {
206
- throw new Error("Not connected");
207
- }
208
-
209
- return await this.publicClient.readContract({
210
- address: this.usdcAddress,
211
- abi: erc20Abi,
212
- functionName: "allowance",
213
- args: [this.owner, this.smartAccountAddress],
214
- }) as bigint;
215
- }
216
-
217
- /**
218
- * Get the nonce for the Smart Account
219
- */
220
- async getNonce(): Promise<bigint> {
221
- if (!this.smartAccountAddress) {
222
- throw new Error("Not connected");
223
- }
224
-
225
- return await this.publicClient.readContract({
226
- address: this.entryPointAddress,
227
- abi: entryPointAbi,
228
- functionName: "getNonce",
229
- args: [this.smartAccountAddress, 0n],
230
- }) as bigint;
231
- }
232
-
233
- /**
234
- * Build initCode for account deployment
235
- */
236
- buildInitCode(): Hex {
237
- if (!this.owner) {
238
- throw new Error("Not connected");
239
- }
240
-
241
- const createAccountData = encodeFunctionData({
242
- abi: factoryAbi,
243
- functionName: "createAccount",
244
- args: [this.owner, 0n],
245
- });
246
-
247
- return `${this.factoryAddress}${createAccountData.slice(2)}` as Hex;
248
- }
249
-
250
-
251
- /**
252
- * Estimate gas for a UserOperation
253
- */
254
- async estimateGas(userOp: Partial<UserOperation>): Promise<GasEstimate> {
255
- return this.bundlerClient.estimateGas(userOp);
256
- }
257
-
258
-
259
- /**
260
- * Build a UserOperation for Batched Execution (e.g. USDC Transfer + Fee)
261
- */
262
- async buildUserOperationBatch(
263
- transactions: { target: Address; value: bigint; data: Hex }[]
264
- ): Promise<UserOperation> {
265
- if (!this.owner || !this.smartAccountAddress) {
266
- throw new Error("Not connected");
267
- }
268
-
269
- const isDeployed = await this.isAccountDeployed();
270
- const initCode = isDeployed ? "0x" : this.buildInitCode();
271
-
272
- // Prepare arrays for executeBatch
273
- const targets = transactions.map((tx) => tx.target);
274
- const values = transactions.map((tx) => tx.value);
275
- const datas = transactions.map((tx) => tx.data);
276
-
277
- // Encode callData for executeBatch
278
- const callData = encodeFunctionData({
279
- abi: smartAccountAbi,
280
- functionName: "executeBatch",
281
- args: [targets, values, datas],
282
- });
283
-
284
- const nonce = await this.getNonce();
285
-
286
- // Estimate gas
287
- const gasEstimate = await this.estimateGas({
288
- sender: this.smartAccountAddress,
289
- nonce,
290
- initCode: initCode as Hex,
291
- callData,
292
- paymasterAndData: this.paymasterAddress as Hex,
293
- });
294
-
295
- return {
296
- sender: this.smartAccountAddress,
297
- nonce,
298
- initCode: initCode as Hex,
299
- callData,
300
- callGasLimit: BigInt(gasEstimate.callGasLimit),
301
- verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
302
- preVerificationGas: BigInt(gasEstimate.preVerificationGas),
303
- maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
304
- maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
305
- paymasterAndData: this.paymasterAddress as Hex,
306
- signature: "0x",
307
- };
308
- }
309
-
310
- /**
311
- * Build a UserOperation to ONLY deploy the account (empty callData)
312
- */
313
- async buildDeployUserOperation(): Promise<UserOperation> {
314
- if (!this.owner || !this.smartAccountAddress) {
315
- throw new Error("Not connected");
316
- }
317
-
318
- const isDeployed = await this.isAccountDeployed();
319
- if (isDeployed) {
320
- throw new Error("Account is already deployed");
321
- }
322
-
323
- const initCode = this.buildInitCode();
324
- const callData = "0x"; // Empty callData for deployment only
325
- const nonce = await this.getNonce();
326
-
327
- // Estimate gas
328
- const gasEstimate = await this.estimateGas({
329
- sender: this.smartAccountAddress,
330
- nonce,
331
- initCode: initCode as Hex,
332
- callData,
333
- paymasterAndData: this.paymasterAddress as Hex,
334
- });
335
-
336
- return {
337
- sender: this.smartAccountAddress,
338
- nonce,
339
- initCode: initCode as Hex,
340
- callData,
341
- callGasLimit: BigInt(gasEstimate.callGasLimit),
342
- verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
343
- preVerificationGas: BigInt(gasEstimate.preVerificationGas),
344
- maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
345
- maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
346
- paymasterAndData: this.paymasterAddress as Hex,
347
- signature: "0x",
348
- };
349
- }
350
-
351
- /**
352
- * Calculate the UserOperation hash
353
- */
354
- getUserOpHash(userOp: UserOperation): Hex {
355
- const packed = encodeAbiParameters(
356
- [
357
- { type: "address" },
358
- { type: "uint256" },
359
- { type: "bytes32" },
360
- { type: "bytes32" },
361
- { type: "uint256" },
362
- { type: "uint256" },
363
- { type: "uint256" },
364
- { type: "uint256" },
365
- { type: "uint256" },
366
- { type: "bytes32" },
367
- ],
368
- [
369
- userOp.sender,
370
- userOp.nonce,
371
- keccak256(userOp.initCode),
372
- keccak256(userOp.callData),
373
- userOp.callGasLimit,
374
- userOp.verificationGasLimit,
375
- userOp.preVerificationGas,
376
- userOp.maxFeePerGas,
377
- userOp.maxPriorityFeePerGas,
378
- keccak256(userOp.paymasterAndData),
379
- ]
380
- );
381
-
382
- const packedHash = keccak256(packed);
383
-
384
- return keccak256(
385
- encodeAbiParameters(
386
- [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }],
387
- [packedHash, this.entryPointAddress, BigInt(this.chainConfig.chain.id)]
388
- )
389
- );
390
- }
391
-
392
- /**
393
- * Sign a UserOperation with MetaMask
394
- */
395
- async signUserOperation(userOp: UserOperation): Promise<UserOperation> {
396
- if (!this.owner) {
397
- throw new Error("Not connected");
398
- }
399
-
400
- const userOpHash = this.getUserOpHash(userOp);
401
-
402
- // Sign with MetaMask using personal_sign (EIP-191)
403
- const signature = (await window.ethereum!.request({
404
- method: "personal_sign",
405
- params: [userOpHash, this.owner],
406
- })) as Hex;
407
-
408
- return {
409
- ...userOp,
410
- signature,
411
- };
412
- }
413
-
414
- /**
415
- * Send a signed UserOperation to the bundler
416
- */
417
- async sendUserOperation(userOp: UserOperation): Promise<Hash> {
418
- return this.bundlerClient.sendUserOperation(userOp);
419
- }
420
-
421
- /**
422
- * Wait for a UserOperation to be confirmed
423
- */
424
- async waitForUserOperation(
425
- userOpHash: Hash,
426
- timeout = 60000
427
- ): Promise<UserOpReceipt> {
428
- return this.bundlerClient.waitForUserOperation(userOpHash, timeout);
429
- }
430
-
431
-
432
- /**
433
- * Request support for token approval (fund if needed)
434
- */
435
- async requestApprovalSupport(
436
- token: Address,
437
- spender: Address,
438
- amount: bigint
439
- ): Promise<ApprovalSupportResult> {
440
- if (!this.owner) {
441
- throw new Error("Not connected");
442
- }
443
- return this.bundlerClient.requestApprovalSupport(token, this.owner, spender, amount);
444
- }
445
-
446
- /**
447
- * Deploy the Smart Account
448
- */
449
- async deployAccount(): Promise<UserOpReceipt> {
450
- const userOp = await this.buildDeployUserOperation();
451
- const signed = await this.signUserOperation(userOp);
452
- const hash = await this.sendUserOperation(signed);
453
- return await this.waitForUserOperation(hash);
454
- }
455
-
456
- /**
457
- * Send a single transaction via the Smart Account
458
- * Abstracts: Build -> Sign -> Send -> Wait
459
- */
460
- async sendTransaction(
461
- tx: { target: Address; value?: bigint; data?: Hex }
462
- ): Promise<UserOpReceipt> {
463
- return this.sendBatchTransaction([tx]);
464
- }
465
-
466
- /**
467
- * Send multiple transactions via the Smart Account (Batched)
468
- * Abstracts: Build -> Sign -> Send -> Wait
469
- */
470
- async sendBatchTransaction(
471
- txs: { target: Address; value?: bigint; data?: Hex }[]
472
- ): Promise<UserOpReceipt> {
473
- // Normalize input (default value to 0, data to 0x)
474
- const transactions = txs.map(tx => ({
475
- target: tx.target,
476
- value: tx.value ?? 0n,
477
- data: tx.data ?? "0x"
478
- }));
479
-
480
- try {
481
- const userOp = await this.buildUserOperationBatch(transactions);
482
- const signed = await this.signUserOperation(userOp);
483
- const hash = await this.sendUserOperation(signed);
484
- return await this.waitForUserOperation(hash);
485
- } catch (error) {
486
- throw this.decodeError(error);
487
- }
488
- }
489
-
490
- /**
491
- * Try to decode meaningful errors from RPC or Revert data
492
- */
493
- private decodeError(error: any): Error {
494
- const msg = error?.message || "";
495
-
496
- // 1. Try to find hex data in the error message (UserOp Revert)
497
- // Look for 0x... in "data": "0x..." or in the message itself
498
- const hexMatch = msg.match(/(0x[0-9a-fA-F]+)/);
499
-
500
- if (hexMatch) {
501
- try {
502
- // Try decoding as standard Error(string)
503
- const decoded = decodeErrorResult({
504
- abi: [
505
- {
506
- inputs: [{ name: "message", type: "string" }],
507
- name: "Error",
508
- type: "error"
509
- },
510
- // Add common EntryPoint errors if known, but generic Reverts are most common
511
- ],
512
- data: hexMatch[0] as Hex
513
- });
514
-
515
- if (decoded.errorName === "Error") {
516
- return new Error(`Smart Account Error: ${decoded.args[0]}`);
517
- }
518
- } catch (e) {
519
- // Failed to decode, stick to original
520
- }
521
- }
522
-
523
- // 2. Common EntryPoint error mapping (simplified)
524
- if (msg.includes("AA21")) return new Error("Smart Account: Native transfer failed (ETH missing?)");
525
- if (msg.includes("AA25")) return new Error("Smart Account: Invalid account nonce");
526
-
527
- return error instanceof Error ? error : new Error(String(error));
528
- }
529
-
530
- /**
531
- * Approve a token for the Smart Account (EOA -> Token -> Smart Account)
532
- * Checks for gas sponsorship (Relayer funding) if needed.
533
- */
534
- async approveToken(
535
- token: Address,
536
- spender: Address,
537
- amount: bigint = 115792089237316195423570985008687907853269984665640564039457584007913129639935n // maxUint256
538
- ): Promise<Hash | "NOT_NEEDED"> {
539
- if (!this.owner) throw new Error("Not connected");
540
-
541
- // 1. Check if we need funding
542
- const support = await this.requestApprovalSupport(token, spender, amount);
543
-
544
- if (support.type === "approve") {
545
- // 2. Encode approve data
546
- const data = encodeFunctionData({
547
- abi: erc20Abi,
548
- functionName: "approve",
549
- args: [spender, amount]
550
- });
551
-
552
- // 3. Send transaction via Wallet (MetaMask)
553
- // If funding was needed, the Relayer has already sent ETH to this.owner
554
- const txHash = await window.ethereum!.request({
555
- method: "eth_sendTransaction",
556
- params: [{
557
- from: this.owner,
558
- to: token,
559
- data,
560
- }]
561
- }) as Hash;
562
-
563
- return txHash;
564
- }
565
-
566
- if (support.type === "permit") {
567
- throw new Error("Permit not yet supported in this SDK version");
568
- }
569
-
570
- return "NOT_NEEDED";
571
- }
572
-
573
- /**
574
- * Transfer ERC-20 tokens from the Smart Account to a recipient
575
- */
576
- async transfer(
577
- token: Address,
578
- recipient: Address,
579
- amount: bigint
580
- ): Promise<UserOpReceipt> {
581
- const data = encodeFunctionData({
582
- abi: erc20Abi,
583
- functionName: "transfer",
584
- args: [recipient, amount]
585
- });
586
-
587
- return this.sendTransaction({
588
- target: token,
589
- value: 0n,
590
- data
591
- });
592
- }
593
-
594
- // Getters
595
- getOwner(): Address | null {
596
- return this.owner;
597
- }
598
-
599
- getSmartAccount(): Address | null {
600
- return this.smartAccountAddress;
601
- }
602
- }
603
-
604
- // Global window types for MetaMask
605
- declare global {
606
- interface Window {
607
- ethereum?: {
608
- request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
609
- on: (event: string, callback: (args: unknown) => void) => void;
610
- removeListener: (event: string, callback: (args: unknown) => void) => void;
611
- };
612
- }
613
- }
@@ -1,93 +0,0 @@
1
- import { type Address, type Hash, type Hex } from "viem";
2
- import { type ChainConfig, type UserOperation, type GasEstimate, type UserOpReceipt, type ApprovalSupportResult } from "./types";
3
- import { entryPointAbi } from "./constants";
4
-
5
- export class BundlerClient {
6
- private bundlerUrl: string;
7
- private chainId: number;
8
- private entryPointAddress: Address;
9
-
10
- constructor(config: ChainConfig, entryPointAddress: Address) {
11
- this.bundlerUrl = config.bundlerUrl;
12
- this.chainId = config.chain.id;
13
- this.entryPointAddress = entryPointAddress;
14
- }
15
-
16
- private async call(method: string, params: any[]): Promise<any> {
17
- const response = await fetch(this.bundlerUrl, {
18
- method: "POST",
19
- headers: { "Content-Type": "application/json" },
20
- body: JSON.stringify({
21
- jsonrpc: "2.0",
22
- id: 1,
23
- method,
24
- params,
25
- }),
26
- });
27
-
28
- const result = await response.json();
29
- if (result.error) {
30
- throw new Error(result.error.message);
31
- }
32
- return result.result;
33
- }
34
-
35
- async estimateGas(userOp: Partial<UserOperation>): Promise<GasEstimate> {
36
- return await this.call("eth_estimateUserOperationGas", [
37
- {
38
- sender: userOp.sender,
39
- nonce: userOp.nonce ? "0x" + userOp.nonce.toString(16) : "0x0",
40
- initCode: userOp.initCode || "0x",
41
- callData: userOp.callData || "0x",
42
- paymasterAndData: userOp.paymasterAndData || "0x",
43
- signature: "0x",
44
- },
45
- this.entryPointAddress,
46
- ]);
47
- }
48
-
49
- async sendUserOperation(userOp: UserOperation): Promise<Hash> {
50
- return await this.call("eth_sendUserOperation", [
51
- {
52
- sender: userOp.sender,
53
- nonce: "0x" + userOp.nonce.toString(16),
54
- initCode: userOp.initCode,
55
- callData: userOp.callData,
56
- callGasLimit: "0x" + userOp.callGasLimit.toString(16),
57
- verificationGasLimit: "0x" + userOp.verificationGasLimit.toString(16),
58
- preVerificationGas: "0x" + userOp.preVerificationGas.toString(16),
59
- maxFeePerGas: "0x" + userOp.maxFeePerGas.toString(16),
60
- maxPriorityFeePerGas: "0x" + userOp.maxPriorityFeePerGas.toString(16),
61
- paymasterAndData: userOp.paymasterAndData,
62
- signature: userOp.signature,
63
- },
64
- this.entryPointAddress,
65
- ]);
66
- }
67
-
68
- async waitForUserOperation(userOpHash: Hash, timeout = 60000): Promise<UserOpReceipt> {
69
- const startTime = Date.now();
70
-
71
- while (Date.now() - startTime < timeout) {
72
- const result = await this.call("eth_getUserOperationReceipt", [userOpHash]);
73
-
74
- if (result) {
75
- return result as UserOpReceipt;
76
- }
77
-
78
- // Wait 2 seconds before polling again
79
- await new Promise((resolve) => setTimeout(resolve, 2000));
80
- }
81
-
82
- throw new Error("Timeout waiting for UserOperation");
83
- }
84
-
85
- async requestApprovalSupport(token: Address, owner: Address, spender: Address, amount: bigint): Promise<ApprovalSupportResult> {
86
- return await this.call("pm_requestApprovalSupport", [
87
- token,
88
- owner,
89
- spender,
90
- amount.toString()
91
- ]);
92
- }
93
- }