import "@ethersproject/shims" import { ethers, BigNumber, BigNumberish } from 'ethers' import { Provider } from '@ethersproject/providers' import { EntryPoint, EntryPoint__factory, UserOperationStruct } from '@account-abstraction/contracts' import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp' import { resolveProperties } from 'ethers/lib/utils' import { PaymasterAPI } from './PaymasterAPI' import { getRequestId, NotPromise, packUserOp } from '@account-abstraction/utils' import { calcPreVerificationGas, GasOverheads } from './calcPreVerificationGas' export interface BaseApiParams { provider: Provider entryPointAddress: string walletAddress?: string overheads?: Partial paymasterAPI?: PaymasterAPI } export interface UserOpResult { transactionHash: string success: boolean } /** * Base class for all Smart Wallet ERC-4337 Clients to implement. * Subclass should inherit 5 methods to support a specific wallet contract: * * - getWalletInitCode - return the value to put into the "initCode" field, if the wallet is not yet deployed. should create the wallet instance using a factory contract. * - getNonce - return current wallet's nonce value * - encodeExecute - encode the call from entryPoint through our wallet to the target contract. * - signRequestId - sign the requestId of a UserOp. * * The user can use the following APIs: * - createUnsignedUserOp - given "target" and "calldata", fill userOp to perform that operation from the wallet. * - createSignedUserOp - helper to call the above createUnsignedUserOp, and then extract the requestId and sign it */ export abstract class BaseWalletAPI { private senderAddress!: string private isPhantom = true // entryPoint connected to "zero" address. allowed to make static calls (e.g. to getSenderAddress) private readonly entryPointView: EntryPoint provider: Provider overheads?: Partial entryPointAddress: string walletAddress?: string paymasterAPI?: PaymasterAPI /** * base constructor. * subclass SHOULD add parameters that define the owner (signer) of this wallet */ protected constructor(params: BaseApiParams) { this.provider = params.provider this.overheads = params.overheads this.entryPointAddress = params.entryPointAddress this.walletAddress = params.walletAddress this.paymasterAPI = params.paymasterAPI // factory "connect" define the contract address. the contract "connect" defines the "from" address. this.entryPointView = EntryPoint__factory.connect(params.entryPointAddress, params.provider).connect(ethers.constants.AddressZero) } async init(): Promise { if (await this.provider.getCode(this.entryPointAddress) === '0x') { throw new Error(`entryPoint not deployed at ${this.entryPointAddress}`) } await this.getWalletAddress() return this } /** * return the value to put into the "initCode" field, if the wallet is not yet deployed. * this value holds the "factory" address, followed by this wallet's information */ abstract getWalletInitCode(): Promise /** * return current wallet's nonce. */ abstract getNonce(): Promise /** * encode the call from entryPoint through our wallet to the target contract. * @param target * @param value * @param data */ abstract encodeExecute(target: string, value: BigNumberish, data: string): Promise /** * sign a userOp's hash (requestId). * @param requestId */ abstract signRequestId(requestId: string): Promise /** * check if the wallet is already deployed. */ async checkWalletPhantom(): Promise { if (!this.isPhantom) { // already deployed. no need to check anymore. return this.isPhantom } const senderAddressCode = await this.provider.getCode(this.getWalletAddress()) if (senderAddressCode.length > 2) { // console.log(`SimpleWallet Contract already deployed at ${this.senderAddress}`) this.isPhantom = false } else { // console.log(`SimpleWallet Contract is NOT YET deployed at ${this.senderAddress} - working in "phantom wallet" mode.`) } return this.isPhantom } /** * calculate the wallet address even before it is deployed */ async getCounterFactualAddress(): Promise { try { const initCode = this.getWalletInitCode() // use entryPoint to query wallet address (factory can provide a helper method to do the same, but // this method attempts to be generic return await this.entryPointView.callStatic.getSenderAddress(initCode) } catch (err: any) { // For newer versions of the entrypoint, the address is returned // in an error const iface = new ethers.utils.Interface(['error SenderAddressResult(address sender)']) const parsedError = iface.parseError(err.data) return parsedError.args.sender } } /** * return initCode value to into the UserOp. * (either deployment code, or empty hex if contract already deployed) */ async getInitCode(): Promise { if (await this.checkWalletPhantom()) { return await this.getWalletInitCode() } return '0x' } /** * return maximum gas used for verification. * NOTE: createUnsignedUserOp will add to this value the cost of creation, if the wallet is not yet created. */ async getVerificationGasLimit(): Promise { return 2000000 } /** * should cover cost of putting calldata on-chain, and some overhead. * actual overhead depends on the expected bundle size */ async getPreVerificationGas(userOp: Partial): Promise { const p = await resolveProperties(userOp) return calcPreVerificationGas(p, this.overheads) } /** * ABI-encode a user operation. used for calldata cost estimation */ packUserOp(userOp: NotPromise): string { return packUserOp(userOp, false) } async encodeUserOpCallDataAndGasLimit(detailsForUserOp: TransactionDetailsForUserOp): Promise<{ callData: string, callGasLimit: BigNumber }> { function parseNumber(a: any): BigNumber | null { if (a == null || a === '') return null return BigNumber.from(a.toString()) } const value = parseNumber(detailsForUserOp.value) ?? BigNumber.from(0) const callData = await this.encodeExecute(detailsForUserOp.target, value, detailsForUserOp.data) const callGasLimit = parseNumber(detailsForUserOp.gasLimit) ?? await this.provider.estimateGas({ from: this.entryPointAddress, to: this.getWalletAddress(), data: callData }) return { callData, callGasLimit } } /** * return requestId for signing. * This value matches entryPoint.getRequestId (calculated off-chain, to avoid a view call) * @param userOp userOperation, (signature field ignored) */ async getRequestId(userOp: UserOperationStruct): Promise { const op = await resolveProperties(userOp) const chainId = await this.provider.getNetwork().then(net => net.chainId) return getRequestId(op, this.entryPointAddress, chainId) } /** * return the wallet's address. * this value is valid even before deploying the wallet. */ async getWalletAddress(): Promise { if (this.senderAddress == null) { if (this.walletAddress != null) { this.senderAddress = this.walletAddress } else { this.senderAddress = await this.getCounterFactualAddress() } } return this.senderAddress } /** * create a UserOperation, filling all details (except signature) * - if wallet is not yet created, add initCode to deploy it. * - if gas or nonce are missing, read them from the chain (note that we can't fill gaslimit before the wallet is created) * @param info */ async createUnsignedUserOp(info: TransactionDetailsForUserOp): Promise { const { callData, callGasLimit } = await this.encodeUserOpCallDataAndGasLimit(info) const initCode = await this.getInitCode() let verificationGasLimit = BigNumber.from(await this.getVerificationGasLimit()) // if (initCode.length > 2) { // // add creation to required verification gas // const initGas = await this.entryPointView.estimateGas.getSenderAddress(initCode) // verificationGasLimit = verificationGasLimit.add(initGas) // } let { maxFeePerGas, maxPriorityFeePerGas } = info if (maxFeePerGas == null || maxPriorityFeePerGas == null) { const feeData = await this.provider.getFeeData() if (maxFeePerGas == null) { maxFeePerGas = feeData.maxFeePerGas ?? undefined } if (maxPriorityFeePerGas == null) { maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined } } const partialUserOp: any = { sender: this.getWalletAddress(), nonce: this.getNonce(), initCode, callData, callGasLimit, verificationGasLimit, maxFeePerGas, maxPriorityFeePerGas } let paymasterAndData: string | undefined if (this.paymasterAPI != null) { paymasterAndData = await this.paymasterAPI.getPaymasterAndData(partialUserOp) } partialUserOp.paymasterAndData = paymasterAndData ?? '0x' return { ...partialUserOp, preVerificationGas: this.getPreVerificationGas(partialUserOp), signature: '' } } /** * Sign the filled userOp. * @param userOp the UserOperation to sign (with signature field ignored) */ async signUserOp(userOp: UserOperationStruct): Promise { const requestId = await this.getRequestId(userOp) const signature = this.signRequestId(requestId) return { ...userOp, signature } } /** * helper method: create and sign a user operation. * @param info transaction details for the userOp */ async createSignedUserOp(info: TransactionDetailsForUserOp): Promise { return await this.signUserOp(await this.createUnsignedUserOp(info)) } /** * get the transaction that has this requestId mined, or null if not found * @param requestId returned by sendUserOpToBundler (or by getRequestId..) * @param timeout stop waiting after this timeout * @param interval time to wait between polls. * @return the transactionHash this userOp was mined, or null if not found. */ async getUserOpReceipt(requestId: string, timeout = 30000, interval = 5000): Promise { const endtime = Date.now() + timeout while (Date.now() < endtime) { const events = await this.entryPointView.queryFilter(this.entryPointView.filters.UserOperationEvent(requestId)) if (events.length > 0) { return events[0].transactionHash } await new Promise(resolve => setTimeout(resolve, interval)) } return null } }