diff --git a/src/rpc/methods/eth_estimateUserOperationGas.ts b/src/rpc/methods/eth_estimateUserOperationGas.ts index 8ffc1091..4581ff58 100644 --- a/src/rpc/methods/eth_estimateUserOperationGas.ts +++ b/src/rpc/methods/eth_estimateUserOperationGas.ts @@ -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], diff --git a/src/rpc/rpcHandler.ts b/src/rpc/rpcHandler.ts index 635ca7f5..b02aba12 100644 --- a/src/rpc/rpcHandler.ts +++ b/src/rpc/rpcHandler.ts @@ -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, @@ -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." ] } diff --git a/test/e2e/tests/eth_estimateUserOperationGas.test.ts b/test/e2e/tests/eth_estimateUserOperationGas.test.ts index 576e9594..07913280 100644 --- a/test/e2e/tests/eth_estimateUserOperationGas.test.ts +++ b/test/e2e/tests/eth_estimateUserOperationGas.test.ts @@ -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, diff --git a/test/e2e/tests/eth_sendUserOperation.test.ts b/test/e2e/tests/eth_sendUserOperation.test.ts index 42f6088f..c24cb69d 100644 --- a/test/e2e/tests/eth_sendUserOperation.test.ts +++ b/test/e2e/tests/eth_sendUserOperation.test.ts @@ -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",