diff --git a/.changeset/cuddly-kings-listen.md b/.changeset/cuddly-kings-listen.md new file mode 100644 index 00000000000..91ceeb9dd5b --- /dev/null +++ b/.changeset/cuddly-kings-listen.md @@ -0,0 +1,17 @@ +--- +"@thirdweb-dev/wallets": patch +--- + +Expose function to estimate SmartWallet transactions, handling pre and post deployment state + +```ts +const cost = smartWallet.estimate(preparedTx); +const costBatch = smartWallet.estimateBatch(preparedTxs); +``` + +Also works with raw transactions + +```ts +const cost = smartWallet.estimateRaw(rawTx); +const costBatch = smartWallet.estimateBatchRaw(rawTxs); +``` diff --git a/packages/wallets/src/evm/connectors/smart-wallet/index.ts b/packages/wallets/src/evm/connectors/smart-wallet/index.ts index db4141a70e3..f2946b2c773 100644 --- a/packages/wallets/src/evm/connectors/smart-wallet/index.ts +++ b/packages/wallets/src/evm/connectors/smart-wallet/index.ts @@ -16,6 +16,7 @@ import { ERC4337EthersSigner } from "./lib/erc4337-signer"; import { BigNumber, ethers, providers, utils } from "ethers"; import { getChainProvider, + getGasPrice, SignerPermissionsInput, SignerWithPermissions, SmartContract, @@ -25,6 +26,8 @@ import { } from "@thirdweb-dev/sdk"; import { AccountAPI } from "./lib/account"; import { AddressZero } from "@account-abstraction/utils"; +import { TransactionDetailsForUserOp } from "./lib/transaction-details"; +import { BatchData } from "./lib/base-api"; export class SmartWalletConnector extends Connector { protected config: SmartWalletConfig; @@ -165,6 +168,8 @@ export class SmartWalletConnector extends Connector { return restrictions.approvedCallTargets.includes(transaction.getTarget()); } + /// PREPARED TRANSACTIONS + /** * Send a single transaction without waiting for confirmations * @param transactions @@ -199,21 +204,14 @@ export class SmartWalletConnector extends Connector { throw new Error("Personal wallet not connected"); } const signer = await this.getSigner(); - const targets = transactions.map((tx) => tx.getTarget()); - const data = transactions.map((tx) => tx.encode()); - const values = await Promise.all(transactions.map((tx) => tx.getValue())); - const callData = await this.accountApi.encodeExecuteBatch( - targets, - values, - data, - ); + const { tx, batchData } = await this.prepareBatchTx(transactions); return await signer.sendTransaction( { to: await signer.getAddress(), - data: callData, + data: tx.encode(), value: 0, }, - true, // batched tx flag + batchData, ); } @@ -232,6 +230,8 @@ export class SmartWalletConnector extends Connector { }; } + /// RAW TRANSACTIONS + async sendRaw( transaction: utils.Deferrable, ): Promise { @@ -259,26 +259,14 @@ export class SmartWalletConnector extends Connector { throw new Error("Personal wallet not connected"); } const signer = await this.getSigner(); - const resolvedTxs = await Promise.all( - transactions.map((transaction) => - ethers.utils.resolveProperties(transaction), - ), - ); - const targets = resolvedTxs.map((tx) => tx.to || AddressZero); - const data = resolvedTxs.map((tx) => tx.data || "0x"); - const values = resolvedTxs.map((tx) => tx.value || BigNumber.from(0)); - const callData = await this.accountApi.encodeExecuteBatch( - targets, - values, - data, - ); + const batch = await this.prepareBatchRaw(transactions); return signer.sendTransaction( { to: await signer.getAddress(), - data: callData, + data: batch.tx.encode(), value: 0, }, - true, // batched tx flag + batch.batchData, // batched tx flag ); } @@ -292,6 +280,67 @@ export class SmartWalletConnector extends Connector { }; } + /// ESTIMATION + + async estimate(transaction: Transaction) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + return this.estimateTx({ + target: transaction.getTarget(), + data: transaction.encode(), + value: await transaction.getValue(), + }); + } + + async estimateRaw( + transaction: utils.Deferrable, + ) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + const tx = await ethers.utils.resolveProperties(transaction); + return this.estimateTx({ + target: tx.to || AddressZero, + data: tx.data?.toString() || "", + value: tx.value || BigNumber.from(0), + }); + } + + async estimateBatch(transactions: Transaction[]) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + const { tx, batchData } = await this.prepareBatchTx(transactions); + return this.estimateTx( + { + target: tx.getTarget(), + data: tx.encode(), + value: await tx.getValue(), + }, + batchData, + ); + } + + async estimateBatchRaw( + transactions: utils.Deferrable[], + ) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + const { tx, batchData } = await this.prepareBatchRaw(transactions); + return this.estimateTx( + { + target: tx.getTarget(), + data: tx.encode(), + value: await tx.getValue(), + }, + batchData, + ); + } + + //// DEPLOYMENT + /** * Manually deploy the smart wallet contract. If already deployed this will throw an error. * Note that this is not necessary as the smart wallet will be deployed automatically on the first transaction the user makes. @@ -301,16 +350,16 @@ export class SmartWalletConnector extends Connector { if (!this.accountApi) { throw new Error("Personal wallet not connected"); } - if (await this.accountApi.isAcountDeployed()) { - throw new Error("Smart wallet already deployed"); - } const signer = await this.getSigner(); const tx = await signer.sendTransaction( { to: await signer.getAddress(), data: "0x", }, - true, // batched tx flag to avoid hitting the Router fallback method + { + targets: [], + data: [], + }, // batched tx flag to avoid hitting the Router fallback method ); const receipt = await tx.wait(); return { receipt }; @@ -334,6 +383,8 @@ export class SmartWalletConnector extends Connector { } } + //// PERMISSIONS + async grantPermissions( target: string, permissions: SignerPermissionsInput, @@ -472,4 +523,96 @@ export class SmartWalletConnector extends Connector { }, }; } + + /// PRIVATE METHODS + + private async estimateTx( + tx: TransactionDetailsForUserOp, + batchData?: BatchData, + ) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + let deployGasLimit = BigNumber.from(0); + const [provider, isDeployed] = await Promise.all([ + this.getProvider(), + this.isDeployed(), + ]); + if (!isDeployed) { + deployGasLimit = await this.estimateDeploymentGasLimit(); + } + const [{ callGasLimit: transactionGasLimit }, gasPrice] = await Promise.all( + [ + this.accountApi.encodeUserOpCallDataAndGasLimit(tx, batchData), + getGasPrice(provider), + ], + ); + const transactionCost = transactionGasLimit.mul(gasPrice); + const deployCost = deployGasLimit.mul(gasPrice); + const totalCost = deployCost.add(transactionCost); + + return { + ether: utils.formatEther(totalCost), + wei: totalCost, + details: { + deployGasLimit, + transactionGasLimit, + gasPrice, + transactionCost, + deployCost, + totalCost, + }, + }; + } + + private async estimateDeploymentGasLimit() { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + const initCode = await this.accountApi.getInitCode(); + const [initGas, verificationGasLimit] = await Promise.all([ + this.accountApi.estimateCreationGas(initCode), + this.accountApi.getVerificationGasLimit(), + ]); + return BigNumber.from(verificationGasLimit).add(initGas); + } + + private async prepareBatchRaw( + transactions: ethers.utils.Deferrable[], + ) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + const resolvedTxs = await Promise.all( + transactions.map((transaction) => + ethers.utils.resolveProperties(transaction), + ), + ); + const targets = resolvedTxs.map((tx) => tx.to || AddressZero); + const data = resolvedTxs.map((tx) => tx.data || "0x"); + const values = resolvedTxs.map((tx) => tx.value || BigNumber.from(0)); + return { + tx: await this.accountApi.prepareExecuteBatch(targets, values, data), + batchData: { + targets, + data, + }, + }; + } + + private async prepareBatchTx(transactions: Transaction[]) { + if (!this.accountApi) { + throw new Error("Personal wallet not connected"); + } + const targets = transactions.map((tx) => tx.getTarget()); + const data = transactions.map((tx) => tx.encode()); + const values = await Promise.all(transactions.map((tx) => tx.getValue())); + return { + tx: await this.accountApi.prepareExecuteBatch(targets, values, data), + batchData: { + targets, + data, + }, + }; + } } diff --git a/packages/wallets/src/evm/connectors/smart-wallet/lib/account.ts b/packages/wallets/src/evm/connectors/smart-wallet/lib/account.ts index afd4d17f284..e7b986458e0 100644 --- a/packages/wallets/src/evm/connectors/smart-wallet/lib/account.ts +++ b/packages/wallets/src/evm/connectors/smart-wallet/lib/account.ts @@ -1,4 +1,9 @@ -import { LOCAL_NODE_PKEY, SmartContract, ThirdwebSDK } from "@thirdweb-dev/sdk"; +import { + LOCAL_NODE_PKEY, + SmartContract, + ThirdwebSDK, + Transaction, +} from "@thirdweb-dev/sdk"; import { BigNumberish, BigNumber, ethers, utils, BytesLike } from "ethers"; import { AccountApiParams } from "../types"; import { BaseAccountAPI } from "./base-api"; @@ -103,33 +108,27 @@ export class AccountAPI extends BaseAccountAPI { return this.params.accountInfo.getNonce(accountContract); } - async encodeExecute( + async prepareExecute( target: string, value: BigNumberish, data: string, - ): Promise { + ): Promise> { const accountContract = await this.getAccountContract(); - const tx = await this.params.accountInfo.execute( + return this.params.accountInfo.execute( accountContract, target, value, data, ); - return tx.encode(); } - async encodeExecuteBatch( + async prepareExecuteBatch( targets: string[], values: BigNumberish[], datas: BytesLike[], - ): Promise { + ): Promise> { const accountContract = await this.getAccountContract(); - const tx = accountContract.prepare("executeBatch", [ - targets, - values, - datas, - ]); - return tx.encode(); + return accountContract.prepare("executeBatch", [targets, values, datas]); } async signUserOpHash(userOpHash: string): Promise { diff --git a/packages/wallets/src/evm/connectors/smart-wallet/lib/base-api.ts b/packages/wallets/src/evm/connectors/smart-wallet/lib/base-api.ts index 27093b9c0f6..e6b2fac6f33 100644 --- a/packages/wallets/src/evm/connectors/smart-wallet/lib/base-api.ts +++ b/packages/wallets/src/evm/connectors/smart-wallet/lib/base-api.ts @@ -1,4 +1,11 @@ -import { ethers, BigNumber, BigNumberish, providers, utils } from "ethers"; +import { + ethers, + BigNumber, + BigNumberish, + providers, + utils, + BytesLike, +} from "ethers"; import { EntryPoint, EntryPoint__factory, @@ -19,7 +26,12 @@ import { CeloBaklavaTestnet, Celo, } from "@thirdweb-dev/chains"; -import { getDynamicFeeData } from "@thirdweb-dev/sdk"; +import { Transaction, getDynamicFeeData } from "@thirdweb-dev/sdk"; + +export type BatchData = { + targets: (string | undefined)[]; + data: BytesLike[]; +}; export interface BaseApiParams { provider: providers.Provider; @@ -103,11 +115,11 @@ export abstract class BaseAccountAPI { * @param value * @param data */ - abstract encodeExecute( + abstract prepareExecute( target: string, value: BigNumberish, data: string, - ): Promise; + ): Promise>; /** * sign a userOp's hash (userOpHash). @@ -186,33 +198,40 @@ export abstract class BaseAccountAPI { async encodeUserOpCallDataAndGasLimit( detailsForUserOp: TransactionDetailsForUserOp, - batched: boolean, + batchData?: BatchData, ): Promise<{ callData: string; callGasLimit: BigNumber }> { - function parseNumber(a: any): BigNumber | null { - if (!a || a === "") { - return null; - } - return BigNumber.from(a.toString()); - } - const value = parseNumber(detailsForUserOp.value) ?? BigNumber.from(0); - const callData = batched + const callData = batchData ? detailsForUserOp.data - : await this.encodeExecute( + : await this.prepareExecute( detailsForUserOp.target, value, detailsForUserOp.data, - ); + ).then((tx) => tx.encode()); - let callGasLimit; + let callGasLimit: BigNumber; const isPhantom = await this.checkAccountPhantom(); if (isPhantom) { // when the account is not deployed yet, we simulate the call to the target contract directly - callGasLimit = await this.provider.estimateGas({ - from: this.getAccountAddress(), - to: detailsForUserOp.target, - data: detailsForUserOp.data, - }); + if (batchData) { + const limits = await Promise.all( + batchData.targets.map((_, i) => + this.provider.estimateGas({ + from: this.getAccountAddress(), + to: batchData.targets[i], + data: batchData.data[i], + }), + ), + ); + callGasLimit = limits.reduce((a, b) => a.add(b), BigNumber.from(0)); + } else { + callGasLimit = await this.provider.estimateGas({ + from: this.getAccountAddress(), + to: detailsForUserOp.target, + data: detailsForUserOp.data, + }); + } + // add 20% overhead for entrypoint checks callGasLimit = callGasLimit.mul(120).div(100); // if the estimation is too low, we use a fixed value of 500k @@ -280,10 +299,10 @@ export abstract class BaseAccountAPI { */ async createUnsignedUserOp( info: TransactionDetailsForUserOp, - batched: boolean, + batchData?: BatchData, ): Promise { const { callData, callGasLimit } = - await this.encodeUserOpCallDataAndGasLimit(info, batched); + await this.encodeUserOpCallDataAndGasLimit(info, batchData); const initCode = await this.getInitCode(); const initGas = await this.estimateCreationGas(initCode); @@ -397,10 +416,10 @@ export abstract class BaseAccountAPI { */ async createSignedUserOp( info: TransactionDetailsForUserOp, - batched: boolean, + batchData?: BatchData, ): Promise { return await this.signUserOp( - await this.createUnsignedUserOp(info, batched), + await this.createUnsignedUserOp(info, batchData), ); } @@ -429,3 +448,10 @@ export abstract class BaseAccountAPI { return null; } } + +function parseNumber(a: any): BigNumber | null { + if (!a || a === "") { + return null; + } + return BigNumber.from(a.toString()); +} diff --git a/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-provider.ts b/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-provider.ts index ea4b793c769..49a4c01aaff 100644 --- a/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-provider.ts +++ b/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-provider.ts @@ -62,15 +62,12 @@ export class ERC4337EthersProvider extends providers.BaseProvider { if (method === "estimateGas") { // hijack this to estimate gas from the entrypoint instead const { callGasLimit } = - await this.smartAccountAPI.encodeUserOpCallDataAndGasLimit( - { - target: params.transaction.to, - data: params.transaction.data, - value: params.transaction.value, - gasLimit: params.transaction.gasLimit, - }, - false, // TODO check this - ); + await this.smartAccountAPI.encodeUserOpCallDataAndGasLimit({ + target: params.transaction.to, + data: params.transaction.data, + value: params.transaction.value, + gasLimit: params.transaction.gasLimit, + }); return callGasLimit; } return await this.originalProvider.perform(method, params); diff --git a/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-signer.ts b/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-signer.ts index 533ba573ed5..09b0493e317 100644 --- a/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-signer.ts +++ b/packages/wallets/src/evm/connectors/smart-wallet/lib/erc4337-signer.ts @@ -2,7 +2,7 @@ import { ethers, providers, utils } from "ethers"; import { Bytes, Signer } from "ethers"; import { ClientConfig } from "@account-abstraction/sdk"; -import { BaseAccountAPI } from "./base-api"; +import { BaseAccountAPI, BatchData } from "./base-api"; import type { ERC4337EthersProvider } from "./erc4337-provider"; import { HttpRpcClient } from "./http-rpc-client"; import { randomNonce } from "./utils"; @@ -36,7 +36,7 @@ export class ERC4337EthersSigner extends Signer { // This one is called by Contract. It signs the request and passes in to Provider to be sent. async sendTransaction( transaction: utils.Deferrable, - batched: boolean = false, + batchData?: BatchData, ): Promise { const tx = await ethers.utils.resolveProperties(transaction); await this.verifyAllNecessaryFields(tx); @@ -50,7 +50,7 @@ export class ERC4337EthersSigner extends Signer { gasLimit: tx.gasLimit, nonce: multidimensionalNonce, }, - batched, + batchData, ); const transactionResponse = diff --git a/packages/wallets/src/evm/wallets/smart-wallet.ts b/packages/wallets/src/evm/wallets/smart-wallet.ts index 46aa82d9397..2ebdf7fb075 100644 --- a/packages/wallets/src/evm/wallets/smart-wallet.ts +++ b/packages/wallets/src/evm/wallets/smart-wallet.ts @@ -181,6 +181,50 @@ export class SmartWallet return connector.executeRaw(transaction); } + /** + * Estimate the gas cost of a single transaction + * @param transaction + * @returns + */ + async estimate(transaction: Transaction) { + const connector = await this.getConnector(); + return connector.estimate(transaction); + } + + /** + * Estimate the gas cost of a batch of transactions + * @param transaction + * @returns + */ + async estimateBatch(transactions: Transaction[]) { + const connector = await this.getConnector(); + return connector.estimateBatch(transactions); + } + + /** + * Estimate the gas cost of a single raw transaction + * @param transaction + * @returns + */ + async estimateRaw( + transactions: utils.Deferrable, + ) { + const connector = await this.getConnector(); + return connector.estimateRaw(transactions); + } + + /** + * Estimate the gas cost of a batch of raw transactions + * @param transaction + * @returns + */ + async estimateBatchRaw( + transactions: utils.Deferrable[], + ) { + const connector = await this.getConnector(); + return connector.estimateBatchRaw(transactions); + } + /** * Send multiple raw transaction in a batch without waiting for confirmations * @param transaction