From 24807f0e3ba97c6164e4d30294ad483374348195 Mon Sep 17 00:00:00 2001 From: boolafish <6198848+boolafish@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:05:18 +0900 Subject: [PATCH 1/2] feat: add missing ERC-7562 opcodes and expand test coverage Add BLOBHASH (0x49) and BLOBBASEFEE (0x4A) opcodes to forbidden opcode validation as required by ERC-7562 [OP-011]. These opcodes were missing from the checker implementation. Significantly expand test coverage for forbidden opcodes from 7% (1/14) to 35% (6/17) by adding tests for: - GASPRICE (0x3A) - GASLIMIT (0x45) - COINBASE (0x41) - ORIGIN (0x32) - INVALID (0xFE) The INVALID opcode test uses try-catch to allow debug trace capture before revert, enabling proper validation of the opcode detection. All 15 tests passing. --- src/ERC4337Checker.sol | 2 + test/ERC4337Checker.t.sol | 97 ++++++++++++++++++++++++++++++++++++++ test/mocks/MockAccount.sol | 42 ++++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/ERC4337Checker.sol b/src/ERC4337Checker.sol index 33f318e..dd35dbe 100644 --- a/src/ERC4337Checker.sol +++ b/src/ERC4337Checker.sol @@ -456,6 +456,8 @@ contract ERC4337Checker { || opcode == 0x44 // DIFFICULTY || opcode == 0x42 // TIMESTAMP || opcode == 0x48 // BASEFEE + || opcode == 0x49 // BLOBHASH + || opcode == 0x4A // BLOBBASEFEE || opcode == 0x40 // BLOCKHASH || opcode == 0x43 // NUMBER || opcode == 0x47 // SELFBALANCE diff --git a/test/ERC4337Checker.t.sol b/test/ERC4337Checker.t.sol index 1e4812b..c682029 100644 --- a/test/ERC4337Checker.t.sol +++ b/test/ERC4337Checker.t.sol @@ -90,6 +90,103 @@ contract ERC4337CheckerTest is Test { checker.printFailureLogs(); } + function test_forbiddenOpCodeGasPrice() public { + address mockAccountAddr = address(mockAccount); + + bytes memory encodedCallData = abi.encodeWithSelector( + MockAccount.execute.selector, + MockAccount.AttackType.FORBIDDEN_OPCODE_GASPRICE + ); + + UserOperation memory userOp = _getUnsignedOp( + mockAccountAddr, + encodedCallData + ); + + assertFalse( + checker.simulateAndVerifyUserOp(vm, userOp, entryPoint) + ); + checker.printFailureLogs(); + } + + function test_forbiddenOpCodeGasLimit() public { + address mockAccountAddr = address(mockAccount); + + bytes memory encodedCallData = abi.encodeWithSelector( + MockAccount.execute.selector, + MockAccount.AttackType.FORBIDDEN_OPCODE_GASLIMIT + ); + + UserOperation memory userOp = _getUnsignedOp( + mockAccountAddr, + encodedCallData + ); + + assertFalse( + checker.simulateAndVerifyUserOp(vm, userOp, entryPoint) + ); + checker.printFailureLogs(); + } + + function test_forbiddenOpCodeCoinbase() public { + address mockAccountAddr = address(mockAccount); + + bytes memory encodedCallData = abi.encodeWithSelector( + MockAccount.execute.selector, + MockAccount.AttackType.FORBIDDEN_OPCODE_COINBASE + ); + + UserOperation memory userOp = _getUnsignedOp( + mockAccountAddr, + encodedCallData + ); + + assertFalse( + checker.simulateAndVerifyUserOp(vm, userOp, entryPoint) + ); + checker.printFailureLogs(); + } + + function test_forbiddenOpCodeOrigin() public { + address mockAccountAddr = address(mockAccount); + + bytes memory encodedCallData = abi.encodeWithSelector( + MockAccount.execute.selector, + MockAccount.AttackType.FORBIDDEN_OPCODE_ORIGIN + ); + + UserOperation memory userOp = _getUnsignedOp( + mockAccountAddr, + encodedCallData + ); + + assertFalse( + checker.simulateAndVerifyUserOp(vm, userOp, entryPoint) + ); + checker.printFailureLogs(); + } + + function test_forbiddenOpCodeInvalid() public { + address mockAccountAddr = address(mockAccount); + + bytes memory encodedCallData = abi.encodeWithSelector( + MockAccount.execute.selector, + MockAccount.AttackType.FORBIDDEN_OPCODE_INVALID + ); + + UserOperation memory userOp = _getUnsignedOp( + mockAccountAddr, + encodedCallData + ); + + // The INVALID opcode (0xFE) is executed in a try-catch block + // This allows the debug trace to capture it, and the checker should detect it + assertFalse( + checker.simulateAndVerifyUserOp(vm, userOp, entryPoint) + ); + checker.printFailureLogs(); + } + function test_outOfGas() public { address mockAccountAddr = address(mockAccount); diff --git a/test/mocks/MockAccount.sol b/test/mocks/MockAccount.sol index 9b07101..d33f6b1 100644 --- a/test/mocks/MockAccount.sol +++ b/test/mocks/MockAccount.sol @@ -20,6 +20,13 @@ contract InvalidActions { function touchInvalidSlot() public view { console2.log("consumeGas: ", invalidSlotAccess); } + + // Function that triggers the INVALID opcode (0xFE) + function triggerInvalidOpcode() public pure { + assembly { + invalid() + } + } } contract MockAccount is BaseAccount { @@ -28,7 +35,12 @@ contract MockAccount is BaseAccount { FORBIDDEN_OPCODE_BLOCKTIME, OUT_OF_GAS, ACCESS_EXTCODE_WITH_ADDRESS_NO_CODE, - TOUCH_UNASSOCIATED_STORAGE_SLOT + TOUCH_UNASSOCIATED_STORAGE_SLOT, + FORBIDDEN_OPCODE_GASPRICE, + FORBIDDEN_OPCODE_GASLIMIT, + FORBIDDEN_OPCODE_COINBASE, + FORBIDDEN_OPCODE_ORIGIN, + FORBIDDEN_OPCODE_INVALID } IEntryPoint private _entryPoint; @@ -55,7 +67,7 @@ contract MockAccount is BaseAccount { { AttackType attackType = _decodeAttackType(userOp.callData); if (attackType == AttackType.FORBIDDEN_OPCODE_BLOCKTIME) { - // forbidden opcode + // forbidden opcode: TIMESTAMP if (block.timestamp < 1) { return 0; } @@ -75,6 +87,32 @@ contract MockAccount is BaseAccount { require(result == bytes32(0), "The EXTCODEHASH of non-existent account should be 0"); } else if (attackType == AttackType.TOUCH_UNASSOCIATED_STORAGE_SLOT) { invalidActions.touchInvalidSlot(); + } else if (attackType == AttackType.FORBIDDEN_OPCODE_GASPRICE) { + // forbidden opcode: GASPRICE + uint256 gasPrice = tx.gasprice; + console2.log("gasprice:", gasPrice); + } else if (attackType == AttackType.FORBIDDEN_OPCODE_GASLIMIT) { + // forbidden opcode: GASLIMIT + uint256 gasLimit = block.gaslimit; + console2.log("gaslimit:", gasLimit); + } else if (attackType == AttackType.FORBIDDEN_OPCODE_COINBASE) { + // forbidden opcode: COINBASE + address coinbase = block.coinbase; + console2.log("coinbase:", coinbase); + } else if (attackType == AttackType.FORBIDDEN_OPCODE_ORIGIN) { + // forbidden opcode: ORIGIN + address origin = tx.origin; + console2.log("origin:", origin); + } else if (attackType == AttackType.FORBIDDEN_OPCODE_INVALID) { + // forbidden opcode: INVALID (0xFE) + // We use try-catch to prevent immediate revert, allowing the debug trace to capture the opcode + try invalidActions.triggerInvalidOpcode() { + // This should never execute as invalid() always reverts + console2.log("Invalid opcode did not revert (unexpected)"); + } catch { + // The invalid opcode was executed and caught + console2.log("Invalid opcode caught in try-catch"); + } } return 0; From db97ce2a474c12c663509d00f42d284b45be697a Mon Sep 17 00:00:00 2001 From: boolafish <6198848+boolafish@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:15:54 +0900 Subject: [PATCH 2/2] fix: enable optimizer and pin solc version for CI builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable Solidity optimizer in foundry.toml to prevent contract size limit issues in CI. Without optimization, EntryPoint compiles to 28,975 bytes, exceeding EIP-170's 24,576 byte limit by 4,399 bytes. Also pin solc version to 0.8.30 for consistency between local and CI. Contract sizes with solc 0.8.30 + optimizer (runs=200): - EntryPoint: 16,299 bytes (margin: 8,277 bytes) ✅ - ERC4337Checker: 14,282 bytes (margin: 10,294 bytes) ✅ - All contracts well under the 24KB limit Without optimizer (CI was failing): - EntryPoint: 28,975 bytes (OVER limit by 4,399 bytes) ❌ - ERC4337Checker: 20,551 bytes (margin: 4,025 bytes) --- foundry.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/foundry.toml b/foundry.toml index 025b841..b305392 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,8 +2,22 @@ src = "src" out = "out" libs = ["lib"] +solc = "0.8.30" +optimizer = true +optimizer_runs = 200 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options gas_limit = "18446744073709551615" memory_limit = 2147483648 + +[profile.ci] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.30" +optimizer = true +optimizer_runs = 200 + +gas_limit = "18446744073709551615" +memory_limit = 2147483648