Skip to content

Commit d40772d

Browse files
authored
fix(eip7702): validate factory field with eip7702 requirements (#712)
* fix(eip7702): validate factory field with eip7702 requirements - Add validation for 0x7702 factory address in both short and padded forms - Require eip7702Auth when factory is set to 0x7702 - Add comprehensive test coverage for both factory address formats * fix(rpc): add eip7702Auth validation for 0x7702 factory - Add early validation check for EIP-7702 userOps with 0x7702 factory - Ensure eip7702Auth is provided when factory is 0x7702 - Update error message for consistency * chore: format * fix(rpc): remove incorrect eip7702 factory validation - Removed validation check that incorrectly rejected userOps with factory 0x7702 but no eip7702Auth - This check was overly restrictive and not aligned with EIP-7702 specification * feat(gas-estimation): add userOp field validation for estimation - Add early userOp field validation in eth_estimateUserOperationGas - Add isEstimation flag to skip gas checks during estimation - Consolidate EIP-7702 factory validation and remove duplication * style(rpc): fix line wrapping in factory validation * chore: format * test(e2e): add ERC-7702 factory validation test - Add test cases for rejecting estimation with 0x7702 factory without eip7702Auth - Test both short form (0x7702) and zero-padded form validation - Skip test for v0.6 which doesn't support factory field * test(eth_estimateUserOperationGas): use bundler client for gas estimation - Separate bundler client creation for gas estimation calls - Rename smartAccountClient for clarity - Pass entryPointAddress explicitly to estimateUserOperationGas
1 parent b39bdd5 commit d40772d

4 files changed

Lines changed: 158 additions & 2 deletions

File tree

src/rpc/methods/eth_estimateUserOperationGas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,16 @@ export const ethEstimateUserOperationGasHandler = createMethodHandler({
284284
chainType
285285
} = rpcHandler.config
286286

287+
// Validate userOp fields (sync - fail fast before async checks)
288+
const [fieldsValid, fieldsError] = rpcHandler.validateUserOpFields({
289+
userOp,
290+
entryPoint,
291+
isEstimation: true
292+
})
293+
if (!fieldsValid) {
294+
throw new RpcError(fieldsError, ERC7769Errors.InvalidFields)
295+
}
296+
287297
// Execute multiple async operations in parallel
288298
const [
289299
[validEip7702Auth, validEip7702AuthError],

src/rpc/rpcHandler.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,32 @@ export class RpcHandler {
145145
validateUserOpFields({
146146
userOp,
147147
entryPoint,
148-
isBoosted = false
148+
isBoosted = false,
149+
isEstimation = false
149150
}: {
150151
userOp: UserOperation
151152
entryPoint: Address
152153
isBoosted?: boolean
154+
isEstimation?: boolean
153155
}): [boolean, string] {
156+
// Check for 0x7702 factory without eip7702Auth
157+
if (
158+
!userOp.eip7702Auth &&
159+
isVersion07(userOp) &&
160+
(userOp.factory === "0x7702" ||
161+
userOp.factory === "0x7702000000000000000000000000000000000000")
162+
) {
163+
return [
164+
false,
165+
"Invalid EIP-7702 userOperation: factory is 0x7702 but eip7702Auth is not provided."
166+
]
167+
}
168+
169+
// Skip gas/field checks during estimation
170+
if (isEstimation) {
171+
return [true, ""]
172+
}
173+
154174
// Validate paymaster signature for EntryPoint 0.9
155175
const paymasterSignatureError = validatePaymasterSignature({
156176
userOp,
@@ -341,11 +361,12 @@ export class RpcHandler {
341361
if (
342362
isVersion07(userOp) &&
343363
userOp.factory !== "0x7702" &&
364+
userOp.factory !== "0x7702000000000000000000000000000000000000" &&
344365
userOp.factory !== null
345366
) {
346367
return [
347368
false,
348-
"Invalid EIP-7702 authorization: UserOperation cannot contain factory that is neither null or 0x7702."
369+
"Invalid EIP-7702 authorization: factory must be null or 0x7702."
349370
]
350371
}
351372

test/e2e/tests/eth_estimateUserOperationGas.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,71 @@ describe.each([
379379
)
380380
})
381381

382+
test.each([
383+
{
384+
testName: "short form (0x7702)",
385+
factory: "0x7702"
386+
},
387+
{
388+
testName:
389+
"zero-padded form (0x7702000000000000000000000000000000000000)",
390+
factory: "0x7702000000000000000000000000000000000000"
391+
}
392+
])(
393+
"Should reject estimation with factory $testName but no eip7702Auth",
394+
async ({ factory }) => {
395+
// Skip for v0.6 - doesn't have factory field
396+
if (entryPointVersion === "0.6") {
397+
return
398+
}
399+
400+
const bundlerClient = createBundlerClient({
401+
chain: foundry,
402+
transport: http(altoRpc)
403+
})
404+
405+
const smartAccountClient = await getSmartAccountClient({
406+
entryPointVersion,
407+
anvilRpc,
408+
altoRpc
409+
})
410+
411+
const op = (await smartAccountClient.prepareUserOperation({
412+
calls: [
413+
{
414+
to: zeroAddress,
415+
data: "0x"
416+
}
417+
]
418+
})) as UserOperation
419+
420+
// Set factory to 0x7702 without providing eip7702Auth
421+
op.factory = factory
422+
op.factoryData = undefined
423+
424+
try {
425+
await bundlerClient.estimateUserOperationGas({
426+
...op,
427+
entryPointAddress: entryPoint
428+
})
429+
expect.fail("Must throw")
430+
} catch (err) {
431+
expect(err).toBeInstanceOf(BaseError)
432+
const error = err as BaseError
433+
434+
expect(error.details).toMatch(
435+
/factory is 0x7702 but eip7702Auth is not provided/i
436+
)
437+
438+
const rpcError = error.walk(
439+
(e) => e instanceof RpcRequestError
440+
) as RpcRequestError
441+
expect(rpcError).toBeDefined()
442+
expect(rpcError.code).toBe(ERC7769Errors.InvalidFields)
443+
}
444+
}
445+
)
446+
382447
test("Should throw AA25 when estimating userOp with nonce + 1", async () => {
383448
const smartAccountClient = await getSmartAccountClient({
384449
entryPointVersion,

test/e2e/tests/eth_sendUserOperation.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,66 @@ describe.each([
533533
expect(receipt.success)
534534
})
535535

536+
test.each([
537+
{
538+
testName: "short form (0x7702)",
539+
factory: "0x7702"
540+
},
541+
{
542+
testName:
543+
"zero-padded form (0x7702000000000000000000000000000000000000)",
544+
factory: "0x7702000000000000000000000000000000000000"
545+
}
546+
])(
547+
"Should reject userOp with factory $testName but no eip7702Auth",
548+
async ({ factory }) => {
549+
// Skip for v0.6 - doesn't have factory field
550+
if (entryPointVersion === "0.6") {
551+
return
552+
}
553+
554+
const client = await getSmartAccountClient({
555+
entryPointVersion,
556+
anvilRpc,
557+
altoRpc
558+
})
559+
560+
const op = (await client.prepareUserOperation({
561+
calls: [
562+
{
563+
to: TO_ADDRESS,
564+
value: VALUE,
565+
data: "0x"
566+
}
567+
]
568+
})) as UserOperation
569+
570+
// Set factory to 0x7702 without providing eip7702Auth
571+
op.factory = factory
572+
op.factoryData = undefined
573+
574+
op.signature = await client.account.signUserOperation(op)
575+
576+
try {
577+
await client.sendUserOperation(op)
578+
expect.fail("Must throw")
579+
} catch (err) {
580+
expect(err).toBeInstanceOf(BaseError)
581+
const error = err as BaseError
582+
583+
expect(error.details).toMatch(
584+
/factory is 0x7702 but eip7702Auth is not provided/i
585+
)
586+
587+
const rpcError = error.walk(
588+
(e) => e instanceof RpcRequestError
589+
) as RpcRequestError
590+
expect(rpcError).toBeDefined()
591+
expect(rpcError.code).toBe(ERC7769Errors.InvalidFields)
592+
}
593+
}
594+
)
595+
536596
test.each([
537597
{
538598
testName: "r is zero",

0 commit comments

Comments
 (0)