// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {ECDSA} from "solady/utils/ECDSA.sol"; import {EIP712} from "solady/utils/EIP712.sol"; import {OwnableRoles} from "solady/auth/OwnableRoles.sol"; import {Initializable} from "solady/utils/Initializable.sol"; import {AgentRegistry} from "./AgentRegistry.sol"; /// solady's ERC1967Factory records a proxy's admin in its OWN storage, so the /// proxy can ask who is allowed to bind it. interface IERC1967FactoryAdmin { function adminOf(address proxy) external view returns (address); } /// The ERC-7579 surface Kernel exposes for "is this an installed executor?" interface IERC7579Modules { function isModuleInstalled(uint256 moduleType, address module, bytes calldata additionalContext) external view returns (bool); } /// @title Agent — B. A UUPS contract, never an EOA, never a Kernel validator. /// /// B IS THIS CONTRACT (the ERC1967 proxy). The keeper EOA holds `KEEPER_ROLE`; /// it is not B. Do not install this as an ERC-7579 validator on V. Do not /// appoint an EOA in AgentRegistry — `appoint` refuses it. An EOA-as-validator /// is the EIP-3009 drain (`GuardBypassEIP3009`): USDC asks V, Kernel routes /// `0x01‖validator` to ECDSA(B), and no execution hook ever runs. /// /// WHAT IT IS. Owner A (the Kernel root, or a contract A already controls) /// owns this proxy. A keeper key holds `KEEPER_ROLE` and may call adapters that /// the bound account has already installed. Adapters recognise this contract /// as the appointed agent (`registry.agentFor(account) == address(this)`) and /// skip EIP-712 because a contract cannot `ecrecover` to itself; `msg.sender` /// is the authorization. /// /// WHAT IT IS NOT. /// - Not a Kernel account. There is no `execute` / `exec(address,bytes)` that /// can hit V. The only outbound call is `callAdapter`, and the target must /// be an installed executor, not the account, not this contract, not the /// registry. /// - Not an ERC-7579 validator or hook. ERC-1271 always returns invalid, so /// a token that asks this address to witness a hash gets nothing. /// - Not a general wallet. Caps, owner checks, install and task replay still /// live on the adapter; this only replaces the EOA signature with a role. /// /// UPGRADE IS THE TIMELOCK'S, AND NOT THIS CONTRACT'S TO GRANT — H-5 of the /// 2026-09-10 round-2 audit. /// /// This used to be `UUPSUpgradeable` with `_authorizeUpgrade` gated on /// `onlyOwner`, behind a solady factory proxy whose admin `cmd/agentdeploy` set /// to the OWNER EOA. Two instant single-EOA upgrade paths on the /// highest-authority contract in the system — and the second survived the first /// being noticed: solady's factory proxy delegatecalls everything that does not /// come from the factory, so `upgradeToAndCall` reached `UUPSUpgradeable` /// regardless of who the factory admin was. The audit's PoC set the timelock as /// factory admin and the owner still swapped the implementation in one /// transaction, with `adminOf` still answering "the timelock". /// /// That matters more here than anywhere else: B drives every adapter on every /// account that appointed it, and `BaseAdapter._consume` skips signature /// verification entirely when `msg.sender == agent`. A hostile implementation /// of this contract is every account's funds, in one block, with nothing to /// observe in advance — which is exactly the power `UpgradeTimelock` exists to /// put a week's notice in front of. /// /// So there is no UUPS entry point at all now. The ONLY way to replace this /// code is `ERC1967Factory.upgrade(proxy, impl)` by the proxy's admin, and /// `cmd/agentdeploy` sets that admin to the same `UpgradeTimelock` that /// administers the registry. `owner()` keeps what it should have kept all /// along: granting keepers, and nothing about the code. contract Agent is OwnableRoles, Initializable, EIP712 { /// ERC-7579 module type for an executor (adapters + AdapterInstaller). uint256 internal constant MODULE_TYPE_EXECUTOR = 2; /// Hot key. Distinct from owner by convention: owner upgrades and grants /// keepers; this role is the only one that may call adapters. uint256 public constant KEEPER_ROLE = _ROLE_0; bytes4 internal constant ERC1271_INVALID = 0xffffffff; /// solady's canonical ERC1967Factory, the same address on every chain used /// here and the one `Protocol.ERC1967_FACTORY` names. address internal constant ERC1967_FACTORY = 0x0000000000006396FF2a80c067f99B3d2Ab4Df24; bytes4 internal constant ON_INSTALL = 0x6d61fe70; // onInstall(bytes) bytes4 internal constant ON_UNINSTALL = 0x8a91b0e3; // onUninstall(bytes) bytes4 internal constant CHANGE_ROOT = 0x52141cd9; // changeRootValidator(...) /* ONE AGENT, EVERY ACCOUNT. The account is a PARAMETER, not a binding. * * This proxy used to store one `account`, write-once, so that a rotatable * binding could not point a stolen keeper at a different V. That shape needs * one proxy per account — and B's address is part of an account's CREATE2 * preimage, so per-account B means deriving B from the owner before the * account exists, and every account address moving. * * The decision is one shared B. What authorises this contract to act for an * account is then the ACCOUNT'S OWN CHOICE, read live: * `registry.agentFor(account) == address(this)`. An account that has not * appointed this proxy cannot be touched through it, and an owner revoking * the appointment stops it immediately — the same switch that already exists. * * THE COST, STATED: a stolen keeper reaches every account that has appointed * this B, not one. That is the blast radius the EOA agent always had — one * key, every account — so it is not new, but it is not the per-account bound * the write-once binding was reaching for either. The signature covers the * account, so a payload authorised for one cannot be replayed against * another. */ /* THE SLOT THE OLD `account` BINDING HELD, KEPT EMPTY ON PURPOSE. * * B's address is part of every account's CREATE2 preimage, so this proxy * cannot move — which means a change to it is always an in-place upgrade, * and an upgrade keeps the storage. Deleting the old `address public * account` would have shifted `registry` up into the slot that held it, so * every adapter call would have read an ACCOUNT ADDRESS as the registry and * asked it who the agent was. It answers nothing, the call reverts * NotAppointed, and nothing anywhere says why. * * Left as a named gap rather than removed, because the next person to add a * field here needs to see that this slot is spoken for. */ address private __unusedWasAccount; /// Same registry adapters read. Write-once for the same reason guardian is. AgentRegistry public registry; event AdapterCalled(address indexed adapter, bytes4 selector); error ZeroAddress(); error KernelCallForbidden(address target); error NotAnAdapter(address adapter); /// The adapter is listed but suspended. Same selector as the registry's. error AdapterIsSuspended(address adapter); error NotAppointed(); error ForbiddenSelector(bytes4 selector); error LengthMismatch(); error EmptyBatch(); error AuthorizationExpired(uint256 deadline); error NotAKeeper(address recovered); /// `initialize` from somebody who is not this proxy's admin, nor the /// factory acting for it. error NotProxyAdmin(address caller, address admin); /// WHERE THIS CODE WAS DEPLOYED, so `onlyProxy` can tell the implementation /// from a proxy delegating into it. /// /// Kept when `UUPSUpgradeable` went (H-5) — `onlyProxy` came from there and /// is worth having on its own account: it refuses a call made directly to /// the implementation, whose storage is not any account's. The constructor /// lock below makes such a call harmless anyway; this makes it loud. address private immutable _self; error NotThroughAProxy(); modifier onlyProxy() { if (address(this) == _self) revert NotThroughAProxy(); _; } constructor() { _self = address(this); _disableInitializers(); // Block initialize-then-delegatecall-selfdestruct on the implementation. // Proxies still initialise; their storage starts empty. _initializeOwner(address(1)); } /// @dev Ownable double-init guard. Required for the constructor lock above. function _guardInitializeOwner() internal pure override returns (bool) { return true; } /// Bind this proxy to one owner and one keeper. /// /// THE FIRST CALLER BECOMES `owner()`, WHICH IS `_authorizeUpgrade`'S GATE, /// and also holds KEEPER_ROLE. And because `BaseAdapter._consume` skips /// signature verification entirely when `msg.sender == agent`, that caller /// drives every adapter on every account that appointed this B. So who may /// call this is the whole question. /// /// The natspec here used to claim "same transaction as the proxy deploy — /// there is no window to front-run initialise", and the only Agent deployer /// in the repo did the opposite and said so: `deployDeterministic` with NO /// initialiser call at step 2, `initialize` as a separate transaction at /// step 4, under a fixed and public salt. The stated reason — "B's address /// has to exist before V can be predicted" — did not hold either: /// `predictDeterministicAddress` answers before the deploy, and V derives /// from B's ADDRESS rather than from its state. The three protocol proxies /// all use `deployDeterministicAndCall` correctly; this was the one left /// behind, and it was the one whose comment claimed otherwise. /// /// Both halves are fixed. `cmd/agentdeploy` now deploys and initialises in /// one call, and this refuses a caller who is not the proxy's admin — so /// the window is closed by the CONTRACT rather than by the care of whoever /// wrote the runbook. /// /// `deployDeterministicAndCall` makes the FACTORY the caller, so the /// factory is allowed. A proxy the canonical factory did not create records /// no admin, and there is nothing to check a caller against; that is the /// shape a test's `LibClone.deployERC1967` produces and it is not a shape /// this system's deploy can reach. function initialize(address owner_, address keeper_, AgentRegistry registry_) external initializer { if (msg.sender != ERC1967_FACTORY) { (bool ok, bytes memory ret) = ERC1967_FACTORY.staticcall( abi.encodeCall(IERC1967FactoryAdmin.adminOf, (address(this))) ); // NO ANSWER IS NOT AN ANSWER, and this fails OPEN on purpose. // // A staticcall to a codeless address returns empty rather than // reverting, so on a chain that has no canonical factory — a bare // test chain, or one the factory has not been deployed to — there // is simply no admin to check a caller against, and a typed call // would revert on the empty buffer and make this contract // uninitialisable there. Likewise a proxy created outside the // factory (LibClone) records no admin. // // The production guarantee is NOT this check: it is that // `cmd/agentdeploy` binds the proxy inside // `deployDeterministicAndCall`, so there is no moment at which an // uninitialised proxy exists for anybody to reach. This is the // second line, for the case where somebody deploys a proxy through // the factory by hand and initialises it in a later transaction. if (ok && ret.length == 32) { address admin = abi.decode(ret, (address)); if (admin != address(0) && msg.sender != admin) { revert NotProxyAdmin(msg.sender, admin); } } } if ( owner_ == address(0) || keeper_ == address(0) || address(registry_) == address(0) ) { revert ZeroAddress(); } registry = registry_; _initializeOwner(owner_); _grantRoles(keeper_, KEEPER_ROLE); } /// The defined Kernel-facing surface. Keeper names an installed adapter and /// its calldata; this contract becomes `msg.sender` on the adapter, which is /// how BaseAdapter authorises a contract agent. /// /// Wrapping every adapter ABI here would be a 1:1 passthrough of today's /// adapter blast radius with a maintenance trap. The bound is the target: /// installed executor, never V, never the registry, never this contract. function callAdapter(address account, address adapter, bytes calldata data) external payable onlyProxy onlyRoles(KEEPER_ROLE) returns (bytes memory) { return _callOne(account, adapter, data, msg.value); } /* ---------------------------------------------------------------- signed * * THE KEEPER AUTHORISES; ANYONE BROADCASTS. That is how this system has * always worked and it is worth saying why it must keep working that way. * * Before B was a contract, an adapter's authority was an EIP-712 signature * from B and the transaction could be relayed by anybody — the relayer key * is a raw key in development, and internal/relayer says that is acceptable * "precisely because R authorises nothing". Making `msg.sender` the whole * authority breaks that sentence: whoever sends becomes whoever may spend, * so the broadcaster would need KEEPER_ROLE and a gas-payer would turn into * a spender bounded only by the caps. * * The keeper cannot be recovered by the ADAPTER — it wants the agent, and * nothing ecrecovers to a contract. So the recovery moves one level up, to * here. B checks the keeper's signature and B is `msg.sender` at the * adapter, which is exactly what BaseAdapter._consume asks for. R goes back * to paying gas and nothing else. * * REPLAY IS THE ADAPTER'S JOB AND ALREADY WAS. Every adapter marks * `spent[account][taskId]` before it acts, so a resubmitted payload dies * there — the same guard that bounded the EOA path this restores. What the * adapter cannot see is a signature lifted onto ANOTHER CHAIN, where that * task id is still unspent; the EIP-712 domain binds chainId and this * proxy's own address, which is what closes it. A second nonce here would * be a second thing to desynchronise for a guarantee the adapter already * gives. */ /* THE ACCOUNT IS IN THE MESSAGE. With one B serving every account, a * signature that named only the adapter and the calldata would authorise the * same call against any account that has appointed this proxy. */ bytes32 private constant CALL_TYPEHASH = keccak256("CallAdapter(address account,address adapter,bytes32 dataHash,uint256 deadline)"); bytes32 private constant BATCH_TYPEHASH = keccak256( "CallAdapters(address account,bytes32 adaptersHash,bytes32 datasHash,uint256 deadline)" ); function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsAgent", "1"); } /// The digest a keeper signs for one call. Public so an off-chain signer /// builds it from the contract rather than from a second implementation of /// the same encoding. function callDigest(address account, address adapter, bytes calldata data, uint256 deadline) public view returns (bytes32) { return _hashTypedData( keccak256(abi.encode(CALL_TYPEHASH, account, adapter, keccak256(data), deadline)) ); } /// The digest for a whole batch — ONE signature over the ordered list, so a /// relayer can neither drop a call nor reorder them. function batchDigest( address account, address[] calldata adapters, bytes[] calldata datas, uint256 deadline ) public view returns (bytes32) { bytes32[] memory each = new bytes32[](datas.length); for (uint256 i; i < datas.length; ++i) { each[i] = keccak256(datas[i]); } return _hashTypedData( keccak256( abi.encode( BATCH_TYPEHASH, account, keccak256(abi.encodePacked(adapters)), keccak256(abi.encodePacked(each)), deadline ) ) ); } function callAdapterSigned( address account, address adapter, bytes calldata data, uint256 deadline, bytes calldata signature ) external onlyProxy returns (bytes memory) { _requireKeeperSigned(callDigest(account, adapter, data, deadline), deadline, signature); return _callOne(account, adapter, data, 0); } function callAdaptersSigned( address account, address[] calldata adapters, bytes[] calldata datas, uint256 deadline, bytes calldata signature ) external onlyProxy returns (bytes[] memory out) { if (adapters.length != datas.length) revert LengthMismatch(); if (adapters.length == 0) revert EmptyBatch(); _requireKeeperSigned( batchDigest(account, adapters, datas, deadline), deadline, signature ); out = new bytes[](adapters.length); for (uint256 i; i < adapters.length; ++i) { out[i] = _callOne(account, adapters[i], datas[i], 0); } } /// Recovered, then role-checked. Both, in that order: a valid signature from /// somebody who is not a keeper is not an authorisation. function _requireKeeperSigned(bytes32 digest, uint256 deadline, bytes calldata signature) internal view { if (block.timestamp > deadline) revert AuthorizationExpired(deadline); address recovered = ECDSA.recoverCalldata(digest, signature); if (!hasAnyRole(recovered, KEEPER_ROLE)) revert NotAKeeper(recovered); } /// The one place the rules live. Both entry points go through it. function _callOne(address account, address adapter, bytes calldata data, uint256 value) internal returns (bytes memory) { if (account == address(0)) revert ZeroAddress(); if (adapter == account || adapter == address(this) || adapter == address(registry)) { revert KernelCallForbidden(adapter); } if (data.length >= 4) { bytes4 sel = bytes4(data[:4]); if (sel == ON_INSTALL || sel == ON_UNINSTALL || sel == CHANGE_ROOT) { revert ForbiddenSelector(sel); } } // THE ACCOUNT'S OWN CHOICE, read live. This is what replaces the stored // binding: an account that has not appointed this proxy cannot be // reached through it, and revoking the appointment stops it at once. if (registry.agentFor(account) != address(this)) revert NotAppointed(); // AND A FACT THE ACCOUNT CANNOT ANSWER FOR ITSELF (M-12). // // Every check above is evaluated against the CALLER-SUPPLIED `account`, // and `_executorInstalled` asks that same account. So a hostile // contract that appoints this proxy — `appoint` is called BY the // account and refuses only EOAs — and returns `true` from // `isModuleInstalled` unconditionally defeated all four: `adapter != // account`, `!= this`, `!= registry`, and "is an installed executor" // were all satisfiable by a contract written to satisfy them. It bought // no new asset movement, because every adapter re-derives the owner // and charges the owner's cap for itself, and it removed a bound this // contract's own documentation states. // // The registry's whitelist is not the account's to answer. An address // the guardian never listed is refused here whatever the account claims // about it. // // MEMBERSHIP, NOT EFFECTIVENESS — H-6 of the round-2 audit. This asked // `isEffectiveAdapter`, which answers "may this be INSTALLED right // now"; the question here is whether something ALREADY INSTALLED may be // used. `whitelistAdapter` re-arms the three-day clock on every write, // and `cmd/curate` rewrites every named adapter on every run — so one // routine curation run took this whole path offline for every account // on the chain for three days, `LoanCloseAdapter` and `SafeExitAdapter` // among them, which are the paths that de-risk a position. A delist // made it permanent for accounts still holding the adapter. // // M-12, which put this line here, was about a hostile caller-supplied // account faking `isModuleInstalled`. Membership answers that: the // account cannot name an address the guardian never listed. The delay // keeps doing its own job at `AdapterInstaller`, which is where // installation happens, and, for a v2 account, at `PolicyGate`. // // AND NOT SUSPENDED. `isUsableAdapter` is membership AND not // suspended, so a guardian or pauser suspension stops every adapter // this proxy drives — v1 and v2's `openStep` alike — in one registry // write. It is a requirement, so narrowing it cannot open anything, // and the success path is still one call. Not the ZK-layer switch: v1 // keeps running while that is off, and v2 meets it at `consume`. if (!registry.isUsableAdapter(adapter)) { if (registry.isSuspended(adapter)) revert AdapterIsSuspended(adapter); revert NotAnAdapter(adapter); } // INSTALLED, OR REACHABLE THROUGH THE POLICY DOOR. // // A v2 account installs NOTHING — one executor in genesis and no // adapters, which is the entire design. So this line refused every // adapter on every v2 account, and the whole proof path was unreachable // in production: `openStep` is agent-only, the agent is this proxy, and // this proxy would not forward. The test that covered it declared its // own `Agent` contract and never came through here. // // RELAXING IT GIVES NOTHING AWAY, because it was never the bound it // looks like. `BaseAdapter._consume` requires installed-OR-proved and // refuses `NotInstalled` otherwise, so an adapter reached here without // either does nothing at all. M-12 — a hostile caller-supplied account // faking `isModuleInstalled` — is answered by the whitelist above: the // account cannot name an address the guardian never listed. if (!_executorInstalled(account, adapter) && registry.policyGate() == address(0)) { revert NotAnAdapter(adapter); } emit AdapterCalled(adapter, data.length >= 4 ? bytes4(data[:4]) : bytes4(0)); (bool ok, bytes memory ret) = adapter.call{value: value}(data); if (!ok) { assembly { revert(add(ret, 0x20), mload(ret)) } } return ret; } /// Deaf to ERC-1271. USDC / Permit2 / CoW asking this address get invalid, /// so a stolen keeper cannot witness a token authorization for V — V is a /// different contract, and this one will not sign for anyone including itself. function isValidSignature(bytes32, bytes calldata) external pure returns (bytes4) { return ERC1271_INVALID; } /// Same answer if someone mistakenly installs this as a Kernel validator /// and a 1271 prefix routes here. Do not install it. function isValidSignatureWithSender(address, bytes32, bytes calldata) external pure returns (bytes4) { return ERC1271_INVALID; } function _executorInstalled(address account, address adapter) internal view returns (bool) { try IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "") returns ( bool ok ) { return ok; } catch { return false; } } // NO `_authorizeUpgrade`, AND NO `upgradeToAndCall` TO AUTHORISE — H-5. // See the header. Replacing this code is `ERC1967Factory.upgrade` by the // proxy's admin, which is the UpgradeTimelock, which takes seven days. }