Skip to content

Commit 5625ff1

Browse files
[SDK] expose estimateUserOpGasCost (#6370)
1 parent 062504f commit 5625ff1

File tree

9 files changed

+205
-33
lines changed

9 files changed

+205
-33
lines changed

.changeset/eighty-pens-judge.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Added `estimateUserOpGasCost()` utility function for estimating the total gas cost in wei/ether of user operations

packages/thirdweb/src/chains/constants.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ const opChains = [
2525
* TODO this should be in the chain definition itself
2626
* @internal
2727
*/
28-
export function isOpStackChain(chain: Chain) {
29-
return opChains.includes(chain.id);
28+
export async function isOpStackChain(chain: Chain) {
29+
if (chain.id === 1337 || chain.id === 31337) {
30+
return false;
31+
}
32+
33+
if (opChains.includes(chain.id)) {
34+
return true;
35+
}
36+
// fallback to checking the stack on rpc
37+
try {
38+
const { getChainMetadata } = await import("./utils.js");
39+
const chainMetadata = await getChainMetadata(chain);
40+
return chainMetadata.stackType === "optimism_bedrock";
41+
} catch {
42+
// If the network check fails, assume it's not a OP chain
43+
return false;
44+
}
3045
}

packages/thirdweb/src/exports/wallets/smart.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
bundleUserOp,
1414
getUserOpGasFees,
1515
estimateUserOpGas,
16+
estimateUserOpGasCost,
1617
} from "../../wallets/smart/lib/bundler.js";
1718

1819
export {

packages/thirdweb/src/transaction/actions/estimate-gas-cost.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function estimateGasCost(
4444
);
4545
}
4646
let l1Fee: bigint;
47-
if (isOpStackChain(transaction.chain)) {
47+
if (await isOpStackChain(transaction.chain)) {
4848
const { estimateL1Fee } = await import("../../gas/estimate-l1-fee.js");
4949
l1Fee = await estimateL1Fee({
5050
transaction,

packages/thirdweb/src/wallets/smart/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ async function _sendUserOp(args: {
556556
}
557557
}
558558

559-
async function getEntrypointFromFactory(
559+
export async function getEntrypointFromFactory(
560560
factoryAddress: string,
561561
client: ThirdwebClient,
562562
chain: Chain,

packages/thirdweb/src/wallets/smart/lib/bundler.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
import { decodeErrorResult } from "viem";
2+
import type { ThirdwebClient } from "../../../client/client.js";
3+
import { getContract } from "../../../contract/contract.js";
24
import { parseEventLogs } from "../../../event/actions/parse-logs.js";
35
import { userOperationRevertReasonEvent } from "../../../extensions/erc4337/__generated__/IEntryPoint/events/UserOperationRevertReason.js";
46
import { postOpRevertReasonEvent } from "../../../extensions/erc4337/__generated__/IEntryPoint_v07/events/PostOpRevertReason.js";
7+
import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js";
58
import type { SerializableTransaction } from "../../../transaction/serialize-transaction.js";
69
import type { TransactionReceipt } from "../../../transaction/types.js";
10+
import { isContractDeployed } from "../../../utils/bytecode/is-contract-deployed.js";
711
import { type Hex, hexToBigInt } from "../../../utils/encoding/hex.js";
812
import { getClientFetch } from "../../../utils/fetch.js";
913
import { stringify } from "../../../utils/json.js";
14+
import { toEther } from "../../../utils/units.js";
15+
import type { Account } from "../../interfaces/wallet.js";
16+
import { getEntrypointFromFactory } from "../index.js";
1017
import {
1118
type BundlerOptions,
1219
type EstimationResult,
1320
type GasPriceResult,
1421
type PmTransactionData,
22+
type SmartWalletOptions,
1523
type UserOperationReceipt,
1624
type UserOperationV06,
1725
type UserOperationV07,
1826
formatUserOperationReceipt,
1927
} from "../types.js";
28+
import { predictSmartAccountAddress } from "./calls.js";
2029
import {
2130
ENTRYPOINT_ADDRESS_v0_6,
2231
MANAGED_ACCOUNT_GAS_BUFFER,
2332
getDefaultBundlerUrl,
2433
} from "./constants.js";
34+
import { prepareUserOp } from "./userop.js";
2535
import { hexlifyUserOp } from "./utils.js";
2636

2737
/**
@@ -111,6 +121,91 @@ export async function estimateUserOpGas(
111121
};
112122
}
113123

124+
/**
125+
* Estimate the gas cost of a user operation.
126+
* @param args - The options for estimating the gas cost of a user operation.
127+
* @returns The estimated gas cost of the user operation.
128+
* @example
129+
* ```ts
130+
* import { estimateUserOpGasCost } from "thirdweb/wallets/smart";
131+
*
132+
* const gasCost = await estimateUserOpGasCost({
133+
* transactions,
134+
* adminAccount,
135+
* client,
136+
* smartWalletOptions,
137+
* });
138+
* ```
139+
* @walletUtils
140+
*/
141+
export async function estimateUserOpGasCost(args: {
142+
transactions: PreparedTransaction[];
143+
adminAccount: Account;
144+
client: ThirdwebClient;
145+
smartWalletOptions: SmartWalletOptions;
146+
}) {
147+
// if factory is passed, but no entrypoint, try to resolve entrypoint from factory
148+
if (
149+
args.smartWalletOptions.factoryAddress &&
150+
!args.smartWalletOptions.overrides?.entrypointAddress
151+
) {
152+
const entrypointAddress = await getEntrypointFromFactory(
153+
args.smartWalletOptions.factoryAddress,
154+
args.client,
155+
args.smartWalletOptions.chain,
156+
);
157+
if (entrypointAddress) {
158+
args.smartWalletOptions.overrides = {
159+
...args.smartWalletOptions.overrides,
160+
entrypointAddress,
161+
};
162+
}
163+
}
164+
165+
const userOp = await prepareUserOp({
166+
transactions: args.transactions,
167+
adminAccount: args.adminAccount,
168+
client: args.client,
169+
smartWalletOptions: args.smartWalletOptions,
170+
isDeployedOverride: await isContractDeployed(
171+
getContract({
172+
address: await predictSmartAccountAddress({
173+
adminAddress: args.adminAccount.address,
174+
factoryAddress: args.smartWalletOptions.factoryAddress,
175+
chain: args.smartWalletOptions.chain,
176+
client: args.client,
177+
}),
178+
chain: args.smartWalletOptions.chain,
179+
client: args.client,
180+
}),
181+
),
182+
});
183+
184+
let gasLimit = 0n;
185+
if ("paymasterVerificationGasLimit" in userOp) {
186+
// v0.7
187+
gasLimit =
188+
BigInt(userOp.paymasterVerificationGasLimit ?? 0) +
189+
BigInt(userOp.paymasterPostOpGasLimit ?? 0) +
190+
BigInt(userOp.verificationGasLimit ?? 0) +
191+
BigInt(userOp.preVerificationGas ?? 0) +
192+
BigInt(userOp.callGasLimit ?? 0);
193+
} else {
194+
// v0.6
195+
gasLimit =
196+
BigInt(userOp.verificationGasLimit ?? 0) +
197+
BigInt(userOp.preVerificationGas ?? 0) +
198+
BigInt(userOp.callGasLimit ?? 0);
199+
}
200+
201+
const gasCost = gasLimit * (userOp.maxFeePerGas ?? 0n);
202+
203+
return {
204+
ether: toEther(gasCost),
205+
wei: gasCost,
206+
};
207+
}
208+
114209
/**
115210
* Get the gas fees of a user operation.
116211
* @param args - The options for getting the gas price of a user operation.

packages/thirdweb/src/wallets/smart/lib/userop.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,32 @@ export async function createAndSignUserOp(options: {
706706
smartWalletOptions: SmartWalletOptions;
707707
waitForDeployment?: boolean;
708708
isDeployedOverride?: boolean;
709+
}) {
710+
const unsignedUserOp = await prepareUserOp({
711+
transactions: options.transactions,
712+
adminAccount: options.adminAccount,
713+
client: options.client,
714+
smartWalletOptions: options.smartWalletOptions,
715+
waitForDeployment: options.waitForDeployment,
716+
isDeployedOverride: options.isDeployedOverride,
717+
});
718+
const signedUserOp = await signUserOp({
719+
client: options.client,
720+
chain: options.smartWalletOptions.chain,
721+
adminAccount: options.adminAccount,
722+
entrypointAddress: options.smartWalletOptions.overrides?.entrypointAddress,
723+
userOp: unsignedUserOp,
724+
});
725+
return signedUserOp;
726+
}
727+
728+
export async function prepareUserOp(options: {
729+
transactions: PreparedTransaction[];
730+
adminAccount: Account;
731+
client: ThirdwebClient;
732+
smartWalletOptions: SmartWalletOptions;
733+
waitForDeployment?: boolean;
734+
isDeployedOverride?: boolean;
709735
}) {
710736
const config = options.smartWalletOptions;
711737
const factoryContract = getContract({
@@ -731,6 +757,7 @@ export async function createAndSignUserOp(options: {
731757
let executeTx: PreparedTransaction;
732758
if (options.transactions.length === 1) {
733759
const tx = options.transactions[0] as PreparedTransaction;
760+
// for single tx, simulate fully
734761
const serializedTx = await toSerializableTransaction({
735762
transaction: tx,
736763
from: accountAddress,
@@ -741,13 +768,21 @@ export async function createAndSignUserOp(options: {
741768
executeOverride: config.overrides?.execute,
742769
});
743770
} else {
771+
// for multiple txs, we can't simulate, just encode
744772
const serializedTxs = await Promise.all(
745-
options.transactions.map((tx) =>
746-
toSerializableTransaction({
747-
transaction: tx,
748-
from: accountAddress,
749-
}),
750-
),
773+
options.transactions.map(async (tx) => {
774+
const [data, to, value] = await Promise.all([
775+
encode(tx),
776+
resolvePromisedValue(tx.to),
777+
resolvePromisedValue(tx.value),
778+
]);
779+
return {
780+
data,
781+
to,
782+
value,
783+
chainId: tx.chain.id,
784+
};
785+
}),
751786
);
752787
executeTx = prepareBatchExecute({
753788
accountContract,
@@ -756,7 +791,7 @@ export async function createAndSignUserOp(options: {
756791
});
757792
}
758793

759-
const unsignedUserOp = await createUnsignedUserOp({
794+
return createUnsignedUserOp({
760795
transaction: executeTx,
761796
factoryContract,
762797
accountContract,
@@ -766,14 +801,6 @@ export async function createAndSignUserOp(options: {
766801
waitForDeployment: options.waitForDeployment,
767802
isDeployedOverride: options.isDeployedOverride,
768803
});
769-
const signedUserOp = await signUserOp({
770-
client: options.client,
771-
chain: config.chain,
772-
adminAccount: options.adminAccount,
773-
entrypointAddress: config.overrides?.entrypointAddress,
774-
userOp: unsignedUserOp,
775-
});
776-
return signedUserOp;
777804
}
778805

779806
async function waitForAccountDeployed(accountContract: ThirdwebContract) {

packages/thirdweb/src/wallets/smart/smart-wallet-integration-v07.test.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
} from "../../exports/extensions/erc4337.js";
1717
import { claimTo } from "../../extensions/erc1155/drops/write/claimTo.js";
1818
import { setContractURI } from "../../extensions/marketplace/__generated__/IMarketplace/write/setContractURI.js";
19-
import { estimateGasCost } from "../../transaction/actions/estimate-gas-cost.js";
2019
import { sendAndConfirmTransaction } from "../../transaction/actions/send-and-confirm-transaction.js";
2120
import { sendBatchTransaction } from "../../transaction/actions/send-batch-transaction.js";
2221
import { waitForReceipt } from "../../transaction/actions/wait-for-tx-receipt.js";
@@ -27,6 +26,7 @@ import { hashTypedData } from "../../utils/hashing/hashTypedData.js";
2726
import { sleep } from "../../utils/sleep.js";
2827
import type { Account, Wallet } from "../interfaces/wallet.js";
2928
import { generateAccount } from "../utils/generateAccount.js";
29+
import { estimateUserOpGasCost } from "./lib/bundler.js";
3030
import { predictSmartAccountAddress } from "./lib/calls.js";
3131
import { DEFAULT_ACCOUNT_FACTORY_V0_7 } from "./lib/constants.js";
3232
import {
@@ -87,6 +87,27 @@ describe.sequential(
8787
expect(predictedAddress).toEqual(smartWalletAddress);
8888
});
8989

90+
it("can estimate gas cost", async () => {
91+
const gasCost = await estimateUserOpGasCost({
92+
transactions: [
93+
claimTo({
94+
contract,
95+
quantity: 1n,
96+
to: smartWalletAddress,
97+
tokenId: 0n,
98+
}),
99+
],
100+
adminAccount: personalAccount,
101+
client: TEST_CLIENT,
102+
smartWalletOptions: {
103+
chain,
104+
sponsorGas: true,
105+
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
106+
},
107+
});
108+
expect(gasCost.ether).not.toBe("0");
109+
});
110+
90111
it("can sign a msg", async () => {
91112
const signature = await smartAccount.signMessage({
92113
message: "hello world",
@@ -202,19 +223,6 @@ describe.sequential(
202223
expect(isDeployed).toEqual(true);
203224
});
204225

205-
it("can estimate a tx", async () => {
206-
const estimates = await estimateGasCost({
207-
transaction: claimTo({
208-
contract,
209-
quantity: 1n,
210-
to: smartWalletAddress,
211-
tokenId: 0n,
212-
}),
213-
account: smartAccount,
214-
});
215-
expect(estimates.wei.toString()).not.toBe("0");
216-
});
217-
218226
it("can execute a batched tx", async () => {
219227
const tx = await sendBatchTransaction({
220228
account: smartAccount,

packages/thirdweb/src/wallets/smart/smart-wallet-integration.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { hashTypedData } from "../../utils/hashing/hashTypedData.js";
2929
import { sleep } from "../../utils/sleep.js";
3030
import type { Account, Wallet } from "../interfaces/wallet.js";
3131
import { generateAccount } from "../utils/generateAccount.js";
32+
import { estimateUserOpGasCost } from "./lib/bundler.js";
3233
import { predictSmartAccountAddress } from "./lib/calls.js";
3334
import { deploySmartAccount } from "./lib/signing.js";
3435
import { smartWallet } from "./smart-wallet.js";
@@ -74,6 +75,26 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential(
7475
});
7576
});
7677

78+
it("can estimate gas cost", async () => {
79+
const gasCost = await estimateUserOpGasCost({
80+
transactions: [
81+
claimTo({
82+
contract,
83+
quantity: 1n,
84+
to: smartWalletAddress,
85+
tokenId: 0n,
86+
}),
87+
],
88+
adminAccount: personalAccount,
89+
client: TEST_CLIENT,
90+
smartWalletOptions: {
91+
chain,
92+
sponsorGas: true,
93+
},
94+
});
95+
expect(gasCost.ether).not.toBe("0");
96+
});
97+
7798
it("can connect", async () => {
7899
expect(smartWalletAddress).toHaveLength(42);
79100
const predictedAddress = await predictSmartAccountAddress({

0 commit comments

Comments
 (0)