Skip to content

Commit fa4369a

Browse files
authored
Upgrade thirdweb to v5.100.1 and update UserOp handling for gas overrides (#894)
* Upgrade thirdweb to v5.100.1 and update UserOp handling for gas overrides * correctly handle job delays for bullmq
1 parent 3a5dce9 commit fa4369a

File tree

3 files changed

+495
-111
lines changed

3 files changed

+495
-111
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"prisma": "^5.14.0",
7070
"prom-client": "^15.1.3",
7171
"superjson": "^2.2.1",
72-
"thirdweb": "5.96.5",
72+
"thirdweb": "^5.100.1",
7373
"undici": "^6.20.1",
7474
"uuid": "^9.0.1",
7575
"viem": "2.22.17",

src/worker/tasks/send-transaction-worker.ts

Lines changed: 183 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import assert from "node:assert";
2-
import { type Job, type Processor, Worker } from "bullmq";
2+
import { DelayedError, type Job, type Processor, Worker } from "bullmq";
33
import superjson from "superjson";
44
import {
5+
type Address,
6+
type Chain,
57
type Hex,
8+
type ThirdwebClient,
69
getAddress,
710
getContract,
811
readContract,
@@ -13,9 +16,9 @@ import { getChainMetadata } from "thirdweb/chains";
1316
import { isZkSyncChain, stringify } from "thirdweb/utils";
1417
import type { Account } from "thirdweb/wallets";
1518
import {
16-
type UserOperation,
1719
bundleUserOp,
18-
createAndSignUserOp,
20+
prepareUserOp,
21+
signUserOp,
1922
smartWallet,
2023
} from "thirdweb/wallets/smart";
2124
import { getContractAddress } from "viem";
@@ -60,12 +63,17 @@ import {
6063
SendTransactionQueue,
6164
} from "../queues/send-transaction-queue";
6265

66+
type VersionedUserOp = Awaited<ReturnType<typeof prepareUserOp>>;
67+
6368
/**
6469
* Submit a transaction to RPC (EOA transactions) or bundler (userOps).
6570
*
6671
* This worker also handles retried EOA transactions.
6772
*/
68-
const handler: Processor<string, void, string> = async (job: Job<string>) => {
73+
const handler: Processor<string, void, string> = async (
74+
job: Job<string>,
75+
token?: string,
76+
) => {
6977
const { queueId, resendCount } = superjson.parse<SendTransactionData>(
7078
job.data,
7179
);
@@ -84,9 +92,9 @@ const handler: Processor<string, void, string> = async (job: Job<string>) => {
8492

8593
if (transaction.status === "queued") {
8694
if (transaction.isUserOp) {
87-
resultTransaction = await _sendUserOp(job, transaction);
95+
resultTransaction = await _sendUserOp(job, transaction, token);
8896
} else {
89-
resultTransaction = await _sendTransaction(job, transaction);
97+
resultTransaction = await _sendTransaction(job, transaction, token);
9098
}
9199
} else if (transaction.status === "sent") {
92100
resultTransaction = await _resendTransaction(job, transaction, resendCount);
@@ -116,6 +124,7 @@ const handler: Processor<string, void, string> = async (job: Job<string>) => {
116124
const _sendUserOp = async (
117125
job: Job,
118126
queuedTransaction: QueuedTransaction,
127+
token?: string,
119128
): Promise<SentTransaction | ErroredTransaction | null> => {
120129
assert(queuedTransaction.isUserOp);
121130

@@ -180,61 +189,97 @@ const _sendUserOp = async (
180189
};
181190
}
182191

183-
let signedUserOp: UserOperation;
184-
try {
185-
// Resolve the user factory from the provided address, or from the `factory()` method if found.
186-
let accountFactoryAddress = userProvidedAccountFactoryAddress;
187-
if (!accountFactoryAddress) {
188-
// TODO: this is not a good solution since the assumption that the account has a factory function is not guaranteed
189-
// instead, we should use default account factory address or throw here.
190-
try {
191-
const smartAccountContract = getContract({
192-
client: thirdwebClient,
193-
chain,
194-
address: accountAddress,
195-
});
196-
const onchainAccountFactoryAddress = await readContract({
197-
contract: smartAccountContract,
198-
method: "function factory() view returns (address)",
199-
params: [],
200-
});
201-
accountFactoryAddress = getAddress(onchainAccountFactoryAddress);
202-
} catch {
203-
throw new Error(
204-
`Failed to find factory address for account '${accountAddress}' on chain '${chainId}'`,
205-
);
206-
}
192+
// Part 1: Prepare the userop
193+
// Step 1: Get factory address
194+
let accountFactoryAddress: Address | undefined;
195+
196+
if (userProvidedAccountFactoryAddress) {
197+
accountFactoryAddress = userProvidedAccountFactoryAddress;
198+
} else {
199+
const smartAccountContract = getContract({
200+
client: thirdwebClient,
201+
chain,
202+
address: accountAddress,
203+
});
204+
205+
try {
206+
const onchainAccountFactoryAddress = await readContract({
207+
contract: smartAccountContract,
208+
method: "function factory() view returns (address)",
209+
params: [],
210+
});
211+
accountFactoryAddress = getAddress(onchainAccountFactoryAddress);
212+
} catch (error) {
213+
const errorMessage = `${wrapError(error, "RPC").message} Failed to find factory address for account`;
214+
const erroredTransaction: ErroredTransaction = {
215+
...queuedTransaction,
216+
status: "errored",
217+
errorMessage,
218+
};
219+
job.log(`Failed to get account factory address: ${errorMessage}`);
220+
return erroredTransaction;
207221
}
222+
}
208223

209-
const transactions = queuedTransaction.batchOperations
210-
? queuedTransaction.batchOperations.map((op) => ({
211-
...op,
212-
chain,
224+
// Step 2: Get entrypoint address
225+
let entrypointAddress: Address | undefined;
226+
if (userProvidedEntrypointAddress) {
227+
entrypointAddress = queuedTransaction.entrypointAddress;
228+
} else {
229+
try {
230+
entrypointAddress = await getEntrypointFromFactory(
231+
adminAccount.address,
232+
thirdwebClient,
233+
chain,
234+
);
235+
} catch (error) {
236+
const errorMessage = `${wrapError(error, "RPC").message} Failed to find entrypoint address for account factory`;
237+
const erroredTransaction: ErroredTransaction = {
238+
...queuedTransaction,
239+
status: "errored",
240+
errorMessage,
241+
};
242+
job.log(
243+
`Failed to find entrypoint address for account factory: ${errorMessage}`,
244+
);
245+
return erroredTransaction;
246+
}
247+
}
248+
249+
// Step 3: Transform transactions for userop
250+
const transactions = queuedTransaction.batchOperations
251+
? queuedTransaction.batchOperations.map((op) => ({
252+
...op,
253+
chain,
254+
client: thirdwebClient,
255+
}))
256+
: [
257+
{
213258
client: thirdwebClient,
214-
}))
215-
: [
216-
{
217-
client: thirdwebClient,
218-
chain,
219-
data: queuedTransaction.data,
220-
value: queuedTransaction.value,
221-
...overrides, // gas-overrides
222-
to: getChecksumAddress(toAddress),
223-
},
224-
];
225-
226-
signedUserOp = (await createAndSignUserOp({
227-
client: thirdwebClient,
259+
chain,
260+
data: queuedTransaction.data,
261+
value: queuedTransaction.value,
262+
...overrides, // gas-overrides
263+
to: getChecksumAddress(toAddress),
264+
},
265+
];
266+
267+
// Step 4: Prepare userop
268+
let unsignedUserOp: VersionedUserOp | undefined;
269+
270+
try {
271+
unsignedUserOp = await prepareUserOp({
228272
transactions,
229273
adminAccount,
274+
client: thirdwebClient,
230275
smartWalletOptions: {
231276
chain,
232277
sponsorGas: true,
233-
factoryAddress: accountFactoryAddress,
278+
factoryAddress: accountFactoryAddress, // from step 1
234279
overrides: {
235280
accountAddress,
236281
accountSalt,
237-
entrypointAddress: userProvidedEntrypointAddress,
282+
entrypointAddress, // from step 2
238283
// TODO: let user pass entrypoint address for 0.7 support
239284
},
240285
},
@@ -243,7 +288,7 @@ const _sendUserOp = async (
243288
// until the previous userop for the same account is mined
244289
// we don't want this behavior in the engine context
245290
waitForDeployment: false,
246-
})) as UserOperation; // TODO support entrypoint v0.7 accounts
291+
});
247292
} catch (error) {
248293
const errorMessage = wrapError(error, "Bundler").message;
249294
const erroredTransaction: ErroredTransaction = {
@@ -255,16 +300,71 @@ const _sendUserOp = async (
255300
return erroredTransaction;
256301
}
257302

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

260-
const userOpHash = await bundleUserOp({
261-
userOp: signedUserOp,
262-
options: {
323+
// Part 2: Sign the userop
324+
let signedUserOp: VersionedUserOp | undefined;
325+
try {
326+
signedUserOp = await signUserOp({
263327
client: thirdwebClient,
264328
chain,
265-
entrypointAddress: userProvidedEntrypointAddress,
266-
},
267-
});
329+
adminAccount,
330+
entrypointAddress,
331+
userOp: unsignedUserOp,
332+
});
333+
} catch (error) {
334+
const errorMessage = `${wrapError(error, "Bundler").message} Failed to sign prepared userop`;
335+
const erroredTransaction: ErroredTransaction = {
336+
...queuedTransaction,
337+
status: "errored",
338+
errorMessage,
339+
};
340+
job.log(`Failed to sign userop: ${errorMessage}`);
341+
return erroredTransaction;
342+
}
343+
344+
job.log(`Populated and signed userOp: ${stringify(signedUserOp)}`);
345+
346+
// Finally: bundle the userop
347+
let userOpHash: Hex;
348+
349+
try {
350+
userOpHash = await bundleUserOp({
351+
userOp: signedUserOp,
352+
options: {
353+
client: thirdwebClient,
354+
chain,
355+
entrypointAddress: userProvidedEntrypointAddress,
356+
},
357+
});
358+
} catch (error) {
359+
const errorMessage = `${wrapError(error, "Bundler").message} Failed to bundle userop`;
360+
const erroredTransaction: ErroredTransaction = {
361+
...queuedTransaction,
362+
status: "errored",
363+
errorMessage,
364+
};
365+
job.log(`Failed to bundle userop: ${errorMessage}`);
366+
return erroredTransaction;
367+
}
268368

269369
return {
270370
...queuedTransaction,
@@ -283,6 +383,7 @@ const _sendUserOp = async (
283383
const _sendTransaction = async (
284384
job: Job,
285385
queuedTransaction: QueuedTransaction,
386+
token?: string,
286387
): Promise<SentTransaction | ErroredTransaction | null> => {
287388
assert(!queuedTransaction.isUserOp);
288389

@@ -372,8 +473,8 @@ const _sendTransaction = async (
372473
job.log(
373474
`Override gas fee (${overrides.maxFeePerGas}) is lower than onchain fee (${populatedTransaction.maxFeePerGas}). Delaying job until ${retryAt}.`,
374475
);
375-
await job.moveToDelayed(retryAt.getTime());
376-
return null;
476+
await job.moveToDelayed(retryAt.getTime(), token);
477+
throw new DelayedError("Delaying job due to gas fee override");
377478
}
378479
}
379480

@@ -646,6 +747,28 @@ export function _updateGasFees(
646747
return updated;
647748
}
648749

750+
async function getEntrypointFromFactory(
751+
factoryAddress: string,
752+
client: ThirdwebClient,
753+
chain: Chain,
754+
) {
755+
const factoryContract = getContract({
756+
address: factoryAddress,
757+
client,
758+
chain,
759+
});
760+
try {
761+
const entrypointAddress = await readContract({
762+
contract: factoryContract,
763+
method: "function entrypoint() public view returns (address)",
764+
params: [],
765+
});
766+
return entrypointAddress;
767+
} catch {
768+
return undefined;
769+
}
770+
}
771+
649772
// Must be explicitly called for the worker to run on this host.
650773
export const initSendTransactionWorker = () => {
651774
const _worker = new Worker(SendTransactionQueue.q.name, handler, {

0 commit comments

Comments
 (0)