Skip to content

Upgrade thirdweb to v5.100.1 and update UserOp handling for gas overrides #894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"prisma": "^5.14.0",
"prom-client": "^15.1.3",
"superjson": "^2.2.1",
"thirdweb": "5.96.5",
"thirdweb": "^5.100.1",
"undici": "^6.20.1",
"uuid": "^9.0.1",
"viem": "2.22.17",
Expand Down
243 changes: 183 additions & 60 deletions src/worker/tasks/send-transaction-worker.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import assert from "node:assert";
import { type Job, type Processor, Worker } from "bullmq";
import { DelayedError, type Job, type Processor, Worker } from "bullmq";
import superjson from "superjson";
import {
type Address,
type Chain,
type Hex,
type ThirdwebClient,
getAddress,
getContract,
readContract,
Expand All @@ -13,9 +16,9 @@ import { getChainMetadata } from "thirdweb/chains";
import { isZkSyncChain, stringify } from "thirdweb/utils";
import type { Account } from "thirdweb/wallets";
import {
type UserOperation,
bundleUserOp,
createAndSignUserOp,
prepareUserOp,
signUserOp,
smartWallet,
} from "thirdweb/wallets/smart";
import { getContractAddress } from "viem";
Expand Down Expand Up @@ -60,12 +63,17 @@ import {
SendTransactionQueue,
} from "../queues/send-transaction-queue";

type VersionedUserOp = Awaited<ReturnType<typeof prepareUserOp>>;

/**
* Submit a transaction to RPC (EOA transactions) or bundler (userOps).
*
* This worker also handles retried EOA transactions.
*/
const handler: Processor<string, void, string> = async (job: Job<string>) => {
const handler: Processor<string, void, string> = async (
job: Job<string>,
token?: string,
) => {
const { queueId, resendCount } = superjson.parse<SendTransactionData>(
job.data,
);
Expand All @@ -84,9 +92,9 @@ const handler: Processor<string, void, string> = async (job: Job<string>) => {

if (transaction.status === "queued") {
if (transaction.isUserOp) {
resultTransaction = await _sendUserOp(job, transaction);
resultTransaction = await _sendUserOp(job, transaction, token);
} else {
resultTransaction = await _sendTransaction(job, transaction);
resultTransaction = await _sendTransaction(job, transaction, token);
}
} else if (transaction.status === "sent") {
resultTransaction = await _resendTransaction(job, transaction, resendCount);
Expand Down Expand Up @@ -116,6 +124,7 @@ const handler: Processor<string, void, string> = async (job: Job<string>) => {
const _sendUserOp = async (
job: Job,
queuedTransaction: QueuedTransaction,
token?: string,
): Promise<SentTransaction | ErroredTransaction | null> => {
assert(queuedTransaction.isUserOp);

Expand Down Expand Up @@ -180,61 +189,97 @@ const _sendUserOp = async (
};
}

let signedUserOp: UserOperation;
try {
// Resolve the user factory from the provided address, or from the `factory()` method if found.
let accountFactoryAddress = userProvidedAccountFactoryAddress;
if (!accountFactoryAddress) {
// TODO: this is not a good solution since the assumption that the account has a factory function is not guaranteed
// instead, we should use default account factory address or throw here.
try {
const smartAccountContract = getContract({
client: thirdwebClient,
chain,
address: accountAddress,
});
const onchainAccountFactoryAddress = await readContract({
contract: smartAccountContract,
method: "function factory() view returns (address)",
params: [],
});
accountFactoryAddress = getAddress(onchainAccountFactoryAddress);
} catch {
throw new Error(
`Failed to find factory address for account '${accountAddress}' on chain '${chainId}'`,
);
}
// Part 1: Prepare the userop
// Step 1: Get factory address
let accountFactoryAddress: Address | undefined;

if (userProvidedAccountFactoryAddress) {
accountFactoryAddress = userProvidedAccountFactoryAddress;
} else {
const smartAccountContract = getContract({
client: thirdwebClient,
chain,
address: accountAddress,
});

try {
const onchainAccountFactoryAddress = await readContract({
contract: smartAccountContract,
method: "function factory() view returns (address)",
params: [],
});
accountFactoryAddress = getAddress(onchainAccountFactoryAddress);
} catch (error) {
const errorMessage = `${wrapError(error, "RPC").message} Failed to find factory address for account`;
const erroredTransaction: ErroredTransaction = {
...queuedTransaction,
status: "errored",
errorMessage,
};
job.log(`Failed to get account factory address: ${errorMessage}`);
return erroredTransaction;
}
}

const transactions = queuedTransaction.batchOperations
? queuedTransaction.batchOperations.map((op) => ({
...op,
chain,
// Step 2: Get entrypoint address
let entrypointAddress: Address | undefined;
if (userProvidedEntrypointAddress) {
entrypointAddress = queuedTransaction.entrypointAddress;
} else {
try {
entrypointAddress = await getEntrypointFromFactory(
adminAccount.address,
thirdwebClient,
chain,
);
} catch (error) {
const errorMessage = `${wrapError(error, "RPC").message} Failed to find entrypoint address for account factory`;
const erroredTransaction: ErroredTransaction = {
...queuedTransaction,
status: "errored",
errorMessage,
};
job.log(
`Failed to find entrypoint address for account factory: ${errorMessage}`,
);
return erroredTransaction;
}
}

// Step 3: Transform transactions for userop
const transactions = queuedTransaction.batchOperations
? queuedTransaction.batchOperations.map((op) => ({
...op,
chain,
client: thirdwebClient,
}))
: [
{
client: thirdwebClient,
}))
: [
{
client: thirdwebClient,
chain,
data: queuedTransaction.data,
value: queuedTransaction.value,
...overrides, // gas-overrides
to: getChecksumAddress(toAddress),
},
];

signedUserOp = (await createAndSignUserOp({
client: thirdwebClient,
chain,
data: queuedTransaction.data,
value: queuedTransaction.value,
...overrides, // gas-overrides
to: getChecksumAddress(toAddress),
},
];

// Step 4: Prepare userop
let unsignedUserOp: VersionedUserOp | undefined;

try {
unsignedUserOp = await prepareUserOp({
transactions,
adminAccount,
client: thirdwebClient,
smartWalletOptions: {
chain,
sponsorGas: true,
factoryAddress: accountFactoryAddress,
factoryAddress: accountFactoryAddress, // from step 1
overrides: {
accountAddress,
accountSalt,
entrypointAddress: userProvidedEntrypointAddress,
entrypointAddress, // from step 2
// TODO: let user pass entrypoint address for 0.7 support
},
},
Expand All @@ -243,7 +288,7 @@ const _sendUserOp = async (
// until the previous userop for the same account is mined
// we don't want this behavior in the engine context
waitForDeployment: false,
})) as UserOperation; // TODO support entrypoint v0.7 accounts
});
} catch (error) {
const errorMessage = wrapError(error, "Bundler").message;
const erroredTransaction: ErroredTransaction = {
Expand All @@ -255,16 +300,71 @@ const _sendUserOp = async (
return erroredTransaction;
}

job.log(`Populated userOp: ${stringify(signedUserOp)}`);
// Handle if `maxFeePerGas` is overridden.
// Set it if the transaction will be sent, otherwise delay the job.
if (
typeof overrides?.maxFeePerGas !== "undefined" &&
unsignedUserOp.maxFeePerGas
) {
if (overrides.maxFeePerGas > unsignedUserOp.maxFeePerGas) {
unsignedUserOp.maxFeePerGas = overrides.maxFeePerGas;
} else {
const retryAt = _minutesFromNow(5);
job.log(
`Override gas fee (${overrides.maxFeePerGas}) is lower than onchain fee (${unsignedUserOp.maxFeePerGas}). Delaying job until ${retryAt}.`,
);
// token is required to acquire lock for delaying currently processing job: https://docs.bullmq.io/patterns/process-step-jobs#delaying
await job.moveToDelayed(retryAt.getTime(), token);
// throwing delayed error is required to notify bullmq worker not to complete or fail the job
throw new DelayedError("Delaying job due to gas fee override");
}
}

const userOpHash = await bundleUserOp({
userOp: signedUserOp,
options: {
// Part 2: Sign the userop
let signedUserOp: VersionedUserOp | undefined;
try {
signedUserOp = await signUserOp({
client: thirdwebClient,
chain,
entrypointAddress: userProvidedEntrypointAddress,
},
});
adminAccount,
entrypointAddress,
userOp: unsignedUserOp,
});
} catch (error) {
const errorMessage = `${wrapError(error, "Bundler").message} Failed to sign prepared userop`;
const erroredTransaction: ErroredTransaction = {
...queuedTransaction,
status: "errored",
errorMessage,
};
job.log(`Failed to sign userop: ${errorMessage}`);
return erroredTransaction;
}

job.log(`Populated and signed userOp: ${stringify(signedUserOp)}`);

// Finally: bundle the userop
let userOpHash: Hex;

try {
userOpHash = await bundleUserOp({
userOp: signedUserOp,
options: {
client: thirdwebClient,
chain,
entrypointAddress: userProvidedEntrypointAddress,
},
});
} catch (error) {
const errorMessage = `${wrapError(error, "Bundler").message} Failed to bundle userop`;
const erroredTransaction: ErroredTransaction = {
...queuedTransaction,
status: "errored",
errorMessage,
};
job.log(`Failed to bundle userop: ${errorMessage}`);
return erroredTransaction;
}

return {
...queuedTransaction,
Expand All @@ -283,6 +383,7 @@ const _sendUserOp = async (
const _sendTransaction = async (
job: Job,
queuedTransaction: QueuedTransaction,
token?: string,
): Promise<SentTransaction | ErroredTransaction | null> => {
assert(!queuedTransaction.isUserOp);

Expand Down Expand Up @@ -372,8 +473,8 @@ const _sendTransaction = async (
job.log(
`Override gas fee (${overrides.maxFeePerGas}) is lower than onchain fee (${populatedTransaction.maxFeePerGas}). Delaying job until ${retryAt}.`,
);
await job.moveToDelayed(retryAt.getTime());
return null;
await job.moveToDelayed(retryAt.getTime(), token);
throw new DelayedError("Delaying job due to gas fee override");
}
}

Expand Down Expand Up @@ -646,6 +747,28 @@ export function _updateGasFees(
return updated;
}

async function getEntrypointFromFactory(
factoryAddress: string,
client: ThirdwebClient,
chain: Chain,
) {
const factoryContract = getContract({
address: factoryAddress,
client,
chain,
});
try {
const entrypointAddress = await readContract({
contract: factoryContract,
method: "function entrypoint() public view returns (address)",
params: [],
});
return entrypointAddress;
} catch {
return undefined;
}
}

// Must be explicitly called for the worker to run on this host.
export const initSendTransactionWorker = () => {
const _worker = new Worker(SendTransactionQueue.q.name, handler, {
Expand Down
Loading