// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {EIP712} from "solady/utils/EIP712.sol"; import {ECDSA} from "solady/utils/ECDSA.sol"; import {AgentRegistry} from "./AgentRegistry.sol"; import {RootOwner} from "./RootOwner.sol"; /// The part of ERC-7579 an account exposes to a module it has installed. /// Kernel exposes which validator is root; the root validator knows whose /// account it is. Two reads, and the owner is a fact rather than a claim. interface IKernelRoot { /// bytes21: a one-byte validation TYPE followed by the validator's address. function rootValidator() external view returns (bytes21); } interface IOwnedValidator { function ecdsaValidatorStorage(address account) external view returns (address owner); } interface IERC20Allowance { function allowance(address owner, address spender) external view returns (uint256); } interface IERC20Balance { function balanceOf(address account) external view returns (uint256); } interface IPolicyGate { function consume( address account, uint256 nullifier, uint256 index, uint256 currentRoot, bytes32 operation, uint256[8] calldata who, uint256[8] calldata what ) external; function commitStep(address account, address adapter) external; function authorizes(address account, address adapter) external view returns (bool); function taskOpenFor(address account, address adapter) external view returns (bytes32); function taskIdFor(uint256 nullifier, uint256 index, address adapter) external pure returns (bytes32); /// The installer whose refusals the gate honours. `PolicyGate.installer`, /// an implementation immutable — see `BaseAdapter.AdapterRefusedByOwner`. function installer() external view returns (IRefusals); } /// `AdapterInstaller.refusedFor`: all three refusal keys an owner can write — /// address, codehash and name — in one read. interface IRefusals { function refusedFor(address account, address adapter) external view returns (bool); } interface IPolicyDoor { function executeFor(address account, address target, uint256 value, bytes calldata callData) external returns (bytes memory); } /// Aave v3's whole-position read: collateral, debt, available borrows, the /// liquidation threshold, the borrow `ltv`, and the health factor. interface IAaveAccountData { function getUserAccountData(address user) external view returns (uint256, uint256, uint256, uint256, uint256, uint256); } interface IERC7579Execution { function executeFromExecutor(bytes32 execMode, bytes calldata executionCalldata) external payable returns (bytes[] memory); } /// WHAT ONE ADAPTER ASKS ANOTHER FOR. See `BaseAdapter.takeDeliveredAmount`. interface IRuntimeSource { function takeDeliveredAmount(address account, bytes32 taskId, address asset) external returns (uint256); function takeDeliveredTokenId(address account, bytes32 taskId) external returns (uint256); } interface IERC7579Modules { function isModuleInstalled(uint256 moduleType, address module, bytes calldata additionalContext) external view returns (bool); } /// @title BaseAdapter — everything an adapter must check before it moves anything. /// /// The point of an adapter is what it does NOT have: a field for calldata, and a /// field for a destination. B sends typed parameters; the adapter builds the call /// itself. That is the whole difference between "the agent is constrained because /// our signer only authorises a closed set of operations" and "the agent is /// constrained because the chain says so", and until this contract existed only /// the first was true. /// /// B is `Agent.sol` (a contract), never an EOA Kernel validator. Adapters /// refuse an EOA appointment (`requireContractAgent`). `installModule` is /// root-validator-only, so only A writes the adapter set. /// /// Configuration is taken at INSTALL, from the account, which means A sets it and /// B can never change it: there is no function here that lets a signed intent /// add a venue. Reconfiguring is A reinstalling. /// /// There is no outbound destination anywhere in this file, and that is not an /// omission. `chaincfg.AgentRecipients()` returns exactly one role — the account /// itself — and says why: "the agent operates the account and has no business /// moving value out of it. Withdrawal is the owner's act, taken with the owner's /// key, and never passes through here." An adapter that could pay an address out /// would be handing B the authority the off-chain model spent a closed set of /// roles refusing it. So adapters deliver to `address(account)`, always, and the /// first draft of this file — which stored a `payoutTo` for A to configure — /// was wrong in the concept and not only in the parameter. abstract contract BaseAdapter is EIP712 { /// Kernel's ExecMode for one plain call: CALLTYPE_SINGLE, EXECTYPE_DEFAULT. /// Encoded as a constant rather than built per call — a batch or a /// delegatecall mode reached from here would hand back the arbitrary /// execution this contract exists to remove. bytes32 internal constant EXEC_SINGLE = bytes32(0); uint256 internal constant MODULE_TYPE_EXECUTOR = 2; /// Kernel's validation type byte for "this is a validator module". bytes1 internal constant VALIDATION_TYPE_VALIDATOR = 0x01; struct Config { bool installed; /// Bumped on every successful onInstall. Venues and operations from a /// prior generation are inert. uint64 gen; } AgentRegistry public immutable registry; mapping(address account => Config) internal _config; /// The contracts this account will deal with, by name. /// /// B names a venue from a closed set — the same indirection `chaincfg.Venue` /// uses off chain — and A decides what each name resolves to. An intent /// carrying a venue ADDRESS would be an intent that could name any contract, /// which is the arbitrary call this design exists to remove. An unmapped name /// resolves to zero and is refused. /// THE TABLE THAT USED TO BE HERE IS NOW IN THE REGISTRY, ONCE PER CHAIN. /// /// `_venue[account][name]` held the same address for every account on a /// chain — protocol knowledge copied into every V, 57 bindings and 58 /// operations per account on Base, about 76% of a bring-up, and NONE of it /// could differ: the only writer was `onInstall`, whose values came from /// `registry.installDataFor(adapter)`, one global blob. The owner promised /// a paragraph above — "an owner who wants an agent that can supply and /// repay but never borrow says so here" — had no path to write it. /// /// So it is read from the registry now, where `whitelistAdapter` indexes /// the same blob once. TWO questions, not one, and both must be asked: /// `venueTarget(name)` says what the name MEANS on this chain, and /// `adapterUsesVenue(this, name)` says whether THIS adapter may say it. /// The second is not optional — B chooses the name, so without it a /// compromised B reaches every venue every adapter curated. /// /// WHAT STAYS PER ACCOUNT is only the installed bit. What the OWNER writes /// moved to the registry, where no redeploy can forget it: the LTV ceiling /// (finding 6 of the 2026-09-26 audit) and the arming switch (finding 15). /// See ACCOUNT_CONFIG_V2.md. /// THE OWNER'S OWN SWITCH, DELIBERATELY OUTSIDE THE INSTALL LIFECYCLE — AND /// NOW OUTSIDE THE ADAPTER. Read from `AgentRegistry.disarmed`; this keeps /// the old `disarmed(address,bytes32)` ABI so every off-chain reader that /// asks an adapter still gets the answer. /// /// `allows` is the GUARDIAN'S grant: the curated payload, indexed once per /// chain in the registry. That is right for authority somebody else hands /// out. /// /// An operation that needs arming is not that. It is the account saying /// something about ITS OWN owner — `pull_from_owner`, `return_to_owner` and /// `migrate_to_owner` are the ones that reach outside the account — and the /// guardian has no part in it. So it is keyed by account, never written by /// an install, never cleared by one. /// /// IT LIVED HERE UNTIL FINDING 15, and here was one adapter's storage. A /// redeploy moves every adapter's address, so a new adapter read every /// account as armed — including an owner who had switched pulls off — /// while the owner's allowance, granted to the ACCOUNT, was still standing. /// The registry outlives every adapter; see `AgentRegistry.armOperation`. /// /// WHY OUTSIDE THE LIFECYCLE MATTERS MORE THAN TIDINESS. `armOperation` /// used to require the module to be installed, which made it impossible to /// put in an account's genesis: adapters are installed on demand, long /// afterwards. So the switch the H-2 fix promised could only ever be thrown /// by a separate transaction nobody built, and `deposit.pull` did not work /// for ANY account on production. /// /// AND IT DISSOLVES M-1 OF THE ROUND-3 AUDIT rather than patching it. That /// finding was `armOperation` stamping `_allowsGen` without recording the /// key, so `_wipeAuthority` missed it and a disarmed operation came back /// live on the next permissionless reinstall. Arming touches nothing of the /// install's: a disarm writes `true` and `true` is what every later read /// sees. /// /// WHAT IS STORED IS THE REFUSAL, SO THE DEFAULT IS ON. /// /// A mapping's zero value is what every account begins with, so the thing /// written down has to be the EXCEPTION. Storing "armed" made silence mean /// "off", and silence is the state of every account that has never been /// heard from — which is how all of production ended up installed and /// refusing. /// /// WHAT THAT COSTS, AND IT IS NOT NOTHING. H-2 put a SECOND control in /// front of `transferFrom(owner -> account)`. On by default there is one /// control left and it is the ERC-20 allowance: a stolen keeper key can /// pull up to it, repeatedly, bounded per day only where the owner set a /// PULL cap in the registry (`AgentRegistry.setPullCap`). Since gen 7 a /// pull is charged to that cap of its own — `chargePull`/`refundPull`, /// tagged with the epoch that paid and refunded only into it, within the /// same transaction — and not to the account's spend cap, which bounds /// what leaves the ACCOUNT rather than what leaves the owner. /// /// SO THE ALLOWANCE IS THE BOUND unless the owner sets a pull cap. An /// owner who grants a small one is bounded to it; an owner who grants an /// unlimited one and sets no pull cap has no second line at all. Nothing in this system may offer them an unlimited approval, and /// `cmd/api/enabledeposits.go` takes the amount from the owner rather than /// defaulting it for exactly this reason. /// /// This reverses the H-2 posture deliberately and on the owner's /// instruction. `AgentRegistry.armOperation(op, false)` is the opt-out and /// is durable. function disarmed(address account, bytes32 operation) public view returns (bool) { return registry.disarmed(account, operation); } /// OPERATIONS THAT NEED A SECOND, SEPARATE ACT OF THE OWNER — H-2 of the /// round-2 audit. /// /// `PullFromOwnerAdapter` and `OwnerTransferAdapter` both justify /// themselves on one: "Separate from installing the module … an owner may /// reasonably install the adapter and not switch it on." /// /// THERE WAS NO SUCH SWITCH. `internal/adapters/registry.go` returned /// `["pull_from_owner"]` / `["return_to_owner"]` unconditionally, /// `cmd/curate` folded them into the same six-tuple `onInstall` payload as /// the venues, `activate` is permissionless, and `onInstall` /// reverts `AlreadyInstalled` on a second call — so NO PATH installed the /// module without the operation. Every account that held the adapter had it /// switched on, and an attacker holding `KEEPER_ROLE` could debit the /// owner's EOA through the standing allowance the adapter is designed /// around, once per epoch, forever. `Agent._callOne` does not decode the /// intent, so an attacker-authored struct passes it. /// /// So the switch exists, it answers ONLY to the account, and the GUARDIAN'S /// INSTALL PAYLOAD CANNOT TOUCH IT: `onInstall` refuses an operation of /// this class outright rather than skipping it, so a stale blob that still /// lists one fails visibly instead of believing it granted something. /// /// It is ON unless the account switched it off — see `disarmed` above for /// why, and for what that costs — and neither an install, a reinstall nor a /// redeploy moves it. (This comment used to say "OFF until the account /// turns it on" and "generation-fenced"; both stopped being true when the /// default flipped, and finding 15 is what the stale text hid.) function _requiresArming(bytes32) internal pure virtual returns (bool) { return false; } // `armOperation` IS THE REGISTRY'S NOW — finding 15. It was here, keyed by // msg.sender like the registry's, and it refused an operation that needs no // arming. The registry cannot ask an adapter's `_requiresArming`, so it // stores a switch for any name; an adapter consults it only for the // operations this function names, so a switch for any other name is inert. /// THE CLEAN SLATE A V2 ACCOUNT HAS NO OTHER WAY TO GET. /// /// Under v1 the owner's reset is a reinstall: `onUninstall` bumps `gen` and /// wipes whatever the subclass curated, and `onInstall` writes it again. /// That is what `installStale`'s natspec means by "a reinstall wipes the /// account's authority and writes it again". /// /// A V2 ACCOUNT NEVER INSTALLS, so none of that ever happens, and /// `refuseAdapter` lives on the installer and cannot reach this contract's /// storage. An owner who refused an adapter because they suspected it /// should still be able to get v1's clean slate. This is that, as a /// deliberate act rather than a side effect of reinstalling. /// /// IT DOES NOT TOUCH THE ARMING SWITCH, in either direction. `disarmed` is /// the owner's own refusal and sits outside the install lifecycle on /// purpose; a reinstall does not clear it and neither does this. Since the /// default is ON, a reset that cleared it would switch `pull_from_owner` /// back on for an owner who had switched it off. Only `armOperation` /// changes it. /// /// The account is the caller, which under Kernel means the root validator /// authorised it: the same authority arming itself rests on. function resetAuthority() external { _requireGenuine(msg.sender); Config storage cfg = _config[msg.sender]; cfg.gen = cfg.gen + 1; _wipeAuthority(msg.sender); emit AuthorityReset(msg.sender, cfg.gen); } /// THE ACCOUNT'S OWN AUTHORITY TO CONFIGURE THIS ADAPTER, v1 or v2. /// /// Twelve curate-shaped setters across four adapters opened with /// `if (!_config[msg.sender].installed) revert NotInstalled(msg.sender)`. /// That flag is written only by `onInstall`, and a v2 account never /// installs — `AdapterInstaller` now actively refuses to let it — so /// Morpho markets, Aave v4 reserve ids, properties and rent delegatees /// could never be written, by the installer, by the guardian, or by the /// account's own owner. Those adapters simply did not work under v2, and /// every step naming one reverted `UnknownMarket` / `UnknownReserve`. /// /// THE INSTALL WAS ONLY EVER A PROXY for "the caller is the account", which /// under Kernel means the root validator authorised it. `armOperation` and /// `setLtvCeiling` were corrected to say that directly; these were missed. /// This is that correction, in one place, so the next such setter inherits /// it rather than re-deriving it. /// /// IT RETURNS THE GENERATION because every one of those tables is /// generation-fenced — `_marketGen[account][name] == _config[account].gen` /// — and `gen` is zero for an account that never installed. Zero is the /// never-written value, so a table written at generation zero would be /// indistinguishable from an empty one on the read side. function _accountConfigGen() internal returns (uint64) { _requireGenuine(msg.sender); Config storage cfg = _config[msg.sender]; if (cfg.gen == 0) cfg.gen = 1; return cfg.gen; } /// Whether this account may perform this operation. /// /// KEPT AS A FUNCTION WITH THE MAPPING'S OLD SIGNATURE, deliberately: it /// was `mapping(address => mapping(bytes32 => bool)) public allows`, and /// `cmd/api/agent.go` and `internal/adapters/installer.go` both call /// `allows(address,bytes32)` on chain. The storage moved; the ABI did not. function allows(address account, bytes32 operation) public view returns (bool) { if (_requiresArming(operation)) return !registry.disarmed(account, operation); // EVERY OTHER OPERATION IS BOUNDED BY THIS ADAPTER'S OWN CODE. // // The operation is a field of the intent, so B chooses it -- and every // adapter dispatches on it and ends `else { revert UnknownOperation(); }`. // An operation this adapter does not implement cannot be performed // whatever any list says, and the curated list said exactly what the // code already says: UniswapAdapter implements ten operations and // `cmd/curate` names those same ten. // // SO THE LIST IS GONE. It cost 5,216 gas per operation at install and // at least 2,079 on the hot path for EVERY operation of EVERY plan, // to hold a second copy of a fact the code states -- two sources of one // truth, which is how they drift. What it could have done and never did // is switch one operation of an adapter off without a redeploy; // `delistAdapter` switches the whole adapter off, immediately. // // WHAT STILL BOUNDS B, none of which moved: the adapter must be // whitelisted (`Agent._callOne`) and installed on this account // (`_consume`); the venue name must be one THIS adapter was curated // with (`_venueOf` -- NOT redundant, because the venue is a free // parameter and a global name table alone would let a compromised B // point SwapAdapter at `rent_share`); the destination asset must be // curated; and every adapter delivers to the account and nowhere else. return true; } /// The most leverage this account will carry, in basis points of its /// collateral value, and the reason it is not a signed field. /// /// PLAN.md calls this "leverage cap enforced on-chain". Until 2026-09-10 it /// was a member of the struct B SIGNS: /// /// if (ltvBps > l.maxLtvBps) revert LtvCapExceeded(...) /// /// comparing the venue's own answer against a number the same compromised /// key chose — and neither `Config` nor the `onInstall` payload carried an /// LTV or health field anywhere, so there was nothing else to compare it /// to. `type(uint16).max` is 655%. The venue's own LLTV is what actually /// stopped a borrow, so the effect was not unbounded leverage; it was that /// the OWNER'S tighter bound did not exist, and a liquidation costs the /// owner the 5-10% penalty with no token leaving the account at all. /// /// TWO BOUNDS, AND B IS INSIDE BOTH. `_effectiveLtvCap` takes the MINIMUM /// of the signed field and this, so B can still ask for something tighter /// than the owner allows and can never ask for something looser. /// /// SET BY THE ACCOUNT, NEVER BY AN INTENT, and only ever DOWNWARDS from /// `AgentRegistry.MAX_LTV_CEILING_BPS` — on the REGISTRY, since finding 6 of /// the 2026-09-26 audit: one row per account that every adapter reads, /// rather than a row in each adapter that a later or redeployed adapter /// read as the maximum. `ltvCeilingOf` below asks it. /// /// NOT IN THE INSTALL PAYLOAD, and that is a compatibility choice rather /// than a preference: the payload's bytes are part of every account's /// genesis preimage. A separate owner statement costs one transaction and /// moves nothing. /// /// NO GENERATION STAMP. A generation makes a stale GRANT inert, which is /// right for venues and operations, and exactly backwards for a RESTRAINT: /// `activate` is permissionless, so a stamp made the owner's ceiling /// resettable to the maximum by any stranger (round-2 fixes). /// WHICH CURATED NAMES CAME FROM THE REGISTRY'S BLOB, AND WHICH THE OWNER /// WROTE BY HAND — M-3 of the round-2 audit. /// /// `AdapterInstaller._curate` applies the guardian's set as a REPLACE-SET: /// it sends `resetCuration()` first, so a name the new blob omits is not /// still agent-callable. That is right for the registry's own names and /// wrong for everybody else's, and `resetCuration` did not distinguish. So /// every name the owner curated through their own root validator — /// `MorphoAdapter.curate`, `PropertyAdapter.curate`, /// `AaveV4Adapter.mapReserves`, `RentAdapter.allowDelegatee` — was deleted /// by a call the owner did not make and could not see coming, and the /// repair is another owner transaction. /// /// This is the distinction, recorded at write time. The installer announces /// a sync by sending `resetSyncedCuration()` (it sent `resetCuration()` /// before M-3), which is the only thing that sends it; every /// `curate`-shaped setter reached in that same transaction marks its name /// as the registry's, and every other one marks it as the owner's. /// `resetSyncedCuration` then drops only the registry's. Only /// `RentAdapter.allowDelegatee` still keeps such a table — the markets, /// reserves and properties moved to AdapterCuration, one table per chain. mapping(address account => mapping(bytes32 name => bool)) internal _curatedBySync; /// Set by `_beginSync` for the length of ONE transaction. /// /// The account rather than a bool, because two accounts can be curated in /// one transaction and the second must not inherit the first's answer. /// Transient: EIP-1153 scopes it to the transaction, which is exactly the /// window `AdapterInstaller._curate` occupies — `resetSyncedCuration` and /// the `curate` calls that follow it are separate top-level calls from the /// installer inside one transaction. /// /// THE WHOLE REST OF THAT TRANSACTION, which is wider than the sync: a /// name the account itself writes later in the same transaction is marked /// the registry's too, and the next sync may drop it. That errs toward /// LESS authority, never more, and an owner's ordinary write is its own /// transaction. address private transient _syncingFor; /// Called by `resetSyncedCuration`. See `_curatedBySync`. function _beginSync() internal { _syncingFor = msg.sender; } /// THE INSTALLER'S SYNC ANNOUNCES ITSELF HERE, AND EVERY ADAPTER ANSWERS — /// #18 of the 2026-10-08 audit. /// /// `AdapterInstaller._curate` sends `resetSyncedCuration()` through the /// account before it replays the guardian's curation calls, and it read an /// EMPTY revert as "this adapter keeps no such table" and carried on. No /// adapter defined the function, so every sync took that branch — the /// one adapter that does keep a per-account table (RentAdapter's /// delegatees) had its sync-written names left standing however the /// guardian's blob changed, and nothing told a missing selector from a /// clean table. /// /// So it exists on EVERY adapter, and it SUCCEEDS on every adapter. The /// ABI is the whole contract with the installer: `resetSyncedCuration()`, /// no arguments, no return, keyed on `msg.sender` (the account, under /// `executeFromExecutor`). An installer may therefore treat ANY revert /// from it — empty or not — as a failure to clean the table and stop. /// /// WHAT IT DOES: drops this account's rows that an earlier SYNC wrote /// (never the owner's own — M-3 of round 2; see `_curatedBySync`), then /// marks the rest of this transaction as a sync for this account, so the /// curation calls that follow mark their rows as the registry's. An adapter /// with no per-account table has nothing to drop, and the default hook is /// empty. /// /// NOT GATED ON `_requireGenuine`, deliberately: a sync must never fail on /// a registration question, and a caller can only ever reach its OWN rows, /// because every row this touches is keyed by `msg.sender`. function resetSyncedCuration() external { _resetSyncedCuration(msg.sender); _beginSync(); } /// Drop `account`'s sync-written rows. Override where the adapter keeps a /// per-account curated table; see RentAdapter. function _resetSyncedCuration(address account) internal virtual {} /// Whether the caller is mid-sync, i.e. whether the name being written now /// belongs to the registry's replace-set. function _inSync() internal view returns (bool) { return _syncingFor == msg.sender; } /// Spent task ids, per account. An account's history cannot be replayed and /// cannot be replayed onto a different account either, because the account is /// part of the signed message AND part of this key. mapping(address account => mapping(bytes32 taskId => bool)) public spent; event AdapterInstalled(address indexed account); // `VenueSet(account, venue, target)` and `OperationAllowed(account, op)` // were declared here and are gone. They were emitted by `onInstall`, once // per binding per ACCOUNT -- 115 of them on a Base bring-up -- and the // information they carried now lives at the registry, once per chain: // `VenueTargetSet`, `VenueScoped`, `VenueUnscoped`, `OperationIndexed`, // `OperationRetired`. Removed rather than left declared-and-never-emitted, // which is the shape of a log an operator keeps waiting for. Nothing off // chain consumed them; watching the registry is now one subscription // instead of one per account. event AdapterUninstalled(address indexed account); event AuthorityReset(address indexed account, uint64 generation); event AmountDelivered(address indexed account, bytes32 indexed taskId, address asset, uint256 amount); event AmountTaken( address indexed account, bytes32 indexed taskId, address indexed by, address asset, uint256 amount ); event TokenIdDelivered(address indexed account, bytes32 indexed taskId, uint256 tokenId); event TokenIdTaken(address indexed account, bytes32 indexed taskId, address indexed by, uint256 tokenId); // `OperationArmed` is the registry's now, with `armOperation` — finding 15. event IntentExecuted(address indexed account, bytes32 indexed taskId, address indexed agent); error AlreadyInstalled(address account); error NotInstalled(address account); /// Only a curated adapter may take a delivered value. See /// `takeDeliveredAmount`. error NotAnAdapter(address caller); error NotTheAgent(address caller, address agent); error NoPolicyGate(); /// The step that was opened named a different operation of this adapter. error OperationNotThisSteps(address account, bytes32 opened, bytes32 asked); /// The intent's taskId is not the one the open step is for. error TaskNotThisSteps(address account, bytes32 opened, bytes32 asked); error NotInstalling(address account); error StillInstalled(address account); /// Not a Kernel the factory created. See `_requireGenuine`. error NotAGenuineAccount(address account); error NoAgent(address account); error WrongSigner(address recovered, address expected); error IntentExpired(uint256 deadline); error TaskAlreadySpent(bytes32 taskId); /// A step said "spend what step X delivered" and step X delivered nothing. error NothingDelivered(address producer, bytes32 taskId); error VenuesMismatched(); error UnknownVenue(address account, bytes32 venue); /// A venue named in a slot its role does not fit — a router asked to hold a /// position, or a pool asked to swap. See `_isRouterVenue` — M-7. error WrongVenueRole(bytes32 venue); error OperationNotAllowed(address account, bytes32 operation); /// An intent named an asset as a DESTINATION that the guardian has not /// curated on this chain. See `_requireCuratedAsset`. error AssetNotCurated(address asset); error ResidualAllowance(address asset, address spender, uint256 left); /// An install payload naming an operation that only the account may turn /// on or off. See `_requiresArming` — H-2. error OperationNeedsArming(bytes32 operation); /// The position ended up looser than the bound it was held to. Declared /// here rather than in two children, because H-1 of the round-2 audit was /// that the bound was read on three of eight leverage-increasing paths — /// and an error that lives next to one enforcement site is an error the /// next adapter forgets to raise. error LtvCapExceeded(uint256 ltvBps, uint16 capBps); /// A split tick that is not strictly inside the position being split. error SplitTickOutsideRange(int24 tick, int24 tickLower, int24 tickUpper); /// A split tick that is not a multiple of the pool's spacing. /// /// NAMED, because this is the one the pool refuses with a bare require and /// therefore with EMPTY returndata -- "execution reverted" and nothing to /// look up. Reproduced on the staging Base fork 2026-09-04 against position /// 5881122: the identical mint one tick along, ON the grid, fails later and /// decodably; the one off it returns 0x. Whoever hits this needs the tick /// and the spacing, and the pool will never tell them. error SplitTickOffGrid(int24 tick, int24 spacing); error CannotEstablishOwner(address account); error WrongOwner(address account, address claimed, address actual); /// THE OWNER'S REFUSAL BINDS AN INSTALLED ADAPTER TOO — #12 of the /// 2026-10-08 audit. /// /// `AdapterInstaller.refuseAdapter` is the owner's "not this adapter". On /// v2 it was always honoured on the hot path: `PolicyGate.consume` and /// `PolicyExecutor.executeFor` both ask `refusedFor` before anything moves. /// On v1 nothing asked. The install is v1's authority, a refusal does not /// uninstall, and so the keeper kept driving a refused adapter until the /// owner sent a second transaction to uninstall it — which the Go side now /// sends right after the refusal (#216), and which `_consume` makes /// unnecessary for safety rather than for tidiness. /// /// THE SAME QUESTION THE GATE ASKS, OF THE SAME INSTALLER. The gate holds /// the installer as an implementation immutable, so `_consume` reads it /// from there: one place says which installer's refusals count, for both /// paths, and no adapter carries a second copy of that address to drift. /// /// NO GATE, NO CHECK. Before the guardian runs `setPolicyWiring` there is /// no installer to read through, and that state already reads as "a v1 /// chain, exactly as before". Every chain the deploy wires is covered from /// the moment it is wired. /// /// The same name and arguments `PolicyExecutor` and `PolicyGate` use for /// the v2 path, so one decoder reads all three. error AdapterRefusedByOwner(address account, address adapter); constructor(AgentRegistry registry_) { registry = registry_; } // ----------------------------------------------------------------------- // Module lifecycle. The caller is always the account. // ----------------------------------------------------------------------- /// Called by the account when owner A installs this adapter. /// /// `data` is `abi.encode(venues, targets, operations)`. /// /// THERE IS NO ASSET TABLE ANY MORE, and that is a deliberate removal /// rather than a thing not yet built. Until 2026-09-20 this payload also /// carried a per-asset, per-epoch spend cap, and every adapter charged its /// outflows against it. It is gone entirely: there is no rate limit on what /// an appointed agent may move, and no list of assets an account deals in. /// /// WHAT THAT COST AND WHAT IT BOUGHT. The owner accepted both halves. It /// cost the one on-chain bound on a compromised agent key's throughput, and /// the check that a swap's DESTINATION was an asset the owner had listed — /// which is what stopped `Swap{assetIn: USDC, assetOut: , minOut: 1}`. What still bounds that key: the curated venue /// table, the `allows` set, the owner's LTV ceiling, `armOperation`, and the fact that /// every adapter delivers to the account and nowhere else. /// /// It bought an account whose authority does not go stale. The cap table was /// a SNAPSHOT: an asset listed on the chain after an account installed an /// adapter had no row, so a perfectly good intent died `NoCapForAsset` and /// the only remedy was a reinstall. It also bought the install itself — /// twenty-eight assets at three cold stores each was most of the gas, and an /// activation that has to fit under a provider's send ceiling is measured in /// how many adapters fit in one transaction. /// /// Kernel registers the executor BEFORE calling onInstall, so we require /// `isModuleInstalled` — a naked `execute(adapter, onInstall)` from the agent /// does not flip that bit and is refused. EOAs / non-Kernel callers fail /// the same check (staticcall fails → NotInstalling) instead of empty revert. function onInstall(bytes calldata data) external payable { if (!_moduleInstalledOnCaller()) revert NotInstalling(msg.sender); // A contract can ANSWER the line above; it cannot be one the factory // made. Asked before anything is written or emitted. _requireGenuine(msg.sender); Config storage cfg = _config[msg.sender]; if (cfg.installed) revert AlreadyInstalled(msg.sender); // STILL DECODED, AND NOTHING IS STORED FROM IT. The registry indexed // these same bytes when the guardian wrote them; copying them again, // per account, is the 345 cold stores this change removes. What the // payload is still read FOR is H-2, below. (bytes32[] memory venues, address[] memory targets, bytes32[] memory operations) = abi.decode(data, (bytes32[], address[], bytes32[])); if (venues.length != targets.length) revert VenuesMismatched(); // INSTALLED BEFORE THE WIPE, because the wipe may have to ACT. // // CowPresignAdapter's teardown revokes the allowance of every order // still resting from a previous install, through `_exec` -- and `_exec` // picks its path by this flag. Set after the wipe, as it was, `_exec` // took the v2 branch mid-install: `PolicyExecutor.executeFor`, which // reverts NoOpenStep on a v2-wired chain and is a call to address zero // on any other. So any reinstall of CoW over a resting order reverted, // v1 adapter update or v2 owner install alike, and the order stayed // fillable. Kernel has already registered this executor // (`_moduleInstalledOnCaller` above), so the v1 path is the one that // works here. A revert anywhere below undoes this with the rest of the // install. // // The GENERATION still moves after the wipe: CowPresign's teardown // matches rows against the old generation. cfg.installed = true; // Subclasses still have curation of their own to clear (markets, // properties, reserves, delegatees, live CoW tokens). Nothing of // BaseAdapter's own is left to wipe. _wipeAuthority(msg.sender); // H-2. An operation that needs the owner's own say-so is not granted by // an install payload, whoever wrote it. Refused rather than skipped: a // blob that still lists one is a blob that believes it is granting // something, and it should learn otherwise. // // BELT AND BRACES NOW, and worth the loop. `_requireOperation` answers // an arming-class operation from the account's own switch alone and // never from the registry index, so a payload that smuggled one past // here would still grant nothing. This keeps the refusal LOUD rather // than silent, at the moment somebody wrote the blob. for (uint256 i = 0; i < operations.length; i++) { if (_requiresArming(operations[i])) revert OperationNeedsArming(operations[i]); } cfg.gen = cfg.gen + 1; emit AdapterInstalled(msg.sender); } /// Called by the account when A uninstalls. Kernel clears the executor slot /// BEFORE calling onUninstall, so we require it is already uninstalled — /// a naked `execute(adapter, onUninstall)` from the agent leaves the Kernel /// bit set and is refused. function onUninstall(bytes calldata) external payable { if (_moduleInstalledOnCaller()) revert StillInstalled(msg.sender); // NOTHING TO UNDO FOR A CALLER THAT NEVER INSTALLED, so nothing is // written and nothing is emitted. Not a `_requireGenuine` refusal: an // uninstall is the owner's remedy and must never wait on a // registration. A real account that installed has `installed` set; // anything else — an EOA, or a contract answering `isModuleInstalled` // with `false` — only ever reached here to have `AdapterUninstalled` // emitted in its name. if (!_config[msg.sender].installed) return; _wipeAuthority(msg.sender); // NOT `delete`. `gen` IS A COUNTER WHOSE ONLY JOB IS NEVER TO REPEAT. // // It was written for the arming switch, when that was // `_armedAt[account][op] == gen`: `delete _config` let the next install // reissue generation 1 and revive every grant stamped 1 (231624c9 #4), // so uninstall was the owner's remedy and a permissionless `activate` // undid it. The switch is the registry's `disarmed` now (finding 15 of // the 2026-09-26 audit), the owner's durable refusal, and no lifecycle // event touches it. // // The counter still must not repeat: every table a subclass curates per // account — `_marketGen`, `_reserveGen`, `_propertyGen`, `_delegateeGen`, // CoW's `_restingGen` and `_liveTokenGen` — is fenced on it as well as // cleared by `_wipeAuthority`, and `resetAuthority` moves it too. The // fence is the second guarantee, and only one if it never repeats: the // first table somebody adds and forgets to wipe would otherwise inherit // the revival silently. Config storage cfg = _config[msg.sender]; cfg.installed = false; cfg.gen = cfg.gen + 1; emit AdapterUninstalled(msg.sender); } /// Refuse anything that is not a Kernel the factory created. /// /// `_moduleInstalledOnCaller` and `ownerOf` ask the ACCOUNT, and a contract /// written to answer them passes both. It never reached anyone else's /// money — every call this adapter makes for it goes back to it — but it /// did get `AdapterInstalled` and `IntentExecuted` emitted in its name by /// our adapters, so a log reader could not tell its activity from a real /// account's. The registry's record is a fact the caller cannot answer for /// itself: see `AgentRegistry.isGenuineAccount`. function _requireGenuine(address account) internal view { if (!registry.isGenuineAccount(account)) revert NotAGenuineAccount(account); } /// True only when `msg.sender` is a Kernel (or ERC-7579) account that has /// this adapter registered as an executor. Anything else — EOA, random /// contract, agent trying to spoof install — is false. A contract written /// to answer `true` passes this, which is why `onInstall` asks /// `_requireGenuine` first. function _moduleInstalledOnCaller() internal view returns (bool) { if (msg.sender.code.length == 0) return false; try IERC7579Modules(msg.sender).isModuleInstalled(MODULE_TYPE_EXECUTOR, address(this), "") returns ( bool ok ) { return ok; } catch { return false; } } function _wipeAuthority(address account) internal virtual { // NOTHING OF BaseAdapter'S OWN IS LEFT TO WIPE, and that is the point // of this change rather than an omission. The venue and operation // tables lived here and were walked from `_venueKeys`/`_opKeys`, two // arrays kept solely so this loop could exist — unbounded, sized by how // much was installed, and at a large enough catalogue an account that // could not be uninstalled at all. Both tables are the registry's now, // and the arming switch (`disarmed`) is outside the install lifecycle. // // The hook stays: AaveV4, Morpho, Property, Rent and CowPresign all // override it for curation of their own, and CowPresign's revokes live // allowances, which is authority nobody else can clear. account; // THE LTV CEILING IS NOT WIPED, and that is H-1/H-4 of the round-2 // audit rather than an oversight. // // It used to be, on the theory that a stale row must be inert like // every other. But `AdapterInstaller.activate` is PERMISSIONLESS, so // "stale" was reachable by any stranger: an owner who tightened to // 3000 lost it on the next reinstall and the account went back to // MAX_LTV_CEILING_BPS, with no transaction of the owner's involved. // The other rows are grants — an omitted venue must not stay live — // and this is a RESTRAINT, where the safe direction is exactly // the opposite. So it is keyed by account alone, it outlives every // generation, and `setLtvCeiling` only ever tightens it. } /// What this account's ceiling actually is — the REGISTRY's answer. /// /// The owner sets it once, on `AgentRegistry.setLtvCeiling`, and every /// adapter reads the same row. It was a row in each adapter's own storage /// until finding 6 of the 2026-09-26 audit: an adapter the account gained /// later — a new catalogue entry, or any redeploy — read zero, which meant /// the maximum, so the keeper could borrow through it past the owner's /// limit. The setter's rules (tighten-only, H-3; keyed by account alone, no /// generation) moved with it; see there. function ltvCeilingOf(address account) public view returns (uint16) { return registry.ltvCeilingOf(account); } /// THE OWNER'S HALF OF THE BOUND, WHICH IS THE HALF THAT WAS MISSING. /// /// H-1 of the round-2 audit: `_requireLtv`/`_effectiveLtvCap` were reached /// from three of the eight-plus paths that can raise leverage, and four of /// the absent ones have no `maxLtvBps` field to compare against at all. /// /// So this takes NO SIGNED FIELD. That is deliberate and it is the point: /// `maxLtvBps` is a number B fills in, and on the production path — where /// `msg.sender == agent` and `BaseAdapter._consume` skips signature /// verification entirely — B chooses it freely. Adding the field to four /// more typehashes would have moved four digests and every account address /// derived from them to buy a bound the compromised key writes. The /// owner's ceiling is the only half of `TWO BOUNDS, AND B IS INSIDE BOTH` /// that a compromised agent cannot author, and it needs nothing signed. /// /// Where a signed field DOES already exist the adapter keeps using /// `_requireLtv`, which takes the tighter of the two. /// /// A borrow is an INFLOW and a liquidation costs the owner the penalty /// with no token leaving the account, so neither shows up as an outflow /// anywhere. This is the only thing standing between an agent and 16x on a /// 94.5% LLTV market. function _requireLtvCeiling(address account, uint256 ltvBps) internal view { uint16 cap = ltvCeilingOf(account); if (ltvBps > cap) revert LtvCapExceeded(ltvBps, cap); } /// A CLOSE OR UNWIND MAY READ MORE LEVERED ONLY WHERE A BORROW COULD HAVE /// TAKEN IT — findings 17 and 18 of the 2026-09-26 audit. /// /// The no-more-levered rule (M-5) admits 10 bps of rise per call, because /// two reads of one position either side of a repay are not the same /// measurement. PER CALL is the flaw: nothing remembers the last call's /// 10 bps, so a keeper repeating small partial closes compounds them, and a /// few hundred walk an Aave position to its liquidation threshold. The /// tolerance cannot be zero, so its reach is bounded instead: a rise is /// admitted only at or under the owner's ceiling, and under the venue's own /// borrow limit where it states one (Aave v3's `ltv`), which is where a /// keeper could have borrowed the position to anyway. A close that does /// not raise leverage is never refused, however far over the ceiling a /// price move has already put the position — that is the close the owner /// needs. /// /// `venueMaxBps` is type(uint256).max where the venue offers no limit. function _requireRiseWithinCeiling( address account, bool rose, uint256 ltvAfterBps, uint256 venueMaxBps ) internal view { if (!rose) return; uint256 cap = ltvCeilingOf(account); if (venueMaxBps < cap) cap = venueMaxBps; if (ltvAfterBps > cap) revert LtvCapExceeded(ltvAfterBps, uint16(cap)); } /// The same rule for an Aave v3 pool, read from the pool: the whole /// position in base currency, and `ltv`, the borrow limit Aave itself would /// hold a new borrow to. `collBefore`/`debtBefore` are the caller's reads /// from before the repay. Rounded up, so a rise is never rounded under. function _requireAaveRiseWithinCeiling( address pool, address account, uint256 collBefore, uint256 debtBefore ) internal view { (uint256 collAfter, uint256 debtAfter,,, uint256 maxLtv,) = IAaveAccountData(pool).getUserAccountData(account); if (debtAfter == 0 || collAfter == 0) return; _requireRiseWithinCeiling( account, debtAfter * collBefore > debtBefore * collAfter, (debtAfter * 10_000 + collAfter - 1) / collAfter, maxLtv ); } /// The bound a risk-increasing operation is actually held to: the tighter /// of what B signed and what the owner allows. /// /// A signed zero is still `MissingLtvCap` at the call site — "no opinion" /// must not read as "the owner's ceiling", because a plan that forgot the /// field is a plan nobody checked. function _effectiveLtvCap(address account, uint16 signed) internal view returns (uint16) { uint16 ceiling = ltvCeilingOf(account); return signed < ceiling ? signed : ceiling; } function isModuleType(uint256 moduleTypeId) external pure returns (bool) { return moduleTypeId == MODULE_TYPE_EXECUTOR; } function isInitialized(address account) external view returns (bool) { return _config[account].installed; } function configOf(address account) external view returns (Config memory) { return _config[account]; } // ----------------------------------------------------------------------- // What every adapter does before it acts. // ----------------------------------------------------------------------- /// Who owns this account, right now, according to the account. /// /// Public because it is the thing a user should be able to check without /// trusting a comment: whose authority an adapter is acting under is a value /// anyone can read, not a promise. /// EVERY ROOT VALIDATOR THIS REPO SHIPS, not only the ECDSA one. /// /// This asked `ecdsaValidatorStorage` and nothing else, so an account rooted /// on ERC1271RootValidator (a Safe or any contract wallet) or on /// WebAuthnRootValidator (a passkey) reverted here — and `_consume` calls /// this before every single operation. The effect was not a drain of ECDSA /// accounts; it was that three of the four validators this repo ships could /// not use any adapter at all, and OwnerTransfer, PullFromOwner and /// PropertyAdapter's `holderFor` fallback failed with them too. /// /// Fail-closed either way: an owner that cannot be established is still /// `CannotEstablishOwner`, which is what `_consume` needs. See RootOwner for /// how each shape is asked and why a passkey's answer is its `exitCaller`. function ownerOf(address account) public view returns (address) { bytes21 root = IKernelRoot(account).rootValidator(); if (bytes1(root) != VALIDATION_TYPE_VALIDATOR) revert CannotEstablishOwner(account); address validator = address(uint160(uint168(root))); address owner = RootOwner.ownerFrom(validator, account); if (owner == address(0)) revert CannotEstablishOwner(account); return owner; } /// Verify that B authorised exactly this, for exactly this account, owned by /// exactly this owner, once. /// /// M-2 OF THE ROUND-2 AUDIT — READ THIS BEFORE BELIEVING THE PARAGRAPH THAT /// USED TO BE HERE. /// /// It said: "`digest` is the adapter's own EIP-712 hash over its own typed /// parameters, so the signature covers every parameter the adapter will act /// on — not a summary of them." That describes the `msg.sender != agent` /// branch below, AND PRODUCTION NEVER TAKES IT. /// `registry.requireContractAgent` forces the agent to be a contract, so the /// signature-skipping branch is the only path a live account uses. On it, /// `deadline`, `taskId` and `signature` are not authorisations at all: all /// three come from the same caller being authorised, `digest(...)` is /// computed by the caller and discarded here, and `Agent._callOne` never /// decodes the intent — it checks the target, the selector, the whitelist /// and the appointment, then forwards raw calldata verbatim. /// /// SO WHAT KEEPER_ROLE ACTUALLY IS: authority over every field of every /// intent on every appointed account except `owner`, bounded by the /// `allows` set, the curated venue table, and the owner-set restraints: the /// LTV ceiling and the per-asset spend cap on the registry (and, for a /// pull from the owner, the pull cap — see `disarmed`), and /// `armOperation` here. The cap is the bound on the AMOUNT. It was removed /// on 2026-09-20 and rebuilt in `AgentRegistry` (shared, lazy, opt-in), so /// on an asset the owner has not capped, a compromised key's throughput is /// still limited only by what the account holds and which venues it may /// reach. /// AGENT_KEY_ATTACK_VECTORS.md should be read with that in mind. /// /// WHY THE DEAD DIGEST IS STILL COMPUTED. Skipping it needs the call site to /// know the answer before calling, and the only orderings that achieve that /// either mark `spent[account][taskId]` BEFORE the signature is checked — so /// a bad signature burns the task id — or ask the registry twice. Neither is /// worth a keccak across eighteen entry points, and the honest fix for "the /// code implies a binding it does not make" is to stop implying it, which is /// what this comment now does. /// /// The EIP-712 path is still real and still correct for any caller that is /// not the appointed agent contract: the domain carries the chain id and /// this contract's address, so an intent does not verify on another chain or /// against another adapter. /// /// The OWNER check is the one that is easy to leave out, and leaving it out /// is not a small gap. Checking only that B signed, and that B is this /// account's appointed agent, authenticates the agent without establishing /// that this account is the one the plan meant. One agent key serves many /// customers' accounts: a planner that picks the wrong account produces a /// signature that is perfectly VALID for it, because the agent is that /// account's agent too. Authentication was never the boundary — the binding /// between owner, account and action is, and it only exists if the owner is /// in the signed message and compared against what the account itself says. /// /// Owner A's authority over all of this rests on the two things only A can /// do: install the adapter, because installModule is root-validator-only, /// and appoint the agent, because the registry's appoint is called BY the /// account. function _consume( address account, address owner, bytes32 taskId, uint256 deadline, bytes32 digest, bytes calldata signature ) internal { // AUTHORITY COMES FROM THE INSTALL OR FROM THE PROOF, NEVER FROM // NEITHER. // // v1's authority is that the owner installed this adapter as an // executor — `installModule` is root-validator-only, so only A can do // it. v2's is that a plan the owner's policy root approved named this // adapter for a step, and the gate spent it. Both are the owner // speaking; one costs a transaction per adapter and one costs nothing. // // The gate is read ONCE, here, for both questions below: whether a // proof opened this step (v2), and whether the owner refused this // adapter (v1, #12). Zero means the chain has no v2 wiring, which is // every chain until `setPolicyWiring` is called — read as "there is no // door" rather than as a failure, so a v1 account on a v1 chain // behaves exactly as it did. bool installed = _config[account].installed; IPolicyGate gate = IPolicyGate(registry.policyGate()); if (!installed && (address(gate) == address(0) || !gate.authorizes(account, address(this)))) { revert NotInstalled(account); } // AND A REAL ACCOUNT. Everything below asks the account about itself — // its owner is read from its own root validator, its agent is the one // it appointed — and a contract built to answer all of that passes all // of it. `onInstall` already refuses one, so for v1 this is the second // line; for a proved step it is the first. _requireGenuine(account); // AND UNDER THE PROOF, THE TASK IS THE STEP'S. // // `spent[account][taskId]` below is the only per-execution guard, and // on the production path (`msg.sender == agent`) no signature is // verified — so the agent authors the taskId. With the window open for // the whole transaction, one proved step ran the operation again and // again under fresh ids. `PolicyGate.taskIdFor` existed for exactly // this and had no caller. if (!installed) { gate.commitStep(account, address(this)); bytes32 want = gate.taskOpenFor(account, address(this)); if (taskId != want) revert TaskNotThisSteps(account, want, taskId); } else if (address(gate) != address(0) && gate.installer().refusedFor(account, address(this))) { // AND UNDER THE INSTALL, NOT AN ADAPTER THE OWNER REFUSED — #12 of // the 2026-10-08 audit. See `AdapterRefusedByOwner`. revert AdapterRefusedByOwner(account, address(this)); } if (block.timestamp > deadline) revert IntentExpired(deadline); if (spent[account][taskId]) revert TaskAlreadySpent(taskId); address actual = ownerOf(account); if (owner != actual) revert WrongOwner(account, owner, actual); address agent = registry.agentFor(account); if (agent == address(0)) revert NoAgent(account); // Fail closed if a pre-check registry still has an EOA appointed. registry.requireContractAgent(agent); // B is Agent.sol: msg.sender == agent (keeper → Agent.callAdapter). // // THIS IS THE PRODUCTION PATH AND IT VERIFIES NO SIGNATURE — M-2. The // caller being the appointed agent is the whole authorisation, so the // `digest` argument above is computed and dropped. See the natspec. // // EIP-712 recover remains for tests that etch bytecode onto a key so // appoint sees a contract; production does not appoint EOAs. if (msg.sender != agent) { address recovered = ECDSA.recoverCalldata(digest, signature); if (recovered != agent) revert WrongSigner(recovered, agent); } // Marked before the call, not after: an adapter that re-enters through a // token hook would otherwise find the task unspent and run it again. spent[account][taskId] = true; emit IntentExecuted(account, taskId, agent); } /// What this step should spend: the number the owner signed, or the one the /// step they named actually delivered. /// /// WHICH ONE IS THE OWNER'S CHOICE AND IS SIGNED. `amountFrom` zero means /// "the literal above" and is what every plan naming its own numbers gets — /// unchanged, byte for byte. A non-zero producer means "spend what that step /// delivered", which is the only way to write a plan whose amount cannot /// exist when it is signed: how much cbBTC $100 buys is not knowable until /// the swap runs. /// /// Zero is refused rather than spent. A producer that delivered nothing is a /// failed step, and a supply of zero that reports success is the quiet /// failure this system keeps meeting. function _amountToSpend( address account, uint256 literal, address amountFrom, bytes32 producerTaskId, address asset ) internal returns (uint256) { if (amountFrom == address(0)) return literal; uint256 delivered = IRuntimeSource(amountFrom).takeDeliveredAmount(account, producerTaskId, asset); if (delivered == 0) revert NothingDelivered(amountFrom, producerTaskId); return delivered; } /// THE RUNTIME BUS LIVES HERE NOW, NOT IN A CONTRACT OF ITS OWN. /// /// A step that spends what an earlier step delivered has to learn a number /// that did not exist when the plan was signed. That used to go through /// `RuntimeBus`, a separate contract at a pinned CREATE2 address — and the /// indirection bought nothing, because the consumer ALREADY names the /// producer: `_amountToSpend` takes `amountFrom`, which is the producing /// adapter's address. The bus was a third party asked to hold a value /// between two contracts that could simply talk. /// /// IT COST THREE THINGS. Its authorisation asked /// `isModuleInstalled(EXECUTOR, caller)` on the account, which is false by /// construction for every v2 account — so every proof-gated step that /// published or consumed an amount reverted, and nine adapters publish on /// their success path. Its address was a constant compiled into every /// adapter, so teaching it about v2 moved the address and therefore all 26 /// adapters'. And it was a contract that had to be deployed on a chain /// before any multi-step plan could run, with `code.length == 0` silently /// meaning "publish nothing". /// /// All three go away. The producer keeps the value in its OWN transient /// storage and hands it over when asked; there is no third address to /// deploy, pin, or authorise against. /// /// WHO MAY ASK. A curated adapter — the same membership question the rest /// of the acting path asks. It is not a money-moving call: the worst a /// hostile curated adapter does is take a number meant for another step, so /// the intended consumer finds zero and refuses with `NothingDelivered`. /// That is a within-transaction denial by an adapter that had to be in the /// same plan, and it is exactly the exposure the bus had. /// /// ONCE. A publication is consumed by one reader, because an amount read /// twice is two steps spending one delivery, and an id read twice is two /// steps acting on a position only one of them created. function takeDeliveredAmount(address account, bytes32 taskId, address asset) external returns (uint256 v) { if (!registry.isWhitelistedAdapter(msg.sender)) revert NotAnAdapter(msg.sender); bytes32 slot = _deliveredSlot(account, taskId, asset); assembly ("memory-safe") { v := tload(slot) tstore(slot, 0) } emit AmountTaken(account, taskId, msg.sender, asset, v); } function takeDeliveredTokenId(address account, bytes32 taskId) external returns (uint256 id) { if (!registry.isWhitelistedAdapter(msg.sender)) revert NotAnAdapter(msg.sender); bytes32 slot = _tokenIdSlot(account, taskId); assembly ("memory-safe") { id := tload(slot) tstore(slot, 0) } emit TokenIdTaken(account, taskId, msg.sender, id); } /// TWO CHANNELS, AND THEY MUST NOT ALIAS. `internal/adapter` types an /// amount and a position id apart precisely so a lock NFT's id cannot be /// spent as money; a single channel would undo that at the moment it /// matters. The tag inside the hash keeps them apart however anything is /// laid out. function _deliveredSlot(address account, bytes32 taskId, address asset) private view returns (bytes32) { return keccak256(abi.encode("amount", address(this), account, taskId, asset)); } function _tokenIdSlot(address account, bytes32 taskId) private view returns (bytes32) { return keccak256(abi.encode("tokenId", address(this), account, taskId)); } /// Hand what this step delivered to later steps in the same transaction. /// /// Called with a MEASURED delta, never with the amount that was asked for. /// A venue reports what it thinks it sent; the balance is what arrived, and /// the difference is fees, rounding and short fills. /// /// Silent when nothing was delivered: publishing a zero would let a later /// step distinguish "ran and delivered nothing" from "did not run", and both /// are refused identically anyway. function _publishDelivered(address account, bytes32 taskId, address asset, uint256 delivered) internal { if (delivered == 0) return; // NOTHING TO PUBLISH TO IS NOT AN ERROR HERE, and that is a rollout // decision rather than laxness. // // These adapters are deployed per chain and the bus is a separate // deployment. A chain that has the new adapters and not yet the bus // would otherwise have every borrow, withdraw and pull revert — turning // an ordering question into an outage. With this, such a chain behaves // exactly as it did before: nothing publishes, and any plan that tried // to CONSUME fails at the consumer with NothingDelivered, which names // the producer it was waiting for. // // The asymmetry is deliberate. Publishing into the void loses nothing; // consuming from the void would spend a number nobody produced, and // _amountToSpend refuses zero for exactly that reason. // // THERE IS NO VOID TO TOLERATE ANY MORE. This used to skip when the bus // contract was absent — a chain could carry the adapters and not the // bus, and `code.length == 0` made every publish a silent no-op. The // value lives in this adapter's own storage now, so it is there // whenever the adapter is. bytes32 slot = _deliveredSlot(account, taskId, asset); assembly ("memory-safe") { tstore(slot, delivered) } emit AmountDelivered(account, taskId, asset, delivered); } /// Hand a TOKEN ID this step created to later steps in this transaction. /// /// M-15 OF THE 2026-09-10 AUDIT. `RuntimeBus` has two channels and says /// why in as many words — "A value with no kind is how a lock NFT id gets /// spent as money", and "mint a position, then supply 'the amount', and the /// account supplies the token id as a quantity". Nine adapter sites then /// published their token ids through `_publishDelivered`, which is the /// AMOUNT channel keyed by asset, with the POSITION MANAGER's address as /// the asset. `publishTokenId` and `consumeTokenId` had no callers at all. /// /// It failed closed only by configuration: the per-asset cap table of the /// time had no row for an NPM address, so a step that consumed the id and /// tried to spend it was refused. That was a coincidence rather than the /// type separation the bus documents — it is not even that now, the table /// having been removed — and `approve(address,uint256)` is /// `0x095ea7b3` for ERC-20 and ERC-721 alike, so an approval built from the /// wrong channel is not obviously wrong from either side. /// /// So an id goes on the id channel. A consumer asking for an AMOUNT keyed /// by the position manager now finds nothing and refuses with /// `NothingDelivered`, which is exactly what the bus's own comment /// promises: "A step that wants a quantity and finds only an id gets zero /// and refuses." /// /// NOTHING CONSUMES IDS YET, and that is unchanged by this. A uni step /// whose Amount is symbolic signs `tokenId = 0` and no adapter resolves it /// from the bus, so a runtime token id does not work today through either /// channel. What this fixes is that the amount channel is no longer where /// ids live, so wiring the consumer is a matter of reading the right one. function _publishTokenId(address account, bytes32 taskId, uint256 tokenId) internal { if (tokenId == 0) return; bytes32 slot = _tokenIdSlot(account, taskId); assembly ("memory-safe") { tstore(slot, tokenId) } emit TokenIdDelivered(account, taskId, tokenId); } /// Say that this step delivered NOTHING of `asset` after all. /// /// The case it exists for. A step can publish and then spend: `_withdraw` /// burns a position, publishes both of its tokens, and then sells one of /// them. The sold token's slot is left holding the burn's figure, which is /// no longer this step's output — it is money that has already gone to the /// router. A later step naming that producer and that asset would spend the /// number a second time, out of whatever idle balance the account happened /// to hold, or revert on an account that held none. /// /// ZERO IS THE HONEST ANSWER, and the bus already treats it as one: /// `consumeAmount` returns zero for an empty slot and `_amountToSpend` /// refuses zero with `NothingDelivered`, naming the producer it was waiting /// for. So a retracted slot fails a consumer exactly as an absent one does. /// /// `_publishDelivered` cannot do this — it is deliberately silent on zero, /// so that publishing into a chain with no bus deployed costs nothing — and /// silence is the bug here rather than the feature. function _retractAmount(address account, bytes32 taskId, address asset) internal { bytes32 slot = _deliveredSlot(account, taskId, asset); assembly ("memory-safe") { tstore(slot, 0) } } /// THE ASSET AN INTENT NAMES AS A DESTINATION MUST BE ONE THE GUARDIAN /// CURATED. /// /// Adapters take an asset ADDRESS from the intent wherever the asset is /// what value ends up IN rather than what leaves: a swap's `assetOut`, a /// CoW order's `buyToken`, a property sale's settlement currency, an LP /// position's pair, a Morpho market's pair. Those are the fields an agent /// can point at a token it wrote. /// /// 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 and B takes /// X out of the pool — H-3 of the 2026-09-10 audit. On the property sell /// path it is worse (C-2(b), CRITICAL): `balanceOf` on a token B wrote /// answers whatever B likes, so `ReceivedLessThanBound` passes with /// certainty and the sale's whole protection is disarmed without the /// venue cooperating at all. /// /// ASKED OF THE REGISTRY, NOT OF STORAGE HERE, and that is the 2026-09-20 /// change. This used to be `_requireCapRow`: a row in the per-asset spend /// cap table, written into every adapter at install. Caps were removed, and /// keeping the list per-account-per-adapter was the worse half of them /// anyway — an asset added to the chain afterwards had no row on accounts /// already installed, so a good intent died and the only remedy was a /// reinstall. One registry answers for every account and every adapter, so /// curating an asset makes it usable everywhere at once. /// /// WHAT THIS DOES NOT DO, unchanged from the note it replaces. It does not /// bound slippage between two assets that ARE curated — that needs a price /// this contract has no source for, and KEY_MODEL.md records that the floor /// is enforced only by the Go signer, which is the same key the threat model /// assumes is compromised. A per-account slippage ceiling against an oracle /// is the remaining half, and it needs an oracle per pair. /// /// Costs nothing on the honest path: `cmd/curate` curates every asset the /// chain knows, plus every curated property's token. function _requireCuratedAsset(address asset) internal view { if (!registry.isCuratedAsset(asset)) revert AssetNotCurated(asset); } /// CHARGE AN OUTFLOW AGAINST THE OWNER'S EPOCH BUDGET. /// /// The one on-chain bound on HOW MUCH a stolen keeper key can move. Every /// other bound in this system is on WHERE: `Agent._callOne` checks the /// adapter is whitelisted and installed and that the selector is not one of /// three, then passes `data` to it verbatim; `_requireCuratedAsset` checks /// the token is one the guardian listed; `allows` checks the operation was /// armed. None of them reads an amount, and `_consume` verifies no /// signature when `msg.sender == agent`, so the keeper composes the /// calldata itself. Size was unbounded between 2026-09-20 and this commit. /// /// A FORWARDER, NOT A TABLE. The deleted cap kept `_cap` in inherited /// BaseAdapter storage, so all 22 adapters held a copy of one budget and an /// install wrote 28 assets x 3 cold stores x 22 adapters. The table lives in /// `AgentRegistry` now: one row per (account, asset) for the whole system, /// created on first spend rather than at install, and an asset with no row /// is UNCAPPED rather than `NoCapForAsset`. /// /// WHERE TO CALL IT. Before the venue moves anything, on the amount the /// account gives up -- not what it receives, and not a flash loan's /// principal, which is borrowed and repaid inside one transaction and was /// never the owner's to lose. /// /// Returns the charge's TAG — where it landed: nothing, an epoch, or a /// plan. Only a refund in a LATER transaction needs it (see the tagged /// `_refund`); every other call site ignores it. function _charge(address account, address asset, uint256 amount) internal returns (uint256 tag) { return registry.charge(account, asset, amount); } /// Give back what the venue did not take, bounded by what this call charged. /// /// Call after the pull with `signed - actual`, IN THE SAME TRANSACTION AS /// THE `_charge`. See `AgentRegistry.refundCharge` for why the second /// argument exists (M-6). The registry refunds only what it saw this /// adapter charge for (account, asset) in this transaction, and only to /// where that charge landed, so nothing has to be threaded from the /// `_charge` — several of these run inside a flash callback. function _refund(address account, address asset, uint256 unused, uint256 charged) internal { registry.refundCharge(account, asset, unused, charged); } /// The same, for a refund in a LATER transaction than its charge: `tag` is /// what that `_charge` returned, stored alongside whatever the refund is /// for. The registry refunds into that epoch or that plan only, and never /// into whatever happens to be current. `CowPresignAdapter` is the one user. function _refund(address account, address asset, uint256 unused, uint256 charged, uint256 tag) internal { registry.refundCharge(account, asset, unused, charged, tag); } /// Make the account perform ONE call that this contract composed. /// /// Every argument here comes from the adapter, never from an intent: an /// intent that could name `target` would be an intent that could name any /// contract, which is the arbitrary execution this design removes. function _exec(address account, address target, uint256 value, bytes memory callData) internal returns (bytes memory) { // TWO WAYS IN, AND THE ACCOUNT DECIDES WHICH BY WHAT IT INSTALLED. // // v1: this adapter is itself an executor on the account, so it calls // the account directly. That costs ~74,000 gas of `installModule` per // adapter per account, and a Base account pays it 22 times. // // v2: the account installed ONE executor, in genesis, and this adapter // is not a module at all. `PolicyExecutor` makes the call on its // behalf, and only while a step this adapter PROVED is open — see its // header for the three checks. The account never activated anything. // // `_config[account].installed` is the adapter's own record and it was // read a moment ago in `_consume`, so choosing costs a warm SLOAD. if (_config[account].installed) { bytes[] memory ret = IERC7579Execution(account).executeFromExecutor( EXEC_SINGLE, abi.encodePacked(target, value, callData) ); return ret.length == 0 ? bytes("") : ret[0]; } return IPolicyDoor(registry.policyExecutor()).executeFor(account, target, value, callData); } /// SPEND ONE STEP OF AN ADMITTED PLAN, AS THIS ADAPTER. /// /// The gate binds a step to the adapter the PLAN named for it, and it does /// that by comparing `msg.sender` against the public adapter list the proof /// committed to. So the call has to come from here — a keeper calling the /// gate directly presents itself and is refused. /// /// SEPARATE FROM THE INTENT, DELIBERATELY. Folding the nullifier, index and /// list into the intent would change the signed digest and every signer /// with it; this is two calls in one transaction instead, which the keeper /// already batches. The window is transient, so it cannot outlive the /// transaction that opened it however the second call goes. /// /// THE AGENT ONLY, because anyone else calling this burns a step of a plan /// they do not own. It is not theft — the window it opens admits only this /// adapter, and an intent still has to pass every other check — but a /// stranger who can spend an account's steps can stop its plans, and that /// is reason enough. function openStep( address account, uint256 nullifier, uint256 index, uint256 root, bytes32 operation, uint256[8] calldata who, uint256[8] calldata what ) external { address agent = registry.agentFor(account); if (agent == address(0)) revert NoAgent(account); if (msg.sender != agent) revert NotTheAgent(msg.sender, agent); address gate = registry.policyGate(); if (gate == address(0)) revert NoPolicyGate(); IPolicyGate(gate).consume(account, nullifier, index, root, operation, who, what); // REMEMBER WHICH OPERATION WAS OPENED, because the gate cannot watch // what happens next. // // The gate checked `operation` against the plan and then returned; the // intent that follows is a separate call and carries its own operation // field, chosen by B. Without this, opening a step for `uni.mint` and // then running `uni.collect` would pass every check — the gate saw // mint, the adapter saw collect, and nothing compared them. // // Transient, so it dies with the transaction that opened it, and // overwritten by the next `openStep` — which is right, because a // transaction that runs two steps has two operations in turn. bytes32 k = _openedKey(account); assembly ("memory-safe") { tstore(k, operation) } } function _openedKey(address account) private pure returns (bytes32) { return keccak256(abi.encode("baseadapter.opened", account)); } /// Refuse an operation this account did not authorise. /// /// An arming-class operation is answered ONLY by the account's own switch — /// never by the registry index — so H-2 holds whatever a payload contains. /// `onInstall` refuses to accept one in a payload as well; either check /// alone would do, and the class is the one place that earns both. function _requireOperation(address account, bytes32 operation) internal view { // ARMING FIRST, AND IT APPLIES TO BOTH PATHS. An arming-class operation // is answered ONLY by the account's own switch, and a proof does not // substitute for the owner throwing it — H-2 holds under v2 exactly as // it does under v1. if (!allows(account, operation)) revert OperationNotAllowed(account, operation); // UNDER THE PROOF, THE STEP NAMED AN OPERATION AND THIS MUST BE IT. // // v1 has the install bounding what this adapter may do for the account, // and `allows` above is the whole of it. v2 has no install, so the // bound is the plan — and the plan is specific: R1 proved // `(adapterName, operation)`, and `openStep` put that operation here. if (!_config[account].installed) { bytes32 k = _openedKey(account); bytes32 opened; assembly ("memory-safe") { opened := tload(k) } if (opened != operation) revert OperationNotThisSteps(account, opened, operation); } } /// VENUE NAMES CARRY A ROLE NOW — M-7 of the round-2 audit. /// /// Three venues are curated onto `LoanShiftAdapter` and `LoanCloseAdapter` /// at once — `morpho_blue`, `aggregation_router`, `aave_v3` — and the agent /// chose which NAME landed in `venue` (the flash lender), `router` (the swap /// target) or `positionVenue` (the pool that holds the debt). Nothing /// checked the role, so `positionVenue = "aggregation_router"` reached /// `_approveFrom(account, loanToken, , owed)` and only THEN /// failed on the `repay` call. /// /// Every one of those paths failed closed ONLY BECAUSE THE SELECTOR DOES NOT /// EXIST ON THE WRONG CONTRACT — an implicit guarantee that ends the day a /// venue gains a fallback — AND THE APPROVALS WERE ALREADY GRANTED BEFORE /// THE REVERT. /// /// DERIVED FROM THE NAME RATHER THAN STORED. A role byte in the install /// payload would be a seventh field in the six-tuple every account's CREATE2 /// preimage commits to, which moves every account address ever derived. The /// names are protocol-wide constants in `internal/adapters/registry.go`, not /// per-deployment strings, so the name IS the role and this only has to say /// so. /// /// ONE DISTINCTION, NOT A TAXONOMY: is this a swap router. That is the axis /// the finding is about — a router is the one venue class that will take an /// approval and spend it — and a classifier with more classes than the /// vulnerability has is a classifier that goes stale. function _isRouterVenue(bytes32 venue) internal pure returns (bool) { return venue == "aggregation_router" || venue == "cow" || venue == "agg_router"; } /// Resolve a venue that must NOT be a swap router: a flash lender, a pool, /// the contract that actually holds a position. See `_isRouterVenue`. function _venueNotRouter(address account, bytes32 venue) internal view returns (address) { if (_isRouterVenue(venue)) revert WrongVenueRole(venue); return _venueOf(account, venue); } /// Resolve a venue that MUST be a swap router. function _venueRouter(address account, bytes32 venue) internal view returns (address) { if (!_isRouterVenue(venue)) revert WrongVenueRole(venue); return _venueOf(account, venue); } /// Resolve a venue name to the contract it means, or refuse. /// /// TWO QUESTIONS. `adapterUsesVenue` is the scope this adapter was curated /// with — B chooses the name, so without it a compromised B could point /// `SwapAdapter` at `rent_share`. `venueTarget` is what the name means on /// this chain. Neither alone is sufficient, and an unmapped name still /// resolves to zero and is refused. function _venueOf(address account, bytes32 venue) internal view returns (address) { if (!registry.adapterUsesVenue(address(this), venue)) revert UnknownVenue(account, venue); address target = registry.venueTarget(venue); if (target == address(0)) revert UnknownVenue(account, venue); return target; } /// The same two questions, answered with zero instead of a revert. /// /// For the callers that treat an unmapped venue as "not configured" rather /// than as an error — a property allow-list that is absent, an Aave spoke /// not curated yet — and they are the reason this is not just `_venueOf` /// in a try block. function _venueOrZero(bytes32 venue) internal view returns (address) { if (!registry.adapterUsesVenue(address(this), venue)) return address(0); return registry.venueTarget(venue); } /// ABI unchanged — `cmd/api/agent.go` calls `venueOf(address,bytes32)`. /// The account is no longer part of the answer and is kept in the /// signature rather than silently dropped from a selector other code sends. function venueOf(address, bytes32 venue) external view returns (address) { if (!registry.adapterUsesVenue(address(this), venue)) return address(0); return registry.venueTarget(venue); } /// Leave no allowance behind. /// /// PLAN.md's "zero residual allowance" control. An allowance that outlives /// the call it was granted for is a standing permission to move the account's /// tokens — the drain that happens later, quietly, long after whatever /// granted it looked fine. A venue that did not spend what it was given has /// not done what was asked, so the leftover is cleared rather than ignored, /// and a token that will not clear it stops the whole operation. function _requireNoResidual(address account, address asset, address spender) internal { uint256 left = IERC20Allowance(asset).allowance(account, spender); if (left != 0) { _exec(account, asset, 0, abi.encodeWithSelector(0x095ea7b3, spender, uint256(0))); left = IERC20Allowance(asset).allowance(account, spender); if (left != 0) revert ResidualAllowance(asset, spender, left); } } /// The tick spacing the factory pairs with a fee tier. /// /// A PURE MAP rather than a `tickSpacing()` read, because the levered /// adapter has no factory venue to find the pool through, and because the /// pairs are fixed by the factory. There are SEVEN, not four: base enabled 200, /// 300 and 400 as well, and this said otherwise until 2026-09-05. /// internal/univ3.SpacingFor holds the same four off chain. /// /// It is used to give a BETTER ERROR, never to permit anything: a tick this /// accepts is still checked by the pool, so the worst a wrong entry could /// do is let a call through to the empty revert it would have hit anyway. /// An unknown tier returns 0 and the caller must treat that as "cannot say". function _tickSpacingFor(uint24 fee) internal pure returns (int24) { if (fee == 100) return 1; if (fee == 200) return 4; if (fee == 300) return 6; if (fee == 400) return 8; if (fee == 500) return 10; if (fee == 3000) return 60; if (fee == 10000) return 200; return 0; } /// Where a split's two children meet, checked against the pool's grid. /// /// Shared by UniswapAdapter._split and RevertLeverageAdapter._splitMint so /// the plain and levered forms of one operation cannot drift apart. function _checkSplitTick(int24 mid, int24 tickLower, int24 tickUpper, uint24 fee) internal pure { if (mid <= tickLower || mid >= tickUpper) revert SplitTickOutsideRange(mid, tickLower, tickUpper); int24 spacing = _tickSpacingFor(fee); if (spacing != 0 && mid % spacing != 0) revert SplitTickOffGrid(mid, spacing); } }