1
1
import assert from "node:assert" ;
2
- import { type Job , type Processor , Worker } from "bullmq" ;
2
+ import { DelayedError , type Job , type Processor , Worker } from "bullmq" ;
3
3
import superjson from "superjson" ;
4
4
import {
5
+ type Address ,
6
+ type Chain ,
5
7
type Hex ,
8
+ type ThirdwebClient ,
6
9
getAddress ,
7
10
getContract ,
8
11
readContract ,
@@ -13,9 +16,9 @@ import { getChainMetadata } from "thirdweb/chains";
13
16
import { isZkSyncChain , stringify } from "thirdweb/utils" ;
14
17
import type { Account } from "thirdweb/wallets" ;
15
18
import {
16
- type UserOperation ,
17
19
bundleUserOp ,
18
- createAndSignUserOp ,
20
+ prepareUserOp ,
21
+ signUserOp ,
19
22
smartWallet ,
20
23
} from "thirdweb/wallets/smart" ;
21
24
import { getContractAddress } from "viem" ;
@@ -60,12 +63,17 @@ import {
60
63
SendTransactionQueue ,
61
64
} from "../queues/send-transaction-queue" ;
62
65
66
+ type VersionedUserOp = Awaited < ReturnType < typeof prepareUserOp > > ;
67
+
63
68
/**
64
69
* Submit a transaction to RPC (EOA transactions) or bundler (userOps).
65
70
*
66
71
* This worker also handles retried EOA transactions.
67
72
*/
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
+ ) => {
69
77
const { queueId, resendCount } = superjson . parse < SendTransactionData > (
70
78
job . data ,
71
79
) ;
@@ -84,9 +92,9 @@ const handler: Processor<string, void, string> = async (job: Job<string>) => {
84
92
85
93
if ( transaction . status === "queued" ) {
86
94
if ( transaction . isUserOp ) {
87
- resultTransaction = await _sendUserOp ( job , transaction ) ;
95
+ resultTransaction = await _sendUserOp ( job , transaction , token ) ;
88
96
} else {
89
- resultTransaction = await _sendTransaction ( job , transaction ) ;
97
+ resultTransaction = await _sendTransaction ( job , transaction , token ) ;
90
98
}
91
99
} else if ( transaction . status === "sent" ) {
92
100
resultTransaction = await _resendTransaction ( job , transaction , resendCount ) ;
@@ -116,6 +124,7 @@ const handler: Processor<string, void, string> = async (job: Job<string>) => {
116
124
const _sendUserOp = async (
117
125
job : Job ,
118
126
queuedTransaction : QueuedTransaction ,
127
+ token ?: string ,
119
128
) : Promise < SentTransaction | ErroredTransaction | null > => {
120
129
assert ( queuedTransaction . isUserOp ) ;
121
130
@@ -180,61 +189,97 @@ const _sendUserOp = async (
180
189
} ;
181
190
}
182
191
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 ;
207
221
}
222
+ }
208
223
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
+ {
213
258
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 ( {
228
272
transactions,
229
273
adminAccount,
274
+ client : thirdwebClient ,
230
275
smartWalletOptions : {
231
276
chain,
232
277
sponsorGas : true ,
233
- factoryAddress : accountFactoryAddress ,
278
+ factoryAddress : accountFactoryAddress , // from step 1
234
279
overrides : {
235
280
accountAddress,
236
281
accountSalt,
237
- entrypointAddress : userProvidedEntrypointAddress ,
282
+ entrypointAddress, // from step 2
238
283
// TODO: let user pass entrypoint address for 0.7 support
239
284
} ,
240
285
} ,
@@ -243,7 +288,7 @@ const _sendUserOp = async (
243
288
// until the previous userop for the same account is mined
244
289
// we don't want this behavior in the engine context
245
290
waitForDeployment : false ,
246
- } ) ) as UserOperation ; // TODO support entrypoint v0.7 accounts
291
+ } ) ;
247
292
} catch ( error ) {
248
293
const errorMessage = wrapError ( error , "Bundler" ) . message ;
249
294
const erroredTransaction : ErroredTransaction = {
@@ -255,16 +300,71 @@ const _sendUserOp = async (
255
300
return erroredTransaction ;
256
301
}
257
302
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
+ }
259
322
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 ( {
263
327
client : thirdwebClient ,
264
328
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
+ }
268
368
269
369
return {
270
370
...queuedTransaction ,
@@ -283,6 +383,7 @@ const _sendUserOp = async (
283
383
const _sendTransaction = async (
284
384
job : Job ,
285
385
queuedTransaction : QueuedTransaction ,
386
+ token ?: string ,
286
387
) : Promise < SentTransaction | ErroredTransaction | null > => {
287
388
assert ( ! queuedTransaction . isUserOp ) ;
288
389
@@ -372,8 +473,8 @@ const _sendTransaction = async (
372
473
job . log (
373
474
`Override gas fee (${ overrides . maxFeePerGas } ) is lower than onchain fee (${ populatedTransaction . maxFeePerGas } ). Delaying job until ${ retryAt } .` ,
374
475
) ;
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" ) ;
377
478
}
378
479
}
379
480
@@ -646,6 +747,28 @@ export function _updateGasFees(
646
747
return updated ;
647
748
}
648
749
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
+
649
772
// Must be explicitly called for the worker to run on this host.
650
773
export const initSendTransactionWorker = ( ) => {
651
774
const _worker = new Worker ( SendTransactionQueue . q . name , handler , {
0 commit comments