diff --git a/package.json b/package.json index c7a24da..ee7fb01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ui-sdk", - "version": "0.6.0", + "version": "0.6.1", "main": "./dist/src/index.js", "license": "MIT", "files": [ diff --git a/src/BaseAccountAPI.ts b/src/BaseAccountAPI.ts index 9c5cea1..3c644bf 100644 --- a/src/BaseAccountAPI.ts +++ b/src/BaseAccountAPI.ts @@ -5,7 +5,7 @@ import { UserOperationStruct } from '@account-abstraction/contracts' -import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp' +import { TransactionDetailsForUserOp, BatchTransactionDetailsForUserOp } from './TransactionDetailsForUserOp' import { resolveProperties } from 'ethers/lib/utils' import { PaymasterAPI } from './PaymasterAPI' import { getUserOpHash, NotPromise, packUserOp } from '@account-abstraction/utils' @@ -101,6 +101,14 @@ export abstract class BaseAccountAPI { */ abstract encodeExecute (target: string, value: BigNumberish, data: string): Promise + /** + * encode a batch of method calls from entryPoint through our account to the target contracts. + * @param targets array of target addresses + * @param values array of values + * @param datas array of call data + */ + abstract encodeExecuteBatch (targets: string[], values: BigNumberish[], datas: string[]): Promise + /** * sign a userOp's hash (userOpHash). * @param userOpHash @@ -403,4 +411,139 @@ export abstract class BaseAccountAPI { * This should be implemented by derived classes */ abstract getSignatureMode(): SignatureMode + + /** + * encode batch operations for user operation call data and estimate gas + */ + async encodeBatchUserOpCallDataAndGasLimit (detailsForUserOp: BatchTransactionDetailsForUserOp): Promise<{ callData: string, callGasLimit: BigNumber }> { + const { targets, values, datas } = detailsForUserOp + + // Validate arrays have same length + if (targets.length !== values.length || targets.length !== datas.length) { + throw new Error('Batch operation arrays must have the same length') + } + + const callData = await this.encodeExecuteBatch(targets, values, datas) + + debug('Starting batch estimation for %d operations', targets.length) + + // If user provided gas limit, use it + if (detailsForUserOp.gasLimit) { + debug('Using provided gas limit: %s', detailsForUserOp.gasLimit.toString()) + return { + callData, + callGasLimit: BigNumber.from(detailsForUserOp.gasLimit) + } + } + + debug('Using bundler estimation for batch operations...') + const initCode = await this.getInitCode() + const verificationGasLimit = await this.getVerificationGasLimit() + + debug('Preparing bundler estimation for batch: %o', { + signatureMode: this.getSignatureMode(), + requestedVerificationGas: verificationGasLimit.toString() + }) + + const partialOp = { + sender: await this.getAccountAddress(), + nonce: await this.getNonce(), + initCode, + callData, + callGasLimit: 0, + verificationGasLimit, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: '0x', + signature: '0x' + } + + const bundlerEstimation = await this.httpRpcClient.estimateUserOpGas(partialOp) + + if (!bundlerEstimation.success) { + throw new Error('Bundler gas estimation failed for batch operation') + } + + debug('Bundler estimation details for batch: %o', { + signatureMode: this.getSignatureMode(), + requestedVerificationGas: verificationGasLimit.toString(), + bundlerVerificationGas: bundlerEstimation.verificationGas.toString(), + callGasLimit: bundlerEstimation.callGasLimit.toString(), + preVerificationGas: bundlerEstimation.preVerificationGas.toString(), + totalGas: ( + Number(bundlerEstimation.verificationGas) + + Number(bundlerEstimation.callGasLimit) + + Number(bundlerEstimation.preVerificationGas) + ).toString() + }) + + return { + callData, + callGasLimit: BigNumber.from(bundlerEstimation.callGasLimit) + } + } + + /** + * create a batch UserOperation, filling all details (except signature) + */ + async createUnsignedBatchUserOp (info: BatchTransactionDetailsForUserOp): Promise { + const { + callData, + callGasLimit + } = await this.encodeBatchUserOpCallDataAndGasLimit(info) + const initCode = await this.getInitCode() + + const initGas = await this.estimateCreationGas(initCode) + const verificationGasLimit = BigNumber.from(await this.getVerificationGasLimit()) + .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: await this.getAccountAddress(), + nonce: info.nonce ?? await this.getNonce(), + initCode, + callData, + callGasLimit, + verificationGasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + paymasterAndData: '0x' + } + + let paymasterAndData: string | undefined + if (this.paymasterAPI != null) { + // fill (partial) preVerificationGas (all except the cost of the generated paymasterAndData) + const userOpForPm = { + ...partialUserOp, + preVerificationGas: await this.getPreVerificationGas(partialUserOp) + } + paymasterAndData = await this.paymasterAPI.getPaymasterAndData(userOpForPm) + } + partialUserOp.paymasterAndData = paymasterAndData ?? '0x' + return { + ...partialUserOp, + preVerificationGas: await this.getPreVerificationGas(partialUserOp), + signature: '' + } + } + + /** + * helper method: create and sign a batch user operation. + */ + async createSignedBatchUserOp (info: BatchTransactionDetailsForUserOp): Promise { + return await this.signUserOp(await this.createUnsignedBatchUserOp(info)) + } } diff --git a/src/ERC4337EthersSigner.ts b/src/ERC4337EthersSigner.ts index a9242a3..00dea4f 100644 --- a/src/ERC4337EthersSigner.ts +++ b/src/ERC4337EthersSigner.ts @@ -2,7 +2,7 @@ import { Deferrable, defineReadOnly } from '@ethersproject/properties' import { Provider, TransactionRequest, TransactionResponse } from '@ethersproject/providers' import { Signer } from '@ethersproject/abstract-signer' -import { Bytes } from 'ethers' +import { Bytes, BigNumber, BigNumberish } from 'ethers' import { ERC4337EthersProvider } from './ERC4337EthersProvider' import { ClientConfig } from './ClientConfig' import { HttpRpcClient } from './HttpRpcClient' @@ -12,6 +12,15 @@ import Debug from 'debug' const debug = Debug('aa.signer') +export interface BatchTransactionRequest { + targets: string[] + datas: string[] + values: BigNumberish[] + gasLimit?: BigNumberish + maxFeePerGas?: BigNumberish + maxPriorityFeePerGas?: BigNumberish +} + export class ERC4337EthersSigner extends Signer { // TODO: we have 'erc4337provider', remove shared dependencies or avoid two-way reference constructor ( @@ -99,6 +108,55 @@ export class ERC4337EthersSigner extends Signer { } } + async verifyAllNecessaryBatchFields (batchRequest: BatchTransactionRequest): Promise { + if (batchRequest.targets.length === 0) { + throw new Error('Empty batch request') + } + if (batchRequest.targets.length !== batchRequest.datas.length || batchRequest.targets.length !== batchRequest.values.length) { + throw new Error('Batch arrays must have the same length') + } + for (const target of batchRequest.targets) { + if (target == null) { + throw new Error('Missing call target in batch') + } + } + } + + /** + * Send a batch of transactions + */ + async sendBatchTransaction(batchRequest: BatchTransactionRequest): Promise { + await this.verifyAllNecessaryBatchFields(batchRequest) + + // Convert values to BigNumber and ensure they're in the correct format + const convertedRequest = { + targets: batchRequest.targets, + datas: batchRequest.datas.map(d => d || '0x'), + values: batchRequest.values.map(v => BigNumber.from(v || 0)), + gasLimit: batchRequest.gasLimit ? BigNumber.from(batchRequest.gasLimit) : undefined, + maxFeePerGas: batchRequest.maxFeePerGas ? BigNumber.from(batchRequest.maxFeePerGas) : undefined, + maxPriorityFeePerGas: batchRequest.maxPriorityFeePerGas ? BigNumber.from(batchRequest.maxPriorityFeePerGas) : undefined + } + + const userOpDetails = { + targets: convertedRequest.targets, + datas: convertedRequest.datas, + values: convertedRequest.values, + ...(batchRequest.gasLimit !== undefined && { gasLimit: convertedRequest.gasLimit }), + maxFeePerGas: convertedRequest.maxFeePerGas, + maxPriorityFeePerGas: convertedRequest.maxPriorityFeePerGas + } + + const userOperation = await this.smartAccountAPI.createSignedBatchUserOp(userOpDetails) + const transactionResponse = await this.erc4337provider.constructUserOpTransactionResponse(userOperation) + try { + await this.httpRpcClient.sendUserOpToBundler(userOperation) + } catch (error: any) { + throw this.unwrapError(error) + } + return transactionResponse + } + connect (provider: Provider): Signer { throw new Error('changing providers is not supported') } diff --git a/src/SimpleAccountAPI.ts b/src/SimpleAccountAPI.ts index 3c1663a..411861a 100644 --- a/src/SimpleAccountAPI.ts +++ b/src/SimpleAccountAPI.ts @@ -137,6 +137,19 @@ export class SimpleAccountAPI extends BaseAccountAPI { ]) } + /** + * encode a batch of method calls from entryPoint to our contract + * @param targets array of target addresses + * @param values array of values + * @param datas array of call data + */ + async encodeExecuteBatch (targets: string[], values: BigNumberish[], datas: string[]): Promise { + const iface = new Interface([ + 'function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata func) external' + ]) + return iface.encodeFunctionData('executeBatch', [targets, values, datas]) + } + async signUserOpHash (userOpHash: string): Promise { const signedMessage = await this.owner.signMessage(arrayify(userOpHash)) const versionBytes = zeroPad( diff --git a/src/TransactionDetailsForUserOp.ts b/src/TransactionDetailsForUserOp.ts index 6419f79..66f2106 100644 --- a/src/TransactionDetailsForUserOp.ts +++ b/src/TransactionDetailsForUserOp.ts @@ -9,3 +9,13 @@ export interface TransactionDetailsForUserOp { maxPriorityFeePerGas?: BigNumberish nonce?: BigNumberish } + +export interface BatchTransactionDetailsForUserOp { + targets: string[] + datas: string[] + values: BigNumberish[] + gasLimit?: BigNumberish + maxFeePerGas?: BigNumberish + maxPriorityFeePerGas?: BigNumberish + nonce?: BigNumberish +}