@1llet.xyz/erc4337-gasless-sdk 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -17,20 +17,14 @@ yarn add @1llet.xyz/erc4337-gasless-sdk viem
17
17
  Define the chain configuration (including your Bundler URL and Paymaster).
18
18
 
19
19
  ```typescript
20
- import { type ChainConfig } from "@1llet.xyz/erc4337-gasless-sdk";
21
- import { baseSepolia } from "viem/chains";
22
-
23
- const config: ChainConfig = {
24
- chain: baseSepolia,
25
- // Your Bundler URL (must support ERC-4337 methods)
26
- bundlerUrl: "https://api.yourbundler.com/rpc",
27
- // Optional: Override RPC URL (defaults to chain.rpcUrls.default)
28
- // rpcUrl: "https://sepolia.base.org",
29
-
30
- // Addresses are automatically resolved for supported chains (Base, Base Sepolia)
31
- // You can override them if needed:
32
- // factoryAddress: "0x...",
33
- };
20
+ // 1. Import Config (Chain Registry)
21
+ import { BASE_SEPOLIA, type ChainConfig } from "@1llet.xyz/erc4337-gasless-sdk";
22
+ import { AccountAbstraction } from "@1llet.xyz/erc4337-gasless-sdk";
23
+
24
+ // 2. Initialize
25
+ const aa = new AccountAbstraction(BASE_SEPOLIA);
26
+
27
+ await aa.connect();
34
28
  ```
35
29
 
36
30
  ### 2. Initialize & Connect
@@ -163,14 +157,31 @@ const receipt = await aa.sendBatchTransaction([
163
157
  ]);
164
158
 
165
159
  // 3. Transfer ERC-20 Tokens (Helper)
166
- // Automatically encodes the transfer call
167
- const receipt = await aa.transfer(
168
- usdcAddress,
169
- recipientAddress,
170
- 1000000n // 1 USDC
171
- );
160
+ // Automatically encodes the
161
+ // 1. Transfer ERC-20 (USDC)
162
+ await aa.transfer("USDC", recipient, amount);
163
+
164
+ // 2. Transfer Native Token (ETH)
165
+ // The SDK detects the "ETH" symbol and sends a native transaction
166
+ await aa.transfer("ETH", recipient, amount);
167
+ ```
168
+
169
+ // 2. Transfer Native Token (ETH)
170
+ // The SDK detects the "ETH" symbol and sends a native transaction
171
+ await aa.transfer("ETH", recipient, amount);
172
172
  ```
173
173
 
174
+ ### Funding the Account
175
+
176
+ Easily deposit ETH from the connected wallet (EOA) to the Smart Account.
177
+
178
+ ```typescript
179
+ // Deposit 0.1 ETH
180
+ const txHash = await aa.deposit(100000000000000000n);
181
+ ```
182
+
183
+ ### High-Level Methods
184
+
174
185
  ### Error Decoding
175
186
  The SDK now automatically tries to decode cryptic "0x..." errors from the EntryPoint into readable messages like:
176
187
  - `Smart Account Error: Transfer amount exceeds balance`
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.0",
6
+ "version": "0.4.0",
7
7
  "description": "SDK for ERC-4337 Gasless Transfers",
8
8
  "main": "./dist/index.js",
9
9
  "module": "./dist/index.mjs",
@@ -1,9 +1,6 @@
1
1
  import {
2
2
  createPublicClient,
3
3
  http,
4
- encodeFunctionData,
5
- encodeAbiParameters,
6
- keccak256,
7
4
  type Address,
8
5
  type Hash,
9
6
  type Hex,
@@ -12,19 +9,17 @@ import {
12
9
  } from "viem";
13
10
  import {
14
11
  factoryAbi,
15
- entryPointAbi,
16
- smartAccountAbi,
17
- erc20Abi,
18
12
  } from "./constants";
19
13
  import {
20
14
  type ChainConfig,
21
15
  type UserOperation,
22
- type GasEstimate,
23
16
  type UserOpReceipt,
24
- type ApprovalSupportResult
17
+ type ApprovalSupportResult,
18
+ type Token
25
19
  } from "./types";
26
- import { DEPLOYMENTS } from "./deployments";
27
20
  import { BundlerClient } from "./BundlerClient";
21
+ import { TokenService } from "./TokenService";
22
+ import { UserOpBuilder } from "./UserOpBuilder";
28
23
 
29
24
  /**
30
25
  * ERC-4337 Account Abstraction Client
@@ -36,41 +31,35 @@ export class AccountAbstraction {
36
31
  private publicClient: PublicClient;
37
32
  private bundlerClient: BundlerClient;
38
33
 
34
+ // Services
35
+ private tokenService: TokenService;
36
+ private userOpBuilder: UserOpBuilder;
37
+
39
38
  // Resolved addresses
40
39
  private entryPointAddress: Address;
41
40
  private factoryAddress: Address;
42
- private paymasterAddress?: Address;
43
- private usdcAddress: Address;
44
41
 
45
42
  constructor(chainConfig: ChainConfig) {
46
43
  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
44
 
63
- this.paymasterAddress = chainConfig.paymasterAddress || defaults?.paymaster;
45
+ // Validation
46
+ if (!chainConfig.entryPointAddress) throw new Error("EntryPoint address required");
47
+ this.entryPointAddress = chainConfig.entryPointAddress;
48
+ if (!chainConfig.factoryAddress) throw new Error("Factory address required");
49
+ this.factoryAddress = chainConfig.factoryAddress;
64
50
 
65
- // Use provided RPC or default from chain
51
+ // Setup Clients
66
52
  const rpcUrl = chainConfig.rpcUrl || chainConfig.chain.rpcUrls.default.http[0];
67
-
68
53
  this.publicClient = createPublicClient({
69
54
  chain: chainConfig.chain,
70
55
  transport: http(rpcUrl),
71
56
  });
72
57
 
73
58
  this.bundlerClient = new BundlerClient(chainConfig, this.entryPointAddress);
59
+
60
+ // Setup Services
61
+ this.tokenService = new TokenService(chainConfig, this.publicClient);
62
+ this.userOpBuilder = new UserOpBuilder(chainConfig, this.bundlerClient, this.publicClient);
74
63
  }
75
64
 
76
65
  /**
@@ -86,19 +75,15 @@ export class AccountAbstraction {
86
75
  method: "eth_requestAccounts",
87
76
  })) as string[];
88
77
 
89
- if (!accounts || accounts.length === 0) {
90
- throw new Error("No accounts found");
91
- }
78
+ if (!accounts || accounts.length === 0) throw new Error("No accounts found");
92
79
 
93
80
  // Check network
94
81
  const chainId = (await window.ethereum.request({
95
82
  method: "eth_chainId",
96
83
  })) as string;
97
-
98
84
  const targetChainId = this.chainConfig.chain.id;
99
85
 
100
86
  if (parseInt(chainId, 16) !== targetChainId) {
101
- // Switch to configured chain
102
87
  try {
103
88
  await window.ethereum.request({
104
89
  method: "wallet_switchEthereumChain",
@@ -106,7 +91,6 @@ export class AccountAbstraction {
106
91
  });
107
92
  } catch (switchError: unknown) {
108
93
  const error = switchError as { code?: number };
109
- // Chain not added, add it
110
94
  if (error.code === 4902) {
111
95
  await window.ethereum.request({
112
96
  method: "wallet_addEthereumChain",
@@ -115,7 +99,7 @@ export class AccountAbstraction {
115
99
  chainId: "0x" + targetChainId.toString(16),
116
100
  chainName: this.chainConfig.chain.name,
117
101
  nativeCurrency: this.chainConfig.chain.nativeCurrency,
118
- rpcUrls: [this.chainConfig.rpcUrl],
102
+ rpcUrls: [this.chainConfig.rpcUrl || this.chainConfig.chain.rpcUrls.default.http[0]],
119
103
  blockExplorerUrls: this.chainConfig.chain.blockExplorers?.default?.url
120
104
  ? [this.chainConfig.chain.blockExplorers.default.url]
121
105
  : [],
@@ -138,14 +122,14 @@ export class AccountAbstraction {
138
122
  }
139
123
 
140
124
  /**
141
- * Get the Smart Account address for an owner (counterfactual)
125
+ * Get the Smart Account address for an owner
142
126
  */
143
127
  async getSmartAccountAddress(owner: Address): Promise<Address> {
144
128
  const address = await this.publicClient.readContract({
145
129
  address: this.factoryAddress,
146
130
  abi: factoryAbi,
147
131
  functionName: "getAccountAddress",
148
- args: [owner, 0n], // salt = 0
132
+ args: [owner, 0n],
149
133
  }) as Address;
150
134
  return address;
151
135
  }
@@ -154,323 +138,58 @@ export class AccountAbstraction {
154
138
  * Check if the Smart Account is deployed
155
139
  */
156
140
  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";
141
+ if (!this.smartAccountAddress) throw new Error("Not connected");
142
+ return this.userOpBuilder.isAccountDeployed(this.smartAccountAddress);
165
143
  }
166
144
 
145
+ // --- Token Methods (Delegated) ---
167
146
 
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;
147
+ getTokenAddress(token: string | Address): Address {
148
+ return this.tokenService.getTokenAddress(token);
182
149
  }
183
150
 
151
+ async getUsdcBalance(): Promise<bigint> {
152
+ if (!this.smartAccountAddress) throw new Error("Not connected");
153
+ return this.tokenService.getBalance("USDC", this.smartAccountAddress);
154
+ }
184
155
 
185
- /**
186
- * Get the EOA's USDC balance
187
- */
188
156
  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;
157
+ if (!this.owner) throw new Error("Not connected");
158
+ return this.tokenService.getBalance("USDC", this.owner);
199
159
  }
200
160
 
201
- /**
202
- * Get the allowance of the Smart Account to spend the EOA's USDC
203
- */
204
161
  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
- };
162
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
163
+ return this.tokenService.getAllowance("USDC", this.owner, this.smartAccountAddress);
412
164
  }
413
165
 
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
- }
166
+ // --- Transactions ---
430
167
 
168
+ async deployAccount(): Promise<UserOpReceipt> {
169
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
431
170
 
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");
171
+ try {
172
+ const userOp = await this.userOpBuilder.buildDeployUserOp(this.owner, this.smartAccountAddress);
173
+ const signed = await this.signUserOperation(userOp);
174
+ const hash = await this.sendUserOperation(signed);
175
+ return await this.waitForUserOperation(hash);
176
+ } catch (error) {
177
+ throw this.decodeError(error);
442
178
  }
443
- return this.bundlerClient.requestApprovalSupport(token, this.owner, spender, amount);
444
179
  }
445
180
 
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
181
  async sendTransaction(
461
182
  tx: { target: Address; value?: bigint; data?: Hex }
462
183
  ): Promise<UserOpReceipt> {
463
184
  return this.sendBatchTransaction([tx]);
464
185
  }
465
186
 
466
- /**
467
- * Send multiple transactions via the Smart Account (Batched)
468
- * Abstracts: Build -> Sign -> Send -> Wait
469
- */
470
187
  async sendBatchTransaction(
471
188
  txs: { target: Address; value?: bigint; data?: Hex }[]
472
189
  ): Promise<UserOpReceipt> {
473
- // Normalize input (default value to 0, data to 0x)
190
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
191
+
192
+ // Normalize
474
193
  const transactions = txs.map(tx => ({
475
194
  target: tx.target,
476
195
  value: tx.value ?? 0n,
@@ -478,7 +197,11 @@ export class AccountAbstraction {
478
197
  }));
479
198
 
480
199
  try {
481
- const userOp = await this.buildUserOperationBatch(transactions);
200
+ const userOp = await this.userOpBuilder.buildUserOperationBatch(
201
+ this.owner,
202
+ this.smartAccountAddress,
203
+ transactions
204
+ );
482
205
  const signed = await this.signUserOperation(userOp);
483
206
  const hash = await this.sendUserOperation(signed);
484
207
  return await this.waitForUserOperation(hash);
@@ -487,49 +210,47 @@ export class AccountAbstraction {
487
210
  }
488
211
  }
489
212
 
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
- });
213
+ async deposit(amount: bigint): Promise<Hash> {
214
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
215
+
216
+ const txHash = await window.ethereum!.request({
217
+ method: "eth_sendTransaction",
218
+ params: [{
219
+ from: this.owner,
220
+ to: this.smartAccountAddress,
221
+ value: "0x" + amount.toString(16)
222
+ }]
223
+ }) as Hash;
224
+ return txHash;
225
+ }
514
226
 
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
- }
227
+ async transfer(
228
+ token: Address | string,
229
+ recipient: Address,
230
+ amount: bigint
231
+ ): Promise<UserOpReceipt> {
232
+ const tokenAddress = this.getTokenAddress(token);
233
+
234
+ // Native Transfer check
235
+ if (tokenAddress === "0x0000000000000000000000000000000000000000") {
236
+ return this.sendTransaction({
237
+ target: recipient,
238
+ value: amount,
239
+ data: "0x"
240
+ });
521
241
  }
522
242
 
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));
243
+ // ERC-20
244
+ const data = this.tokenService.encodeTransfer(recipient, amount);
245
+ return this.sendTransaction({
246
+ target: tokenAddress,
247
+ value: 0n,
248
+ data
249
+ });
528
250
  }
529
251
 
530
252
  /**
531
- * Approve a token for the Smart Account (EOA -> Token -> Smart Account)
532
- * Checks for gas sponsorship (Relayer funding) if needed.
253
+ * Approve a token for the Smart Account
533
254
  */
534
255
  async approveToken(
535
256
  token: Address,
@@ -538,19 +259,10 @@ export class AccountAbstraction {
538
259
  ): Promise<Hash | "NOT_NEEDED"> {
539
260
  if (!this.owner) throw new Error("Not connected");
540
261
 
541
- // 1. Check if we need funding
542
262
  const support = await this.requestApprovalSupport(token, spender, amount);
543
263
 
544
264
  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
265
+ const data = this.tokenService.encodeApprove(spender, amount);
554
266
  const txHash = await window.ethereum!.request({
555
267
  method: "eth_sendTransaction",
556
268
  params: [{
@@ -559,46 +271,77 @@ export class AccountAbstraction {
559
271
  data,
560
272
  }]
561
273
  }) as Hash;
562
-
563
274
  return txHash;
564
275
  }
565
276
 
566
- if (support.type === "permit") {
567
- throw new Error("Permit not yet supported in this SDK version");
568
- }
569
-
277
+ if (support.type === "permit") throw new Error("Permit not yet supported");
570
278
  return "NOT_NEEDED";
571
279
  }
572
280
 
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
- });
281
+ // --- Core Bridge to Bundler/UserOp ---
282
+
283
+ // Deprecated/Legacy but kept for compatibility or advanced usage?
284
+ // buildUserOperationBatch moved to internal usage mostly, but maybe exposed?
285
+ // If I remove them from public API, that is a BREAKING change if user used them.
286
+ // User requested "modularize", but usually expects same public API.
287
+ // I will expose them as simple delegates if needed, or assume they primarily use sendBatchTransaction.
288
+ // The previous implementation exposed `buildUserOperationBatch`.
289
+ async buildUserOperationBatch(transactions: any[]) {
290
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
291
+ return this.userOpBuilder.buildUserOperationBatch(this.owner, this.smartAccountAddress, transactions);
292
+ }
586
293
 
587
- return this.sendTransaction({
588
- target: token,
589
- value: 0n,
590
- data
591
- });
294
+ async signUserOperation(userOp: UserOperation): Promise<UserOperation> {
295
+ if (!this.owner) throw new Error("Not connected");
296
+
297
+ const userOpHash = this.userOpBuilder.getUserOpHash(userOp);
298
+
299
+ const signature = (await window.ethereum!.request({
300
+ method: "personal_sign",
301
+ params: [userOpHash, this.owner],
302
+ })) as Hex;
303
+
304
+ return { ...userOp, signature };
592
305
  }
593
306
 
594
- // Getters
595
- getOwner(): Address | null {
596
- return this.owner;
307
+ async sendUserOperation(userOp: UserOperation): Promise<Hash> {
308
+ return this.bundlerClient.sendUserOperation(userOp);
597
309
  }
598
310
 
599
- getSmartAccount(): Address | null {
600
- return this.smartAccountAddress;
311
+ async waitForUserOperation(hash: Hash, timeout = 60000) {
312
+ return this.bundlerClient.waitForUserOperation(hash, timeout);
313
+ }
314
+
315
+ // Internal but exposed via BundlerClient originally
316
+ async requestApprovalSupport(token: Address, spender: Address, amount: bigint): Promise<ApprovalSupportResult> {
317
+ if (!this.owner) throw new Error("Not connected");
318
+ return this.bundlerClient.requestApprovalSupport(token, this.owner, spender, amount);
319
+ }
320
+
321
+ // Error Decoding (Private)
322
+ private decodeError(error: any): Error {
323
+ const msg = error?.message || "";
324
+ const hexMatch = msg.match(/(0x[0-9a-fA-F]+)/);
325
+
326
+ if (hexMatch) {
327
+ try {
328
+ const decoded = decodeErrorResult({
329
+ abi: [{ inputs: [{ name: "message", type: "string" }], name: "Error", type: "error" }],
330
+ data: hexMatch[0] as Hex
331
+ });
332
+ if (decoded.errorName === "Error") return new Error(`Smart Account Error: ${decoded.args[0]}`);
333
+ } catch (e) { /* ignore */ }
334
+ }
335
+
336
+ if (msg.includes("AA21")) return new Error("Smart Account: Native transfer failed (ETH missing?)");
337
+ if (msg.includes("AA25")) return new Error("Smart Account: Invalid account nonce");
338
+
339
+ return error instanceof Error ? error : new Error(String(error));
601
340
  }
341
+
342
+ // Getters
343
+ getOwner() { return this.owner; }
344
+ getSmartAccount() { return this.smartAccountAddress; }
602
345
  }
603
346
 
604
347
  // Global window types for MetaMask
@@ -0,0 +1,92 @@
1
+ import { type Address, type PublicClient, encodeFunctionData } from "viem";
2
+ import { type ChainConfig, type Token } from "./types";
3
+ import { erc20Abi } from "./constants";
4
+
5
+ export class TokenService {
6
+ private tokens: Map<string, Token> = new Map();
7
+ private publicClient: PublicClient;
8
+
9
+ constructor(chainConfig: ChainConfig, publicClient: PublicClient) {
10
+ this.publicClient = publicClient;
11
+
12
+ // Initialize Tokens
13
+ chainConfig.tokens.forEach(token => {
14
+ this.tokens.set(token.symbol.toUpperCase(), token);
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Resolve token address from symbol or return address if provided
20
+ */
21
+ getTokenAddress(token: string | Address): Address {
22
+ // Native Token (ETH)
23
+ if (token === "ETH") {
24
+ return "0x0000000000000000000000000000000000000000";
25
+ }
26
+
27
+ if (token.startsWith("0x")) return token as Address;
28
+ const info = this.tokens.get(token.toUpperCase());
29
+ if (!info) throw new Error(`Token ${token} not found in chain config`);
30
+ return info.address;
31
+ }
32
+
33
+ /**
34
+ * Get balance of a token for an account
35
+ */
36
+ async getBalance(token: string | Address, account: Address): Promise<bigint> {
37
+ const address = this.getTokenAddress(token);
38
+
39
+ // Native Balance
40
+ if (address === "0x0000000000000000000000000000000000000000") {
41
+ return await this.publicClient.getBalance({ address: account });
42
+ }
43
+
44
+ // ERC-20 Balance
45
+ return await this.publicClient.readContract({
46
+ address,
47
+ abi: erc20Abi,
48
+ functionName: "balanceOf",
49
+ args: [account],
50
+ }) as bigint;
51
+ }
52
+
53
+ /**
54
+ * Get allowance (ERC-20 only)
55
+ */
56
+ async getAllowance(token: string | Address, owner: Address, spender: Address): Promise<bigint> {
57
+ const address = this.getTokenAddress(token);
58
+
59
+ if (address === "0x0000000000000000000000000000000000000000") {
60
+ return 0n; // Native token has no allowance
61
+ }
62
+
63
+ return await this.publicClient.readContract({
64
+ address,
65
+ abi: erc20Abi,
66
+ functionName: "allowance",
67
+ args: [owner, spender],
68
+ }) as bigint;
69
+ }
70
+
71
+ /**
72
+ * Encode transfer data
73
+ */
74
+ encodeTransfer(recipient: Address, amount: bigint): `0x${string}` {
75
+ return encodeFunctionData({
76
+ abi: erc20Abi,
77
+ functionName: "transfer",
78
+ args: [recipient, amount]
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Encode approve data
84
+ */
85
+ encodeApprove(spender: Address, amount: bigint): `0x${string}` {
86
+ return encodeFunctionData({
87
+ abi: erc20Abi,
88
+ functionName: "approve",
89
+ args: [spender, amount]
90
+ });
91
+ }
92
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ type Address,
3
+ type Hash,
4
+ type Hex,
5
+ type PublicClient,
6
+ encodeFunctionData,
7
+ encodeAbiParameters,
8
+ keccak256
9
+ } from "viem";
10
+ import { type ChainConfig, type UserOperation, type GasEstimate } from "./types";
11
+ import { BundlerClient } from "./BundlerClient";
12
+ import { factoryAbi, smartAccountAbi, entryPointAbi } from "./constants";
13
+
14
+ export class UserOpBuilder {
15
+ private chainConfig: ChainConfig;
16
+ private bundlerClient: BundlerClient;
17
+ private publicClient: PublicClient;
18
+ private entryPointAddress: Address;
19
+ private factoryAddress: Address;
20
+
21
+ constructor(
22
+ chainConfig: ChainConfig,
23
+ bundlerClient: BundlerClient,
24
+ publicClient: PublicClient
25
+ ) {
26
+ this.chainConfig = chainConfig;
27
+ this.bundlerClient = bundlerClient;
28
+ this.publicClient = publicClient;
29
+
30
+ // Resolved in AA or here? Let's assume passed valid config or resolve again
31
+ // Ideally we shouldn't duplicate logic. AA resolves them.
32
+ // Let's rely on config having them or resolving valid ones.
33
+ // For now take from config or defaults.
34
+ this.entryPointAddress = chainConfig.entryPointAddress!; // Assumed validated by AA
35
+ this.factoryAddress = chainConfig.factoryAddress!;
36
+ }
37
+
38
+ async getNonce(smartAccountAddress: Address): Promise<bigint> {
39
+ return await this.publicClient.readContract({
40
+ address: this.entryPointAddress,
41
+ abi: entryPointAbi,
42
+ functionName: "getNonce",
43
+ args: [smartAccountAddress, 0n],
44
+ }) as bigint;
45
+ }
46
+
47
+ buildInitCode(owner: Address): Hex {
48
+ const createAccountData = encodeFunctionData({
49
+ abi: factoryAbi,
50
+ functionName: "createAccount",
51
+ args: [owner, 0n],
52
+ });
53
+ return `${this.factoryAddress}${createAccountData.slice(2)}` as Hex;
54
+ }
55
+
56
+ async isAccountDeployed(smartAccountAddress: Address): Promise<boolean> {
57
+ const code = await this.publicClient.getCode({
58
+ address: smartAccountAddress,
59
+ });
60
+ return code !== undefined && code !== "0x";
61
+ }
62
+
63
+ async buildUserOperationBatch(
64
+ owner: Address,
65
+ smartAccountAddress: Address,
66
+ transactions: { target: Address; value: bigint; data: Hex }[]
67
+ ): Promise<UserOperation> {
68
+ const isDeployed = await this.isAccountDeployed(smartAccountAddress);
69
+ const initCode = isDeployed ? "0x" : this.buildInitCode(owner);
70
+
71
+ const targets = transactions.map((tx) => tx.target);
72
+ const values = transactions.map((tx) => tx.value);
73
+ const datas = transactions.map((tx) => tx.data);
74
+
75
+ const callData = encodeFunctionData({
76
+ abi: smartAccountAbi,
77
+ functionName: "executeBatch",
78
+ args: [targets, values, datas],
79
+ });
80
+
81
+ const nonce = await this.getNonce(smartAccountAddress);
82
+
83
+ const partialOp = {
84
+ sender: smartAccountAddress,
85
+ nonce,
86
+ initCode: initCode as Hex,
87
+ callData,
88
+ paymasterAndData: (this.chainConfig.paymasterAddress || "0x") as Hex,
89
+ };
90
+
91
+ const gasEstimate = await this.bundlerClient.estimateGas(partialOp);
92
+
93
+ return {
94
+ ...partialOp,
95
+ callGasLimit: BigInt(gasEstimate.callGasLimit),
96
+ verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
97
+ preVerificationGas: BigInt(gasEstimate.preVerificationGas),
98
+ maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
99
+ maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
100
+ signature: "0x",
101
+ };
102
+ }
103
+
104
+ async buildDeployUserOp(
105
+ owner: Address,
106
+ smartAccountAddress: Address
107
+ ): Promise<UserOperation> {
108
+ const isDeployed = await this.isAccountDeployed(smartAccountAddress);
109
+ if (isDeployed) throw new Error("Account already deployed");
110
+
111
+ const initCode = this.buildInitCode(owner);
112
+ const callData = "0x";
113
+ const nonce = await this.getNonce(smartAccountAddress);
114
+
115
+ const partialOp = {
116
+ sender: smartAccountAddress,
117
+ nonce,
118
+ initCode: initCode as Hex,
119
+ callData: callData as Hex,
120
+ paymasterAndData: (this.chainConfig.paymasterAddress || "0x") as Hex,
121
+ };
122
+
123
+ const gasEstimate = await this.bundlerClient.estimateGas(partialOp);
124
+
125
+ return {
126
+ ...partialOp,
127
+ callGasLimit: BigInt(gasEstimate.callGasLimit),
128
+ verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
129
+ preVerificationGas: BigInt(gasEstimate.preVerificationGas),
130
+ maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
131
+ maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
132
+ signature: "0x",
133
+ };
134
+ }
135
+
136
+ getUserOpHash(userOp: UserOperation): Hex {
137
+ const packed = encodeAbiParameters(
138
+ [
139
+ { type: "address" },
140
+ { type: "uint256" },
141
+ { type: "bytes32" },
142
+ { type: "bytes32" },
143
+ { type: "uint256" },
144
+ { type: "uint256" },
145
+ { type: "uint256" },
146
+ { type: "uint256" },
147
+ { type: "uint256" },
148
+ { type: "bytes32" },
149
+ ],
150
+ [
151
+ userOp.sender,
152
+ userOp.nonce,
153
+ keccak256(userOp.initCode),
154
+ keccak256(userOp.callData),
155
+ userOp.callGasLimit,
156
+ userOp.verificationGasLimit,
157
+ userOp.preVerificationGas,
158
+ userOp.maxFeePerGas,
159
+ userOp.maxPriorityFeePerGas,
160
+ keccak256(userOp.paymasterAndData),
161
+ ]
162
+ );
163
+
164
+ const packedHash = keccak256(packed);
165
+
166
+ return keccak256(
167
+ encodeAbiParameters(
168
+ [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }],
169
+ [packedHash, this.entryPointAddress, BigInt(this.chainConfig.chain.id)]
170
+ )
171
+ );
172
+ }
173
+ }
package/src/chains.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { type ChainConfig } from "./types";
2
+ import { base, baseSepolia } from "viem/chains";
3
+
4
+ export const BASE_MAINNET: ChainConfig = {
5
+ chain: base,
6
+ bundlerUrl: "http://localhost:3000/rpc?chain=base", // Default to local bundler pattern
7
+
8
+ // Addresses
9
+ entryPointAddress: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
10
+ factoryAddress: "0xe2584152891E4769025807DEa0cD611F135aDC68",
11
+ paymasterAddress: "0x1e13Eb16C565E3f3FDe49A011755e50410bb1F95",
12
+
13
+ tokens: [
14
+ {
15
+ symbol: "USDC",
16
+ decimals: 6,
17
+ address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
18
+ },
19
+ {
20
+ symbol: "ETH",
21
+ decimals: 18,
22
+ address: "0x0000000000000000000000000000000000000000"
23
+ }
24
+ ]
25
+ };
26
+
27
+ export const BASE_SEPOLIA: ChainConfig = {
28
+ chain: baseSepolia,
29
+ bundlerUrl: "http://localhost:3000/rpc?chain=baseSepolia", // Default to local bundler pattern
30
+
31
+ // Addresses
32
+ entryPointAddress: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
33
+ factoryAddress: "0x9406Cc6185a346906296840746125a0E44976454",
34
+ // Paymaster not configured in deployments.ts for Sepolia?
35
+
36
+ tokens: [
37
+ {
38
+ symbol: "USDC",
39
+ decimals: 6,
40
+ address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
41
+ },
42
+ {
43
+ symbol: "ETH",
44
+ decimals: 18,
45
+ address: "0x0000000000000000000000000000000000000000"
46
+ }
47
+ ]
48
+ };
49
+
50
+ // Map accessible by ChainID
51
+ export const CHAIN_CONFIGS: Record<number, ChainConfig> = {
52
+ [base.id]: BASE_MAINNET,
53
+ [baseSepolia.id]: BASE_SEPOLIA
54
+ };
package/src/index.ts CHANGED
@@ -1,4 +1,12 @@
1
- export * from "./AccountAbstraction";
2
- export * from "./types";
3
- export * from "./constants";
4
- export * from "./deployments";
1
+ // Core
2
+ export { AccountAbstraction } from "./AccountAbstraction";
3
+ export { BundlerClient } from "./BundlerClient";
4
+
5
+ // Config & Registry
6
+ export { BASE_MAINNET, BASE_SEPOLIA, CHAIN_CONFIGS } from "./chains";
7
+
8
+ // Types
9
+ export type { ChainConfig, Token, UserOperation, UserOpReceipt } from "./types";
10
+
11
+ // Constants (ABIs)
12
+ export { erc20Abi, smartAccountAbi, entryPointAbi } from "./constants";
package/src/types.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { type Address, type Chain, type Hash, type Hex } from "viem";
2
2
 
3
+ export interface Token {
4
+ symbol: string;
5
+ decimals: number;
6
+ address: Address;
7
+ }
8
+
3
9
  export interface ChainConfig {
4
10
  chain: Chain;
5
11
  rpcUrl?: string; // Optional, defaults to chain.rpcUrls.default
@@ -7,7 +13,7 @@ export interface ChainConfig {
7
13
  entryPointAddress?: Address;
8
14
  factoryAddress?: Address;
9
15
  paymasterAddress?: Address;
10
- usdcAddress?: Address;
16
+ tokens: Token[];
11
17
  }
12
18
 
13
19
  export interface UserOperation {
@@ -1,22 +0,0 @@
1
- import { type Address } from "viem";
2
-
3
- export const DEPLOYMENTS: Record<number, {
4
- entryPoint: Address;
5
- factory: Address;
6
- paymaster?: Address;
7
- usdc: Address;
8
- }> = {
9
- // Base Mainnet
10
- 8453: {
11
- entryPoint: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
12
- factory: "0xe2584152891E4769025807DEa0cD611F135aDC68",
13
- paymaster: "0x1e13Eb16C565E3f3FDe49A011755e50410bb1F95",
14
- usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
15
- },
16
- // Base Sepolia
17
- 84532: {
18
- entryPoint: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
19
- factory: "0x9406Cc6185a346906296840746125a0E44976454",
20
- usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
21
- }
22
- };