diff --git a/.changeset/eighty-pens-judge.md b/.changeset/eighty-pens-judge.md new file mode 100644 index 00000000000..72092eb0750 --- /dev/null +++ b/.changeset/eighty-pens-judge.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Added `estimateUserOpGasCost()` utility function for estimating the total gas cost in wei/ether of user operations diff --git a/packages/thirdweb/src/chains/constants.ts b/packages/thirdweb/src/chains/constants.ts index 6aa78e774c6..b6e8756ec82 100644 --- a/packages/thirdweb/src/chains/constants.ts +++ b/packages/thirdweb/src/chains/constants.ts @@ -25,6 +25,21 @@ const opChains = [ * TODO this should be in the chain definition itself * @internal */ -export function isOpStackChain(chain: Chain) { - return opChains.includes(chain.id); +export async function isOpStackChain(chain: Chain) { + if (chain.id === 1337 || chain.id === 31337) { + return false; + } + + if (opChains.includes(chain.id)) { + return true; + } + // fallback to checking the stack on rpc + try { + const { getChainMetadata } = await import("./utils.js"); + const chainMetadata = await getChainMetadata(chain); + return chainMetadata.stackType === "optimism_bedrock"; + } catch { + // If the network check fails, assume it's not a OP chain + return false; + } } diff --git a/packages/thirdweb/src/exports/wallets/smart.ts b/packages/thirdweb/src/exports/wallets/smart.ts index f0c4bd6cf16..ffacbb0fe7d 100644 --- a/packages/thirdweb/src/exports/wallets/smart.ts +++ b/packages/thirdweb/src/exports/wallets/smart.ts @@ -13,6 +13,7 @@ export { bundleUserOp, getUserOpGasFees, estimateUserOpGas, + estimateUserOpGasCost, } from "../../wallets/smart/lib/bundler.js"; export { diff --git a/packages/thirdweb/src/transaction/actions/estimate-gas-cost.ts b/packages/thirdweb/src/transaction/actions/estimate-gas-cost.ts index 235695efa2a..a4d5dfe9f1a 100644 --- a/packages/thirdweb/src/transaction/actions/estimate-gas-cost.ts +++ b/packages/thirdweb/src/transaction/actions/estimate-gas-cost.ts @@ -44,7 +44,7 @@ export async function estimateGasCost( ); } let l1Fee: bigint; - if (isOpStackChain(transaction.chain)) { + if (await isOpStackChain(transaction.chain)) { const { estimateL1Fee } = await import("../../gas/estimate-l1-fee.js"); l1Fee = await estimateL1Fee({ transaction, diff --git a/packages/thirdweb/src/wallets/smart/index.ts b/packages/thirdweb/src/wallets/smart/index.ts index cb9570ef572..0adcc53e5d7 100644 --- a/packages/thirdweb/src/wallets/smart/index.ts +++ b/packages/thirdweb/src/wallets/smart/index.ts @@ -556,7 +556,7 @@ async function _sendUserOp(args: { } } -async function getEntrypointFromFactory( +export async function getEntrypointFromFactory( factoryAddress: string, client: ThirdwebClient, chain: Chain, diff --git a/packages/thirdweb/src/wallets/smart/lib/bundler.ts b/packages/thirdweb/src/wallets/smart/lib/bundler.ts index f8516710c1f..88b06399905 100644 --- a/packages/thirdweb/src/wallets/smart/lib/bundler.ts +++ b/packages/thirdweb/src/wallets/smart/lib/bundler.ts @@ -1,27 +1,37 @@ import { decodeErrorResult } from "viem"; +import type { ThirdwebClient } from "../../../client/client.js"; +import { getContract } from "../../../contract/contract.js"; import { parseEventLogs } from "../../../event/actions/parse-logs.js"; import { userOperationRevertReasonEvent } from "../../../extensions/erc4337/__generated__/IEntryPoint/events/UserOperationRevertReason.js"; import { postOpRevertReasonEvent } from "../../../extensions/erc4337/__generated__/IEntryPoint_v07/events/PostOpRevertReason.js"; +import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js"; import type { SerializableTransaction } from "../../../transaction/serialize-transaction.js"; import type { TransactionReceipt } from "../../../transaction/types.js"; +import { isContractDeployed } from "../../../utils/bytecode/is-contract-deployed.js"; import { type Hex, hexToBigInt } from "../../../utils/encoding/hex.js"; import { getClientFetch } from "../../../utils/fetch.js"; import { stringify } from "../../../utils/json.js"; +import { toEther } from "../../../utils/units.js"; +import type { Account } from "../../interfaces/wallet.js"; +import { getEntrypointFromFactory } from "../index.js"; import { type BundlerOptions, type EstimationResult, type GasPriceResult, type PmTransactionData, + type SmartWalletOptions, type UserOperationReceipt, type UserOperationV06, type UserOperationV07, formatUserOperationReceipt, } from "../types.js"; +import { predictSmartAccountAddress } from "./calls.js"; import { ENTRYPOINT_ADDRESS_v0_6, MANAGED_ACCOUNT_GAS_BUFFER, getDefaultBundlerUrl, } from "./constants.js"; +import { prepareUserOp } from "./userop.js"; import { hexlifyUserOp } from "./utils.js"; /** @@ -111,6 +121,91 @@ export async function estimateUserOpGas( }; } +/** + * Estimate the gas cost of a user operation. + * @param args - The options for estimating the gas cost of a user operation. + * @returns The estimated gas cost of the user operation. + * @example + * ```ts + * import { estimateUserOpGasCost } from "thirdweb/wallets/smart"; + * + * const gasCost = await estimateUserOpGasCost({ + * transactions, + * adminAccount, + * client, + * smartWalletOptions, + * }); + * ``` + * @walletUtils + */ +export async function estimateUserOpGasCost(args: { + transactions: PreparedTransaction[]; + adminAccount: Account; + client: ThirdwebClient; + smartWalletOptions: SmartWalletOptions; +}) { + // if factory is passed, but no entrypoint, try to resolve entrypoint from factory + if ( + args.smartWalletOptions.factoryAddress && + !args.smartWalletOptions.overrides?.entrypointAddress + ) { + const entrypointAddress = await getEntrypointFromFactory( + args.smartWalletOptions.factoryAddress, + args.client, + args.smartWalletOptions.chain, + ); + if (entrypointAddress) { + args.smartWalletOptions.overrides = { + ...args.smartWalletOptions.overrides, + entrypointAddress, + }; + } + } + + const userOp = await prepareUserOp({ + transactions: args.transactions, + adminAccount: args.adminAccount, + client: args.client, + smartWalletOptions: args.smartWalletOptions, + isDeployedOverride: await isContractDeployed( + getContract({ + address: await predictSmartAccountAddress({ + adminAddress: args.adminAccount.address, + factoryAddress: args.smartWalletOptions.factoryAddress, + chain: args.smartWalletOptions.chain, + client: args.client, + }), + chain: args.smartWalletOptions.chain, + client: args.client, + }), + ), + }); + + let gasLimit = 0n; + if ("paymasterVerificationGasLimit" in userOp) { + // v0.7 + gasLimit = + BigInt(userOp.paymasterVerificationGasLimit ?? 0) + + BigInt(userOp.paymasterPostOpGasLimit ?? 0) + + BigInt(userOp.verificationGasLimit ?? 0) + + BigInt(userOp.preVerificationGas ?? 0) + + BigInt(userOp.callGasLimit ?? 0); + } else { + // v0.6 + gasLimit = + BigInt(userOp.verificationGasLimit ?? 0) + + BigInt(userOp.preVerificationGas ?? 0) + + BigInt(userOp.callGasLimit ?? 0); + } + + const gasCost = gasLimit * (userOp.maxFeePerGas ?? 0n); + + return { + ether: toEther(gasCost), + wei: gasCost, + }; +} + /** * Get the gas fees of a user operation. * @param args - The options for getting the gas price of a user operation. diff --git a/packages/thirdweb/src/wallets/smart/lib/userop.ts b/packages/thirdweb/src/wallets/smart/lib/userop.ts index 77f11c98825..70a1201595c 100644 --- a/packages/thirdweb/src/wallets/smart/lib/userop.ts +++ b/packages/thirdweb/src/wallets/smart/lib/userop.ts @@ -706,6 +706,32 @@ export async function createAndSignUserOp(options: { smartWalletOptions: SmartWalletOptions; waitForDeployment?: boolean; isDeployedOverride?: boolean; +}) { + const unsignedUserOp = await prepareUserOp({ + transactions: options.transactions, + adminAccount: options.adminAccount, + client: options.client, + smartWalletOptions: options.smartWalletOptions, + waitForDeployment: options.waitForDeployment, + isDeployedOverride: options.isDeployedOverride, + }); + const signedUserOp = await signUserOp({ + client: options.client, + chain: options.smartWalletOptions.chain, + adminAccount: options.adminAccount, + entrypointAddress: options.smartWalletOptions.overrides?.entrypointAddress, + userOp: unsignedUserOp, + }); + return signedUserOp; +} + +export async function prepareUserOp(options: { + transactions: PreparedTransaction[]; + adminAccount: Account; + client: ThirdwebClient; + smartWalletOptions: SmartWalletOptions; + waitForDeployment?: boolean; + isDeployedOverride?: boolean; }) { const config = options.smartWalletOptions; const factoryContract = getContract({ @@ -731,6 +757,7 @@ export async function createAndSignUserOp(options: { let executeTx: PreparedTransaction; if (options.transactions.length === 1) { const tx = options.transactions[0] as PreparedTransaction; + // for single tx, simulate fully const serializedTx = await toSerializableTransaction({ transaction: tx, from: accountAddress, @@ -741,13 +768,21 @@ export async function createAndSignUserOp(options: { executeOverride: config.overrides?.execute, }); } else { + // for multiple txs, we can't simulate, just encode const serializedTxs = await Promise.all( - options.transactions.map((tx) => - toSerializableTransaction({ - transaction: tx, - from: accountAddress, - }), - ), + options.transactions.map(async (tx) => { + const [data, to, value] = await Promise.all([ + encode(tx), + resolvePromisedValue(tx.to), + resolvePromisedValue(tx.value), + ]); + return { + data, + to, + value, + chainId: tx.chain.id, + }; + }), ); executeTx = prepareBatchExecute({ accountContract, @@ -756,7 +791,7 @@ export async function createAndSignUserOp(options: { }); } - const unsignedUserOp = await createUnsignedUserOp({ + return createUnsignedUserOp({ transaction: executeTx, factoryContract, accountContract, @@ -766,14 +801,6 @@ export async function createAndSignUserOp(options: { waitForDeployment: options.waitForDeployment, isDeployedOverride: options.isDeployedOverride, }); - const signedUserOp = await signUserOp({ - client: options.client, - chain: config.chain, - adminAccount: options.adminAccount, - entrypointAddress: config.overrides?.entrypointAddress, - userOp: unsignedUserOp, - }); - return signedUserOp; } async function waitForAccountDeployed(accountContract: ThirdwebContract) { diff --git a/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts b/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts index 8c304a0dc7f..8e64e840028 100644 --- a/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts +++ b/packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts @@ -16,7 +16,6 @@ import { } from "../../exports/extensions/erc4337.js"; import { claimTo } from "../../extensions/erc1155/drops/write/claimTo.js"; import { setContractURI } from "../../extensions/marketplace/__generated__/IMarketplace/write/setContractURI.js"; -import { estimateGasCost } from "../../transaction/actions/estimate-gas-cost.js"; import { sendAndConfirmTransaction } from "../../transaction/actions/send-and-confirm-transaction.js"; import { sendBatchTransaction } from "../../transaction/actions/send-batch-transaction.js"; import { waitForReceipt } from "../../transaction/actions/wait-for-tx-receipt.js"; @@ -27,6 +26,7 @@ import { hashTypedData } from "../../utils/hashing/hashTypedData.js"; import { sleep } from "../../utils/sleep.js"; import type { Account, Wallet } from "../interfaces/wallet.js"; import { generateAccount } from "../utils/generateAccount.js"; +import { estimateUserOpGasCost } from "./lib/bundler.js"; import { predictSmartAccountAddress } from "./lib/calls.js"; import { DEFAULT_ACCOUNT_FACTORY_V0_7 } from "./lib/constants.js"; import { @@ -87,6 +87,27 @@ describe.sequential( expect(predictedAddress).toEqual(smartWalletAddress); }); + it("can estimate gas cost", async () => { + const gasCost = await estimateUserOpGasCost({ + transactions: [ + claimTo({ + contract, + quantity: 1n, + to: smartWalletAddress, + tokenId: 0n, + }), + ], + adminAccount: personalAccount, + client: TEST_CLIENT, + smartWalletOptions: { + chain, + sponsorGas: true, + factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7, + }, + }); + expect(gasCost.ether).not.toBe("0"); + }); + it("can sign a msg", async () => { const signature = await smartAccount.signMessage({ message: "hello world", @@ -202,19 +223,6 @@ describe.sequential( expect(isDeployed).toEqual(true); }); - it("can estimate a tx", async () => { - const estimates = await estimateGasCost({ - transaction: claimTo({ - contract, - quantity: 1n, - to: smartWalletAddress, - tokenId: 0n, - }), - account: smartAccount, - }); - expect(estimates.wei.toString()).not.toBe("0"); - }); - it("can execute a batched tx", async () => { const tx = await sendBatchTransaction({ account: smartAccount, diff --git a/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts b/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts index 50f0c572916..605eb8ae006 100644 --- a/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts +++ b/packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts @@ -29,6 +29,7 @@ import { hashTypedData } from "../../utils/hashing/hashTypedData.js"; import { sleep } from "../../utils/sleep.js"; import type { Account, Wallet } from "../interfaces/wallet.js"; import { generateAccount } from "../utils/generateAccount.js"; +import { estimateUserOpGasCost } from "./lib/bundler.js"; import { predictSmartAccountAddress } from "./lib/calls.js"; import { deploySmartAccount } from "./lib/signing.js"; import { smartWallet } from "./smart-wallet.js"; @@ -74,6 +75,26 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential( }); }); + it("can estimate gas cost", async () => { + const gasCost = await estimateUserOpGasCost({ + transactions: [ + claimTo({ + contract, + quantity: 1n, + to: smartWalletAddress, + tokenId: 0n, + }), + ], + adminAccount: personalAccount, + client: TEST_CLIENT, + smartWalletOptions: { + chain, + sponsorGas: true, + }, + }); + expect(gasCost.ether).not.toBe("0"); + }); + it("can connect", async () => { expect(smartWalletAddress).toHaveLength(42); const predictedAddress = await predictSmartAccountAddress({