// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; struct Execution { address target; uint256 value; bytes callData; } interface IERC7579Execution { function executeFromExecutor(bytes32 execMode, bytes calldata executionCalldata) external payable returns (bytes[] memory); } interface IWhitelist { /// MEMBERSHIP, AND THE DELAY IS `PolicyGate`'S QUESTION. /// /// This asked effectiveness for one round, which put a third answer to one /// question in a third contract. Effectiveness on an ACTING path is H-6: /// `whitelistAdapter` used to re-arm the clock on any rewrite, identical /// bytes included, so one routine curate run took a live adapter offline /// for three days. `Agent._callOne` was moved off effectiveness for /// exactly this reason. /// /// The delay's real subject is a NEW address, or new configuration, /// becoming reachable, and for a v2 account the one place that happens is /// the gate: no step, no window, no door. `consume` refuses a caller the /// registry does not call effective (`AdapterNotYetEffective`), so a /// window never opens for a pending adapter, and `authorizes` asks again /// each time it is read, so one opened before a rewrite in the same /// transaction closes with it. `gate.authorizes` below is that answer; /// asking here too would be a second copy of it. /// `TestTheDelayIsAskedAtExactlyOneChokePoint` holds that. Used for the /// TARGET test too, where being broader than effectiveness is what keeps a /// pending adapter out. function isWhitelistedAdapter(address adapter) external view returns (bool); } interface IRefusals { function refusedFor(address account, address adapter) external view returns (bool); } interface IWindow { function authorizes(address account, address adapter) external view returns (bool); } interface ICurationPointer { function curation() external view returns (address); } /// THE ONE EXECUTOR. An account installs this and nothing else, ever. /// /// WHAT IT IS FOR. Activating a Base account costs 3,502,158 gas across three /// transactions, and about 1.63M of that is Kernel's own `installModule`, once /// per adapter. No proof reaches that number: Kernel gates /// `executeFromExecutor` on ITS module registry and reverts `InvalidExecutor` /// otherwise, and nothing this codebase does changes a third party's storage /// read. The cost is not the install — it is that every adapter needs one. /// /// So this removes the need rather than the write. One module is installed, in /// genesis, beside the `AdapterInstaller` that is already there; adapters stop /// being modules and become contracts that compose calls and ask this one to /// make them. An account is then live the moment it exists, with no activation /// at all, and a 23rd adapter costs its owner nothing. /// /// WHAT STOPS THIS BEING ARBITRARY EXECUTION, which is the only question that /// matters. `executeFromExecutor` does not inspect the call — an installed /// executor can make the account do anything — so a module that forwards for /// whoever asks has handed the account away. Three things must all hold, and /// each answers a different attacker: /// /// 1. THE CALLER IS A CURATED ADAPTER. Same test `AgentRegistry.charge` uses /// for the same reason: an arbitrary contract must not be able to reach /// this at all. Bounds WHO. /// 2. THE OWNER HAS NOT REFUSED IT. All three refusal keys — address, /// codehash, name — so a redeploy or a rename does not walk past the /// owner's remedy. Under v1 the owner's remedy was uninstalling the /// module; there are no per-adapter modules here, so this IS the remedy, /// and it has to be as sharp as the one it replaces. Bounds WHETHER. /// 3. A PROVED STEP IS OPEN FOR THE PAIR, IN THIS TRANSACTION. The adapter /// spent a step of a plan whose proof named this account, this chain, this /// policy root and this registry; `PolicyGate.consume` opened the window /// and the transaction closes it. Bounds WHEN. /// /// (1) and (2) are standing facts. (3) is what makes the grant momentary: a /// curated, unrefused adapter can do nothing here until it has spent a step it /// proved, and can do nothing after the transaction ends. /// /// WHAT IT DOES NOT BOUND, said plainly. A curated adapter that has spent a /// step may compose ANY call for that account until the transaction ends — an /// approve and a swap are two calls of one step, and this contract cannot tell /// those from a transfer to the adapter's own address. That is the same trust /// v1 places in an installed executor, and v1 places it in 22 of them rather /// than one. What changes is the blast radius: under v1 a faulty adapter /// reaches only accounts that installed it; here it reaches any account whose /// owner curated the capability and whose plan it can get admitted. The spend /// cap and the refusal list bound that. They do not remove it, and no proof /// does either. contract PolicyExecutor { bytes32 internal constant EXEC_SINGLE = bytes32(0); /// ERC-7579 call type 0x01 in the mode's high byte. bytes32 internal constant EXEC_BATCH = bytes32(uint256(1) << 248); IWhitelist public immutable registry; IRefusals public immutable installer; IWindow public immutable gate; error NotAnAdapter(address caller); error AdapterRefusedByOwner(address account, address adapter); error NoOpenStep(address account, address adapter); /// The call would reach a contract that decides what this door is allowed /// to do. See `_refuseControlTarget`. error ForbiddenTarget(address target); event ExecutedFor(address indexed account, address indexed adapter, address indexed target); constructor(IWhitelist registry_, IRefusals installer_, IWindow gate_) { registry = registry_; installer = installer_; gate = gate_; } /// Make `account` perform one call that `msg.sender` composed. /// /// The argument shape is `BaseAdapter._exec`'s on purpose: an adapter that /// routes here rather than calling the account directly changes one line. function executeFor(address account, address target, uint256 value, bytes calldata callData) external returns (bytes memory) { _refuseControlTarget(account, target); if (!registry.isWhitelistedAdapter(msg.sender)) revert NotAnAdapter(msg.sender); if (installer.refusedFor(account, msg.sender)) { revert AdapterRefusedByOwner(account, msg.sender); } if (!gate.authorizes(account, msg.sender)) revert NoOpenStep(account, msg.sender); bytes[] memory ret = IERC7579Execution(account).executeFromExecutor( EXEC_SINGLE, abi.encodePacked(target, value, callData) ); emit ExecutedFor(account, msg.sender, target); return ret.length == 0 ? bytes("") : ret[0]; } /// EVERY CALL OF ONE STEP, THROUGH THE DOOR ONCE. /// /// A step is rarely one call — an approve and a swap are the ordinary shape /// — and `executeFor` charges the three checks to each of them. ERC-7579 /// already has a batch call type for exactly this, and ERC-7710's /// `redeemDelegations` takes an array of modes for the same reason: the /// authority is granted once, so it should be CHECKED once. /// /// `EXEC_BATCH` is call type 0x01 in the high byte of the mode, and the /// payload is an abi-encoded `Execution[]`. The account loops; the door /// does not. function executeBatchFor(address account, Execution[] calldata calls) external returns (bytes[] memory) { for (uint256 i = 0; i < calls.length; i++) { _refuseControlTarget(account, calls[i].target); } if (!registry.isWhitelistedAdapter(msg.sender)) revert NotAnAdapter(msg.sender); if (installer.refusedFor(account, msg.sender)) { revert AdapterRefusedByOwner(account, msg.sender); } if (!gate.authorizes(account, msg.sender)) revert NoOpenStep(account, msg.sender); return IERC7579Execution(account).executeFromExecutor(EXEC_BATCH, abi.encode(calls)); } /// A MOMENTARY GRANT MUST NOT BE ABLE TO MAKE ITSELF PERMANENT. /// /// The header above says the window is what keeps this bounded: "a curated, /// unrefused adapter can do nothing here until it has spent a step it /// proved, and can do nothing after the transaction ends", and that "the /// spend cap and the refusal list bound that." /// /// THEY DID NOT, because every one of those bounds is keyed on /// `msg.sender`, and this door makes the ACCOUNT the sender. One open step /// reached all of them: /// /// registry `setOpsRoot` — rewrite the policy every later proof is /// checked against, permanently. /// registry `setCap` — raise the spend cap. That is bound one. /// installer `allowAdapter` — clear this account's refusal of the caller. /// That is bound two, self-clearing. /// account `installModule`— Kernel's own gate is /// `onlyEntryPointOrSelfOrRoot`, and a call /// FROM the account satisfies it. That is a /// permanent validator or executor, which is /// the account handed away. /// registry `armOperation` — H-2's class, which `onInstall` refuses to /// grant from a guardian payload, granted /// here — and it reaches the owner's EOA /// through PullFromOwnerAdapter's allowance. /// /// `Agent._callOne` has had this guard all along — it refuses `adapter == /// account`, `== address(this)` and `== address(registry)` — and B has no /// other path to these targets: it holds no validator on any account, so /// no `execute` (AdapterOnlyNoExecutePath.t.sol). This door was written /// without `_callOne`'s refusal, so the new path had none of the restraints /// the old one was given. /// /// THE ACCOUNT ITSELF IS THE IMPORTANT ONE and the easiest to overlook: a /// call to any other contract is bounded by what that contract lets an /// arbitrary caller do, and a call to the account is bounded by nothing, /// because the account trusts itself. function _refuseControlTarget(address account, address target) private view { if ( target == account || target == address(this) || target == address(registry) || target == address(installer) || target == address(gate) ) revert ForbiddenTarget(target); // AdapterCuration.pinMarket is msg.sender-keyed: an open step that can // call curation as the account freezes any Morpho market pin without // owner intent. Curation was missing from this list. address cur = ICurationPointer(address(registry)).curation(); if (cur != address(0) && target == cur) revert ForbiddenTarget(target); // AND NEVER ANOTHER ADAPTER. My first version of this list stopped at // the five above and an auditor walked straight round it: adapters // carried `armOperation`, gated solely on `msg.sender == account`, // which this door MANUFACTURES — so one proved step armed // `pull_from_owner`, reaching the owner's EOA through its standing // allowance. That switch is the registry's now, and the registry is // refused above. Adapters still carry account-gated setters of their // own — `BaseAdapter.resetAuthority`, and `RentAdapter.allowDelegatee`, // which adds a rent recipient — and each is the same shape. // // An adapter is a CALLER here, never a callee. Whitelisted rather than // effective, because a pending one must not be a target either. if (registry.isWhitelistedAdapter(target)) revert ForbiddenTarget(target); } // ── ERC-7579 module surface ───────────────────────────────────────────── // // Stateless by design. Every per-account fact this consults lives in the // registry, the installer or the gate, so installing writes nothing and // uninstalling loses nothing — which is what lets it sit in genesis and // makes "an account is live the moment it exists" true rather than nearly // true. It also means there is no `onInstall` payload to go stale, which // was `Venues are a snapshot too` and `Caps are a snapshot` both. function onInstall(bytes calldata) external payable {} function onUninstall(bytes calldata) external payable {} function isModuleType(uint256 moduleTypeId) external pure returns (bool) { return moduleTypeId == 2; // executor } function isInitialized(address) external pure returns (bool) { return true; } }