// SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// Poseidon(3) as circomlibjs generates it. See src/generated/Poseidon3.sol. /// The two calls the registry makes into `PolicyGate` to settle a charge /// against a proved plan's allowance rather than the epoch. interface IPlanAllowance { /// `planned`: the adapter holds an open step and the plan paid. `covered`: /// the plan's reservation still sits in the CURRENT epoch of this token's /// cap, so the epoch is not charged again; when false the registry charges /// the epoch as well. `nullifier`: which plan, so a later refund can name it. function draw(address account, address adapter, address asset, uint256 amount) external returns (bool planned, bool covered, uint256 nullifier); /// Give `unused` back to the plan `nullifier` — the one that paid, named by /// the charge's tag, and never whichever plan happens to be open now. function undraw(address account, address adapter, address asset, uint256 unused, uint256 nullifier) external; } interface IPoseidon3Like { function poseidon(uint256[3] calldata input) external view returns (uint256); } /// The two calls `createAccount` makes into Kernel v3.1's factory. interface IKernelFactoryLike { function createAccount(bytes calldata data, bytes32 salt) external payable returns (address); function getAddress(bytes calldata data, bytes32 salt) external view returns (address); } /// @title AgentRegistry — which agent contract may act for which account. /// /// B IS `contracts/src/Agent.sol`, a UUPS proxy, never an EOA. An EOA appointed /// as B can be installed as a Kernel validator and then drain via EIP-3009 /// ERC-1271 (`GuardBypassEIP3009`) without hitting adapters or any execution /// hook. `appoint` refuses EOAs and EIP-7702. New accounts use /// `GatedECDSAValidator` as root so A cannot `installModule(VALIDATOR, eoa)` /// either. The keeper key on Agent is hot; it is not B. /// /// This is the answer to "who is B", asked on chain rather than assumed. Every /// adapter reads it before it will move anything, so revoking here stops every /// adapter at once without touching a single account. /// /// Two writers, deliberately asymmetric: /// /// The ACCOUNT appoints its own agent. `msg.sender` is the account, and the /// only key that can make a Kernel account call out is its root validator — /// owner A. So appointment is A's decision, structurally, and this contract /// does not need to know how A authorises anything. There is no admin who can /// point an account at an agent its owner never chose. /// /// The GUARDIAN can only revoke, and revocation is one way. A guardian that /// could appoint would be a second owner for every account; a guardian that /// can only revoke is, at worst, able to stop the agent — which is the same /// power a denial of service already has, and much less than the power to /// redirect. It exists because a compromised B is one key across every account /// on every chain, and N owners each noticing and each sending a transaction /// is not a kill switch. /// /// AN ACCOUNT IS BOUND TO ITS FIRST AGENT, FOR EVER. The account's address is /// CREATE2 over its initData, and that initData contains the `appoint` call, so /// the address ALREADY commits to which B governs it. Binding is what stops the /// STATE from contradicting the ADDRESS: `agentFor` an account is either the /// agent its genesis named or nothing, and no later caller — the owner, a /// module, a whitelisted adapter executing as the account — can point it /// somewhere else. Nobody has to read state to know which B an account answers /// to; the address is the answer. /// /// Zero is still allowed, and it is what makes binding safe to have. An owner /// stands the whole thing down in ONE call, which is the only way to stop every /// adapter at once without uninstalling each one and racing whoever they are /// stopping. Coming back is allowed too — to the bound agent, and to nothing /// else. /// /// The price, stated plainly rather than discovered: revocation is now TERMINAL /// for every account that named the revoked agent. Appointing a different key is /// no longer a recovery, so those accounts can never automate again. Nothing is /// stuck — A always reaches its account through the root validator, and can /// spend, withdraw and uninstall — but automation at that address is over. B is /// a UUPS proxy for exactly this reason: a bug in B is an UPGRADE. Revocation is /// for a B that must never run again, and it costs every account that named it. contract AgentRegistry { /// The agent each account has appointed. Zero means "no agent", which is the /// state every account starts in and the state every adapter refuses. mapping(address account => address agent) internal _appointed; /// Keys that must never act again, whatever any account says. mapping(address agent => bool) public revoked; /// The only address that may revoke. /// /// STORAGE rather than immutable, because this contract lives behind a proxy /// — and an immutable is baked into the implementation's code, which is the /// thing an upgrade replaces. It would read as zero through the proxy. /// /// Still write-once: set at initialisation and never again, so it keeps the /// property the immutable was there for. A rotatable guardian is a /// key whose compromise adds the power to un-revoke, and the whole value of /// one-way revocation is that it cannot be undone by whoever stole it. address public guardian; event AgentAppointed(address indexed account, address indexed agent, address indexed previous); event AgentRevoked(address indexed agent); /// A pre-binding-rule appointment written back from the logs. See /// `seedBinding` — M-17. event BindingSeeded(address indexed account, address indexed agent); error NotGuardian(); error AgentIsRevoked(address agent); error AppointingSelf(); error AgentMustBeContract(address agent); error AgentMustNotBeEIP7702(address agent); error AgentIsFixed(address bound, address attempted); /// `seedBinding` on an address with no code. See there. error AccountNotDeployed(address account); error AlreadyInitialized(); /// Set the guardian, once. /// /// An `initialize` rather than a constructor because constructor code does /// not run in a proxy's context: it executes against the IMPLEMENTATION's /// storage, which nothing ever reads. A contract behind a proxy that keeps /// its setup in a constructor is one whose setup silently did not happen. /// /// Callable by anyone, exactly once. Front-running it is not a risk worth /// guarding against here: deployment and initialisation go in one /// transaction through ERC1967Factory's deployDeterministicAndCall, so there /// is no window between them for anybody to act in. function initialize(address guardian_) external { if (guardian != address(0)) revert AlreadyInitialized(); if (guardian_ == address(0)) revert NotGuardian(); guardian = guardian_; } /// Appoint, replace, or remove this account's agent. /// /// Called BY the account. Passing the zero address removes the agent, which /// is how an owner stands the whole thing down without waiting for anyone. /// /// Revocation is NOT checked here, deliberately, and removing that check is /// what makes it safe to appoint from an account's genesis. /// /// It never added security: `agentFor` applies revocation at READ time and /// returns the zero address for a revoked key, so an account that appoints /// one gets an agent that cannot act. The check here only decided WHERE the /// refusal happened. /// /// And where mattered enormously. An appointment inside `initConfig` runs /// during `initialize`, so a revert there reverts the whole deployment — and /// the account address is CREATE2 over that same initData, so there is no /// second version of it to deploy instead. One guardian revocation would /// have made every not-yet-deployed account of every owner permanently /// undeployable on every chain, stranding anything already bridged to those /// counterfactual addresses. Revocation is meant to stop a key, not to /// destroy addresses that have never been used. /// /// AND IT IS BOUND. The first agent an account appoints is the only non-zero /// agent it can ever hold. Afterwards this accepts zero — stand down — and /// the bound agent — come back — and refuses everything else. /// /// The binding is recorded on first use rather than at deployment because /// nothing here observes an account being created. An account that appointed /// BEFORE this rule existed binds to what it already holds, not to what the /// call is trying to set: otherwise the first appointment after the upgrade /// would be one free re-point, which is the thing being closed. function appoint(address agent) external { // An account that appointed itself would let anything able to make it // call out sign its own authorisations, which is every module it has. if (agent == msg.sender) revert AppointingSelf(); address bound = _bound[msg.sender]; if (bound == address(0)) { bound = _appointed[msg.sender]; if (bound == address(0)) bound = agent; if (bound != address(0)) _bound[msg.sender] = bound; } // Zero is a stand-down, never a re-point, so it is always allowed. if (agent != address(0) && agent != bound) revert AgentIsFixed(bound, agent); requireContractAgent(agent); address previous = _appointed[msg.sender]; _appointed[msg.sender] = agent; emit AgentAppointed(msg.sender, agent, previous); } /// Zero (clear the agent) is allowed. Anything else must be a real contract, /// not an EOA and not an EIP-7702 EOA-with-code. Called from appoint and /// from BaseAdapter so a stale EOA appointment cannot move funds. /// /// AND A SUSPENDED ADAPTER IS REFUSED HERE — the belt. `BaseAdapter._consume` /// calls this on every execution, with the ADAPTER as `msg.sender`, so every /// adapter refuses itself while suspended without a byte of adapter code /// changing. That covers what `Agent._callOne` cannot: an account whose /// genesis bound a contract agent other than `Agent.sol`, and a chain where /// the `Agent` upgrade lags this one. The other two callers are `appoint` /// (`msg.sender` is the account) and `seedBinding` (the guardian), neither /// of which is ever a listed adapter. A caller-sensitive check inside a /// view is unusual, so `TestRequireContractAgentHasThreeCallers` pins who /// calls it. function requireContractAgent(address agent) public view { if (_suspended[msg.sender]) revert AdapterIsSuspended(msg.sender); if (agent == address(0)) return; bytes memory code = agent.code; if (code.length == 0) revert AgentMustBeContract(agent); // 7702 designation: 0xef0100 || address. It has code, so the length // check would pass, and Kernel 1271 would still treat it as a signer. if (code.length >= 2 && code[0] == 0xef && code[1] == 0x01) { revert AgentMustNotBeEIP7702(agent); } } // --- The adapter whitelist --------------------------------------------- // // WHICH adapters an account may be given, and with what configuration. // // Here rather than in each account's genesis, for two reasons that pull the // same way. The install payload names chain-specific addresses — USDC is a // different contract on every chain, as is each venue — and an account's // genesis is inside its CREATE2 address preimage, so a per-chain byte there // would give one owner a different account address per chain. That address // is the deposit target a bridge sends to, so forking it strands money. // // And an account should be able to gain a tenth adapter without its address // moving. Pinning the set in the genesis would make every new adapter a // migration of every user. // // The cost is real and should be read rather than skimmed: this moves the // power to introduce an executor into an account from NOBODY to the // guardian. An executor can move an account's funds. That trade is // deliberate — the guardian is cold and the agent key is hot — but the // owner's remedy is unchanged and unilateral: uninstall the module, or // change the root validator, neither of which needs our cooperation. /// The adapters an account may be given, in whitelist order. address[] private _adapters; /// The install payload each one is given, curated per chain. mapping(address adapter => bytes) private _installData; /// Calls to make ON the adapter, AS the account, right after installing it. /// /// Installing is not the whole of setting an adapter up. A Morpho market is /// five parameters and there are several; an Aave v4 spoke maps a dozen /// reserve ids; a property names a vault, a token and a marketplace. None of /// that fits the flat install payload, so each adapter exposes its own /// curate-shaped function — and every one of them is callable ONLY by the /// account, which is the same "the account must act" problem installModule /// has. /// /// It was solved by impersonating the owner on a fork, which is not /// something a real chain offers. Carrying the calls here lets the installer /// make them through the account, so an account arrives curated with no /// owner transaction — and they are bounded by the same whitelist and the /// same delay as everything else the guardian decides. mapping(address adapter => bytes[]) private _curation; /// How many times an adapter's curation has been rewritten. /// /// So an account can be asked whether what it holds is current WITHOUT /// re-sending every call to find out. Curation is idempotent, so a blind /// re-apply is harmless — but it is a transaction, and paying for one on /// every group to discover there was nothing to do is a cost with no /// corresponding fact. /// /// A counter rather than a hash of the calls: it is one word, it never needs /// the calls loaded to compare, and it cannot collide. mapping(address adapter => uint64) private _curationVersion; /// When each one becomes installable. /// /// THE BOUND ON THE GUARDIAN, and the reason this contract is not simply a /// list. Whitelisting moves the power to put an executor into somebody's /// account from nobody to the guardian, and an executor moves funds. Without /// a delay, "the guardian is cold" is the only thing between a compromise and /// every account that has not yet been activated. /// /// A delay does not stop a hostile adapter. It makes one VISIBLE before it /// can be installed — the event is emitted at proposal, the address is /// readable, and an owner who does not like what they see has the whole /// window to uninstall, change their root validator, or move. That is the /// same remedy they always had; the delay is what gives them time to use it. mapping(address adapter => uint64) private _effectiveAt; /// Long enough for a person to notice, short enough to ship an adapter. /// /// A constant rather than a setter: a configurable delay is one a /// compromised guardian sets to zero. Genesis may skip it while /// `activationDelayEnabled` is false; `enableActivationDelay` turns it on /// once and cannot turn it off. uint64 public constant ACTIVATION_DELAY = 3 days; event AdapterProposed(address indexed adapter, uint64 effectiveAt); event AdapterDelisted(address indexed adapter); event AdapterCancelled(address indexed adapter); event ActivationDelayEnabled(); error NotAnAdapter(); /// A name whose field `uint256(name) >> 8` is zero writes the padding leaf's /// name into the adapter tree - see `setAdapterName`. error AdapterNameIsEmpty(address adapter, bytes32 name); error NoSuchAdapter(address adapter); error EmptyInstallData(); error ActivationDelayAlreadyEnabled(); error AdapterNotPending(address adapter, uint64 effectiveAt); /// Add an adapter, or replace the configuration of one already listed. /// /// Replacing the payload is allowed and does not re-install anything: an /// account that already has the adapter keeps the configuration it was given. /// This only decides what a LATER activation hands out. /// /// Empty installData is refused: `_installData.length == 0` is the /// not-listed sentinel, so storing empty would brick delist and make /// AdapterInstaller.activate revert for every account after the delay. function whitelistAdapter( address adapter, bytes calldata installData_, bytes[] calldata curation_ ) external { _onlyGuardian(); if (adapter == address(0) || adapter.code.length == 0) revert NotAnAdapter(); if (installData_.length == 0) revert EmptyInstallData(); bool fresh = _installData[adapter].length == 0; if (fresh) { _adapters.push(adapter); } // WHETHER ANYTHING ACTUALLY CHANGED — H-6. `cmd/curate` re-whitelists // every named deployed adapter on every run with no diff check, so the // ordinary case is a rewrite of identical bytes. Comparing before // writing is what lets the clock below stay still for those. bool same = !fresh && keccak256(_installData[adapter]) == keccak256(installData_) && _sameCuration(adapter, curation_); // THE PREVIOUS PAYLOAD, CAPTURED BEFORE IT IS OVERWRITTEN. // // `_index` needs it to retire operations this rewrite DROPS. Without // that, an operation removed from an adapter's payload would stay // allowed for ever -- the global mirror of the sticky-authority bug // the per-account generation fence exists to prevent. bytes memory previous = fresh ? bytes("") : _installData[adapter]; _installData[adapter] = installData_; delete _curation[adapter]; for (uint256 i = 0; i < curation_.length; i++) { _curation[adapter].push(curation_[i]); } // Bumped on every write, including one that happens to set the same // calls. An account is then told to re-apply something it already has, // which is idempotent and cheap; the opposite mistake is an account // quietly missing a market. // NOT BUMPED WHEN NOTHING MOVED — H-6/M-18. A version bump tells every // account it is out of date, and `AdapterInstaller.syncCuration` now // acts on that. For identical bytes there is nothing to re-apply. if (same) return; // The payload moved, so the global tables move with it. Identical // bytes returned above and never reach this, which is what keeps a // no-op `cmd/curate` run from rewriting 115 slots. _index(adapter, previous, installData_); // THE ADAPTER TREE, and only once the adapter has a NAME. // // `cmd/curate` writes the payload and then the name, two calls. Walking // the path on both is 14 Poseidon hashes where 7 will do — measured at // 1,370,613 a adapter against 685,000 — and the intermediate root is // one nobody can use anyway: a leaf commits to the name, so an unnamed // adapter's leaf is a value that is about to change. // // `setAdapterName` does the update instead, and a re-curate of an // already-named adapter updates here as it should. // // THE FIELD, NOT THE BYTES. The leaf commits to `uint256(name) >> 8`, // so a name below 2^8 (a legacy `bytes32(1)`, say) is "named" to a // bytes test and writes the padding leaf's own name. See // `setAdapterName`. if (uint256(_adapterName[adapter]) >> 8 != 0) _capUpdate(adapter, true); _curationVersion[adapter]++; // The clock restarts on a CONFIG change too, not only on a new adapter. // // The payload names the venues an adapter may reach and the caps it may // spend within. Letting the guardian rewrite that instantly, on an // adapter already past its delay, would leave the delay guarding the // address and nothing else — and the address was never the dangerous // part. An adapter goes back to pending until the window passes. // // AN ACCOUNT THAT INSTALLED IT KEEPS RUNNING IT, and that used to mean // on the NEW configuration: `onInstall` keeps nothing from the // payload, and `_venueOf` reads `_adapterVenue`, which `_index` above // had just rewritten. So a venue the payload ADDS waits the same // window before it resolves (`_adapterVenue`); the venues it keeps // work throughout, and one it drops is gone at once, which narrows and // needs no notice. Curation waits too: `curationVersion` is zero while // the adapter is pending. A v2 account has no install to keep, so for // it the adapter is refused at `PolicyGate` until the window passes. // // AND NOT FOR A REWRITE OF THE SAME BYTES — H-6 of the round-2 audit. // There is no new authority to give notice of, and the cost of // restarting anyway was three days in which `installStale` called every // live account stale (M-18). Nothing about the delay's purpose asks for // a clock reset on a no-op. _restartClock(adapter); } /// When a write made now becomes usable: now while the delay is off (a /// chain being bootstrapped), else ACTIVATION_DELAY from now. One body for /// the adapter's clock and a new venue scope's, which must agree. function _noticeEnds() private view returns (uint64 at) { at = uint64(block.timestamp); if (activationDelayEnabled) at += ACTIVATION_DELAY; } /// Put an adapter back on notice. See `whitelistAdapter` and /// `setAdapterName`, its two callers. function _restartClock(address adapter) private { uint64 at = _noticeEnds(); _effectiveAt[adapter] = at; emit AdapterProposed(adapter, at); } /// Arm the three-day delay. One way. /// /// Fresh deploys start with it off so the genesis whitelist is installable /// in the same block. After this, every later `whitelistAdapter` waits /// ACTIVATION_DELAY. Adapters already listed stay effective; this only /// changes future writes. function enableActivationDelay() external { _onlyGuardian(); if (activationDelayEnabled) revert ActivationDelayAlreadyEnabled(); activationDelayEnabled = true; emit ActivationDelayEnabled(); } /// Whether the stored curation blob is byte-for-byte what is being written. function _sameCuration(address adapter, bytes[] calldata next) internal view returns (bool) { bytes[] storage have = _curation[adapter]; if (have.length != next.length) return false; for (uint256 i = 0; i < have.length; i++) { if (keccak256(have[i]) != keccak256(next[i])) return false; } return true; } /// Withdraw a whitelist still inside ACTIVATION_DELAY, so it never becomes /// installable. Once `block.timestamp >= effectiveAt` a stranger can /// `activate` and this is too late — use `delistAdapter`. function cancelAdapter(address adapter) external { _onlyGuardian(); uint64 at = _effectiveAt[adapter]; if (at == 0 || block.timestamp >= at) revert AdapterNotPending(adapter, at); _delist(adapter); emit AdapterCancelled(adapter); } /// Remove an adapter from the whitelist. /// /// Does NOT uninstall it from accounts that already have it. Removing it /// from here stops it being handed to accounts that do not — which is the /// only thing this contract has ever been able to affect. Reaching into /// existing accounts would be the power this deliberately does not take. function delistAdapter(address adapter) external { _onlyGuardian(); if (!_listed(adapter)) revert NoSuchAdapter(adapter); _delist(adapter); emit AdapterDelisted(adapter); } function _delist(address adapter) internal { // Zero the leaf BEFORE the payload goes, since `_capUpdate` reads it — // and a delisted adapter keeps its SLOT so every other adapter's // position is stable and proofs about them survive. _capUpdate(adapter, false); // RETIRE THE VENUE SCOPES WHILE THE PAYLOAD IS STILL HERE. // // `_index` retires an adapter's old scopes by decoding `previous` — // and this used to delete `_installData` without doing that, so after a // delist there was nothing left to retire FROM. A later // `whitelistAdapter` on the same address takes the `fresh` path with // `previous == ""` and skips the retire loop entirely, which made every // venue the adapter was EVER scoped to immortal: narrowing a payload // by delist-then-relist silently did nothing. The identical narrowing // without a delist works, which is what makes this a bug here rather // than a design choice. // // `_adapterVenue` is the mapping whose own note says that without it // "a compromised B reaches every venue every adapter curated". _index(adapter, _installData[adapter], bytes("")); delete _installData[adapter]; delete _curation[adapter]; _curationVersion[adapter]++; delete _effectiveAt[adapter]; for (uint256 i = 0; i < _adapters.length; i++) { if (_adapters[i] == adapter) { _adapters[i] = _adapters[_adapters.length - 1]; _adapters.pop(); break; } } } /// The adapters that may be installed RIGHT NOW. /// /// Filtered by the delay rather than returning everything and trusting the /// caller to check. AdapterInstaller is permissionless and anyone may call /// it the instant a proposal lands, so a list that included pending entries /// would make the delay advisory. /// /// AND BY SUSPENSION: a suspended adapter is not handed to anyone. That is /// the installer's whole view of it — absent here, empty `installDataFor`, /// zero curation — so it installs nothing and uninstalls nothing, and on /// resume everything it reads is back byte for byte. NOT the list a policy /// is built from: that is `capabilitySet`, which keeps suspended adapters. function adapters() external view returns (address[] memory) { uint256 n; for (uint256 i = 0; i < _adapters.length; i++) { if (_installable(_adapters[i])) n++; } address[] memory out = new address[](n); uint256 j; for (uint256 i = 0; i < _adapters.length; i++) { if (_installable(_adapters[i])) { out[j++] = _adapters[i]; } } return out; } /// Everything proposed, effective or not, so a watcher can see what is /// coming. The delay is worth nothing if the only way to learn about an /// adapter is for it to already be installable. function proposedAdapters() external view returns (address[] memory) { return _adapters; } /// When an adapter becomes installable. Zero means it is not proposed. function effectiveAt(address adapter) external view returns (uint64) { return _effectiveAt[adapter]; } /// Is this address an adapter that may be installed RIGHT NOW? /// /// The same question `adapters()` answers, asked about ONE address so a /// caller does not have to walk the list. Added for `Agent._callOne`, whose /// every other target check was evaluated against a caller-supplied /// `account` and could therefore be answered by that account (M-12 of the /// 2026-09-10 audit): this is a fact about the REGISTRY, which a hostile /// account cannot fake. /// /// `_callOne` asks usability now (H-6, below, and `isUsableAdapter`). It has /// no caller on chain any more: `PolicyGate.consume` and `authorizes` ask /// `policyMayUse`, which is this AND not suspended AND the ZK layer on. It /// keeps its meaning — "has served its notice" — for the off-chain readers /// that ask about the delay specifically (`Curate.s.sol`, `zkgate.ReadPending`): /// folding suspension in would report a suspended adapter as pending. function isEffectiveAdapter(address adapter) external view returns (bool) { return _isEffective(adapter); } function _isEffective(address adapter) internal view returns (bool) { uint64 at = _effectiveAt[adapter]; return at != 0 && block.timestamp >= at; } /// IS THIS ADDRESS AN ADAPTER AT ALL — H-6 of the round-2 audit. /// /// The distinction this draws is the one M-12's fix collapsed. /// `isEffectiveAdapter` answers "may this be INSTALLED right now", and /// `Agent._callOne` used it to decide whether an adapter already installed /// may be USED. Those are not the same question, and `whitelistAdapter` /// re-arms the three-day clock on EVERY write — including a byte-identical /// rewrite of an already-live adapter, which is what `cmd/curate` performs /// for every named adapter on every run, with no diff check. /// /// So one routine curation run took `callAdapter` / `callAdapterSigned` / /// `callAdaptersSigned` offline for EVERY already-installed adapter on /// EVERY account for three days — `LoanCloseAdapter` and `SafeExitAdapter`, /// the de-risking paths, included — and a `delistAdapter` on a superseded /// address made it permanent for accounts still holding it. That /// contradicts this contract's own two sentences: "Reaching into existing /// accounts would be the power this deliberately does not take", and "ones /// already installed in accounts are unaffected". /// /// M-12 was about a hostile CALLER-SUPPLIED ACCOUNT faking /// `isModuleInstalled`, and whitelist MEMBERSHIP answers that completely: a /// hostile account still cannot name an address the guardian never listed. /// The delay is what stops a hostile adapter reaching accounts; it was /// never meant to withdraw one that is already there. /// /// `_installData.length != 0` is this contract's own not-listed sentinel — /// `whitelistAdapter` refuses empty data and `delistAdapter` clears it — so /// a delisted adapter answers false here and can no longer be driven. /// /// SUSPENSION IS DELIBERATELY ABSENT, and must stay absent. This view is /// also asked as a REFUSAL: `PolicyExecutor._refuseControlTarget` refuses /// to let an open step call any address this answers true for, because an /// adapter's account-gated setters trust `msg.sender == account`. A /// suspended adapter that answered false here would stop counting as an /// adapter at that door, and one open step could call its setters. The /// acting path asks `isUsableAdapter` instead, which is this AND not /// suspended. function isWhitelistedAdapter(address adapter) external view returns (bool) { return _listed(adapter); } /// The not-listed sentinel, in one place: `whitelistAdapter` refuses empty /// install data and `_delist` clears it. function _listed(address adapter) private view returns (bool) { return _installData[adapter].length != 0; } /// The configuration an adapter is installed with. /// /// Empty when the adapter is not whitelisted OR has not passed its delay OR /// is suspended, so a caller that somehow reaches for a pending or /// suspended adapter still gets nothing to install it with. The stored /// bytes are untouched by a suspension; this only withholds them. function installDataFor(address adapter) external view returns (bytes memory) { if (!_installable(adapter)) return ""; return _installData[adapter]; } /// The calls to make on an adapter, as the account, after installing it. /// /// Empty for an adapter still inside its delay or suspended, for the same /// reason installDataFor is: a caller reaching for a pending or suspended /// adapter must get nothing it can act on. function curationFor(address adapter) external view returns (bytes[] memory) { if (!_installable(adapter)) return new bytes[](0); return _curation[adapter]; } /// How many curation calls the registry has for an adapter right now. /// /// Zero while the adapter is inside its delay or suspended, for the same /// reason `curationFor` returns an empty array there — and zero, too, for an /// adapter whitelisted with no curation at all, which is the case /// `AdapterInstaller._curatable` could not previously distinguish from "has /// something to write" (M-1 of the 2026-09-10 audit). /// /// A count rather than the array, because the caller only needs to know /// whether there IS anything before it sends a `resetCuration`. function curationCount(address adapter) external view returns (uint256) { if (!_installable(adapter)) return 0; return _curation[adapter].length; } /// The current version of an adapter's curation. /// /// Zero while the adapter is inside its delay, which makes an account's /// recorded version differ from it and keeps a pending change from looking /// applied. Zero while suspended too — and `AdapterInstaller` reads zero as /// "nothing to hand out", so neither `installStale` nor `curationStale` /// calls a suspended adapter stale, and on resume the version is back. function curationVersion(address adapter) external view returns (uint64) { if (!_installable(adapter)) return 0; return _curationVersion[adapter]; } /// Stop a key everywhere, in one write. One way. function revoke(address agent) external { _onlyGuardian(); revoked[agent] = true; emit AgentRevoked(agent); } /// RECORD A BINDING THAT PREDATES THE BINDING RULE — M-17 of the round-2 /// audit. /// /// An account that appointed B and then called `appoint(0)` under the /// PRE-BINDING implementation has `_appointed == 0` and `_bound == 0`, which /// is byte-identical to an account that never appointed anything. Its next /// `appoint(X)` therefore binds to whatever it names — and the class that /// CAN re-point is exactly the class the design says cannot: "the address is /// the answer." /// /// `appoint` cannot fix this from the inside, because the two states it /// would have to tell apart are the same state. The history is in the /// `AgentAppointed` logs, and this is how it gets written back. /// /// ONE WAY, AND ONLY FROM NOTHING. A binding that already exists is not /// touched, so this can never re-point an account — which is the property /// the whole rule is for. /// /// GUARDIAN-ONLY, and it grants the guardian no authority it lacked: seeding /// a binding for an account that never appointed would stop that account /// using a different agent, and `revoke` above already stops any agent /// everywhere. The remedy for a wrong seed is the same as for a wrong /// revocation — replace the guardian, which `UpgradeTimelock` provides for. function seedBinding(address account, address agent) external { _onlyGuardian(); if (agent == address(0) || agent == account) revert AppointingSelf(); // ONLY A DEPLOYED ACCOUNT — #38 of the 2026-10-08 audit. A seed is a // record of an appointment made under the pre-binding rule, and only // an account with code can have made one. Seeded on an address with // no code yet, the binding is written ahead of the genesis: the // genesis' own `appoint(B)` then reverts `AgentIsFixed` unless the // seed named exactly B, the deployment reverts with it, and the // address is CREATE2 over that genesis, so there is no other version // of it to deploy — anything already sent there is stranded. if (account.code.length == 0) revert AccountNotDeployed(account); if (_bound[account] != address(0)) revert AgentIsFixed(_bound[account], agent); requireContractAgent(agent); _bound[account] = agent; emit BindingSeeded(account, agent); } /// The agent that may act for this account right now, or zero. /// /// Revocation is applied HERE rather than being left to each caller, so a /// new adapter cannot forget to check it. Reading the raw appointment is /// deliberately not exposed: the only question worth asking is the one that /// accounts for revocation, and offering both invites the wrong one. function agentFor(address account) external view returns (address) { address agent = _appointed[account]; if (agent == address(0) || revoked[agent]) return address(0); return agent; } /// The only non-zero agent this account can ever hold. Zero means it has /// never appointed one and is still free to. /// /// A different question from `agentFor`, and both are worth asking: this one /// ignores revocation and ignores standing down, so it answers "which B /// could this account ever answer to" rather than "which B can act now". function boundAgent(address account) external view returns (address) { return _bound[account]; } // --- APPEND ONLY BELOW --------------------------------------------------- // // This contract is behind a proxy whose implementation the timelock // replaces, and its storage outlives every one of them. A variable INSERTED // anywhere above moves every mapping after it: `_effectiveAt` is slot 7 and // `cmd/curate` writes that slot by hand on forks, and the adapter payloads // would start being read out of the curation mapping. New state goes here, // at the end, and AgentRegistryLayout.t.sol fails if it does not. /// The agent each account is bound to. See `appoint`. mapping(address account => address agent) internal _bound; /// False at genesis: `whitelistAdapter` is immediately effective. /// `enableActivationDelay` sets this true once; there is no setter back. bool public activationDelayEnabled; // ────────────────────────────────────────── the curated asset set ── // // WHAT AN AGENT MAY NAME AS A DESTINATION. // // Adapters take an asset ADDRESS from the intent in a handful of places // where it is the thing value ends up in rather than the thing that leaves: // a swap's `assetOut`, a CoW order's `buyToken`, a property sale's // settlement currency, an LP position's two tokens, a Morpho market's pair. // Those are the fields where an agent that has deployed its own token can // take the whole position and leave something worthless behind: // // B deploys JUNK, creates the USDC/JUNK pool, initialises it at a price // where 1 wei is worth X USDC, and signs Swap{assetIn: USDC, amountIn: X, // assetOut: JUNK, minOut: 1}. The account receives 1 wei of JUNK and B // takes X out of the pool. (H-3 of the 2026-09-10 audit.) The property // sale is worse — C-2(b), a CRITICAL: `balanceOf` on a token B WROTE // answers whatever B likes, so the "did we receive enough" check passes // with certainty. // // UNTIL 2026-09-20 THE LIST WAS THE PER-ASSET CAP TABLE, written into every // adapter at install. That worked and cost two things worth more than it: // an asset added to the chain afterwards had no row on accounts already // installed, so a good intent died `NoCapForAsset` and the only remedy was // a reinstall; and twenty-eight assets at three cold stores each was most // of an install's gas, on every adapter, for every account. Spend caps were // removed entirely and the list came here instead. // // WHY THE REGISTRY. It is per chain, it is already the thing every adapter // consults before it acts, and it is already where "what the guardian has // curated" lives. One write here answers for every account and every // adapter at once — so an asset added to the chain is usable immediately // and nothing has to be reinstalled — and it costs an install nothing, // because there is nothing per-account to write. // // EFFECTIVE IMMEDIATELY, unlike an adapter, and that is deliberate. // `whitelistAdapter` waits out ACTIVATION_DELAY because a compromised // guardian ALONE can steal with it: an executor can make an account do // anything and `activate` is permissionless. An asset cannot. Curating one // only widens what an agent may name, so the drain needs the guardian's key // AND the agent's — and a three-day freeze on every newly listed token // would be paid every time the asset table grows, for a second factor. If // that trade is ever revisited, the delay machinery is right above. // // 0 IS NOT CURATED, which is the fail-closed direction: a chain whose assets // have never been curated refuses every guarded destination rather than // permitting all of them. mapping(address asset => bool) private _curatedAsset; /// Curated assets in the order they were added, so a reader can enumerate /// what this chain permits without replaying events. address[] private _assetList; event AssetCurated(address indexed asset); event AssetDelisted(address indexed asset); error NotAnAsset(); error NoSuchAsset(address asset); /// Add assets an agent may name as a destination. Guardian only, additive. /// /// Idempotent per asset: `cmd/curate` sends the whole set on every run, and /// re-sending one already curated writes nothing and emits nothing. That is /// what keeps a routine curate from growing `_assetList` without bound. function curateAssets(address[] calldata assets_) external { _onlyGuardian(); for (uint256 i = 0; i < assets_.length; i++) { address a = assets_[i]; // A DESTINATION MUST BE A CONTRACT. The zero address and an EOA are // both "somebody passed the wrong thing", and curating either would // put a hole in the list that reads as deliberate. if (a == address(0) || a.code.length == 0) revert NotAnAsset(); if (_curatedAsset[a]) continue; _curatedAsset[a] = true; _assetList.push(a); emit AssetCurated(a); } } /// Remove one. Immediate, like every other narrowing in this contract. function delistAsset(address asset) external { _onlyGuardian(); if (!_curatedAsset[asset]) revert NoSuchAsset(asset); delete _curatedAsset[asset]; uint256 n = _assetList.length; for (uint256 i = 0; i < n; i++) { if (_assetList[i] == asset) { _assetList[i] = _assetList[n - 1]; _assetList.pop(); break; } } emit AssetDelisted(asset); } /// What every adapter asks before it lets an intent name a destination. function isCuratedAsset(address asset) external view returns (bool) { return _curatedAsset[asset]; } function curatedAssets() external view returns (address[] memory) { return _assetList; } // ── THE CONFIG EVERY ACCOUNT USED TO BE HANDED A COPY OF ──────────────── // // Measured on Base: a full bring-up writes 57 venue bindings and 58 // operations into ONE account, of which 18 venues and 47 operations are // distinct, and NONE of it can differ between accounts -- the only writer // of the per-account table is `onInstall`, and its values come from // `installDataFor(adapter)`, a single global blob. 345 cold stores per // account recording something that cannot vary. See ACCOUNT_CONFIG_V2.md. // // So the blob is indexed HERE, once per chain, at the moment the guardian // whitelists it -- no new ceremony, no new key, and the three-day delay // that already guards the payload guards this too, because it IS the // payload. `BaseAdapter` then reads these instead of its own copy. // // APPEND ONLY, and these are slots 12 and 13. /// venue name -> the contract it resolves to on this chain. /// /// SHARED, AND NEVER CLEARED BY A DELIST. Six adapters name `uniswap_npm` /// and all six mean the same position manager; clearing it because one of /// them was delisted would blind the other five. A name outlives any single /// adapter's listing, so retiring one is `delistAdapter`'s job and not this /// table's. mapping(bytes32 venue => address target) private _venueTarget; /// (adapter, venue) -> whether that adapter's payload names it. /// /// SEPARATE FROM `_venueTarget`, AND THE REASON IS THE THREAT MODEL. The /// name is chosen by B, in the intent -- that indirection exists so an /// intent cannot carry a venue ADDRESS. `_venueTarget` alone would let a /// compromised B name ANY curated venue through ANY adapter, so that /// `SwapAdapter` could be pointed at `rent_share`. Today the per-account /// table scopes the name to the adapter that was curated for it, and that /// scope survives here. /// /// Global like the others: what an adapter may name is a property of the /// adapter, identical for every account, so it is stored once per chain. /// /// THE VALUE IS WHEN THE SCOPE BECOMES USABLE, zero for none. It was a /// bool, and an added venue was live on every installed account the block /// it was written: `onInstall` keeps nothing from the payload and /// `_venueOf` reads this table, so the notice a config change serves /// guarded the NEXT activation and not the accounts already holding the /// adapter. A new scope now waits the same ACTIVATION_DELAY; one the /// rewrite keeps keeps its time, so a curate run takes no venue offline /// (H-6). SAME SLOT: a stored `true` reads as 1, a time long past, so every /// scope written before this is live as it was. mapping(address adapter => mapping(bytes32 venue => uint64 usableFrom)) private _adapterVenue; /// adapter -> the stable name it is known by, e.g. `revert_fi`. /// /// THE ONE THING ABOUT AN ADAPTER THAT SURVIVES A REDEPLOY. Its address is /// CREATE2 over its initcode and its codehash IS its code, so both move the /// moment a line changes -- which is why F-16 fell back to identifying an /// adapter by the OPERATIONS it carried, the only stable handle then /// available. That cost one slot per operation per account: 20,411 gas x 58 /// on Base. /// /// A name costs one slot per adapter per chain and says what the owner /// actually meant. "I removed revert_fi" is a sentence about an adapter, /// not about the six operations it happens to carry this quarter. /// /// SET SEPARATELY rather than added to `whitelistAdapter`, which has 73 /// call sites. Unnamed is not an error: `AdapterInstaller` falls back to /// the address, which is exactly where it was before, so nothing has to be /// named before anything else works. /// /// The guardian names it, and can rename it -- and for a v2 account a /// rename IS new authority. R1 proves the owner's approvals by NAME, so a /// contract renamed into a name an owner granted can do what that name /// could. It therefore serves the same notice as a new adapter, and the /// owner's remedy -- refusing it by name -- has the window to act in. mapping(address adapter => bytes32 name) private _adapterName; event AdapterNamed(address indexed adapter, bytes32 indexed name, bytes32 previous); function setAdapterName(address adapter, bytes32 name) external { _onlyGuardian(); // A NAME MUST HAVE A NON-ZERO FIELD, listed or not. // // The leaf carries `uint256(name) >> 8`, so `bytes32(0)` AND every name // below 2^8 write the same leaf: Poseidon3(adapter, 0, opsHash). The // padding leaf of every owner's capability tree is Poseidon(0, 0), so // circuit v1 lets an ACTIVE step use the pair (0, 0), and this leaf is // the one thing its R10 then asks of the registry. One adapter named // this way reaches EVERY v2 account, including owners whose narrow // policy never named it. // // The test is on the FIELD, as the leaf is, and it holds for an // adapter that is not listed yet: naming first and listing second must // not be a way round. Un-naming is not needed - `delistAdapter` writes // the zero leaf - and `Curate.s.sol` and `cmd/curate` // refuse an empty name before it gets here. `PolicyGate` refuses the // sentinel operation too, so neither contract is the only defence. // docs/audits/2026-10-01-zk-circuit-v1.md, F-01. if (uint256(name) >> 8 == 0) revert AdapterNameIsEmpty(adapter, name); bytes32 had = _adapterName[adapter]; _adapterName[adapter] = name; emit AdapterNamed(adapter, name, had); // The name is IN the leaf, so renaming moves the root. bool listed = _listed(adapter); _capUpdate(adapter, listed); // AND THE CLOCK RESTARTS, the rule `whitelistAdapter` keeps for a // config change. Not for the same name set again, which is what // `cmd/curate` sends on every run: H-6, no new authority, no notice. if (listed && had != name) _restartClock(adapter); } /// Zero means unnamed, and the caller falls back to the address. function adapterName(address adapter) external view returns (bytes32) { return _adapterName[adapter]; } /// A venue name now resolves somewhere else than it did. /// /// NOT REFUSED, and that is deliberate. A redeployed venue is the ordinary /// reason for this and refusing would make correcting one impossible; the /// audit trail is what makes it reviewable. event VenueTargetSet(bytes32 indexed venue, address previous, address target); event VenueScoped(address indexed adapter, bytes32 indexed venue); event VenueUnscoped(address indexed adapter, bytes32 indexed venue); /// Mirror an install payload's VENUES into the global tables. /// /// OPERATIONS ARE NOT INDEXED, and that is the point rather than an /// omission. The operation is a field of the intent, but every adapter /// dispatches on it and ends `else { revert UnknownOperation(); }` -- so an /// operation an adapter does not implement cannot be performed whatever a /// list says, and the list held exactly what the code already stated. /// Venues are the opposite case: the name is a free parameter that resolves /// to a call target, so what an adapter may NAME has to be recorded. /// /// A PAYLOAD THAT DOES NOT DECODE INDEXES NOTHING, rather than reverting -- /// the same choice `AdapterInstaller._operationsOf` makes, behind the same /// external try/catch boundary, so `whitelistAdapter` does not acquire a /// validation it never had. Fifty call sites whitelist with stub bytes. /// A PAYLOAD THAT DOES NOT DECODE MUST INDEX NOTHING — AND IT USED TO /// UN-INDEX EVERYTHING. /// /// The retire loop below ran FIRST and the `try this.payloadIn(next)` catch /// then returned, so an undecodable payload left the adapter whitelisted, /// effective, and scoped to no venue at all — every intent it carries /// reverting `UnknownVenue`, `LoanCloseAdapter` and `SafeExitAdapter` among /// them, which are the paths that de-risk a position. The same early return /// fires on `venues.length != targets.length`, which nothing validates /// before this point. /// /// So the NEW payload is decoded first and a bad one leaves both tables /// exactly as they were. function _index(address adapter, bytes memory previous, bytes memory next) private { bytes32[] memory venues; address[] memory targets; if (next.length != 0) { (bool ok, bytes32[] memory v, address[] memory t,) = _decoded(next); if (!ok || v.length != t.length) return; (venues, targets) = (v, t); } if (previous.length != 0) { // Undecodable, it decodes to nothing and retires nothing. (, bytes32[] memory wasVenues,,) = _decoded(previous); // The SCOPE is retired; the target is not. A name this adapter // may no longer reach is still the same contract to the five // other adapters that name it. // // AND ONLY A SCOPE THE NEW PAYLOAD DROPS. One it names again // keeps the time it became usable, so a rewrite that adds a // venue does not put the others back on notice. for (uint256 i = 0; i < wasVenues.length; i++) { if (_adapterVenue[adapter][wasVenues[i]] == 0) continue; if (_contains(venues, wasVenues[i])) continue; delete _adapterVenue[adapter][wasVenues[i]]; emit VenueUnscoped(adapter, wasVenues[i]); } } // A NEW SCOPE SERVES THE NOTICE, from the same moment and for the same // time as the `_effectiveAt` that `whitelistAdapter` writes next. uint64 usableFrom = _noticeEnds(); for (uint256 i = 0; i < venues.length; i++) { // A changed target waits — see `_setVenueTarget`. _setVenueTarget(venues[i], targets[i]); if (_adapterVenue[adapter][venues[i]] == 0) { _adapterVenue[adapter][venues[i]] = usableFrom; emit VenueScoped(adapter, venues[i]); } } } function _contains(bytes32[] memory list, bytes32 x) private pure returns (bool) { for (uint256 i = 0; i < list.length; i++) { if (list[i] == x) return true; } return false; } // ── THE ONE BOUND ON A COMPROMISED AGENT KEY'S THROUGHPUT ─────────────── // // Removed outright on 2026-09-20 and this is it coming back. BaseAdapter's // own note on the removal says what it cost: "the one on-chain bound on a // compromised agent key's throughput", and since then nothing has limited // how much a stolen B can move -- only WHERE, which is the account. // // IT WAS DELETED FOR TWO REASONS AND BOTH ARE FIXED HERE. // // COST. `_cap` was inherited BaseAdapter storage, so all 22 adapters kept // their own copy and the budget was "divided across the adapters" // (a52afe54). Twenty-eight assets at three cold stores each, per adapter: // ~37M per account, which is why it could not stay. One shared table has // nothing to divide, and a row is created on first SPEND rather than at // install -- an account trading three assets pays three slots, not // 28 x 22. // // STALENESS. `_spendCap` reverted `NoCapForAsset` when an asset had no // row, so an asset curated after an account installed killed a perfectly // good intent and the only remedy was a reinstall. Here an unset cap // means UNCAPPED: every account already on chain keeps exactly the // behaviour it has, and a cap binds when an owner sets one. // // NOT GENERATION-FENCED, and the old one was. `activate` is permissionless, // so a stamp tied to `_config.gen` let any stranger's reinstall reset an // owner's budget -- the old per-adapter cap also reset what // had been SPENT. This is a RESTRAINT and follows `_ltvCeiling`: keyed by // account alone, outliving every generation. // // ONE SLOT. 96 + 96 + 48 = 240 bits. uint96 holds 7.9e28 raw units, which // is 7.9e22 USDC at six decimals and 7.9e10 tokens at eighteen. struct Cap { uint96 perEpoch; uint96 spent; uint48 epochStart; } mapping(address account => mapping(address asset => Cap)) private _cap; /// A CONSTANT, not a setting. A configurable epoch is one somebody widens /// until the cap means nothing, and the length is the denominator of the /// whole bound. The same argument `ACTIVATION_DELAY` makes for itself. uint48 public constant CAP_EPOCH = 1 days; event CapSet(address indexed account, address indexed asset, uint96 perEpoch); event CapCharged(address indexed account, address indexed asset, uint256 amount, uint96 spent); event CapRefunded(address indexed account, address indexed asset, uint256 unused, uint96 spent); event CapReleased(address indexed account, address indexed asset, uint256 amount, uint96 spent); error CapExceeded(address account, address asset, uint256 want, uint256 left); error RefundExceedsCharge(address asset, uint256 unused, uint256 charged); error NotThePolicyGate(address caller); /// `setCaps` was given a different number of amounts than assets. error CapsMismatched(uint256 assets, uint256 amounts); /// `setCaps` named the same asset twice. error CapRepeated(address asset); /// The ACCOUNT sets its own budget. Zero removes the cap. /// /// Caller is the account, which under Kernel means the root validator /// authorised it -- the same authority that installs an adapter, and never /// the guardian. A cap is the only bound on how much of A's money B may /// move, so a guardian setting it would transfer that authority away from /// the owner who holds it. /// /// RAISING DOES NOT REFUND. `spent` is left where it is, so an owner who /// notices a cap is too tight mid-epoch gets the headroom they asked for /// and not a fresh epoch as well. function setCap(address asset, uint96 perEpoch) external { _setCap(msg.sender, asset, perEpoch, false); } /// Set several caps in one call — the same statement as `setCap`, per /// asset, all or none. /// /// WHY IT EXISTS. A cap is per asset, so an owner who caps USDC has not /// capped WETH, and a stolen key moves whatever was left uncapped. Closing /// that took one wallet transaction per asset, which is the shape of a /// control people set once for the asset they are thinking about and never /// finish. One call covers the whole list, atomically: a batch that cannot /// be applied in full is applied not at all. /// /// A REPEATED ASSET IS REFUSED rather than resolved last-wins. Two numbers /// for one asset is a mistake in the list, and picking one silently would /// leave the owner believing the other. /// /// SAME AUTHORITY AS `setCap`: the account is the caller, and B cannot be. /// `Agent._callOne` refuses the registry as a target outright, whatever the /// selector, and B holds no `execute` path to reach it any other way — so /// this needs no entry of its own. function setCaps(address[] calldata assets, uint96[] calldata perEpoch) external { uint256 n = assets.length; if (perEpoch.length != n) revert CapsMismatched(n, perEpoch.length); for (uint256 i = 0; i < n; i++) { for (uint256 j = 0; j < i; j++) { if (assets[j] == assets[i]) revert CapRepeated(assets[i]); } _setCap(msg.sender, assets[i], perEpoch[i], false); } } /// `pull` picks the row: the spend cap, or the pull cap at the end of this /// contract. One set of rules, one body of code, for both. function _setCap(address account, address asset, uint96 perEpoch, bool pull) private { Cap storage c = _capRow(account, asset, pull); c.perEpoch = perEpoch; if (c.epochStart == 0) c.epochStart = uint48(block.timestamp); if (pull) emit PullCapSet(account, asset, perEpoch); else emit CapSet(account, asset, perEpoch); } /// The spend cap's row, or the pull cap's. The only place either table is /// indexed outside `reserve` and `release`, which are the spend cap's alone. function _capRow(address account, address asset, bool pull) private view returns (Cap storage) { return pull ? _pullCap[account][asset] : _cap[account][asset]; } function capOf(address account, address asset) external view returns (uint96 perEpoch, uint96 spent, uint48 epochStart) { return _capOf(account, asset, false); } function _capOf(address account, address asset, bool pull) private view returns (uint96, uint96, uint48) { Cap storage c = _capRow(account, asset, pull); return (c.perEpoch, c.spent, c.epochStart); } /// Charge an outflow against this epoch's budget, or refuse it. /// /// CALLER IS THE ADAPTER. Gated on the whitelist for the reason /// `noteOperation` is: an ungated caller could exhaust an account's budget /// for assets it has nothing to do with, which is a denial of service on /// the owner's own money. /// /// UNCAPPED IS NOT AN ERROR. An asset with no cap returns without writing, /// which is what keeps this from becoming the `NoCapForAsset` that made a /// newly curated asset break live accounts. /// /// RETURNS WHERE THE CHARGE LANDED — its TAG — so a refund can go back to /// that place and nowhere else: /// /// 0 nothing was charged: uncapped, and no plan; /// `epochStart` the epoch that began then paid (always /// non-zero: `setCap` stamps it, and it only /// moves forward); /// `_PLAN_TAG | nullifier` the plan with that nullifier paid. /// /// WHY A REFUND NEEDS IT. `refundCharge` used to refund into whatever was /// current at REFUND time: the plan open now, or this epoch. For a refund /// in the charge's own transaction those are the same thing. For the one /// refund that is not — `CowPresignAdapter.cancel`, days after `presign` — /// they are not: a day-1 order cancelled after any day-2 charge forgave /// day-2 `spent` (twice the cap in a day), an order placed while uncapped /// un-spent charges once the owner capped the token, and an order charged /// outside any plan refunded INTO the plan open at cancel time, which then /// spent past its proved total. A tag cannot be current-by-accident. /// /// A NEW RETURN VALUE IS ABI-COMPATIBLE. The selector is unchanged, and an /// adapter compiled against the old `charge` ignores the returndata. function charge(address account, address asset, uint256 amount) external returns (uint256 tag) { Cap storage c = _cap[account][asset]; // A PROVED PLAN PAYS FROM ITS OWN ALLOWANCE. When this adapter holds // an open v2 step for this account, the charge draws on the allowance // `PolicyGate` reserved against this very cap when the plan's first // step opened — so the epoch is not charged twice, and a charge past // what the plan declared reverts whether or not the owner set a cap. // No open step is a v1 charge, and everything below is unchanged. // // UNLESS THE RESERVATION IS NOT IN THIS EPOCH. A reservation is charged // to the epoch its first step opened in, and `openStep` alone keeps it // alive: reserve on day 1, never commit, and on day 2 the keeper spent // the stale allowance AND a full fresh cap — N plans reserved on N days // moved N caps at once. A draw the gate reports as not covered (rolled // over, or reserved while the token was uncapped and capped since) is // charged to TODAY's epoch as well: a no-op if still uncapped, // `CapExceeded` if today is full. The allowance is still drawn, so the // plan's proved total binds too; a plan that crosses midnight keeps // running and pays today's cap for what it moves today. address gate = policyGate; if (gate != address(0)) { (bool planned, bool covered, uint256 nullifier) = IPlanAllowance(gate).draw(account, msg.sender, asset, amount); if (planned) { if (!covered && c.perEpoch != 0) _spendEpoch(c, account, asset, amount, false); tag = _PLAN_TAG | nullifier; _noteCharge(account, asset, tag, amount, false); return tag; } } return _chargeEpoch(c, account, asset, amount, false); } /// The epoch charge `charge` and `chargePull` share, on the row `pull` /// picks: uncapped returns tag 0, an unready caller is refused, then the /// spend, and the tag is the epoch that paid. function _chargeEpoch(Cap storage c, address account, address asset, uint256 amount, bool pull) private returns (uint256 tag) { // THE CAP FIRST, AND THE CALLER SECOND. The order is the finding. // // This gate is about budget EXHAUSTION — an adapter still inside // `ACTIVATION_DELAY` must not be able to spend an account's epoch // budget before anyone has had three days to notice it. Where there is // no cap there is no budget, so there is nothing for an unready adapter // to exhaust and the check protects nothing. // // Ordered ahead of the fast return it re-opened H-6 one layer down. // Caps are opt-in, so `limit == 0` is the state of very nearly every // (account, asset) pair; and `whitelistAdapter` re-arms the clock on // any write whose bytes differ, which one added venue or one added // Morpho market is enough to do. So a routine curate took every // charging adapter offline on EVERY account for three days — // `SafeExitAdapter` and `LoanCloseAdapter` among them, the paths that // de-risk a position — for a bound those accounts never asked for. // That is verbatim the outage `Agent._callOne` was moved off // `isEffectiveAdapter` to end. if (c.perEpoch == 0) return 0; if (!_isEffective(msg.sender)) revert NotAnAdapter(); // FLOORED, BECAUSE THE OWNER CAN LOWER A CAP BELOW WHAT IS SPENT. // // `setCap` writes `perEpoch` and deliberately does not touch `spent` — // "raising does not refund". Lowering has the same freedom, and // tightening a cap is the natural reaction to a suspected compromise. // Unfloored, `limit - spent` underflows and every charge on that // (account, asset) reverts with an arithmetic panic rather than // `CapExceeded` — for the rest of the epoch, including the de-risking // paths, and including `charge(..., 0)`. The owner's only unblock was // to RAISE the cap above what was already spent, which is the opposite // of what they just asked for. _spendEpoch(c, account, asset, amount, pull); tag = c.epochStart; _noteCharge(account, asset, tag, amount, pull); } /// Charge a proved plan's whole allowance for one token against this /// epoch's cap, at once, before its first step moves anything. /// /// CALLER IS THE POLICY GATE, and only from `consume`, which only an /// account's own agent reaches. See `PolicyGate._declared` for why this is /// not done at the permissionless `admit`. /// /// Same rules as `charge`: uncapped is not an error, and over the cap is /// `CapExceeded`. What differs is the caller check — the gate is not an /// adapter, and `_isEffective` would refuse it. /// /// Returns what it CHARGED — zero for an uncapped token — so the gate can /// bound a later `release` by it. function reserve(address account, address asset, uint256 amount) external returns (uint256 charged) { _onlyPolicyGate(); Cap storage c = _cap[account][asset]; if (c.perEpoch == 0) return 0; _spendEpoch(c, account, asset, amount, false); return amount; } /// Give back to the epoch what a settled plan reserved and did not spend. /// /// CALLER IS THE POLICY GATE, from `settle`, with `amount` already bounded /// by what `reserve` charged for this plan and token. This is the second /// primitive here that un-spends budget, and it takes the same care as /// `refundCharge`: the bound lives with the caller that knows the charge. /// /// ONLY INTO THE EPOCH THE RESERVATION WAS CHARGED TO. After a rollover /// `spent` belongs to a later epoch, and subtracting from it would forgive /// charges this plan never made. Rolled over means either `epochStart` has /// moved past the reservation (something charged since), or the epoch the /// reservation sits in has simply ended (nothing has). function release(address account, address asset, uint256 amount, uint256 reservedAt) external { _onlyPolicyGate(); Cap storage c = _cap[account][asset]; if (c.perEpoch == 0) return; uint48 start = c.epochStart; if (reservedAt < start || block.timestamp >= uint256(start) + CAP_EPOCH) return; uint96 spent = c.spent; uint96 next = amount >= spent ? 0 : uint96(uint256(spent) - amount); c.spent = next; emit CapReleased(account, asset, amount, next); } /// `reserve` and `release` answer to the gate alone, and to nobody while /// no gate is wired. function _onlyPolicyGate() private view { if (msg.sender != policyGate || msg.sender == address(0)) revert NotThePolicyGate(msg.sender); } function _spendEpoch(Cap storage c, address account, address asset, uint256 amount, bool pull) private { uint96 limit = c.perEpoch; // Snap to the start of the epoch this timestamp falls in, so a long // idle period does not leave the boundary drifting behind. uint48 start = c.epochStart; if (block.timestamp >= uint256(start) + CAP_EPOCH) { uint48 elapsed = uint48(block.timestamp) - start; c.epochStart = start + (elapsed / CAP_EPOCH) * CAP_EPOCH; c.spent = 0; } // Floored — see `charge`. uint256 left = limit > c.spent ? uint256(limit) - uint256(c.spent) : 0; if (amount > left) { if (pull) revert PullCapExceeded(account, asset, amount, left); revert CapExceeded(account, asset, amount, left); } uint96 next = uint96(uint256(c.spent) + amount); c.spent = next; if (pull) emit PullCharged(account, asset, amount, next); else emit CapCharged(account, asset, amount, next); } /// The bit that marks a tag as a PLAN's. A nullifier is a BN254 field /// element, below 2^254 — the snarkjs verifier `checkField`s every public /// signal — so it never reaches this bit, and an epoch tag is a uint48 /// timestamp. Should a future circuit's verifier skip that check, the cost /// is a refund that misses: `PolicyGate.undraw` credits a nullifier only /// for the account that owns it and only up to what this adapter drew /// from it, so a mis-decoded tag names a plan with nothing to give back. uint256 private constant _PLAN_TAG = 1 << 255; /// Give back what the venue did not take, to WHERE IT WAS CHARGED. /// /// A charge is levied on the SIGNED amount, before the venue is called, /// because refusing an over-budget intent after it has moved money is not a /// refusal. But a venue routinely takes less than it was offered -- repay /// `min(amount, debt)`, a fee-on-transfer token, a partially filled limit /// order -- and charging the offer would make the difference unspendable /// for the rest of the epoch. /// /// M-6 OF THE ROUND-2 AUDIT, KEPT. This is the only primitive here that can /// UN-SPEND budget, and in the deleted per-adapter cap an over-refund was /// silent and floored: `unused > spent` set `spent = 0`, forgiving EVERY /// PRIOR CHARGE IN THE EPOCH rather than its own. Both flash call sites /// pass a figure decoded from a callback payload, which is exactly the /// shape where a number that should be small is chosen by somebody else. So /// the caller states what it charged and a refund past that REVERTS. /// /// AND THE CALLER STATES WHERE: `tag` is what `charge` returned. This is /// the entry for a refund in a LATER transaction than its charge — /// `CowPresignAdapter`'s cancel and teardown, and nothing else — and /// `df8ea362`'s "epoch still current and `unused <= spent`" was not enough /// for it: one 1-wei charge on day 2 makes the epoch current again, and a /// day-1 refund then forgave day-2 spend. So: /// /// - tag 0: nothing was charged, nothing comes back — including after /// the owner capped the token, and after a cap removed and re-set /// (`setCap` keeps a non-zero `epochStart`, so a start-time check alone /// would have let that through); /// - an epoch tag: only into THAT epoch, while it is still running and /// only up to what it has spent. A rolled epoch already gave the budget /// back on its own; /// - a plan tag: to THAT plan via `PolicyGate.undraw`, bounded by what /// this adapter drew from it, and dropped once it settled. Never the /// epoch: the plan's reservation is what the epoch holds, and `settle` /// is what gives it back. /// /// Every miss is a conservative OVER-charge — budget not returned — and /// never a grant. /// /// UNREACHABLE BY THE KEEPER. `Agent._callOne` refuses `adapter == /// address(registry)`, so the only way here is an adapter passing its own /// `charged` and `tag` -- the same posture the per-adapter version had. function refundCharge(address account, address asset, uint256 unused, uint256 charged, uint256 tag) external { if (unused > charged) revert RefundExceedsCharge(asset, unused, charged); if (unused == 0) return; _refund(account, asset, unused, tag, false); } /// THE SAME-TRANSACTION REFUND, AND THE ENTRY EVERY DEPLOYED ADAPTER CALLS. /// /// This was the only `refundCharge`, and it refunded into whatever was /// current. An adapter already installed keeps its bytecode and its call /// sites for as long as an owner keeps it (ADAPTER_LIFECYCLE.md, case A: /// the superseded address "stays installed and keeps working"), against /// this same proxy after every upgrade. Removing the selector would revert /// every one of their refunds — and with them the steps that make one. /// Keeping its old meaning would keep the bug for every one of them, /// `CowPresignAdapter`'s cancel included. /// /// SO IT NAMES NO TAG AND FORGIVES NOTHING ACROSS A TRANSACTION. It refunds /// only what THIS adapter charged for this (account, asset) in THIS /// transaction — recorded by `_noteCharge` in transient storage, which dies /// with the transaction — and only to where that charge landed. A cancel /// in a later transaction finds no record and is a no-op: the order's /// budget stays charged until its epoch rolls, which is the conservative /// failure and the one `presign` already documents ("an order that never /// fills still consumed cap for the epoch"). /// /// AND IT IS WHAT CURRENT ADAPTERS USE FOR THEIR SAME-TRANSACTION REFUNDS. /// Every `_refund` but the CoW adapter's runs in the transaction of its own /// `_charge`, many inside a flash callback a long way from it. Bounding /// them by what the registry itself saw charged is tighter than a tag the /// caller hands back, and needs nothing threaded through those callbacks. /// /// The record holds the LAST tag and what was charged under it; a charge /// under a different tag starts it again. Every call site refunds within /// the same adapter call as its charge, and a plan cannot open or close in /// the middle of one, so the last tag is the charge's own. Were one ever /// to refund an earlier charge after a later one under another tag, the /// refund lands on the later charge's destination, bounded by that charge: /// total forgiven never exceeds total charged in the transaction. function refundCharge(address account, address asset, uint256 unused, uint256 charged) external { _refundThisTx(account, asset, unused, charged, false); } /// The same-transaction refund `refundCharge` and `refundPull` share, on /// the row `pull` picks. The two records are kept under different keys, so /// a pull's refund can only ever give back what a pull charged, and a /// spend's only what a spend charged. function _refundThisTx(address account, address asset, uint256 unused, uint256 charged, bool pull) private { if (unused > charged) revert RefundExceedsCharge(asset, unused, charged); bytes32 k = _chargeKey(account, asset, pull); uint256 tag; uint256 held; assembly ("memory-safe") { tag := tload(k) held := tload(add(k, 1)) } if (unused == 0 || unused > held) return; assembly ("memory-safe") { tstore(add(k, 1), sub(held, unused)) } _refund(account, asset, unused, tag, pull); } function _refund(address account, address asset, uint256 unused, uint256 tag, bool pull) private { if (tag == 0) return; // Back to the plan that paid. See `charge`. // // NEVER FOR A PULL. `chargePull` does not ask the gate, so no pull tag // carries the plan bit: the pull's record holds an epoch start or // nothing. Refused here as well, by construction rather than by that // argument, so a pull's refund can never reach `undraw` and grow a // plan's allowance past what its proof declared. if (tag & _PLAN_TAG != 0) { address gate = policyGate; if (!pull && gate != address(0)) { IPlanAllowance(gate).undraw(account, msg.sender, asset, unused, tag ^ _PLAN_TAG); } return; } Cap storage c = _capRow(account, asset, pull); if (c.perEpoch == 0) return; // Same order as `charge`: nothing to refund means nothing to protect. if (!_isEffective(msg.sender)) revert NotAnAdapter(); // ONLY INTO THE EPOCH THAT WAS CHARGED, and only while it runs. Once // it has ended its budget came back by itself on the next write; a // later `epochStart` is a later epoch, whatever `spent` reads. uint48 start = c.epochStart; if (tag != start || block.timestamp >= uint256(start) + CAP_EPOCH) return; uint96 spent = c.spent; if (unused > spent) return; uint96 next = uint96(uint256(spent) - unused); c.spent = next; if (pull) emit PullRefunded(account, asset, unused, next); else emit CapRefunded(account, asset, unused, next); } /// Remember, for this transaction only, where `msg.sender`'s charge for /// (account, asset) landed and how much has been charged there. Read by the /// tagless `refundCharge` and by `refundPull`, each from its own record. /// Nothing charged (tag 0) is not recorded. function _noteCharge(address account, address asset, uint256 tag, uint256 amount, bool pull) private { bytes32 k = _chargeKey(account, asset, pull); assembly ("memory-safe") { let held := 0 if eq(tload(k), tag) { held := tload(add(k, 1)) } tstore(k, tag) tstore(add(k, 1), add(held, amount)) } } /// The transient record's key. `pull` is in the preimage, so the spend /// record and the pull record of one (adapter, account, asset) are two /// records. Nothing else in this contract uses transient storage, so the /// key needs no domain string to keep clear of anything. function _chargeKey(address account, address asset, bool pull) private view returns (bytes32) { return keccak256(abi.encode(msg.sender, account, asset, pull)); } // ── the owner's LTV ceiling ───────────────────────────────────────────── // // ONE PER ACCOUNT, HERE, AND NOT ONE PER ADAPTER — finding 6 of the // 2026-09-26 audit, in the shape the spend cap above already has. // // It lived in each adapter's storage, so an adapter an account gained later // — a new catalogue entry `activateOnly` installed, or any redeploy, which // moves every address — read zero, which meant MAX_LTV_CEILING_BPS, and the // keeper could borrow through it past the owner's limit. A new protocol // generation would have reset every owner's ceiling at once. The installer's // `setLtvCeilingEverywhere` papered over the fan-out and skipped any adapter // still inside its re-whitelist delay (finding 10). One row, read by every // adapter, has neither problem. Nobody had set one on either live chain // when this moved (LtvCeilingSet: zero events on Base or Arbitrum), so // nothing is carried over. // // A RESTRAINT, like the cap: keyed by account alone, no generation, no // reset by any reinstall, and it only ever TIGHTENS — H-3 of the round-2 // audit. B cannot reach it: `Agent._callOne` refuses the registry as a // target whatever the selector, and B holds no `execute` path to reach it // any other way. /// The loosest ceiling there is, and the one an account that never set its /// own reads. uint16 public constant MAX_LTV_CEILING_BPS = 8000; /// DECLARED AFTER `_cap`, and that is load-bearing: this contract is a proxy /// implementation, so a new slot goes at the end of the layout. mapping(address account => uint16 bps) private _ltvCeiling; event LtvCeilingSet(address indexed account, uint16 bps); /// Zero, or looser than MAX_LTV_CEILING_BPS. error BadLtvCeiling(uint16 bps); /// Looser than the account's current ceiling. It only ever tightens. error LtvCeilingNotTighter(uint16 have, uint16 want); /// Tighten this account's ceiling. The account is the caller, which under /// Kernel means the root validator authorised it. function setLtvCeiling(uint16 bps) external { if (bps == 0 || bps > MAX_LTV_CEILING_BPS) revert BadLtvCeiling(bps); uint16 have = ltvCeilingOf(msg.sender); if (bps > have) revert LtvCeilingNotTighter(have, bps); _ltvCeiling[msg.sender] = bps; emit LtvCeilingSet(msg.sender, bps); } /// What this account's ceiling is: its own, or MAX_LTV_CEILING_BPS. function ltvCeilingOf(address account) public view returns (uint16) { uint16 set = _ltvCeiling[account]; return set == 0 || set > MAX_LTV_CEILING_BPS ? MAX_LTV_CEILING_BPS : set; } // ── the owner's arming switch ─────────────────────────────────────────── // // ONE PER ACCOUNT AND OPERATION, HERE, AND NOT ONE PER ADAPTER — finding 15 // of the 2026-09-26 audit, the ceiling's move made again for the same // reason. // // `pull_from_owner`, `return_to_owner` and `migrate_to_owner` reach into // the OWNER'S OWN WALLET, and each is on unless the account switched it // off (BaseAdapter's `_requiresArming` says why the default is on). The // switch-off lived in the adapter's storage, so a redeploy — which moves // every address — forgot it: the new adapter read "armed", the owner's // allowance to the ACCOUNT was still standing, and the keeper could spend // it again. Here it outlives every adapter. Nobody had switched one off on // either live chain when this moved (OperationArmed: zero events on Base or // Arbitrum), so nothing is carried over. // // KEYED BY THE OPERATION'S NAME, NOT BY ADAPTER: "never pull from my // wallet" is a statement about a capability, so it covers every adapter // that implements it, today's and the next deploy's. Whether an operation // consults this at all is the adapter's `_requiresArming`, a pure function // of the name; storing a switch for a name nothing arms changes nothing. // // B cannot reach it: `Agent._callOne` refuses the registry as a target // whatever the selector, and B holds no `execute` path to reach it any // other way. /// DECLARED AFTER `_ltvCeiling`: a proxy implementation appends. /// /// What is stored is the REFUSAL, so the zero value every account starts /// with means on. See `BaseAdapter._requiresArming`. mapping(address account => mapping(bytes32 operation => bool)) public disarmed; event OperationArmed(address indexed account, bytes32 indexed operation, bool on); /// Switch one of those operations back on, or off. The account is the /// caller, which under Kernel means the root validator authorised it — /// the authority that installs adapters, never the guardian's payload. /// Nothing here can revert on an account's state, so it is never the call /// that fails the batch an owner signed it in. function armOperation(bytes32 operation, bool on) external { disarmed[msg.sender][operation] = !on; emit OperationArmed(msg.sender, operation, on); } // ── WHICH TREE IS THE OWNER'S ────────────────────────────────────────── // // APPEND ONLY, and this is slot 16. // // THE GAP AN END-TO-END ATTEMPT FOUND. `capabilitySet()` lets a prover // assemble the capability tree from the chain, and a proof then claims a // `policyRoot`. Nothing on chain could say whether that claim was the // OWNER'S tree or one the prover preferred, so `PolicyGate` verified a // proof about a policy nobody had agreed to. Five of the circuit's seven // public inputs were checked; this is the sixth. // // THE ACCOUNT SETS IT, and nobody else. A root the guardian could write is // the payload this exists to bound — `ADAPTERS_AT_GENESIS.md` section 5's // unanswered "whoever curates it can point an account at a contract A never // chose" is exactly the authority being refused here. // // ZERO MEANS UNCOMMITTED, not forbidden. Every account already on chain has // no root and must keep working; `PolicyGate` refuses a PROOF against an // uncommitted account rather than refusing the account. The cap learned // this the expensive way with `NoCapForAsset`. mapping(address account => bytes32 root) private _opsRoot; event OpsRootSet(address indexed account, bytes32 previous, bytes32 root, uint8 version); error PolicyWithoutCircuit(); /// Commit to a capability tree, AND to the circuit version whose proofs /// may speak for it. Reached by the account, through its root validator — /// the same authority that installs an adapter. /// /// WHY THE VERSION IS THE OWNER'S TO NAME. `PolicyGate` holds more than one /// verifier over time, added by the guardian. The root alone does not stop /// a new one: a verifier is only as honest as its circuit, and a broken or /// malicious verifier will accept a "proof" claiming whatever root it is /// shown — including this one. So the gate admits a plan only under the /// version the owner committed here, and a verifier the owner never named /// can admit nothing for them, however it was added. /// /// A ROOT WITHOUT A VERSION IS REFUSED, and so is a version without a /// root: either would leave the gate to guess. Both zero is "uncommitted". /// /// Moving to a new version is this call again, and it does NOT move the /// account's address — genesis bytes are what they were. function setOpsRoot(bytes32 root, uint8 version) external { if ((root == bytes32(0)) != (version == 0)) revert PolicyWithoutCircuit(); bytes32 previous = _opsRoot[msg.sender]; _opsRoot[msg.sender] = root; _opsVersion[msg.sender] = version; emit OpsRootSet(msg.sender, previous, root, version); } function opsRootOf(address account) external view returns (bytes32) { return _opsRoot[account]; } /// The owner's commitment whole: the root, and the circuit version it /// was committed under. function policyOf(address account) external view returns (bytes32 root, uint8 version) { return (_opsRoot[account], _opsVersion[account]); } /// THE WHOLE CAPABILITY SET, IN ONE CALL. /// /// A prover building a policy witness needs, for every effective adapter, /// its chain-agnostic NAME and the operations curated for it. Assembled /// from the existing views that is `1 + 2N` calls — 45 round trips for /// Base's twenty-two — and every one of them is a separate chance to read a /// DIFFERENT BLOCK than the last. A capability set stitched from two blocks /// is a tree that matches neither. /// /// So it is one call, at one block, and it returns exactly what a leaf is /// built from. /// /// EFFECTIVE ONLY, and the adapter TREE holds pending leaves too. So this /// says what may run and which slot each one holds; the tree itself is /// rebuilt from `adapterLeaves()`, not from this. /// /// SUSPENDED ADAPTERS STAY IN, which is why this walks `_adapters` rather /// than calling `adapters()`. `cmd/api` builds the standard v2 policy /// document from this call and that document is fixed into every new v2 /// account's address; the prover resolves leaf names from it too. If a /// suspension removed an adapter here, an owner choosing v2 during one /// would get a different account address for ever, and the prover could /// not name a slot the tree still holds. /// /// THE NAME, NOT THE ADDRESS, is what a leaf keys on: an adapter's address /// is CREATE2 over its initcode, so it differs per chain and moves on any /// rebuild, and a root built from addresses could not be the same on two /// chains. The addresses come back anyway because a caller resolving /// today's deployment needs them — but nothing in a commitment should use /// them. /// /// AN UNNAMED ADAPTER COMES BACK WITH bytes32(0), and that is a real state /// rather than an error: `setAdapterName` is a separate guardian call, and /// an adapter curated before it existed has no name. The caller must decide /// what to do — `AdapterInstaller._requireCommitted` refuses such an adapter /// outright, because its only other identity is its address. /// /// UNDECODABLE PAYLOADS COME BACK EMPTY rather than reverting, the same /// fail-closed choice `_index` makes: fifty call sites in the suite /// whitelist with stub bytes, and one of them must not take a view down. function capabilitySet() external view returns (address[] memory addrs, bytes32[] memory names, bytes32[][] memory operations) { uint256 n; for (uint256 i = 0; i < _adapters.length; i++) { if (_isEffective(_adapters[i])) n++; } addrs = new address[](n); n = 0; for (uint256 i = 0; i < _adapters.length; i++) { if (_isEffective(_adapters[i])) addrs[n++] = _adapters[i]; } names = new bytes32[](addrs.length); operations = new bytes32[][](addrs.length); for (uint256 i = 0; i < addrs.length; i++) { names[i] = _adapterName[addrs[i]]; (,,, operations[i]) = _decoded(_installData[addrs[i]]); } } // ── THE ADAPTER TREE ─────────────────────────────────────────────────── // // APPEND ONLY, slots 17-20. // // A MERKLE ROOT OF THE ADAPTER SET, MAINTAINED AS ADAPTERS ARE REGISTERED. // `capabilityDigest()` below answers the same question by walking all // twenty-two and hashing them, which is fine for an off-chain read and not // fine on a path a step takes: `PolicyGate.consume` runs once per step. // This is one SLOAD. // // POSEIDON, NOT KECCAK, AND THAT IS THE WHOLE DESIGN DECISION. The point of // this root is that a CIRCUIT can recompute it from the adapter list it was // given, and a match proves both sides hold the same addresses. Measured: // // keccak ~36 gas on chain, ~502 CONSTRAINTS A BYTE in circuit. A // 22-leaf tree would be ~675,000 constraints -- two and a half // times the whole 64-step policy circuit. // Poseidon 29,744 gas on chain (PoseidonOnChain.t.sol), ~240 // constraints in circuit. // // So the expensive side is the one that can afford it. A depth-7 path is // 208,208 gas per adapter change and ~4.6M for a full twenty-two curate, // against a curation run that already costs 13.2M once per chain. // // WHY A TREE AND NOT A RUNNING HASH. `root = H(root, leaf)` is cheaper and // cannot express this set: adapters are DELISTED and RE-CURATED, and a // chain commits to a history rather than to what is there now. A leaf index // assigned once and updated in place handles both, and gives membership // proofs as well. uint256 private constant CAP_DEPTH = 7; // 128 leaves; Base curates 22 /// 1-based, and assigned on FIRST whitelist only. An adapter delisted and /// re-added keeps its slot, so the tree does not grow without bound and a /// proof about its position stays meaningful. mapping(address adapter => uint256 slot) private _capSlot; uint256 private _capLeaves; mapping(uint256 level => mapping(uint256 index => uint256)) private _capNode; uint256 private _capRoot; /// Poseidon(3), as EVM bytecode. Set once; zero disables the tree entirely, /// which is what a chain that has not deployed it looks like. address public poseidon3; /// THE GATE AND THE DOOR, FOR ADAPTERS THAT WERE NEVER INSTALLED. /// /// A v2 account installs ONE executor, in genesis, and every adapter /// reaches it through `PolicyExecutor` against a proved step. Adapters have /// to find both, and putting them in a constructor would move all 26 /// adapter addresses for want of a parameter — CREATE2 is over the /// initcode. They live here instead, where the adapter already looks. /// /// SET ONCE, like `poseidon3`, and for a sharper reason. Whatever address /// sits in `policyExecutor` is the contract accounts hand /// `executeFromExecutor` to; a guardian who could rewrite it could repoint /// every v2 account at a contract of their choosing, with no delay and /// nothing to refuse. Zero means "this chain has no v2", which is what /// every chain looks like until it is deployed, and `BaseAdapter` reads it /// as "there is no door" rather than failing. address public policyGate; address public policyExecutor; // APPEND ONLY, and this is slot 24 — the last one, after `policyExecutor`. // It belongs beside `_opsRoot` in meaning and cannot sit there in storage: // a declaration there would shift slots 17-23 under a live proxy. // // account => the circuit version its `_opsRoot` was committed under. mapping(address account => uint8 version) private _opsVersion; // ── WHERE THE CURATED MARKETS, RESERVES AND PROPERTIES LIVE ───────────── // // APPEND ONLY, slot 25. `AdapterCuration` holds the chain-global tables // MorphoAdapter, AaveV4Adapter and PropertyAdapter used to copy into every // account. It is its own proxy because this contract has ~7 KB of EIP-170 // headroom and the tables need more; pointing at it once, like the policy // wiring, means no adapter holds its address and a fix there moves nothing. address public curation; event CurationSet(address curation); error CurationAlreadySet(); /// Set once, by the guardian. Refused while unset rather than defaulted: /// an adapter that reads a zero curation reverts, and says why. function setCuration(address curation_) external { _onlyGuardian(); if (curation != address(0)) revert CurationAlreadySet(); if (curation_ == address(0) || curation_.code.length == 0) revert CurationAlreadySet(); curation = curation_; emit CurationSet(curation_); } // ── A VENUE'S TARGET CHANGES ONLY AFTER A DELAY ───────────────────────── // // APPEND ONLY, slot 26. // // THE GAP. `_index` wrote `_venueTarget` the moment an adapter was // whitelisted, and the ACTIVATION_DELAY on that adapter did not cover it: // the target is GLOBAL, read live by every adapter's `_venueOf`. So one // guardian transaction — whitelist a dummy adapter whose payload maps // `morpho` to a hostile contract — repointed Morpho for every account at // once, with nothing to refuse and nobody given time to notice. // // NOW: a NEW venue name is live at once (it adds a choice and moves nothing // an account holds); a CHANGED target for an existing name waits // ACTIVATION_DELAY while that delay is on, is announced, can be cancelled, // and goes live lazily — `venueTarget` answers the pending target once it // matures, so nothing has to be sent for it to take effect. struct VenueChange { address target; uint64 at; } mapping(bytes32 venue => VenueChange) private _venueChange; event VenueTargetProposed(bytes32 indexed venue, address current, address target, uint64 liveAt); event VenueChangeCancelled(bytes32 indexed venue, address target); error NoVenueChange(bytes32 venue); /// Withdraw a venue change before it goes live. function cancelVenueChange(bytes32 venue) external { _onlyGuardian(); _settleVenue(venue); VenueChange memory c = _venueChange[venue]; if (c.at == 0) revert NoVenueChange(venue); delete _venueChange[venue]; emit VenueChangeCancelled(venue, c.target); } /// The change waiting to go live, if any: its target and when. function venueChangeOf(bytes32 venue) external view returns (address target, uint64 liveAt) { VenueChange memory c = _venueChange[venue]; if (c.at == 0 || block.timestamp >= c.at) return (address(0), 0); return (c.target, c.at); } function _liveVenue(bytes32 venue) private view returns (address) { VenueChange memory c = _venueChange[venue]; if (c.at != 0 && block.timestamp >= c.at) return c.target; return _venueTarget[venue]; } function _settleVenue(bytes32 venue) private { VenueChange memory c = _venueChange[venue]; if (c.at != 0 && block.timestamp >= c.at) { address had = _venueTarget[venue]; _venueTarget[venue] = c.target; delete _venueChange[venue]; emit VenueTargetSet(venue, had, c.target); } } /// Point `venue` at `target`: at once for a new name, or while the /// activation delay is off (a chain being bootstrapped); otherwise as a /// change that goes live after ACTIVATION_DELAY. function _setVenueTarget(bytes32 venue, address target) private { _settleVenue(venue); address had = _venueTarget[venue]; if (had == target) { // Back to what it is: a pending change to something else is dropped. if (_venueChange[venue].at != 0) { emit VenueChangeCancelled(venue, _venueChange[venue].target); delete _venueChange[venue]; } return; } if (had == address(0) || !activationDelayEnabled) { _venueTarget[venue] = target; delete _venueChange[venue]; emit VenueTargetSet(venue, had, target); return; } // The same change proposed again keeps its clock; a different one // restarts it. if (_venueChange[venue].target == target && _venueChange[venue].at != 0) return; uint64 at = uint64(block.timestamp) + ACTIVATION_DELAY; _venueChange[venue] = VenueChange({target: target, at: at}); emit VenueTargetProposed(venue, had, target, at); } event AdapterRootChanged(uint256 previous, uint256 root); event PoseidonSet(address at); event PolicyWiringSet(address gate, address executor); error PolicyWiringAlreadySet(); error PoseidonAlreadySet(); error TreeFull(); /// Both at once: a gate with no door admits plans nothing can execute, and /// a door with no gate is an executor that asks nobody. Neither half is a /// state this should be able to sit in. function setPolicyWiring(address gate, address executor) external { _onlyGuardian(); if (policyGate != address(0) || policyExecutor != address(0)) { revert PolicyWiringAlreadySet(); } // AND NEITHER ARGUMENT MAY BE ZERO. The paragraph above says "neither // half is a state this should be able to sit in" and the guard only // checked the CURRENT values — so `setPolicyWiring(gate, 0)` was // accepted and then permanently locked, because `policyGate != 0` // refuses every repair. One typo, one chain, no way back but a // seven-day timelocked upgrade. if (gate == address(0) || executor == address(0)) revert PolicyWiringAlreadySet(); policyGate = gate; policyExecutor = executor; emit PolicyWiringSet(gate, executor); } function setPoseidon(address at) external { _onlyGuardian(); if (poseidon3 != address(0)) revert PoseidonAlreadySet(); poseidon3 = at; emit PoseidonSet(at); } /// The root a circuit recomputes from the adapter list. function adapterRoot() external view returns (uint256) { return _capRoot; } /// Which leaf an adapter occupies, 1-based. Zero means never registered. function adapterSlot(address adapter) external view returns (uint256) { return _capSlot[adapter]; } /// THE TREE AS STORED, PENDING LEAVES INCLUDED. /// /// `adapterRoot` moves the moment a named adapter is written, and /// `capabilitySet` lists only what is past its delay. So while anything is /// pending the tree cannot be rebuilt from the effective set, and the /// prover refused every plan on the chain for three days. Slot i+1 is /// index i, and zero is an empty or delisted slot. The delay is enforced /// at `PolicyGate.consume`, not by leaving a leaf out. /// /// Root and leaves in one call, so they come from one block. function adapterLeaves() external view returns (uint256 root, uint256[] memory leaves) { root = _capRoot; leaves = new uint256[](_capLeaves); for (uint256 i = 0; i < leaves.length; i++) { leaves[i] = _capNode[0][i]; } } /// An empty subtree at `level`, precomputed because hashing zeros on chain /// at 29,744 gas a time to discover they are still zero is absurd. /// THESE ARE POSEIDON(3) WITH A ZERO THIRD INPUT, not Poseidon(2), and the /// first version of this table was the latter. Poseidon's constants and /// state size depend on the arity, so `poseidon([z, z])` and /// `poseidon([z, z, 0])` are different values — and the contract hashes /// with the three-input form in `_h2`. /// /// Nothing failed loudly: both sides still hashed, the root was still a /// root, and it simply was not the one a prover rebuilding the tree /// computes. `zk/js/adaptertree.mjs` is what caught it, which is the entire /// reason that script compares rather than trusts. function _capZero(uint256 level) private pure returns (uint256) { if (level == 0) return 0; if (level == 1) return 5317387130258456662214331362918410991734007599705406860481038345552731150762; if (level == 2) return 9812414178475803340839139158582354220739337609202958512535602806054931746961; if (level == 3) return 7322633945077259032682024932954913463513568325240502820874542314815927346487; if (level == 4) return 16929149424524530436729775292550421834673593742723062717286603826761585709809; if (level == 5) return 23098632876840743663251548102066485470962930865629278919508896488246243265; if (level == 6) return 2123750298125559841504124710859431170957241945016480685906506805722781166204; return 13302159249236892575265607551689539815688121638747860469556773975648528529983; } function _capNodeAt(uint256 level, uint256 index) private view returns (uint256) { uint256 v = _capNode[level][index]; return v == 0 ? _capZero(level) : v; } function _h2(uint256 a, uint256 b) private view returns (uint256) { // Poseidon(3) with a zero third input, so one deployed contract serves // both the leaf and the path. return IPoseidon3Like(poseidon3).poseidon([a, b, uint256(0)]); } /// Put `adapter`'s current state at its slot and walk the path up. /// /// A DELISTED ADAPTER GETS THE ZERO LEAF rather than being removed, which /// is what keeps every other adapter's position stable — moving them would /// invalidate every proof anybody holds about the set. function _capUpdate(address adapter, bool present) private { if (poseidon3 == address(0)) return; // tree not enabled on this chain uint256 slot = _capSlot[adapter]; if (slot == 0) { if (!present) return; // delisting something never registered if (_capLeaves >= (1 << CAP_DEPTH)) revert TreeFull(); slot = ++_capLeaves; _capSlot[adapter] = slot; } uint256 leaf; if (present) { (,,, bytes32[] memory ops) = _decoded(_installData[adapter]); // ADDRESS, NAME AND OPERATIONS. The address is what makes this say // something the policy root cannot: that root names adapters so it // can be identical on every chain, and therefore cannot pin which // contract answers to a name. // BOTH HASHES ARE SHIFTED RIGHT EIGHT BITS, and it is not // cosmetic. A `bytes32` name is ASCII LEFT-ALIGNED, so even a // one-character name is about 2^254 as a uint256 — and the BN254 // field is ~2^253.9. Passing it raw means the contract and the // circuit each reduce modulo p and have to agree about having done // so, which is a thing that works until a name is chosen that // makes it not. A keccak is 256 bits and has the same problem. // // Shifting is the cheap way to be in the field by construction // rather than by reduction. It costs the low byte of each, which // for a name is the 32nd character and for a hash is 8 bits of a // 256-bit digest. leaf = IPoseidon3Like(poseidon3).poseidon([ uint256(uint160(adapter)), uint256(_adapterName[adapter]) >> 8, uint256(keccak256(abi.encode(ops))) >> 8 ]); } uint256 index = slot - 1; _capNode[0][index] = leaf; for (uint256 level = 0; level < CAP_DEPTH; level++) { uint256 sibling = index ^ 1; uint256 left = index & 1 == 0 ? _capNodeAt(level, index) : _capNodeAt(level, sibling); uint256 right = index & 1 == 0 ? _capNodeAt(level, sibling) : _capNodeAt(level, index); index >>= 1; _capNode[level + 1][index] = _h2(left, right); } uint256 previous = _capRoot; _capRoot = _capNode[CAP_DEPTH][0]; if (previous != _capRoot) emit AdapterRootChanged(previous, _capRoot); } /// A FINGERPRINT OF THE REGISTRY, ADDRESSES INCLUDED. /// /// The policy root commits to NAMES — `(uniswap, mint)` — and deliberately /// not to addresses, because an adapter's address is CREATE2 over its /// initcode and a root built from addresses could not be the same on two /// chains. That is what keeps one owner at one account address everywhere, /// and the bridge deposits with it. /// /// The cost of that choice is precise: a proof proves the OWNER approved /// `(uniswap, mint)`. It cannot say that the contract answering to /// `uniswap` today is the one they approved. Between proving and executing, /// the guardian may redeploy it, delist and re-add it, or rename something /// else into the slot — and `ADAPTERS_AT_GENESIS.md` section 5's unanswered /// "whoever curates it can point an account at a contract A never chose" is /// exactly that gap. /// /// So this is the other half, kept SEPARATE from the root rather than /// folded into it. It hashes address, name and operations for the whole /// effective set, so it moves when anything about the deployment moves — /// which is precisely why it must not be inside a commitment an owner /// signs: they would be re-signing on every curate run. /// /// AN OFF-CHAIN FINGERPRINT NOW. `PolicyGate` used to record it at admit /// and compare it at every step; it compares `adapterRoot` instead, which /// is one SLOAD and moves only on a write, and asks the registry whether /// the step's caller has served its delay. See `PolicyGate.consume`. /// /// ORDER IS `_adapters` ORDER, the same walk `adapters()` makes, so two /// readers at the same block agree. function capabilityDigest() external view returns (bytes32) { (address[] memory addrs, bytes32[] memory names, bytes32[][] memory ops) = this.capabilitySet(); return keccak256(abi.encode(addrs, names, ops)); } /// An install payload decoded, or `ok == false` and three EMPTY arrays when /// it does not decode. The one try/catch every reader shares: `_index`, /// `capabilitySet` and `_capUpdate` each read a payload that fifty test /// call sites write as stub bytes, and none of them may revert on one. function _decoded(bytes memory payload) private view returns (bool ok, bytes32[] memory venues, address[] memory targets, bytes32[] memory ops) { try this.payloadIn(payload) returns ( bytes32[] memory v, address[] memory t, bytes32[] memory o ) { return (true, v, t, o); } catch {} } /// `abi.decode` behind an external boundary, so `_decoded` can catch it. function payloadIn(bytes calldata payload) external pure returns (bytes32[] memory, address[] memory, bytes32[] memory) { return abi.decode(payload, (bytes32[], address[], bytes32[])); } /// What a venue name resolves to. Zero means nobody curated it. function venueTarget(bytes32 venue) external view returns (address) { return _liveVenue(venue); } /// Whether this adapter's curated payload names this venue. /// /// Asked alongside `venueTarget`: the first says WHAT a name means on this /// chain, this says WHETHER this adapter may say it. Both are needed, and /// dropping this one is how a compromised B reaches another adapter's /// venues. /// /// NOT BEFORE THE SCOPE HAS SERVED ACTIVATION_DELAY — see `_adapterVenue`. /// Until then the adapter's other venues work and this one does not. /// /// OR ONCE THE ADAPTER IS EFFECTIVE. On a real chain that adds nothing: a /// scope's time and the adapter's `_effectiveAt` come from the same write, /// and a later one only moves the adapter's later. It is for a fork, where /// `just curate-fast` clears `_effectiveAt` by storage write and would /// otherwise leave every venue it had just added unusable for three days. function adapterUsesVenue(address adapter, bytes32 venue) external view returns (bool) { uint64 at = _adapterVenue[adapter][venue]; if (at == 0) return false; return block.timestamp >= at || _isEffective(adapter); } // ── SUSPENSION AND THE ZK-LAYER SWITCH ───────────────────────────────── // // APPEND ONLY. `forge inspect AgentRegistry storageLayout` puts // `_suspended` at slot 29, `pauser` at slot 30 offset 0 and // `_policyLayerOff` at slot 30 offset 20. (The slot numbers written in // the older comments above — 16, 17-20, 24, 25, 26 — are stale by two: // the real slots are 18, 19-22, 26, 27 and 28. Trust the tool, not them.) // AgentRegistryLayout.t.sol pins all three. // // WHAT THIS IS FOR. `delistAdapter` was the only global off, and it is // destructive: it drops the install data, the curation, the venue scopes // and the tree leaf, and a relist re-arms the three-day delay. That is the // right tool for an adapter that must never come back, and the wrong one // for "stop this now while we look". A suspension is the second: it stops // every account holding the adapter, on every path the protocol drives, // and DELETES NOTHING — so resuming restores exactly what was there, and // a proof admitted before the suspension is still good after it. // // MEMBERSHIP AND USABILITY ARE DIFFERENT QUESTIONS, and suspension must // never change the first. `isWhitelistedAdapter` is also asked as a // REFUSAL — `PolicyExecutor._refuseControlTarget` will not let an open // step CALL an adapter, because an adapter's account-gated setters // (`resetAuthority`, `allowDelegatee`) trust `msg.sender == account`. If a // suspended adapter stopped counting as an adapter there, suspending it // would open exactly that door. So: // // isWhitelistedAdapter listed (membership) // isUsableAdapter listed && !suspended (Agent) // policyMayUse layer on && effective && !suspended (PolicyGate) // _installable effective && !suspended (adapters(), // installDataFor, curation*) // // `TestEveryMembershipCallerHasADecision` walks contracts/src and fails on // any caller of these views that has not been classified. // // WHO. The guardian, or a narrower PAUSER the guardian names, may suspend // and may switch the ZK layer off. Only the guardian may resume or switch // it back on, and the pauser can do nothing else in this contract. Resume // is instant — no delay — by the owner's decision: it restores what the // guardian had already let through the delay, and it grants nothing the // guardian could not grant anyway. // // WHAT IS NOT STOPPED. The owner acting on their own account through the // root validator — including calling an adapter they installed as the // account — is the owner's authority, not the protocol's, and there is no // chokepoint for it short of changing adapter bytecode, which would move // every adapter's address. /// Adapters stopped everywhere by the guardian or the pauser. Keyed by /// ADDRESS, and deliberately untouched by `whitelistAdapter`, `_delist` and /// `setAdapterName`: `cmd/curate` rewrites every adapter and delists /// orphans, and if any of those cleared this a routine curate run would be /// a resume that no guardian decided. mapping(address adapter => bool) private _suspended; /// May suspend adapters and switch the ZK layer OFF, and nothing else. /// Zero means nobody but the guardian. A hot key by design — its worst /// case is a denial of service the guardian undoes in one call. address public pauser; /// The ZK (v2) layer, OFF when true. The zero value is ON, so a registry /// upgraded onto this code starts with the layer running exactly as it was. bool private _policyLayerOff; event PauserSet(address indexed previous, address indexed pauser); event AdapterSuspended(address indexed adapter, address indexed by); event AdapterResumed(address indexed adapter); event PolicyLayerSwitched(bool on, address indexed by); error NotGuardianOrPauser(address caller); /// Shared, by signature, with `Agent` and `PolicyGate`: one selector for /// "stopped by a suspension" wherever it is raised. error AdapterIsSuspended(address adapter); /// Name the pauser, or clear it with zero. Guardian only. function setPauser(address pauser_) external { _onlyGuardian(); emit PauserSet(pauser, pauser_); pauser = pauser_; } function _onlyGuardian() private view { if (msg.sender != guardian) revert NotGuardian(); } function _guardianOrPauser() private view { if (msg.sender != guardian && msg.sender != pauser) revert NotGuardianOrPauser(msg.sender); } /// Stop an adapter for every account, at once. Guardian or pauser. /// /// Only a listed adapter — pending ones included, so a bad proposal can be /// stopped before its delay runs out. Suspending twice is a no-op with no /// event. function suspendAdapter(address adapter) external { _guardianOrPauser(); if (!_listed(adapter)) revert NoSuchAdapter(adapter); if (_suspended[adapter]) return; _suspended[adapter] = true; emit AdapterSuspended(adapter, msg.sender); } /// Lift a suspension. Guardian only, and instant. /// /// Allowed on an address that is no longer listed, so a flag left behind /// by a delist can be cleared; a no-op when nothing is suspended. function resumeAdapter(address adapter) external { _onlyGuardian(); if (!_suspended[adapter]) return; delete _suspended[adapter]; emit AdapterResumed(adapter); } /// Switch the ZK (v2) layer. Off: guardian or pauser. On: guardian only, /// instant. /// /// Off stops `PolicyGate.admit`, `admitBatch`, `consume` and `authorizes` /// — and through `authorizes`, `PolicyExecutor.executeFor` — and nothing /// else: v1 execution through `Agent` does not ask it, a v2 owner still /// controls their account through its root, and `PolicyGate.settle` still /// refunds a reservation. `setPolicyWiring` stays set-once: a guardian who /// could rewrite the executor could repoint every v2 account. function switchPolicyLayer(bool on) external { if (on) { _onlyGuardian(); } else { _guardianOrPauser(); } if (_policyLayerOff != on) return; // already in that state _policyLayerOff = !on; emit PolicyLayerSwitched(on, msg.sender); } /// Is this LISTED adapter suspended now. /// /// LISTED AND FLAGGED, NOT THE RAW FLAG. The flag outlives a delist on /// purpose (a relist must not be a resume), so the raw bit can sit on an /// address that is no longer an adapter at all. Every reader of this view /// asks it to DIAGNOSE a refusal — `Agent._callOne`, `PolicyGate.consume`, /// the api, preflight, manifest — and for a delisted address the true /// answer is "not an adapter, re-curate", not "suspended, wait for the /// guardian": the relayer parks on `AdapterIsSuspended` and the run page /// offers Resume, which a delisted adapter can never take. The flag left /// behind still applies the moment the address is relisted (this answers /// true again from then on), and `resumeAdapter` still clears it while /// delisted. The belt in `requireContractAgent` reads the raw flag: it is /// a refusal, and a stricter one there opens nothing. function isSuspended(address adapter) external view returns (bool) { return _suspended[adapter] && _listed(adapter); } function policyLayerOn() external view returns (bool) { return !_policyLayerOff; } /// May an adapter already in an account be DRIVEN now: listed and not /// suspended. `Agent._callOne` asks this. Not the delay (H-6: that is an /// install question) and not the ZK layer (v1 keeps running while it is /// off). function isUsableAdapter(address adapter) external view returns (bool) { return _listed(adapter) && !_suspended[adapter]; } /// May a v2 step use this adapter now: the layer is on, the adapter has /// served its delay, and it is not suspended. `PolicyGate.consume` and /// `PolicyGate.authorizes` ask this — the one place v2 meets the delay. function policyMayUse(address adapter) external view returns (bool) { return !_policyLayerOff && _installable(adapter); } /// What `adapters()`, `installDataFor` and the curation views hand out. function _installable(address adapter) private view returns (bool) { return _isEffective(adapter) && !_suspended[adapter]; } // ── ACCOUNT PROVENANCE ────────────────────────────────────────────────── // // DECLARED LAST, AND IT MUST STAY LAST. This contract is a proxy, so // `isGenuineAccount` is appended after every slot the deployed // implementations already use; a state variable added above it would shift // it onto somebody else's slot. /// Kernel v3.1's factory: every account this protocol derives is CREATE2'd /// from it (chaincfg `ProtocolKernelFactory`, the same address on every /// chain). A constant, so it reads the same through the proxy and no deploy /// step has to remember to set it. address public constant KERNEL_FACTORY = 0xaac5D4240AF87249B3f71BC8E4A2cae074A3E419; /// Accounts PROVEN to be Kernel proxies that factory created. /// /// WHY THIS EXISTS. Every adapter decided "is this an account" by asking the /// caller — `isModuleInstalled`, `rootValidator` — and a contract written /// to answer those questions passes them. Nothing it reached moved anyone /// else's money, because every adapter acts on the account it was handed, /// but it did reach our adapters, which then emitted `AdapterInstalled` and /// `IntentExecuted` naming it. Anything reading those logs was reading /// events a stranger had authored. /// /// NOT A CODEHASH. The factory deploys solady's ERC1967 proxy, whose runtime /// code is the same whatever implementation it points at, so a proxy aimed /// at an attacker's implementation has exactly a real account's codehash. /// An ADDRESS the factory produced is the fact that cannot be imitated: /// CREATE2 commits to the factory, its immutable implementation and the /// genesis, and only the factory can deploy there. /// /// WHAT IT DOES NOT SAY. That the account has OUR genesis. The factory is /// permissionless, so a real Kernel with a stranger's initConfig is /// genuine here too. It is a real Kernel, so it answers honestly; whether /// it is one of ours is a question about its genesis, answered off chain by /// deriving the address from the owner. mapping(address account => bool) public isGenuineAccount; event AccountRegistered(address indexed account); error FactoryAddressMismatch(address predicted, address created); /// Create an account through the factory — or find it, if it exists — and /// record that it is genuine. /// /// THE FACTORY'S OWN SIGNATURE, so it is a drop-in `to` for the deploy /// transaction and for an ERC-4337 `initCode`: the account's address is /// still the factory's CREATE2 and does not move. The factory returns an /// existing account without re-initialising it, so this is also how an /// account deployed before this existed is registered: call it again with /// the same genesis. Anyone may call it; it records a fact, not a grant. /// /// RECORDED BEFORE THE FACTORY RUNS, because the factory runs the genesis, /// and a genesis that installs an adapter reaches `onInstall` before this /// function would otherwise have returned. The prediction is checked against /// what the factory returns, and a mismatch reverts the record with it. function createAccount(bytes calldata data, bytes32 salt) external payable returns (address account) { IKernelFactoryLike factory = IKernelFactoryLike(KERNEL_FACTORY); address predicted = factory.getAddress(data, salt); if (!isGenuineAccount[predicted]) { isGenuineAccount[predicted] = true; emit AccountRegistered(predicted); } account = factory.createAccount{value: msg.value}(data, salt); if (account != predicted) revert FactoryAddressMismatch(predicted, account); } // ── THE PULL'S OWN BUDGET ─────────────────────────────────────────────── // // The owner's decision of 2026-10-09 (KERNEL_ADAPTERS_AND_ZK.md §18.7, // "Amended 2026-10-09"). A pull from the owner's wallet was charged // against the SPEND cap above, the same `(account, asset)` row the swap // that follows it charges, so "pull X, swap X" counted 2X against a cap the // owner set at X, and on v2 the plan had to declare 2X. A pull is not value // leaving the account at a price: it brings value IN. What it does leave is // the owner's wallet, and audit M-9 is right that something must bound how // fast the agent draws on a standing allowance. That bound is this table: // a second budget, per `(account, asset)`, with the spend cap's exact // rules, which the pull charges INSTEAD of the spend cap. // // - UNSET MEANS UNLIMITED, as for the spend cap, and the bound is then // the ERC-20 allowance the owner granted the account. // - THE ACCOUNT SETS IT, under the same authority as `setCap`: the // caller is the account, so the root validator authorised it; B cannot // reach the registry (`Agent._callOne` refuses it as a target). // - NOT DRAWN FROM A v2 PLAN. `chargePull` never asks the gate: a proved // plan's allowance is what the plan may SPEND, and the pull spends // nothing, so a plan declares nothing for it. The swap that follows // still draws on the plan and charges the spend cap as before. // - TAGGED LIKE A SPEND CHARGE (the 2026-10-08 audit's #6/#19/#33): the // charge returns the epoch that paid, or 0 when uncapped, and records // it for this transaction under the pull's own key. `refundPull` gives // back only what this adapter pulled for this (account, asset) in this // transaction, only into that epoch while it runs, and never to a plan. // // APPENDED, after `isGenuineAccount`. Gen 7 is a fresh deployment, so no // live proxy's slots constrain it; it goes last because this contract is // still a proxy implementation and every later upgrade appends. mapping(address account => mapping(address asset => Cap)) private _pullCap; event PullCapSet(address indexed account, address indexed asset, uint96 perEpoch); event PullCharged(address indexed account, address indexed asset, uint256 amount, uint96 spent); event PullRefunded(address indexed account, address indexed asset, uint256 unused, uint96 spent); /// A pull past what is left of this epoch's PULL cap. Its own error, so an /// owner reads which of the two budgets refused. error PullCapExceeded(address account, address asset, uint256 want, uint256 left); /// The ACCOUNT sets its own pull budget. Zero removes it. Same rules, and /// the same code, as `setCap`: the caller is the account, and raising /// does not refund. function setPullCap(address asset, uint96 perEpoch) external { _setCap(msg.sender, asset, perEpoch, true); } function pullCapOf(address account, address asset) external view returns (uint96 perEpoch, uint96 spent, uint48 epochStart) { return _capOf(account, asset, true); } /// Charge a pull from the owner against this epoch's PULL budget, or /// refuse it with `PullCapExceeded`. `charge`'s epoch rules and order /// (uncapped first, then the caller), without its plan branch: no open v2 /// step pays for a pull. Returns the tag, as `charge` does. function chargePull(address account, address asset, uint256 amount) external returns (uint256 tag) { return _chargeEpoch(_pullCap[account][asset], account, asset, amount, true); } /// Give back the part of a pull the owner was not debited (a token that /// moved less than asked) — M-9: the pull cap holds what LEFT THE OWNER. /// `refundCharge`'s rules: past what the caller says it charged REVERTS /// (M-6), and beyond that it is bounded by what the registry itself saw /// this adapter pull in this transaction, into the epoch that paid. function refundPull(address account, address asset, uint256 unused, uint256 charged) external { _refundThisTx(account, asset, unused, charged, true); } }