@1llet.xyz/erc4337-gasless-sdk 0.1.7 → 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
@@ -143,6 +137,56 @@ const address = await aa.getSmartAccountAddress(ownerAddress);
143
137
  const receipt = await aa.deployAccount();
144
138
  ```
145
139
 
140
+ ### Simplified Transactions (v0.2.0+)
141
+
142
+ Send transactions without manually building, signing, and waiting.
143
+
144
+ ```typescript
145
+ // 1. Send ETH or Call Contract (Single)
146
+ const receipt = await aa.sendTransaction({
147
+ target: "0x123...",
148
+ value: 1000000000000000000n, // 1 ETH
149
+ data: "0x..." // Optional callData
150
+ });
151
+
152
+ // 2. Send Multiple Transactions (Batch)
153
+ // Great for approving + swapping, or multiple transfers
154
+ const receipt = await aa.sendBatchTransaction([
155
+ { target: "0xToken...", data: encodeApproveData },
156
+ { target: "0xSwap...", data: encodeSwapData }
157
+ ]);
158
+
159
+ // 3. Transfer ERC-20 Tokens (Helper)
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
+ ```
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
+
185
+ ### Error Decoding
186
+ The SDK now automatically tries to decode cryptic "0x..." errors from the EntryPoint into readable messages like:
187
+ - `Smart Account Error: Transfer amount exceeds balance`
188
+ - `Smart Account: Native transfer failed`
189
+
146
190
  ### Simplified Approvals
147
191
 
148
192
  ```typescript
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.7",
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,29 +1,25 @@
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,
10
- type PublicClient
7
+ type PublicClient,
8
+ decodeErrorResult
11
9
  } from "viem";
12
10
  import {
13
11
  factoryAbi,
14
- entryPointAbi,
15
- smartAccountAbi,
16
- erc20Abi,
17
12
  } from "./constants";
18
13
  import {
19
14
  type ChainConfig,
20
15
  type UserOperation,
21
- type GasEstimate,
22
16
  type UserOpReceipt,
23
- type ApprovalSupportResult
17
+ type ApprovalSupportResult,
18
+ type Token
24
19
  } from "./types";
25
- import { DEPLOYMENTS } from "./deployments";
26
20
  import { BundlerClient } from "./BundlerClient";
21
+ import { TokenService } from "./TokenService";
22
+ import { UserOpBuilder } from "./UserOpBuilder";
27
23
 
28
24
  /**
29
25
  * ERC-4337 Account Abstraction Client
@@ -35,41 +31,35 @@ export class AccountAbstraction {
35
31
  private publicClient: PublicClient;
36
32
  private bundlerClient: BundlerClient;
37
33
 
34
+ // Services
35
+ private tokenService: TokenService;
36
+ private userOpBuilder: UserOpBuilder;
37
+
38
38
  // Resolved addresses
39
39
  private entryPointAddress: Address;
40
40
  private factoryAddress: Address;
41
- private paymasterAddress?: Address;
42
- private usdcAddress: Address;
43
41
 
44
42
  constructor(chainConfig: ChainConfig) {
45
43
  this.chainConfig = chainConfig;
46
- const chainId = chainConfig.chain.id;
47
- const defaults = DEPLOYMENTS[chainId];
48
-
49
- // Resolve addresses (Config > Defaults > Error)
50
- const entryPoint = chainConfig.entryPointAddress || defaults?.entryPoint;
51
- if (!entryPoint) throw new Error(`EntryPoint address not found for chain ${chainId}`);
52
- this.entryPointAddress = entryPoint;
53
-
54
- const factory = chainConfig.factoryAddress || defaults?.factory;
55
- if (!factory) throw new Error(`Factory address not found for chain ${chainId}`);
56
- this.factoryAddress = factory;
57
44
 
58
- const usdc = chainConfig.usdcAddress || defaults?.usdc;
59
- if (!usdc) throw new Error(`USDC address not found for chain ${chainId}`);
60
- this.usdcAddress = usdc;
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;
61
50
 
62
- this.paymasterAddress = chainConfig.paymasterAddress || defaults?.paymaster;
63
-
64
- // Use provided RPC or default from chain
51
+ // Setup Clients
65
52
  const rpcUrl = chainConfig.rpcUrl || chainConfig.chain.rpcUrls.default.http[0];
66
-
67
53
  this.publicClient = createPublicClient({
68
54
  chain: chainConfig.chain,
69
55
  transport: http(rpcUrl),
70
56
  });
71
57
 
72
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);
73
63
  }
74
64
 
75
65
  /**
@@ -85,19 +75,15 @@ export class AccountAbstraction {
85
75
  method: "eth_requestAccounts",
86
76
  })) as string[];
87
77
 
88
- if (!accounts || accounts.length === 0) {
89
- throw new Error("No accounts found");
90
- }
78
+ if (!accounts || accounts.length === 0) throw new Error("No accounts found");
91
79
 
92
80
  // Check network
93
81
  const chainId = (await window.ethereum.request({
94
82
  method: "eth_chainId",
95
83
  })) as string;
96
-
97
84
  const targetChainId = this.chainConfig.chain.id;
98
85
 
99
86
  if (parseInt(chainId, 16) !== targetChainId) {
100
- // Switch to configured chain
101
87
  try {
102
88
  await window.ethereum.request({
103
89
  method: "wallet_switchEthereumChain",
@@ -105,7 +91,6 @@ export class AccountAbstraction {
105
91
  });
106
92
  } catch (switchError: unknown) {
107
93
  const error = switchError as { code?: number };
108
- // Chain not added, add it
109
94
  if (error.code === 4902) {
110
95
  await window.ethereum.request({
111
96
  method: "wallet_addEthereumChain",
@@ -114,7 +99,7 @@ export class AccountAbstraction {
114
99
  chainId: "0x" + targetChainId.toString(16),
115
100
  chainName: this.chainConfig.chain.name,
116
101
  nativeCurrency: this.chainConfig.chain.nativeCurrency,
117
- rpcUrls: [this.chainConfig.rpcUrl],
102
+ rpcUrls: [this.chainConfig.rpcUrl || this.chainConfig.chain.rpcUrls.default.http[0]],
118
103
  blockExplorerUrls: this.chainConfig.chain.blockExplorers?.default?.url
119
104
  ? [this.chainConfig.chain.blockExplorers.default.url]
120
105
  : [],
@@ -137,14 +122,14 @@ export class AccountAbstraction {
137
122
  }
138
123
 
139
124
  /**
140
- * Get the Smart Account address for an owner (counterfactual)
125
+ * Get the Smart Account address for an owner
141
126
  */
142
127
  async getSmartAccountAddress(owner: Address): Promise<Address> {
143
128
  const address = await this.publicClient.readContract({
144
129
  address: this.factoryAddress,
145
130
  abi: factoryAbi,
146
131
  functionName: "getAccountAddress",
147
- args: [owner, 0n], // salt = 0
132
+ args: [owner, 0n],
148
133
  }) as Address;
149
134
  return address;
150
135
  }
@@ -153,356 +138,210 @@ export class AccountAbstraction {
153
138
  * Check if the Smart Account is deployed
154
139
  */
155
140
  async isAccountDeployed(): Promise<boolean> {
156
- if (!this.smartAccountAddress) {
157
- throw new Error("Not connected");
158
- }
159
-
160
- const code = await this.publicClient.getCode({
161
- address: this.smartAccountAddress,
162
- });
163
- return code !== undefined && code !== "0x";
141
+ if (!this.smartAccountAddress) throw new Error("Not connected");
142
+ return this.userOpBuilder.isAccountDeployed(this.smartAccountAddress);
164
143
  }
165
144
 
145
+ // --- Token Methods (Delegated) ---
166
146
 
167
- /**
168
- * Get the USDC balance of the Smart Account
169
- */
170
- async getUsdcBalance(): Promise<bigint> {
171
- if (!this.smartAccountAddress) {
172
- throw new Error("Not connected");
173
- }
174
-
175
- return await this.publicClient.readContract({
176
- address: this.usdcAddress,
177
- abi: erc20Abi,
178
- functionName: "balanceOf",
179
- args: [this.smartAccountAddress],
180
- }) as bigint;
147
+ getTokenAddress(token: string | Address): Address {
148
+ return this.tokenService.getTokenAddress(token);
181
149
  }
182
150
 
151
+ async getUsdcBalance(): Promise<bigint> {
152
+ if (!this.smartAccountAddress) throw new Error("Not connected");
153
+ return this.tokenService.getBalance("USDC", this.smartAccountAddress);
154
+ }
183
155
 
184
- /**
185
- * Get the EOA's USDC balance
186
- */
187
156
  async getEoaUsdcBalance(): Promise<bigint> {
188
- if (!this.owner) {
189
- throw new Error("Not connected");
190
- }
191
-
192
- return await this.publicClient.readContract({
193
- address: this.usdcAddress,
194
- abi: erc20Abi,
195
- functionName: "balanceOf",
196
- args: [this.owner],
197
- }) as bigint;
157
+ if (!this.owner) throw new Error("Not connected");
158
+ return this.tokenService.getBalance("USDC", this.owner);
198
159
  }
199
160
 
200
- /**
201
- * Get the allowance of the Smart Account to spend the EOA's USDC
202
- */
203
161
  async getAllowance(): Promise<bigint> {
204
- if (!this.owner || !this.smartAccountAddress) {
205
- throw new Error("Not connected");
206
- }
207
-
208
- return await this.publicClient.readContract({
209
- address: this.usdcAddress,
210
- abi: erc20Abi,
211
- functionName: "allowance",
212
- args: [this.owner, this.smartAccountAddress],
213
- }) as bigint;
162
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
163
+ return this.tokenService.getAllowance("USDC", this.owner, this.smartAccountAddress);
214
164
  }
215
165
 
216
- /**
217
- * Get the nonce for the Smart Account
218
- */
219
- async getNonce(): Promise<bigint> {
220
- if (!this.smartAccountAddress) {
221
- throw new Error("Not connected");
166
+ // --- Transactions ---
167
+
168
+ async deployAccount(): Promise<UserOpReceipt> {
169
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
170
+
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);
222
178
  }
179
+ }
223
180
 
224
- return await this.publicClient.readContract({
225
- address: this.entryPointAddress,
226
- abi: entryPointAbi,
227
- functionName: "getNonce",
228
- args: [this.smartAccountAddress, 0n],
229
- }) as bigint;
181
+ async sendTransaction(
182
+ tx: { target: Address; value?: bigint; data?: Hex }
183
+ ): Promise<UserOpReceipt> {
184
+ return this.sendBatchTransaction([tx]);
230
185
  }
231
186
 
232
- /**
233
- * Build initCode for account deployment
234
- */
235
- buildInitCode(): Hex {
236
- if (!this.owner) {
237
- throw new Error("Not connected");
187
+ async sendBatchTransaction(
188
+ txs: { target: Address; value?: bigint; data?: Hex }[]
189
+ ): Promise<UserOpReceipt> {
190
+ if (!this.owner || !this.smartAccountAddress) throw new Error("Not connected");
191
+
192
+ // Normalize
193
+ const transactions = txs.map(tx => ({
194
+ target: tx.target,
195
+ value: tx.value ?? 0n,
196
+ data: tx.data ?? "0x"
197
+ }));
198
+
199
+ try {
200
+ const userOp = await this.userOpBuilder.buildUserOperationBatch(
201
+ this.owner,
202
+ this.smartAccountAddress,
203
+ transactions
204
+ );
205
+ const signed = await this.signUserOperation(userOp);
206
+ const hash = await this.sendUserOperation(signed);
207
+ return await this.waitForUserOperation(hash);
208
+ } catch (error) {
209
+ throw this.decodeError(error);
238
210
  }
239
-
240
- const createAccountData = encodeFunctionData({
241
- abi: factoryAbi,
242
- functionName: "createAccount",
243
- args: [this.owner, 0n],
244
- });
245
-
246
- return `${this.factoryAddress}${createAccountData.slice(2)}` as Hex;
247
211
  }
248
212
 
249
-
250
- /**
251
- * Estimate gas for a UserOperation
252
- */
253
- async estimateGas(userOp: Partial<UserOperation>): Promise<GasEstimate> {
254
- return this.bundlerClient.estimateGas(userOp);
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;
255
225
  }
256
226
 
257
-
258
- /**
259
- * Build a UserOperation for Batched Execution (e.g. USDC Transfer + Fee)
260
- */
261
- async buildUserOperationBatch(
262
- transactions: { target: Address; value: bigint; data: Hex }[]
263
- ): Promise<UserOperation> {
264
- if (!this.owner || !this.smartAccountAddress) {
265
- throw new Error("Not connected");
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
+ });
266
241
  }
267
242
 
268
- const isDeployed = await this.isAccountDeployed();
269
- const initCode = isDeployed ? "0x" : this.buildInitCode();
270
-
271
- // Prepare arrays for executeBatch
272
- const targets = transactions.map((tx) => tx.target);
273
- const values = transactions.map((tx) => tx.value);
274
- const datas = transactions.map((tx) => tx.data);
275
-
276
- // Encode callData for executeBatch
277
- const callData = encodeFunctionData({
278
- abi: smartAccountAbi,
279
- functionName: "executeBatch",
280
- args: [targets, values, datas],
281
- });
282
-
283
- const nonce = await this.getNonce();
284
-
285
- // Estimate gas
286
- const gasEstimate = await this.estimateGas({
287
- sender: this.smartAccountAddress,
288
- nonce,
289
- initCode: initCode as Hex,
290
- callData,
291
- paymasterAndData: this.paymasterAddress as Hex,
243
+ // ERC-20
244
+ const data = this.tokenService.encodeTransfer(recipient, amount);
245
+ return this.sendTransaction({
246
+ target: tokenAddress,
247
+ value: 0n,
248
+ data
292
249
  });
293
-
294
- return {
295
- sender: this.smartAccountAddress,
296
- nonce,
297
- initCode: initCode as Hex,
298
- callData,
299
- callGasLimit: BigInt(gasEstimate.callGasLimit),
300
- verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
301
- preVerificationGas: BigInt(gasEstimate.preVerificationGas),
302
- maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
303
- maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
304
- paymasterAndData: this.paymasterAddress as Hex,
305
- signature: "0x",
306
- };
307
250
  }
308
251
 
309
252
  /**
310
- * Build a UserOperation to ONLY deploy the account (empty callData)
253
+ * Approve a token for the Smart Account
311
254
  */
312
- async buildDeployUserOperation(): Promise<UserOperation> {
313
- if (!this.owner || !this.smartAccountAddress) {
314
- throw new Error("Not connected");
315
- }
255
+ async approveToken(
256
+ token: Address,
257
+ spender: Address,
258
+ amount: bigint = 115792089237316195423570985008687907853269984665640564039457584007913129639935n // maxUint256
259
+ ): Promise<Hash | "NOT_NEEDED"> {
260
+ if (!this.owner) throw new Error("Not connected");
316
261
 
317
- const isDeployed = await this.isAccountDeployed();
318
- if (isDeployed) {
319
- throw new Error("Account is already deployed");
320
- }
262
+ const support = await this.requestApprovalSupport(token, spender, amount);
321
263
 
322
- const initCode = this.buildInitCode();
323
- const callData = "0x"; // Empty callData for deployment only
324
- const nonce = await this.getNonce();
325
-
326
- // Estimate gas
327
- const gasEstimate = await this.estimateGas({
328
- sender: this.smartAccountAddress,
329
- nonce,
330
- initCode: initCode as Hex,
331
- callData,
332
- paymasterAndData: this.paymasterAddress as Hex,
333
- });
264
+ if (support.type === "approve") {
265
+ const data = this.tokenService.encodeApprove(spender, amount);
266
+ const txHash = await window.ethereum!.request({
267
+ method: "eth_sendTransaction",
268
+ params: [{
269
+ from: this.owner,
270
+ to: token,
271
+ data,
272
+ }]
273
+ }) as Hash;
274
+ return txHash;
275
+ }
334
276
 
335
- return {
336
- sender: this.smartAccountAddress,
337
- nonce,
338
- initCode: initCode as Hex,
339
- callData,
340
- callGasLimit: BigInt(gasEstimate.callGasLimit),
341
- verificationGasLimit: BigInt(gasEstimate.verificationGasLimit),
342
- preVerificationGas: BigInt(gasEstimate.preVerificationGas),
343
- maxFeePerGas: BigInt(gasEstimate.maxFeePerGas),
344
- maxPriorityFeePerGas: BigInt(gasEstimate.maxPriorityFeePerGas),
345
- paymasterAndData: this.paymasterAddress as Hex,
346
- signature: "0x",
347
- };
277
+ if (support.type === "permit") throw new Error("Permit not yet supported");
278
+ return "NOT_NEEDED";
348
279
  }
349
280
 
350
- /**
351
- * Calculate the UserOperation hash
352
- */
353
- getUserOpHash(userOp: UserOperation): Hex {
354
- const packed = encodeAbiParameters(
355
- [
356
- { type: "address" },
357
- { type: "uint256" },
358
- { type: "bytes32" },
359
- { type: "bytes32" },
360
- { type: "uint256" },
361
- { type: "uint256" },
362
- { type: "uint256" },
363
- { type: "uint256" },
364
- { type: "uint256" },
365
- { type: "bytes32" },
366
- ],
367
- [
368
- userOp.sender,
369
- userOp.nonce,
370
- keccak256(userOp.initCode),
371
- keccak256(userOp.callData),
372
- userOp.callGasLimit,
373
- userOp.verificationGasLimit,
374
- userOp.preVerificationGas,
375
- userOp.maxFeePerGas,
376
- userOp.maxPriorityFeePerGas,
377
- keccak256(userOp.paymasterAndData),
378
- ]
379
- );
380
-
381
- const packedHash = keccak256(packed);
382
-
383
- return keccak256(
384
- encodeAbiParameters(
385
- [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }],
386
- [packedHash, this.entryPointAddress, BigInt(this.chainConfig.chain.id)]
387
- )
388
- );
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);
389
292
  }
390
293
 
391
- /**
392
- * Sign a UserOperation with MetaMask
393
- */
394
294
  async signUserOperation(userOp: UserOperation): Promise<UserOperation> {
395
- if (!this.owner) {
396
- throw new Error("Not connected");
397
- }
295
+ if (!this.owner) throw new Error("Not connected");
398
296
 
399
- const userOpHash = this.getUserOpHash(userOp);
297
+ const userOpHash = this.userOpBuilder.getUserOpHash(userOp);
400
298
 
401
- // Sign with MetaMask using personal_sign (EIP-191)
402
299
  const signature = (await window.ethereum!.request({
403
300
  method: "personal_sign",
404
301
  params: [userOpHash, this.owner],
405
302
  })) as Hex;
406
303
 
407
- return {
408
- ...userOp,
409
- signature,
410
- };
304
+ return { ...userOp, signature };
411
305
  }
412
306
 
413
- /**
414
- * Send a signed UserOperation to the bundler
415
- */
416
307
  async sendUserOperation(userOp: UserOperation): Promise<Hash> {
417
308
  return this.bundlerClient.sendUserOperation(userOp);
418
309
  }
419
310
 
420
- /**
421
- * Wait for a UserOperation to be confirmed
422
- */
423
- async waitForUserOperation(
424
- userOpHash: Hash,
425
- timeout = 60000
426
- ): Promise<UserOpReceipt> {
427
- return this.bundlerClient.waitForUserOperation(userOpHash, timeout);
311
+ async waitForUserOperation(hash: Hash, timeout = 60000) {
312
+ return this.bundlerClient.waitForUserOperation(hash, timeout);
428
313
  }
429
314
 
430
-
431
- /**
432
- * Request support for token approval (fund if needed)
433
- */
434
- async requestApprovalSupport(
435
- token: Address,
436
- spender: Address,
437
- amount: bigint
438
- ): Promise<ApprovalSupportResult> {
439
- if (!this.owner) {
440
- throw new Error("Not connected");
441
- }
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");
442
318
  return this.bundlerClient.requestApprovalSupport(token, this.owner, spender, amount);
443
319
  }
444
320
 
445
- /**
446
- * Deploy the Smart Account
447
- */
448
- async deployAccount(): Promise<UserOpReceipt> {
449
- const userOp = await this.buildDeployUserOperation();
450
- const signed = await this.signUserOperation(userOp);
451
- const hash = await this.sendUserOperation(signed);
452
- return await this.waitForUserOperation(hash);
453
- }
454
-
455
- /**
456
- * Approve a token for the Smart Account (EOA -> Token -> Smart Account)
457
- * Checks for gas sponsorship (Relayer funding) if needed.
458
- */
459
- async approveToken(
460
- token: Address,
461
- spender: Address,
462
- amount: bigint = 115792089237316195423570985008687907853269984665640564039457584007913129639935n // maxUint256
463
- ): Promise<Hash | "NOT_NEEDED"> {
464
- if (!this.owner) throw new Error("Not connected");
465
-
466
- // 1. Check if we need funding
467
- const support = await this.requestApprovalSupport(token, spender, amount);
468
-
469
- if (support.type === "approve") {
470
- // 2. Encode approve data
471
- const data = encodeFunctionData({
472
- abi: erc20Abi,
473
- functionName: "approve",
474
- args: [spender, amount]
475
- });
476
-
477
- // 3. Send transaction via Wallet (MetaMask)
478
- // If funding was needed, the Relayer has already sent ETH to this.owner
479
- const txHash = await window.ethereum!.request({
480
- method: "eth_sendTransaction",
481
- params: [{
482
- from: this.owner,
483
- to: token,
484
- data,
485
- }]
486
- }) as Hash;
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]+)/);
487
325
 
488
- return txHash;
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 */ }
489
334
  }
490
335
 
491
- if (support.type === "permit") {
492
- throw new Error("Permit not yet supported in this SDK version");
493
- }
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");
494
338
 
495
- return "NOT_NEEDED";
339
+ return error instanceof Error ? error : new Error(String(error));
496
340
  }
497
341
 
498
342
  // Getters
499
- getOwner(): Address | null {
500
- return this.owner;
501
- }
502
-
503
- getSmartAccount(): Address | null {
504
- return this.smartAccountAddress;
505
- }
343
+ getOwner() { return this.owner; }
344
+ getSmartAccount() { return this.smartAccountAddress; }
506
345
  }
507
346
 
508
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
- };