Skip to content
10 changes: 10 additions & 0 deletions src/rpc/methods/eth_estimateUserOperationGas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,16 @@ export const ethEstimateUserOperationGasHandler = createMethodHandler({
chainType
} = rpcHandler.config

// Validate userOp fields (sync - fail fast before async checks)
const [fieldsValid, fieldsError] = rpcHandler.validateUserOpFields({
userOp,
entryPoint,
isEstimation: true
})
if (!fieldsValid) {
throw new RpcError(fieldsError, ERC7769Errors.InvalidFields)
}

// Execute multiple async operations in parallel
const [
[validEip7702Auth, validEip7702AuthError],
Expand Down
25 changes: 23 additions & 2 deletions src/rpc/rpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,32 @@ export class RpcHandler {
validateUserOpFields({
userOp,
entryPoint,
isBoosted = false
isBoosted = false,
isEstimation = false
}: {
userOp: UserOperation
entryPoint: Address
isBoosted?: boolean
isEstimation?: boolean
}): [boolean, string] {
// Check for 0x7702 factory without eip7702Auth
if (
!userOp.eip7702Auth &&
isVersion07(userOp) &&
(userOp.factory === "0x7702" ||
userOp.factory === "0x7702000000000000000000000000000000000000")
) {
return [
false,
"Invalid EIP-7702 userOperation: factory is 0x7702 but eip7702Auth is not provided."
]
}

// Skip gas/field checks during estimation
if (isEstimation) {
return [true, ""]
}

// Validate paymaster signature for EntryPoint 0.9
const paymasterSignatureError = validatePaymasterSignature({
userOp,
Expand Down Expand Up @@ -341,11 +361,12 @@ export class RpcHandler {
if (
isVersion07(userOp) &&
userOp.factory !== "0x7702" &&
userOp.factory !== "0x7702000000000000000000000000000000000000" &&
userOp.factory !== null
) {
return [
false,
"Invalid EIP-7702 authorization: UserOperation cannot contain factory that is neither null or 0x7702."
"Invalid EIP-7702 authorization: factory must be null or 0x7702."
]
}

Expand Down
65 changes: 65 additions & 0 deletions test/e2e/tests/eth_estimateUserOperationGas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,71 @@ describe.each([
)
})

test.each([
{
testName: "short form (0x7702)",
factory: "0x7702"
},
{
testName:
"zero-padded form (0x7702000000000000000000000000000000000000)",
factory: "0x7702000000000000000000000000000000000000"
}
])(
"Should reject estimation with factory $testName but no eip7702Auth",
async ({ factory }) => {
// Skip for v0.6 - doesn't have factory field
if (entryPointVersion === "0.6") {
return
}

const bundlerClient = createBundlerClient({
chain: foundry,
transport: http(altoRpc)
})

const smartAccountClient = await getSmartAccountClient({
entryPointVersion,
anvilRpc,
altoRpc
})

const op = (await smartAccountClient.prepareUserOperation({
calls: [
{
to: zeroAddress,
data: "0x"
}
]
})) as UserOperation

// Set factory to 0x7702 without providing eip7702Auth
op.factory = factory
op.factoryData = undefined

try {
await bundlerClient.estimateUserOperationGas({
...op,
entryPointAddress: entryPoint
})
expect.fail("Must throw")
} catch (err) {
expect(err).toBeInstanceOf(BaseError)
const error = err as BaseError

expect(error.details).toMatch(
/factory is 0x7702 but eip7702Auth is not provided/i
)

const rpcError = error.walk(
(e) => e instanceof RpcRequestError
) as RpcRequestError
expect(rpcError).toBeDefined()
expect(rpcError.code).toBe(ERC7769Errors.InvalidFields)
}
}
)

test("Should throw AA25 when estimating userOp with nonce + 1", async () => {
const smartAccountClient = await getSmartAccountClient({
entryPointVersion,
Expand Down
60 changes: 60 additions & 0 deletions test/e2e/tests/eth_sendUserOperation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,66 @@ describe.each([
expect(receipt.success)
})

test.each([
{
testName: "short form (0x7702)",
factory: "0x7702"
},
{
testName:
"zero-padded form (0x7702000000000000000000000000000000000000)",
factory: "0x7702000000000000000000000000000000000000"
}
])(
"Should reject userOp with factory $testName but no eip7702Auth",
async ({ factory }) => {
// Skip for v0.6 - doesn't have factory field
if (entryPointVersion === "0.6") {
return
}

const client = await getSmartAccountClient({
entryPointVersion,
anvilRpc,
altoRpc
})

const op = (await client.prepareUserOperation({
calls: [
{
to: TO_ADDRESS,
value: VALUE,
data: "0x"
}
]
})) as UserOperation

// Set factory to 0x7702 without providing eip7702Auth
op.factory = factory
op.factoryData = undefined

op.signature = await client.account.signUserOperation(op)

try {
await client.sendUserOperation(op)
expect.fail("Must throw")
} catch (err) {
expect(err).toBeInstanceOf(BaseError)
const error = err as BaseError

expect(error.details).toMatch(
/factory is 0x7702 but eip7702Auth is not provided/i
)

const rpcError = error.walk(
(e) => e instanceof RpcRequestError
) as RpcRequestError
expect(rpcError).toBeDefined()
expect(rpcError.code).toBe(ERC7769Errors.InvalidFields)
}
}
)

test.each([
{
testName: "r is zero",
Expand Down
Loading