// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {ECDSA} from "solady/utils/ECDSA.sol"; import {IValidator, IHook} from "kernel/interfaces/IERC7579Modules.sol"; import {PackedUserOperation} from "kernel/interfaces/PackedUserOperation.sol"; import { SIG_VALIDATION_SUCCESS_UINT, SIG_VALIDATION_FAILED_UINT, MODULE_TYPE_VALIDATOR, MODULE_TYPE_HOOK, ERC1271_MAGICVALUE, ERC1271_INVALID } from "kernel/types/Constants.sol"; import {NoExtraValidator} from "../NoExtraValidator.sol"; import {RootBinding} from "../RootBinding.sol"; /// ECDSA root validator that refuses extra Kernel validators. /// /// ZeroDev's shipped `ECDSAValidator` (`0x845ADb2C…`) is `IHook` so A can /// direct-call `installModule`. Its `preCheck` only checks `msg.sender == /// owner`. That is the EIP-3009 footgun: A (or a script following the old /// "install B as validator" recipe) adds an EOA validator, USDC asks V, /// Kernel routes `0x01‖validator` to ECDSA(EOA). /// /// This is that validator plus `NoExtraValidator`. Same storage layout /// (`ecdsaValidatorStorage`) so `BaseAdapter.ownerOf` / `ownerexit` keep working. /// Deployed immutably CREATE2 — same reason as ERC1271RootValidator: whoever /// can replace root-validator code owns every account that names it. /// /// THE ADDRESS IS IN THE SALT. Switching ECDSARoot from the canonical /// validator to this one moves every new account address. Historical accounts /// stay on `0x845ADb2C…` via `RestoreGenesis`. contract GatedECDSAValidator is IValidator, IHook { event OwnerRegistered(address indexed kernel, address indexed owner); error SenderIsNotOwner(address sender, address owner); error OwnerCannotBeZero(); mapping(address kernel => address owner) public ecdsaValidatorStorage; function onInstall(bytes calldata data) external payable override { if (ecdsaValidatorStorage[msg.sender] != address(0)) revert AlreadyInitialized(msg.sender); address owner = address(bytes20(data[0:20])); if (owner == address(0)) revert OwnerCannotBeZero(); ecdsaValidatorStorage[msg.sender] = owner; emit OwnerRegistered(msg.sender, owner); } function onUninstall(bytes calldata) external payable override { if (ecdsaValidatorStorage[msg.sender] == address(0)) revert NotInitialized(msg.sender); // A ROOT CANNOT BE DE-REGISTERED OUT FROM UNDER ITS OWN ACCOUNT. // Kernel guards `uninstallModule(1, …)` and does not guard // `uninstallModule(4, …)`, which reaches here just the same because // `isModuleType(4)` is true. Clearing the slot is what re-arms // `onInstall`. See RootBinding. RootBinding.requireNotRootOf(msg.sender, address(this)); delete ecdsaValidatorStorage[msg.sender]; } function isModuleType(uint256 typeID) external pure override returns (bool) { return typeID == MODULE_TYPE_VALIDATOR || typeID == MODULE_TYPE_HOOK; } function isInitialized(address smartAccount) external view override returns (bool) { return ecdsaValidatorStorage[smartAccount] != address(0); } function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external payable override returns (uint256) { // Same `isRootOf` gate as `isValidSignatureWithSender`: rotation must // revoke ERC-4337 authority, not only the ERC-1271 read path. if (!RootBinding.isRootOf(msg.sender, address(this))) { return SIG_VALIDATION_FAILED_UINT; } address owner = ecdsaValidatorStorage[msg.sender]; if (!_isOwner(owner, userOpHash, userOp.signature)) { return SIG_VALIDATION_FAILED_UINT; } NoExtraValidator.reject(msg.sender, address(this), userOp.callData); return SIG_VALIDATION_SUCCESS_UINT; } function isValidSignatureWithSender(address sender, bytes32 hash, bytes calldata sig) external view override returns (bytes4) { // NOT ROOT, NOT SIGNING — two holes, one answer, both in RootBinding. // // `sender == msg.sender` is Kernel's ENABLE MODE and nothing else: // `_checkEnableSig` asks the root validator with `address(this)` as the // sender, while `Kernel.isValidSignature` always passes the external // caller. Enable mode installs a validator through // `_enableValidationWithSig` with NO `installModule` calldata anywhere // — the path the ZeroDev SDK reaches for by default — so the calldata // filter never sees it. Refusing the enable digest is the only place // this can be stopped without forking Kernel. // // The `isRootOf` check answers the other one: `changeRootValidator` // leaves the previous root installed with a live `validationConfig`, // and `Kernel.isValidSignature` will route `0x01‖oldRoot` to it // forever. A validator that is no longer this account's root signs // nothing for it. if (sender == msg.sender) return ERC1271_INVALID; if (!RootBinding.isRootOf(msg.sender, address(this))) return ERC1271_INVALID; address owner = ecdsaValidatorStorage[msg.sender]; if (!_isOwner(owner, hash, sig)) return ERC1271_INVALID; return ERC1271_MAGICVALUE; } /// Direct call: A is msgSender. Also inspects calldata so /// `installModule(VALIDATOR, …)` and `execute(self, that)` revert here, /// before Kernel installs anything. function preCheck(address msgSender, uint256, bytes calldata msgData) external payable override returns (bytes memory) { address owner = ecdsaValidatorStorage[msg.sender]; if (msgSender != owner) revert SenderIsNotOwner(msgSender, owner); NoExtraValidator.reject(msg.sender, address(this), msgData); return ""; } function postCheck(bytes calldata) external payable override {} function _isOwner(address owner, bytes32 hash, bytes calldata sig) internal view returns (bool) { if (owner == address(0)) return false; if (owner == ECDSA.recover(hash, sig)) return true; return owner == ECDSA.recover(ECDSA.toEthSignedMessageHash(hash), sig); } }