From 603a701abf030007471aedd8c1d94598768a9e2d Mon Sep 17 00:00:00 2001 From: I Am Kio Date: Thu, 17 Jul 2025 10:45:21 +0100 Subject: [PATCH] AI PR --- .../SessionKeyValidator.ts | 244 ++++++++++-------- src/sdk/base/BaseAccountAPI.ts | 12 +- src/sdk/base/EtherspotWalletAPI.ts | 131 +++++++--- src/sdk/base/HttpRpcClient.ts | 39 ++- src/sdk/common/OperationUtils.ts | 11 +- src/sdk/common/getInitData.ts | 65 +++-- src/sdk/common/rxjs/error.subject.ts | 49 ++-- src/sdk/common/service.ts | 27 +- src/sdk/common/utils/deep-compare.ts | 33 ++- src/sdk/common/utils/hashing-utils.ts | 76 ++++-- src/sdk/sdk.ts | 17 ++ .../wallet/providers/key.wallet-provider.ts | 41 ++- 12 files changed, 495 insertions(+), 250 deletions(-) diff --git a/src/sdk/SessionKeyValidator/SessionKeyValidator.ts b/src/sdk/SessionKeyValidator/SessionKeyValidator.ts index 67b02a6..4895acf 100644 --- a/src/sdk/SessionKeyValidator/SessionKeyValidator.ts +++ b/src/sdk/SessionKeyValidator/SessionKeyValidator.ts @@ -5,6 +5,7 @@ import { DEFAULT_ERC20_SESSION_KEY_VALIDATOR_ADDRESS, Networks } from "../networ import { encodeFunctionData, Hex, parseAbi, PublicClient } from "viem"; import { erc20Abi, sessionKeyValidatorAbi } from "../common/abis.js"; import { MODULE_TYPE, deepHexlify, resolveProperties, UserOperation } from "../common/index.js"; +import { ErrorHandler } from '../errorHandler/errorHandler.service.js'; export class SessionKeyValidator { @@ -34,6 +35,16 @@ export class SessionKeyValidator { } } + /** + * Enable a session key for the specified token and function. + * @param token Token address + * @param functionSelector Function selector + * @param spendingLimit Spending limit + * @param validAfter Valid after timestamp + * @param validUntil Valid until timestamp + * @param keyStore Optional key store + * @returns Session key response + */ async enableSessionKey( token: string, functionSelector: string, @@ -50,29 +61,29 @@ export class SessionKeyValidator { const apiKey = apiKeyMatch ? apiKeyMatch[1] : null; if (erc20SessionKeyValidatorAddress == null) { - throw new Error('ERC20SessionKeyValidator contract address is required'); + throw new ErrorHandler('ERC20SessionKeyValidator contract address is required', 1); } if (etherspotWalletAddress == null) { - throw new Error('etherspotWalletAddress is required'); + throw new ErrorHandler('Etherspot wallet address is required', 1); } if (apiKey == null) { - throw new Error('API Key is required'); + throw new ErrorHandler('API Key is required', 1); } if (!token || token == null || token == '') { - throw new Error('Token is required'); + throw new ErrorHandler('Token is required', 1); } if (!functionSelector || functionSelector == null || functionSelector == '') { - throw new Error('Function Selector is required'); + throw new ErrorHandler('Function Selector is required', 1); } const isAValidTokenIndicator = await this.isAValidToken(token); if (!isAValidTokenIndicator) { - throw new Error(`Token: ${token} does not exist or is invalid`); + throw new ErrorHandler(`Token: ${token} does not exist or is invalid`, 1); } const data = await this.generateSessionKeyData( @@ -85,8 +96,8 @@ export class SessionKeyValidator { validUntil, apiKey, false, - keyStore ? keyStore : null, - ) + keyStore || null, + ); const enableSessionKeyData = encodeFunctionData({ functionName: 'enableSessionKey', @@ -111,36 +122,125 @@ export class SessionKeyValidator { await this.deleteSessionKey(etherspotWalletAddress, chainId, apiKey, data.sessionKey); throw error; } - } catch (error) { - throw error; + } catch (err) { + if (err instanceof ErrorHandler) { + throw err; + } + throw new ErrorHandler(`Failed to enable session key: ${err instanceof Error ? err.message : String(err)}`, 1); } } + /** + * Disable a session key. + * @param sessionKey Session key to disable + * @returns Session key response + */ + async disableSessionKey(sessionKey: string): Promise { + try { + const etherspotWalletAddress = await this.modularSdk.getCounterFactualAddress(); + const chainId = await this.getChainId(); + const erc20SessionKeyValidatorAddress = await this.getERC20SessionKeyValidator(); + const apiKeyMatch = this.providerURL.match(/api-key=([^&]+)/); + const apiKey = apiKeyMatch ? apiKeyMatch[1] : null; + + if (erc20SessionKeyValidatorAddress == null) { + throw new ErrorHandler('ERC20SessionKeyValidator contract address is required', 1); + } + + if (etherspotWalletAddress == null) { + throw new ErrorHandler('Etherspot wallet address is required', 1); + } + + if (apiKey == null) { + throw new ErrorHandler('API Key is required', 1); + } + + if (!sessionKey || sessionKey == null || sessionKey == '') { + throw new ErrorHandler('Session Key is required', 1); + } + + const data = await this.generateSessionKeyData( + etherspotWalletAddress, + chainId, + '', // token + '', // functionSelector + '', // spendingLimit + 0, // validAfter + 0, // validUntil + apiKey, + false, + null, // keyStore + sessionKey, + ); + + return data; + } catch (err) { + if (err instanceof ErrorHandler) { + throw err; + } + throw new ErrorHandler(`Failed to disable session key: ${err instanceof Error ? err.message : String(err)}`, 1); + } + } + /** + * Rotate a session key. + * @param oldSessionKey Old session key + * @param token Token address + * @param functionSelector Function selector + * @param spendingLimit Spending limit + * @param validAfter Valid after timestamp + * @param validUntil Valid until timestamp + * @param keyStore Optional key store + * @returns Session key response + */ async rotateSessionKey( + oldSessionKey: string, token: string, functionSelector: string, spendingLimit: string, validAfter: number, validUntil: number, - oldSessionKey: string, keyStore?: KeyStore, ): Promise { try { - const account = await this.modularSdk.getCounterFactualAddress(); + const etherspotWalletAddress = await this.modularSdk.getCounterFactualAddress(); const chainId = await this.getChainId(); const erc20SessionKeyValidatorAddress = await this.getERC20SessionKeyValidator(); const apiKeyMatch = this.providerURL.match(/api-key=([^&]+)/); const apiKey = apiKeyMatch ? apiKeyMatch[1] : null; + if (erc20SessionKeyValidatorAddress == null) { + throw new ErrorHandler('ERC20SessionKeyValidator contract address is required', 1); + } + + if (etherspotWalletAddress == null) { + throw new ErrorHandler('Etherspot wallet address is required', 1); + } + + if (apiKey == null) { + throw new ErrorHandler('API Key is required', 1); + } + + if (!oldSessionKey || oldSessionKey == null || oldSessionKey == '') { + throw new ErrorHandler('Old Session Key is required', 1); + } + + if (!token || token == null || token == '') { + throw new ErrorHandler('Token is required', 1); + } + + if (!functionSelector || functionSelector == null || functionSelector == '') { + throw new ErrorHandler('Function Selector is required', 1); + } + const isAValidTokenIndicator = await this.isAValidToken(token); if (!isAValidTokenIndicator) { - throw new Error(`Token: ${token} is does not exist or is invalid`); + throw new ErrorHandler(`Token: ${token} does not exist or is invalid`, 1); } const data = await this.generateSessionKeyData( - account, + etherspotWalletAddress, chainId, token, functionSelector, @@ -149,84 +249,16 @@ export class SessionKeyValidator { validUntil, apiKey, true, - keyStore ? keyStore : null, + keyStore || null, oldSessionKey, - ) - - const rotateSessionKeyData = encodeFunctionData({ - functionName: 'rotateSessionKey', - abi: parseAbi(sessionKeyValidatorAbi), - args: [data.oldSessionKey, data.enableSessionKeyData], - }); - - this.modularSdk.clearUserOpsFromBatch(); - - await this.modularSdk.addUserOpsToBatch({ to: erc20SessionKeyValidatorAddress, data: rotateSessionKeyData }); - - try { - const op = await this.modularSdk.estimate(); - - const uoHash = await this.modularSdk.send(op); - - if (uoHash) { - await this.deleteSessionKey(account, chainId, apiKey, data.oldSessionKey); - } - else { - await this.deleteSessionKey(account, chainId, apiKey, data.sessionKey); - } - - return { - userOpHash: uoHash, - sessionKey: data.sessionKey, - } - } catch (error) { - await this.deleteSessionKey(account, chainId, apiKey, data.sessionKey); - throw error; - } - } catch (error) { - throw error; - } - } - - async disableSessionKey(sessionKey: string): Promise { - try { - const account = await this.modularSdk.getCounterFactualAddress(); - const erc20SessionKeyValidator = await this.getERC20SessionKeyValidator(); - const chainId = await this.getChainId(); - const apiKeyMatch = this.providerURL.match(/api-key=([^&]+)/); - const apiKey = apiKeyMatch ? apiKeyMatch[1] : null; - - const getSessionKeyData = await this.getSessionKey( - account, - chainId, - apiKey, - sessionKey, - ) + ); - const disableSessionKeyData = encodeFunctionData({ - functionName: 'disableSessionKey', - abi: parseAbi(sessionKeyValidatorAbi), - args: [getSessionKeyData.sessionKey], - }); - - this.modularSdk.clearUserOpsFromBatch(); - - await this.modularSdk.addUserOpsToBatch({ to: erc20SessionKeyValidator, data: disableSessionKeyData }); - - const op = await this.modularSdk.estimate(); - - const uoHash = await this.modularSdk.send(op); - - if (uoHash) { - await this.deleteSessionKey(account, chainId, apiKey, sessionKey); - } - - return { - userOpHash: uoHash, - sessionKey: getSessionKeyData.sessionKey, + return data; + } catch (err) { + if (err instanceof ErrorHandler) { + throw err; } - } catch (error) { - throw error; + throw new ErrorHandler(`Failed to rotate session key: ${err instanceof Error ? err.message : String(err)}`, 1); } } @@ -329,36 +361,36 @@ export class SessionKeyValidator { let response = null; try { if (!apiKey || apiKey == null) { - throw new Error('API Key is required'); + throw new ErrorHandler('API Key is required', 1); } const url = `${PERMISSIONS_URL}/account/generateSessionKeyData?apiKey=${apiKey}`; if (account == null) { - throw new Error('Account is required'); + throw new ErrorHandler('Account is required', 1); } const now = Math.floor(Date.now() / 1000); if (validAfter < now + 29) { - throw new Error('validAfter must be greater than current time by at least 30 seconds'); + throw new ErrorHandler('validAfter must be greater than current time by at least 30 seconds', 1); } if (validUntil == 0 || validUntil < validAfter || validUntil < now) { - throw new Error('validUntil must be greater than validAfter and current time'); + throw new ErrorHandler('validUntil must be greater than validAfter and current time', 1); } if (!token || token == null || token == '') { - throw new Error('Token is required'); + throw new ErrorHandler('Token is required', 1); } if (!functionSelector || functionSelector == null || functionSelector == '') { - throw new Error('Function Selector is required'); + throw new ErrorHandler('Function Selector is required', 1); } if (!spendingLimit || spendingLimit == null || spendingLimit == '') { - throw new Error('Spending Limit is required'); + throw new ErrorHandler('Spending Limit is required', 1); } const requestBody = { @@ -388,10 +420,10 @@ export class SessionKeyValidator { return responseJson } else { const responseJson = await response.json(); - throw new Error(responseJson.message) + throw new ErrorHandler(responseJson.message, 1); } } catch (err) { - throw new Error(err.message) + throw new ErrorHandler(err.message, 1); } } @@ -420,10 +452,10 @@ export class SessionKeyValidator { return responseJson } else { const responseJson = await response.json(); - throw new Error(responseJson.message) + throw new ErrorHandler(responseJson.message, 1); } } catch (err) { - throw new Error(err.message) + throw new ErrorHandler(err.message, 1); } } @@ -451,10 +483,10 @@ export class SessionKeyValidator { return responseJson } else { const responseJson = await response.json(); - throw new Error(responseJson.message) + throw new ErrorHandler(responseJson.message, 1); } } catch (err) { - throw new Error(err.message) + throw new ErrorHandler(err.message, 1); } } @@ -485,10 +517,10 @@ export class SessionKeyValidator { return responseJson } else { const responseJson = await response.json(); - throw new Error(responseJson.message) + throw new ErrorHandler(responseJson.message, 1); } } catch (err) { - throw new Error(err.message) + throw new ErrorHandler(err.message, 1); } } @@ -517,10 +549,10 @@ export class SessionKeyValidator { return responseJson } else { const responseJson = await response.json(); - throw new Error(responseJson.message) + throw new ErrorHandler(responseJson.message, 1); } } catch (err) { - throw new Error(err.message) + throw new ErrorHandler(err.message, 1); } } diff --git a/src/sdk/base/BaseAccountAPI.ts b/src/sdk/base/BaseAccountAPI.ts index a09d534..442d51c 100644 --- a/src/sdk/base/BaseAccountAPI.ts +++ b/src/sdk/base/BaseAccountAPI.ts @@ -12,6 +12,7 @@ import { BaseAccountUserOperationStruct, FeeData } from '../types/user-operation import { BigNumber, BigNumberish } from '../types/bignumber.js'; import { MessagePayload, WalletProviderLike, WalletService } from '../wallet/index.js'; import { DEFAULT_MULTIPLE_OWNER_ECDSA_VALIDATOR_ADDRESS, Networks } from '../network/index.js'; +import { ErrorHandler } from '../errorHandler/errorHandler.service.js'; export interface BaseApiParams { entryPointAddress: string; @@ -249,7 +250,8 @@ export abstract class BaseAccountAPI { } /** - * calculate the account address even before it is deployed + * Calculate the account address even before it is deployed. + * @returns Counterfactual address */ async getCounterFactualAddress(): Promise { const initCode = await this.getAccountInitCode(); @@ -264,11 +266,13 @@ export abstract class BaseAccountAPI { args: [initCode] }); - } catch (e: any) { - return e.errorArgs.sender; + if (e?.errorArgs?.sender) { + return e.errorArgs.sender; + } + throw new ErrorHandler(`Failed to get counterfactual address: ${e instanceof Error ? e.message : String(e)}`, 1); } - throw new Error('must handle revert'); + throw new ErrorHandler('getCounterFactualAddress: must handle revert', 1); } /** diff --git a/src/sdk/base/EtherspotWalletAPI.ts b/src/sdk/base/EtherspotWalletAPI.ts index 2b59f2c..97dee96 100644 --- a/src/sdk/base/EtherspotWalletAPI.ts +++ b/src/sdk/base/EtherspotWalletAPI.ts @@ -7,6 +7,7 @@ import { DEFAULT_BOOTSTRAP_ADDRESS, DEFAULT_QUERY_PAGE_SIZE, Networks } from '.. import { BigNumber, BigNumberish } from '../types/bignumber.js'; import { BaseAccountAPI, BaseApiParams } from './BaseAccountAPI.js'; import { BootstrapConfig, _makeBootstrapConfig, makeBootstrapConfig } from './Bootstrap.js'; +import { ErrorHandler } from '../errorHandler/errorHandler.service.js'; // Creating a constant for the sentinel address using viem const SENTINEL_ADDRESS = getAddress("0x0000000000000000000000000000000000000001"); @@ -298,53 +299,60 @@ export class EtherspotWalletAPI extends BaseAccountAPI { return this.accountAddress; } + /** + * Get the current account nonce. + * @param key Optional nonce key + * @returns Current nonce as BigNumber + */ async getNonce(key: BigNumber = BigNumber.from(0)): Promise { const accountAddress = await this.getAccountAddress(); const nonceKey = key.eq(0) ? this.validatorAddress : key.toHexString(); if (!nonceKey) { - throw new Error('nonce key not defined'); + throw new ErrorHandler('Nonce key not defined', 1); } if (!this.checkAccountPhantom()) { - let isAddressIndicator = false; try { isAddressIndicator = isAddress(getAddress(nonceKey), { strict: true }); if (!isAddressIndicator) { - throw new Error(`Invalid Validator Address: ${nonceKey}`); - } - else { + throw new ErrorHandler(`Invalid Validator Address: ${nonceKey}`, 1); + } else { const isModuleInstalled = await this.isModuleInstalled(MODULE_TYPE.VALIDATOR, nonceKey); if (!isModuleInstalled) { - throw new Error(`Validator: ${nonceKey} is not installed in the wallet`); + throw new ErrorHandler(`Validator: ${nonceKey} is not installed in the wallet`, 1); } } - } catch (e) { console.error(`Error caught : ${e}`); - throw new Error(`Invalid Validator Address: ${nonceKey}`); + throw new ErrorHandler(`Invalid Validator Address: ${nonceKey}`, 1); } } - const dummyKey = getAddress(nonceKey) + "00000000" - - const nonceResponse = await this.publicClient.readContract({ - address: this.entryPointAddress as Hex, - abi: parseAbi(entryPointAbi), - functionName: 'getNonce', - args: [accountAddress, BigInt(dummyKey)] - }); - return nonceResponse as BigNumber; + const dummyKey = getAddress(nonceKey) + "00000000"; + + try { + const nonceResponse = await this.publicClient.readContract({ + address: this.entryPointAddress as Hex, + abi: parseAbi(entryPointAbi), + functionName: 'getNonce', + args: [accountAddress, BigInt(dummyKey)] + }); + return nonceResponse as BigNumber; + } catch (error) { + throw new ErrorHandler(`Failed to get nonce: ${error instanceof Error ? error.message : String(error)}`, 1); + } } /** - * encode a method call from entryPoint to our contract - * @param target - * @param value - * @param data + * Encode a method call from entryPoint to our contract. + * @param target Target address + * @param value Value to send + * @param data Call data + * @returns Encoded execute data */ async encodeExecute(target: string, value: BigNumberish, data: string): Promise { const executeMode = getExecuteMode({ @@ -352,27 +360,32 @@ export class EtherspotWalletAPI extends BaseAccountAPI { execType: EXEC_TYPE.DEFAULT }); - // Assuming toHex is a function that accepts string | number | bigint | boolean | Uint8Array - // Convert BigNumberish to a string if it's a BigNumber - // Convert BigNumberish or Bytes to a compatible type + // Validate inputs + if (!target || typeof target !== 'string') { + throw new Error('Invalid target address'); + } + if (!data || typeof data !== 'string') { + throw new Error('Invalid call data'); + } + + // Convert BigNumberish to a compatible type for toHex let valueToProcess: string | number | bigint | boolean | Uint8Array; if (BigNumber.isBigNumber(value)) { valueToProcess = value.toString(); // Convert BigNumber to string } else if (isBytes(value)) { valueToProcess = new Uint8Array(value); // Convert Bytes to Uint8Array + } else if (typeof value === 'bigint') { + valueToProcess = value; + } else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + valueToProcess = value; } else { - // Here, TypeScript is unsure about the type of `value` - // You need to ensure `value` is of a type compatible with `valueToProcess` - // If `value` can only be string, number, bigint, boolean, or Uint8Array, this assignment is safe - // If `value` can be of other types (like Bytes), you need an explicit conversion or handling here - // For example, if there's a chance `value` is still `Bytes`, you could handle it like so: - if (typeof value === 'object' && value !== null && 'length' in value) { - // Assuming this condition is sufficient to identify Bytes-like objects - // Convert it to Uint8Array - valueToProcess = new Uint8Array(Object.values(value)); - } else { - valueToProcess = value as string | number | bigint | boolean | Uint8Array; + // Handle other BigNumberish types + try { + const bn = BigNumber.from(value); + valueToProcess = bn.toString(); + } catch (err) { + throw new Error(`Invalid value type: ${typeof value}`); } } @@ -389,22 +402,58 @@ export class EtherspotWalletAPI extends BaseAccountAPI { }); } + /** + * Sign a user operation hash. + * @param userOpHash Hash to sign + * @returns Signature + */ async signUserOpHash(userOpHash: string): Promise { return await this.services.walletService.signUserOp(userOpHash as Hex); } + /** + * Encode a batch of method calls from entryPoint to our contract. + * @param targets Array of target addresses + * @param values Array of values to send + * @param datas Array of call data + * @returns Encoded batch execute data + */ async encodeBatch(targets: string[], values: BigNumberish[], datas: string[]): Promise { + // Validate input arrays + if (!targets || !values || !datas) { + throw new ErrorHandler('Targets, values, and datas arrays are required', 1); + } + + if (targets.length !== values.length || targets.length !== datas.length) { + throw new ErrorHandler('Targets, values, and datas arrays must have the same length', 1); + } + + if (targets.length === 0) { + throw new ErrorHandler('Cannot encode empty batch', 1); + } const executeMode = getExecuteMode({ callType: CALL_TYPE.BATCH, execType: EXEC_TYPE.DEFAULT }); - const result = targets.map((target, index) => ({ - target: target as Hex, - value: values[index], - callData: datas[index] as Hex - })); + const result = targets.map((target, index) => { + // Validate each target + if (!target || typeof target !== 'string') { + throw new ErrorHandler(`Invalid target address at index ${index}`, 1); + } + + // Validate each data + if (!datas[index] || typeof datas[index] !== 'string') { + throw new ErrorHandler(`Invalid call data at index ${index}`, 1); + } + + return { + target: target as Hex, + value: values[index], + callData: datas[index] as Hex + }; + }); const convertedResult = result.map(item => ({ ...item, @@ -421,7 +470,7 @@ export class EtherspotWalletAPI extends BaseAccountAPI { const calldata = encodeAbiParameters( parseAbiParameters('(address target,uint256 value,bytes callData)[]'), [convertedResult] - ) + ); return encodeFunctionData({ functionName: 'execute', diff --git a/src/sdk/base/HttpRpcClient.ts b/src/sdk/base/HttpRpcClient.ts index e5c406a..a064d9e 100644 --- a/src/sdk/base/HttpRpcClient.ts +++ b/src/sdk/base/HttpRpcClient.ts @@ -64,21 +64,56 @@ export class HttpRpcClient { } } + /** + * Handle RPC errors and convert them to ErrorHandler instances. + * @param err Error to handle + */ handleRPCError(err: any) { const body: RpcRequestError = this.parseViemRPCRequestError(err); if (body && body?.details && body?.code) { throw new ErrorHandler(body.details, body.code); } else { - throw new Error(JSON.stringify(err)); + throw new ErrorHandler(JSON.stringify(err), 1); } } + /** + * Parse viem RPC request errors. + * @param error Error to parse + * @returns Parsed RPC request error + */ parseViemRPCRequestError(error: any): RpcRequestError { if (error instanceof RpcRequestError) { return JSON.parse(JSON.stringify(error)); } - // TODO handle BaseError and ContractFunctionExecutionError + // Handle BaseError and ContractFunctionExecutionError + if (error && typeof error === 'object') { + // Check if it's a viem BaseError + if (error.name === 'BaseError' || error.name === 'ContractFunctionExecutionError') { + return { + code: error.code || 1, + details: error.message || 'Unknown error', + data: error.data, + } as RpcRequestError; + } + + // Handle other error types + if (error.message) { + return { + code: error.code || 1, + details: error.message, + data: error.data, + } as RpcRequestError; + } + } + + // Fallback for unknown error types + return { + code: 1, + details: typeof error === 'string' ? error : 'Unknown RPC error', + data: error, + } as RpcRequestError; } /** diff --git a/src/sdk/common/OperationUtils.ts b/src/sdk/common/OperationUtils.ts index f3dc5c9..b7ba0dd 100644 --- a/src/sdk/common/OperationUtils.ts +++ b/src/sdk/common/OperationUtils.ts @@ -3,7 +3,12 @@ import { BaseAccountUserOperationStruct } from '../types/user-operation-types.js import { toHex } from 'viem'; import { BigNumber } from '../types/bignumber.js'; -export function toJSON(op: Partial): Promise { +/** + * Converts a partial BaseAccountUserOperationStruct to a JSON object with all values hexlified. + * @param op Partial user operation + * @returns Promise resolving to a JSON object with hexlified values + */ +export async function toJSON(op: Partial): Promise> { return resolveProperties(op).then((userOp) => Object.keys(userOp) .map((key) => { @@ -14,14 +19,14 @@ export function toJSON(op: Partial): Promise ({ ...set, [k]: v, }), - {}, + {} as Record, ), ); } diff --git a/src/sdk/common/getInitData.ts b/src/sdk/common/getInitData.ts index 811a3d5..0484d14 100644 --- a/src/sdk/common/getInitData.ts +++ b/src/sdk/common/getInitData.ts @@ -7,42 +7,55 @@ import { } from 'viem' import { InitialModules, Module } from './types.js' import { bootstrapAbi, factoryAbi } from './abis.js' +import { ErrorHandler } from '../errorHandler/errorHandler.service.js' +/** + * Extract initial modules from init code. + * @param initCode Hex encoded init code + * @returns Initial modules configuration + */ export const getInitData = ({ initCode, }: { initCode: Hex }): InitialModules => { - const { args: initCodeArgs } = decodeFunctionData({ - abi: parseAbi(factoryAbi), - data: slice(initCode, 20), - }) + try { + const { args: initCodeArgs } = decodeFunctionData({ + abi: parseAbi(factoryAbi), + data: slice(initCode, 20), + }) - if (initCodeArgs?.length !== 2) { - throw new Error('Invalid init code') - } + if (initCodeArgs?.length !== 2) { + throw new ErrorHandler('Invalid init code: expected 2 arguments', 1); + } - const initCallData = decodeAbiParameters( - [ - { name: 'bootstrap', type: 'address' }, - { name: 'initCallData', type: 'bytes' }, - ], - initCodeArgs[1] as Hex, - ) + const initCallData = decodeAbiParameters( + [ + { name: 'bootstrap', type: 'address' }, + { name: 'initCallData', type: 'bytes' }, + ], + initCodeArgs[1] as Hex, + ) - const { args: initCallDataArgs } = decodeFunctionData({ - abi: parseAbi(bootstrapAbi), - data: initCallData[1], - }) + const { args: initCallDataArgs } = decodeFunctionData({ + abi: parseAbi(bootstrapAbi), + data: initCallData[1], + }) - if (initCallDataArgs?.length !== 4) { - throw new Error('Invalid init code') - } + if (initCallDataArgs?.length !== 4) { + throw new ErrorHandler('Invalid init code: expected 4 bootstrap arguments', 1); + } - return { - validators: initCallDataArgs[0] as Module[], - executors: initCallDataArgs[1] as Module[], - hooks: [initCallDataArgs[2]] as Module[], - fallbacks: initCallDataArgs[3] as Module[], + return { + validators: initCallDataArgs[0] as Module[], + executors: initCallDataArgs[1] as Module[], + hooks: [initCallDataArgs[2]] as Module[], + fallbacks: initCallDataArgs[3] as Module[], + } + } catch (error) { + if (error instanceof ErrorHandler) { + throw error; + } + throw new ErrorHandler(`Failed to decode init data: ${error instanceof Error ? error.message : String(error)}`, 1); } } diff --git a/src/sdk/common/rxjs/error.subject.ts b/src/sdk/common/rxjs/error.subject.ts index e071dee..71a2ae5 100644 --- a/src/sdk/common/rxjs/error.subject.ts +++ b/src/sdk/common/rxjs/error.subject.ts @@ -1,63 +1,76 @@ import { Subject } from 'rxjs'; /** - * @ignore + * ErrorSubject is a Subject for error handling, enforcing Error types. */ -export class ErrorSubject extends Subject { +export class ErrorSubject extends Subject { + /** + * Complete the error subject. + */ complete(): void { // } - next(value?: any): void { - if (value) { + /** + * Emit an error if it is an instance of Error. + * @param value Error to emit + */ + next(value?: Error): void { + if (value instanceof Error) { super.next(value); + } else if (value) { + // Wrap non-Error values + super.next(new Error(String(value))); } } - wrap(func: () => T): T { - let result: any; - + /** + * Wrap a function and emit any errors thrown as Error. + * @param func Function to execute + */ + wrap(func: () => T): T | null { + let result: T | null; try { result = func(); - if (result instanceof Promise) { - result = result.catch((err) => { - this.next(err); + result = (result.catch((err) => { + this.next(err instanceof Error ? err : new Error(String(err))); return null; - }); + }) as unknown) as T; } } catch (err) { - this.next(err); + this.next(err instanceof Error ? err : new Error(String(err))); result = null; } - return result; } + /** + * Catch errors from a function and emit them, optionally call onComplete. + * @param func Function to execute + * @param onComplete Optional completion callback + */ catch(func: () => T, onComplete?: () => any): void { const fireOnComplete = () => { if (onComplete) { onComplete(); } }; - try { const promise = func(); - if (promise instanceof Promise) { promise .catch((err) => { - this.next(err); + this.next(err instanceof Error ? err : new Error(String(err))); }) .finally(() => { fireOnComplete(); }); return; } - fireOnComplete(); } catch (err) { - this.next(err); + this.next(err instanceof Error ? err : new Error(String(err))); fireOnComplete(); } } diff --git a/src/sdk/common/service.ts b/src/sdk/common/service.ts index 43b2f0f..4057a2a 100644 --- a/src/sdk/common/service.ts +++ b/src/sdk/common/service.ts @@ -1,6 +1,9 @@ import { Subscription } from 'rxjs'; import { Context } from '../context.js'; +/** + * Abstract base class for services with lifecycle and subscription management. + */ export abstract class Service { protected context: Context; private inited = false; @@ -8,6 +11,10 @@ export abstract class Service { private attachedCounter = 0; private subscriptions: Subscription[] = []; + /** + * Initialize the service with the given context. Idempotent. + * @param context Service context + */ init(context: Context): void { if (!this.inited) { this.inited = true; @@ -25,6 +32,9 @@ export abstract class Service { ++this.attachedCounter; } + /** + * Destroy the service and clean up subscriptions. Idempotent. + */ destroy(): void { if (!this.attachedCounter) { return; @@ -55,10 +65,21 @@ export abstract class Service { return this.context.services; } + /** + * Add subscriptions, preventing duplicates. + * @param subscriptions Subscriptions to add + */ protected addSubscriptions(...subscriptions: Subscription[]): void { - this.subscriptions.push(...subscriptions.filter((subscription) => !!subscription)); + for (const sub of subscriptions) { + if (sub && !this.subscriptions.includes(sub)) { + this.subscriptions.push(sub); + } + } } + /** + * Remove and unsubscribe all subscriptions. + */ protected removeSubscriptions(): void { for (const subscription of this.subscriptions) { subscription.unsubscribe(); @@ -66,6 +87,10 @@ export abstract class Service { this.subscriptions = []; } + /** + * Replace all subscriptions with new ones. + * @param subscriptions New subscriptions + */ protected replaceSubscriptions(...subscriptions: Subscription[]): void { this.removeSubscriptions(); this.addSubscriptions(...subscriptions); diff --git a/src/sdk/common/utils/deep-compare.ts b/src/sdk/common/utils/deep-compare.ts index d0910f6..f508c5a 100644 --- a/src/sdk/common/utils/deep-compare.ts +++ b/src/sdk/common/utils/deep-compare.ts @@ -2,9 +2,13 @@ import { BigNumber } from '../../types/bignumber.js'; import { isBigNumber } from './bignumber-utils.js'; /** - * @ignore + * Deeply compares two values for equality, supporting BigNumber, Date, arrays, and objects. + * Throws an error if an unexpected type is encountered. + * @param a First value + * @param b Second value + * @returns True if equal, false otherwise */ -export function deepCompare(a: any, b: any): boolean { +export function deepCompare(a: unknown, b: unknown): boolean { let result = false; const aType = typeof a; @@ -16,7 +20,7 @@ export function deepCompare(a: any, b: any): boolean { } else if (a === b) { result = true; } else if (isBigNumber(a) && isBigNumber(b)) { - result = (a as BigNumber).eq(b); + result = (a as BigNumber).eq(b as BigNumber); } else if (a instanceof Date && b instanceof Date) { result = a.getTime() === b.getTime(); } else { @@ -24,33 +28,35 @@ export function deepCompare(a: any, b: any): boolean { const bIsArray = Array.isArray(b); if (aIsArray && bIsArray) { - const aLength = a.length; - const bLength = b.length; + const aLength = (a as unknown[]).length; + const bLength = (b as unknown[]).length; if (aLength === bLength) { result = true; for (let index = 0; index < aLength; index += 1) { - if (!deepCompare(a[index], b[index])) { + if (!deepCompare((a as unknown[])[index], (b as unknown[])[index])) { result = false; break; } } } } else if (!aIsArray && !bIsArray) { - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); + const aKeys = Object.keys(a as object); + const bKeys = Object.keys(b as object); if (aKeys.length === bKeys.length) { result = true; for (const key of aKeys) { - if (!deepCompare(a[key], b[key])) { + if (!deepCompare((a as Record)[key], (b as Record)[key])) { result = false; break; } } } + } else { + throw new Error('deepCompare: Mismatched array/object types'); } } break; @@ -59,8 +65,15 @@ export function deepCompare(a: any, b: any): boolean { result = true; break; - default: + case 'undefined': + case 'boolean': + case 'number': + case 'string': result = a === b; + break; + + default: + throw new Error(`deepCompare: Unexpected type '${aType}' encountered`); } } diff --git a/src/sdk/common/utils/hashing-utils.ts b/src/sdk/common/utils/hashing-utils.ts index 7309158..0c453f9 100644 --- a/src/sdk/common/utils/hashing-utils.ts +++ b/src/sdk/common/utils/hashing-utils.ts @@ -3,73 +3,95 @@ import { isAddress } from "./viem-utils.js"; import { BytesLike } from "../index.js"; import { isHex as isAHex, stringToBytes} from 'viem'; +/** + * Computes the keccak256 hash of the input data. + * Handles string, address, hex, and object (Uint8Array/Buffer) types. + * @param data Input data to hash + * @returns Keccak256 hash as a string + */ export function keccak256(data: BytesLike): string { let result = ''; - + if (data) { switch (typeof data) { case 'string': if (isAddress(data)) { result = keccak256(encodePacked(['address'], [data as Hex])) - } else if (isHex(data)) { + } else if (isAHex(data)) { result = keccak256(encodePacked(['bytes'], [data as Hex])); } else { result = keccak256(encodePacked(['string'], [data as Hex])); } break; case 'object': { - //result = utils.solidityKeccak256(['bytes'], [data]); - // TODO-LibraryFix - this needs debugging as its migrated from ethers - result = keccak256(encodePacked(['bytes'], [data.toString() as Hex])); + // LibraryFix: Only handle Uint8Array or Buffer for object input + if (data instanceof Uint8Array || (typeof Buffer !== 'undefined' && data instanceof Buffer)) { + // Convert to hex string first + const hexString = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); + result = keccak256(encodePacked(['bytes'], [`0x${hexString}` as Hex])); + } else { + // Warn and throw for unsupported object types + console.warn('keccak256: Unsupported object type for hashing. Only Uint8Array or Buffer are supported.'); + throw new Error('Unsupported object type for keccak256 hashing'); + } break; } + default: + throw new Error('Unsupported data type for keccak256 hashing'); } } - + return result; - } - - export function isHex(hex: string, size = 0): boolean { +} + +export function isHex(hex: string, size = 0): boolean { let result = isAHex(hex); - + if (result && size > 0) { result = hex.length === size * 2 + 2; } - + return result; - } - - export function toHexFromBytesLike(data: BytesLike): string { +} + +export function toHexFromBytesLike(data: BytesLike): string { let result = ''; - + if (data !== null) { switch (typeof data) { case 'string': - if (isHex(data)) { + if (isAddress(data)) { + result = data; + } else if (isAHex(data)) { result = data; } else { - result = toHexFromBytesLike(stringToBytes(data)); + const bytes = stringToBytes(data); + result = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); } break; - + case 'object': try { - result = toHexFromBytesLike(data as any); - } catch (err) { - result = ''; + if (data instanceof Uint8Array || (typeof Buffer !== 'undefined' && data instanceof Buffer)) { + result = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''); + } else { + throw new Error('Unsupported object type'); + } + } catch (error) { + throw new Error('invalid hex data'); } break; } } - + if (!result) { throw new Error('invalid hex data'); } - + return result; - } - - export function concatHex(...hex: string[]): string { +} + +export function concatHex(...hex: string[]): string { return hex.map((item, index) => (index ? item.slice(2) : item)).join(''); - } +} \ No newline at end of file diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts index 159a01a..303618a 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -21,6 +21,7 @@ import { ErrorHandler } from './errorHandler/errorHandler.service.js'; import { EtherspotBundler } from './bundler/index.js'; import { Account, formatEther, Hex, http, type PublicClient } from 'viem'; import { BigNumber, BigNumberish } from './types/bignumber.js'; +import { ValidationException } from './common/exceptions/validation.exception.js'; /** * Modular-Sdk @@ -40,6 +41,11 @@ export class ModularSdk { private userOpsBatch: BatchUserOpsRequest = { to: [], data: [], value: [] }; + /** + * Maximum allowed batch size for user operations. + */ + private static readonly MAX_BATCH_SIZE = 50; + constructor(walletProvider: WalletProviderLike, optionsLike: SdkOptions) { let walletConnectProvider; if (isWalletConnectProvider(walletProvider)) { @@ -167,6 +173,11 @@ export class ModularSdk { return this.etherspotWallet.getCounterFactualAddress(); } + /** + * Estimate gas and prepare a user operation batch. + * Throws if the batch is empty or exceeds the maximum allowed size. + * @param params Estimation parameters + */ async estimate(params: { paymasterDetails?: PaymasterApi, gasDetails?: TransactionGasInfoForUserOp, @@ -179,6 +190,12 @@ export class ModularSdk { if (this.userOpsBatch.to.length < 1) { throw new ErrorHandler('cannot sign empty transaction batch', 1); } + if (this.userOpsBatch.to.length > ModularSdk.MAX_BATCH_SIZE) { + throw new ErrorHandler(`Batch size exceeds maximum allowed (${ModularSdk.MAX_BATCH_SIZE})`, 1); + } + if (this.userOpsBatch.data.length !== this.userOpsBatch.to.length || this.userOpsBatch.value.length !== this.userOpsBatch.to.length) { + throw new ValidationException([{ property: 'userOpsBatch', constraints: { length: 'Batch arrays must be of equal length' } }]); + } if (paymasterDetails?.url) { const paymasterAPI = new VerifyingPaymasterAPI(paymasterDetails.url, this.etherspotWallet.entryPointAddress, paymasterDetails.context ?? {}) diff --git a/src/sdk/wallet/providers/key.wallet-provider.ts b/src/sdk/wallet/providers/key.wallet-provider.ts index 099dc6d..2a69d03 100644 --- a/src/sdk/wallet/providers/key.wallet-provider.ts +++ b/src/sdk/wallet/providers/key.wallet-provider.ts @@ -2,7 +2,11 @@ import { Hash, Hex, TransactionRequest, WalletClient, createWalletClient, http, import { MessagePayload, WalletProvider } from './interfaces.js'; import { privateKeyToAccount } from 'viem/accounts'; import { Networks } from '../../network/index.js'; +import { ErrorHandler } from '../../errorHandler/errorHandler.service.js'; +/** + * Wallet provider that uses a private key for signing. + */ export class KeyWalletProvider implements WalletProvider { readonly type = 'Key'; readonly address: string; @@ -10,29 +14,42 @@ export class KeyWalletProvider implements WalletProvider { readonly wallet: WalletClient; + /** + * Create a new key wallet provider. + * @param chainId Chain ID + * @param privateKey Private key + * @param chain Optional chain configuration + */ constructor(chainId: number, privateKey: string, chain?: Chain) { + if (!privateKey || typeof privateKey !== 'string') { + throw new ErrorHandler('Invalid private key provided', 1); + } + this.wallet = createWalletClient({ account: privateKeyToAccount(privateKey as Hex), chain: Networks[chainId]?.chain ?? chain, transport: http() }); - if (!this.wallet.account) throw new Error('No account address set. Please provide a valid accountaddress'); + if (!this.wallet.account) { + throw new ErrorHandler('No account address set. Please provide a valid account address', 1); + } const { address } = this.wallet.account; this.address = address; + this.accountAddress = address; } async signMessage(message: string, validatorAddress?: Address, factoryAddress?: Address, initCode?: Hex): Promise { - if (!this.wallet.account) throw new Error('No account set'); + if (!this.wallet.account) throw new ErrorHandler('No account set', 1); const signature = await this.wallet.signMessage({ message: {raw: toBytes(hashMessage({raw : toBytes(message)}))}, account: this.wallet.account }) - if (!validatorAddress) throw new Error('No validator address provided'); - if (!factoryAddress) throw new Error('No factory address provided'); - if (!initCode) throw new Error('No init code provided'); + if (!validatorAddress) throw new ErrorHandler('No validator address provided', 1); + if (!factoryAddress) throw new ErrorHandler('No factory address provided', 1); + if (!initCode) throw new ErrorHandler('No init code provided', 1); if (initCode !== '0x') { const abiCoderResult = encodeAbiParameters( parseAbiParameters('address, bytes, bytes'), @@ -48,7 +65,7 @@ export class KeyWalletProvider implements WalletProvider { // eslint-disable-next-line @typescript-eslint/no-unused-vars async signTypedData(msg: MessagePayload, validatorAddress?: Address, factoryAddress?: Address, initCode?: Hex): Promise { - if (!this.wallet.account) throw new Error('No account set'); + if (!this.wallet.account) throw new ErrorHandler('No account set', 1); const signature = await this.wallet.signTypedData({ domain: msg.domain, types: msg.types, @@ -56,9 +73,9 @@ export class KeyWalletProvider implements WalletProvider { message: msg.message, account: this.wallet.account }) - if (!validatorAddress) throw new Error('No validator address provided'); - if (!factoryAddress) throw new Error('No factory address provided'); - if (!initCode) throw new Error('No init code provided'); + if (!validatorAddress) throw new ErrorHandler('No validator address provided', 1); + if (!factoryAddress) throw new ErrorHandler('No factory address provided', 1); + if (!initCode) throw new ErrorHandler('No init code provided', 1); if (initCode !== '0x') { const abiCoderResult = encodeAbiParameters( parseAbiParameters('address, bytes, bytes'), @@ -81,7 +98,7 @@ export class KeyWalletProvider implements WalletProvider { } async signUserOp(message: Hex): Promise { - if (!this.wallet.account) throw new Error('No account set'); + if (!this.wallet.account) throw new ErrorHandler('No account set', 1); return this.wallet.signMessage({ message: { raw: message }, account: this.wallet.account @@ -89,7 +106,7 @@ export class KeyWalletProvider implements WalletProvider { } async eth_sendTransaction(transaction: TransactionRequest): Promise { - if (!this.wallet.account) throw new Error('No account set'); + if (!this.wallet.account) throw new ErrorHandler('No account set', 1); return this.wallet.sendTransaction({ ...transaction, account: this.wallet.account, @@ -99,7 +116,7 @@ export class KeyWalletProvider implements WalletProvider { } async eth_signTransaction(transaction: TransactionRequest): Promise { - if (!this.wallet.account) throw new Error('No account set'); + if (!this.wallet.account) throw new ErrorHandler('No account set', 1); return this.wallet.signTransaction({ ...transaction, account: this.wallet.account,