// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {AgentRegistry} from "./AgentRegistry.sol"; interface IERC7579Execution { function executeFromExecutor(bytes32 execMode, bytes calldata executionCalldata) external payable returns (bytes[] memory); } interface IERC7579Modules { function installModule(uint256 moduleType, address module, bytes calldata initData) external payable; function uninstallModule(uint256 moduleType, address module, bytes calldata deInitData) external payable; function isModuleInstalled(uint256 moduleType, address module, bytes calldata additionalContext) external view returns (bool); } /// @title AdapterInstaller — bring an account up on a new chain with no owner transaction. /// /// The problem this exists for. Kernel gates `installModule` behind /// `onlyEntryPointOrSelfOrRoot`, so installing an adapter has to come from the /// owner — and the whole point of this system is that the owner signs ONCE and /// never appears on the chains their money reaches. Until now the gap was /// bridged by impersonating the owner on a fork, which is not a thing that /// exists on a real chain. /// /// How it closes. `executeFromExecutor` checks only that its caller is a /// registered executor and then performs the call AS the account, so an /// executor that asks the account to call ITSELF arrives at `installModule` with /// `msg.sender == address(this)` and passes. This contract is installed once, at /// genesis, in the same `initConfig` that appoints B (`Agent.sol`) in the /// registry — B is never installed as a Kernel validator — and from then on /// ANYONE can bring the account's adapters up. The relayer /// pays. The owner is not consulted, and does not need to know. /// /// WHAT MAKES IT SAFE, because an executor is not a small thing to install. /// `executeFromExecutor` does not inspect the call: an installed executor can /// make the account do anything, including move every token it holds. Three /// properties bound this one, and all three are load-bearing: /// /// 1. `activate` takes no configuration. Not the module, not the payload. A /// caller who could name the module would be naming a contract /// the account is about to trust with its funds — so the caller names /// nothing, and the answer comes from the registry instead. /// 2. The registry is immutable here and named in the account's genesis, so it /// cannot be swapped for one an attacker curates. /// 3. This contract has no owner, no admin, no upgrade path and no other /// outbound call shape. Its address is CREATE2 over its own bytecode, so the /// address in an account's genesis IS the audit of what it can do. /// /// A compromised agent key gains nothing FROM THIS CONTRACT: calling `activate` /// as a stranger installs only what the guardian already whitelisted. /// /// It does not follow that anything holding `execute` is bounded. It is not: a /// key scoped to `execute` reaches `installModule` by making the account call /// itself, without this contract's help — see test/AgentBlastRadius.t.sol and /// ADAPTERS_AT_GENESIS.md §5a. What bounds the agent is that it holds NO /// validator, so it has no `execute` to scope: B is Agent.sol and reaches the /// account only through installed adapters (test/AdapterOnlyNoExecutePath.t.sol; /// test/GuardBypassEIP3009.t.sol is why no hook could have done it instead). contract AdapterInstaller { /// ERC-7579 module type for an executor. uint256 internal constant MODULE_TYPE_EXECUTOR = 2; /// CALLTYPE_SINGLE / EXECTYPE_DEFAULT, the same mode BaseAdapter uses. bytes32 internal constant EXEC_SINGLE = bytes32(0); /// STORAGE, not immutable: this contract lives behind a proxy, and an /// immutable is baked into the implementation's code — the thing an upgrade /// replaces. Through the proxy it would read as zero, and every activation /// would ask an empty address what to install. /// /// Write-once, so it keeps the property the immutable carried: the registry /// an account's genesis trusts cannot be swapped for one an attacker curates. AgentRegistry public registry; /// The curation version each account has applied, per adapter. /// /// Recorded here rather than on the adapter because it is a fact about this /// installer's bookkeeping, not about the position — and the adapters are /// audited contracts nobody should have to change to add a counter. mapping(address account => mapping(address adapter => uint64)) public appliedCuration; /// Adapters the account has refused. `activate` / `activateOnly` skip these /// so a stranger cannot undo `activateOnly` or reinstall after uninstall. /// Cleared only by the account itself via `allowAdapter`. mapping(address account => mapping(address adapter => bool)) public refused; event Activated(address indexed account, address indexed adapter); event AlreadyPresent(address indexed account, address indexed adapter); /// An adapter `activate` could not install, and the reason the account gave /// for it. Emitted rather than reverted — see `activate`. event ActivationSkipped(address indexed account, address indexed adapter, bytes reason); event Curated(address indexed account, address indexed adapter, uint256 calls); /// THE DERIVED KEYS ARE IN THE EVENT, not just the address. /// /// A refusal writes THREE keys — by address, by codehash, and by identity — /// and each of the last two is computed AT WRITE TIME from state that moves /// afterwards. `adapter.codehash` is read from the account then; `_identity` /// is `registry.adapterName(adapter)` then, and the guardian may rename or /// delist. So an event carrying only the address does not let anyone replay /// history into the set that was actually stored — they would recompute /// today's keys and get a different answer. /// /// That matters because the set has to be reconstructable OFF CHAIN, for a /// ZK policy engine proving NON-membership of a revocation /// (`zk/circuits/policy.circom` R8), which needs the exact set the chain /// holds and not an approximation of it. /// /// THESE ARE NO LONGER THE ONLY WAY TO READ IT, and this said they were. A /// mapping cannot be enumerated — but the revocation tree's leaf positions /// are the CAPABILITY tree's, and that domain comes from /// `registry.capabilitySet()` in one call. So a prover walks the catalogue /// and asks `refusedFor` per adapter rather than replaying history: 22 /// calls on Base, one multicall, no indexer and no block range. /// `test/RevocationFromState.t.sol` builds the set both ways and asserts /// they agree. /// /// The events keep two jobs the views cannot do: noticing a change without /// polling, and covering an adapter the guardian has since DELISTED, which /// has left the catalogue and so left the walk's domain. The second is moot /// for a proof — R10 requires the step's target to be a leaf of /// `adapterRoot`, and a delisted adapter is not one, so a step naming it /// cannot be proved whether or not it also reads as revoked. event AdapterRefused( address indexed account, address indexed adapter, bytes32 codehash, bytes32 identity ); event AdapterAllowed( address indexed account, address indexed adapter, bytes32 codehash, bytes32 identity ); error NothingWhitelisted(); /// The account committed a capability root, so it runs on proofs and a /// permissionless install would silently take it back to v1. Not raised /// when the account itself calls `activateOnly`: that is its owner asking. error AccountIsPolicyGated(address account); error NoInstallDataFor(address adapter); error AdapterRefusedByAccount(address account, address adapter); error AlreadyInitialized(); error CannotCurateInstaller(); /// `activateOnly` naming an adapter this account has uninstalled. See H-4. error AdapterUninstalledByAccount(address account, address adapter); /// The `reason` `activate` emits for an adapter this account uninstalled, /// so an operator can tell it apart from "no install data" and from a /// genuine refusal inside `onInstall`. bytes internal constant UNINSTALLED = "uninstalled"; /// `activateOnly` naming an adapter whose CODE this account refused or /// uninstalled at some other address. See F-16. error AdapterCodeRefusedByAccount(address account, address adapter, bytes32 codehash); error AdapterCodeUninstalledByAccount(address account, address adapter, bytes32 codehash); /// `activateOnly` naming an adapter that carries an operation this account /// revoked. See F-16. The operation is named because the adapter's address /// is not the thing that was revoked and an operator reading a revert /// against a brand new address would otherwise have nothing to go on. error OperationRevokedByAccount(address account, address adapter, bytes32 operation); /// The `reason`s `activate` emits for the same three, distinct from /// `UNINSTALLED` because the adapter named in the event is one this account /// has never held — only its code, or one of its operations, is familiar. bytes internal constant REFUSED_CODE = "refused-code"; bytes internal constant UNINSTALLED_CODE = "uninstalled-code"; bytes internal constant REVOKED = "revoked-operation"; /// `installOne` from anybody but this contract. error NotSelf(); /// An adapter with curation to apply answered `resetSyncedCuration` with /// an empty revert: it has no such function, so a replace-set cannot /// drop what the previous sync wrote. See `_curate`. error ResetSyncedCurationFailed(address adapter); /// A curation call whose selector is not a name-table writer. See /// `CURATION_SELECTORS`. error CurationCallNotAllowed(address adapter, bytes4 selector); /// Name the registry, once. See AgentRegistry.initialize for why this is not /// a constructor. function initialize(AgentRegistry registry_) external { if (address(registry) != address(0)) revert AlreadyInitialized(); if (address(registry_) == address(0)) revert NothingWhitelisted(); registry = registry_; } /// Install every whitelisted adapter this account does not have yet. /// /// PERMISSIONLESS, and idempotent. Anyone may call it, repeatedly, for any /// account — which is the point: the relayer reaching a new chain calls it /// without asking anybody, and calling it twice does nothing the second time. /// /// Idempotence is not a nicety. A group is retried, a chain reorganises, two /// workers race — and `installModule` reverts `AlreadyInstalled`, which would /// fail the whole activation and leave an account half-equipped. Each adapter /// is checked before it is installed, and one already present is skipped /// rather than treated as an error. /// /// AND NEITHER IS PARTIAL SUCCESS (M-10 of the 2026-09-10 audit). /// /// This used to be all-or-nothing: ONE adapter that could not install /// reverted the whole call and left the account with ZERO adapters. The /// triggers are ordinary rather than exotic — /// `CowPresignAdapter.onInstall` performs an on-chain /// `approve(relayer, 0)`, so a paused or blocklisting sell token reverts it; /// a stale `installed` flag after a swallowed `onUninstall` does the same — /// and the account that loses everything is the one whose relayer was /// bringing it up on a new chain. /// /// `activate` means "install everything you can", so a refusal is RECORDED /// and skipped. `activateOnly` keeps reverting, because there the caller /// named the adapters and a silent omission would be an install nobody /// asked about. function activate(address account) external { // A V2 ACCOUNT IS NOT BROUGHT UP BY A STRANGER. // // `activate` is permissionless on purpose — that is what lets a relayer // bring an account up on a new chain with no owner transaction. For a // v1 account it installs what the guardian curated and changes nothing // about how authority works. // // For a v2 account it changed EVERYTHING. `_config[account].installed` // is the single bit `BaseAdapter._exec` branches on to decide whether // to go through `PolicyExecutor`, and the bit `_requireOperation` uses // to decide whether the step's operation binds. Flipping it took an // account whose owner committed a capability root into its own address // and handed the keeper v1 authority over every curated adapter — no // plan, no proof, no step, no window. Any address could do it. // // An owner who wants one adapter to run as v1 asks for THAT ONE, as the // account, through `activateOnly` -- which lets the account itself // through and applies every check below to what it named. "Everything // the guardian whitelisted" is never what a v2 owner means, so this // refuses the account too. What must not happen is a STRANGER choosing // the account's security model. if (_policyGated(account)) revert AccountIsPolicyGated(account); address[] memory adapters = registry.adapters(); if (adapters.length == 0) revert NothingWhitelisted(); for (uint256 i = 0; i < adapters.length; i++) { address adapter = adapters[i]; if (refused[account][adapter]) continue; if (IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "")) { _sawHeld(account, adapter); emit AlreadyPresent(account, adapter); continue; } // AN UNINSTALL IS A REFUSAL — H-4 of the round-2 audit. // // `refused` is set only by the account calling `refuseAdapter`, and // Kernel's uninstall path never touches this contract. So the // owner's unilateral remedy — uninstall the adapter to stop it, the // one `AgentRegistry:188-190` relies on — was undone by ANY // STRANGER in the next block, and `installStale`'s own natspec // spells out what that costs: "a reinstall wipes the account's // authority and writes it again from the registry … there is no // automatic sync here and there should not be one." `activate` WAS // that automatic sync for every adapter not currently installed. // // `appliedInstall` is the record that this account HAS held this // adapter. Held-and-now-absent is an uninstall, whoever did it, and // this contract does not undo one. The owner opts back in with // `allowAdapter`, which clears the record — the same shape as // lifting a `refuseAdapter`, and the same authority. if (_held(account, adapter)) { // F-16. This is the ONLY moment the uninstalled adapter is // still reachable — `delistAdapter` pops it out of // `registry.adapters()` and empties its install data, so after // the redeploy that follows there is nothing left to read the // revoked operations out of. Record them now, while there is. refusedIdentity[account][_identity(adapter)] = true; emit ActivationSkipped(account, adapter, UNINSTALLED); continue; } // F-16: THE SAME CODE AT A DIFFERENT ADDRESS. // // The two checks above are keyed by the adapter's ADDRESS, and an // address proves nothing about what is deployed at it: C-1 found // the protocol's CREATE2 salts are landable by anybody. Asking the // code what it is closes that — the same pin `AccountKycForwarder` // puts on `kernelProxyCodehash`, and with the same caveat: a // codehash says the runtime bytecode matches, never that the thing // behind it is the one the guardian meant. bytes32 codehash = adapter.codehash; if (refusedCode[account][codehash]) { emit ActivationSkipped(account, adapter, REFUSED_CODE); continue; } if (_codeUninstalled(account, adapter, codehash)) { emit ActivationSkipped(account, adapter, UNINSTALLED_CODE); continue; } bytes memory payload = registry.installDataFor(adapter); if (payload.length == 0) { emit ActivationSkipped(account, adapter, ""); continue; } // F-16: THE SAME CAPABILITY, REBUILT. // // A codehash cannot reach the case this finding was written for, // because a fixed adapter has a different codehash BY DEFINITION — // a bug fix, an optimiser setting, a changed constant, and CREATE2 // hands the same capability a new address with a new code. What // survives a redeploy is the operation name: those are // protocol-wide constants that `cmd/curate` carries unchanged from // one version to the next, which is the same property // `BaseAdapter`'s venue roles already rely on. bool revoked = _identityRefused(account, adapter); if (revoked) { emit ActivationSkipped(account, adapter, REVOKED); continue; } _tryInstall(account, adapter, payload); } } /// Install, and let one failure be one failure. /// /// The reason is emitted rather than dropped: an operator needs to know /// WHICH adapter refused and why, and after a permissionless call that /// otherwise succeeded there is nothing else to read it from. function _tryInstall(address account, address adapter, bytes memory payload) internal { try this.installOne(account, adapter, payload) {} catch (bytes memory reason) { emit ActivationSkipped(account, adapter, reason); } } /// `_install` behind an external boundary, so `_tryInstall` can catch it. /// /// SELF-ONLY. An external function that installs an executor on an /// arbitrary account with an arbitrary payload would be exactly the /// authority this contract exists to bound — `activate` gets its payload /// from `registry.installDataFor` and nowhere else, and this must not /// become a second way in. function installOne(address account, address adapter, bytes calldata payload) external { if (msg.sender != address(this)) revert NotSelf(); _install(account, adapter, payload); } /// Install only the adapters a caller asks for. /// /// ON DEMAND, because a plan that swaps and bridges has no use for the rent /// adapter, and installing it anyway costs gas nobody needed to spend and /// leaves an executor on the account that nothing will ever call. An executor /// is authority; the fewer an account carries, the smaller the blast radius /// when one of them turns out to be flawed. /// /// NAMING IS NOT CHOOSING, and that distinction is what keeps this safe. /// The caller selects a SUBSET of what the guardian has already whitelisted /// and waited out the delay for — it cannot introduce anything. An address /// that is not effective has no install data, and this refuses rather than /// installing it with an empty payload. /// /// A REFUSED ADAPTER REVERTS HERE, and the natspec used to say it was /// skipped (M-10). It is not, and reverting is right: the caller NAMED this /// adapter, so silently omitting it would report an activation that did not /// happen. `activate` skips, because there the caller named nothing. /// /// The same asymmetry applies to a failing install: one that cannot install /// fails this call, because the caller asked for it specifically. /// /// ON A v2 ACCOUNT, ONLY THE ACCOUNT. Installing an adapter on a /// policy-gated account takes that adapter out from behind the proof, so it /// is the owner's choice and nobody else's: `msg.sender` must be the /// account, which in practice is its root validator sending /// `execute(installer, activateOnly(account, [adapter]))`. The account then /// gets exactly what a v1 account gets from anyone -- only what the /// guardian whitelisted and waited out the delay for, installed with the /// registry's curated payload, and never what the account refused or let /// go. That is the WHOLE of what this binds: the root validator can still /// send a raw `installModule` for any executor, as on v1. /// /// "THE ACCOUNT" IS WHOEVER CAN MAKE IT CALL OUT, and this cannot tell them /// apart: `msg.sender` is the account either way. On a passkey account /// that includes the WebAuthn root's `exitCaller`, whose walk refuses only /// calls that target the account itself -- so it may send this, and /// `allowAdapter` before it, and install any adapter the guardian /// whitelisted, lifting the owner's refusal of it on the way. Accepted: it /// is documented as a second full-authority key, it could already move /// every asset out and clear the ops root, and it still gets nothing the /// whitelist does not hold. Refusing it belongs in that validator's walk, /// which would move every passkey-rooted address. function activateOnly(address account, address[] calldata wanted) external { // Same bound as `activate`, and louder because the caller named it -- // except for the account itself, which is the owner asking. if (_policyGated(account) && msg.sender != account) revert AccountIsPolicyGated(account); for (uint256 i = 0; i < wanted.length; i++) { address adapter = wanted[i]; if (refused[account][adapter]) revert AdapterRefusedByAccount(account, adapter); if (IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "")) { _sawHeld(account, adapter); emit AlreadyPresent(account, adapter); continue; } // The same rule as `activate` — H-4 — and it REVERTS here for the // reason a refusal does: the caller named this adapter, so silently // omitting it would report an activation that did not happen. if (_held(account, adapter)) { revert AdapterUninstalledByAccount(account, adapter); } // F-16, and reverting for the same reason the two checks above do. // No `_revokeOperationsOf` here: this path never skips, so there is // no silent branch that needs a record left behind it. bytes32 codehash = adapter.codehash; if (refusedCode[account][codehash]) { revert AdapterCodeRefusedByAccount(account, adapter, codehash); } if (_codeUninstalled(account, adapter, codehash)) { revert AdapterCodeUninstalledByAccount(account, adapter, codehash); } bytes memory payload = registry.installDataFor(adapter); if (payload.length == 0) revert NoInstallDataFor(adapter); bool revoked = _identityRefused(account, adapter); bytes32 operation = _identity(adapter); if (revoked) revert OperationRevokedByAccount(account, adapter, operation); _install(account, adapter, payload); } } /// Account refuses an adapter. Sticky across permissionless `activate` so /// uninstall + stranger-reactivate cannot put it back. Call as the account. /// /// STICKY ACROSS A REDEPLOY TOO, which it was not — F-16. The natspec above /// promised "sticky" and meant it, but the record was keyed by the address, /// and every adapter address on Base moved on 2026-09-20 for reasons that /// had nothing to do with anybody's refusal. So this now writes the two keys /// that outlive a redeploy alongside the address: the code, and the /// operations the registry says this adapter carries. function refuseAdapter(address adapter) external { bytes32 codehash = adapter.codehash; bytes32 id = _identity(adapter); refused[msg.sender][adapter] = true; refusedCode[msg.sender][codehash] = true; refusedIdentity[msg.sender][id] = true; emit AdapterRefused(msg.sender, adapter, codehash, id); } /// Lift a prior refusal, AND consent to a reinstall after an uninstall. /// /// Both, because both are the same statement: "this account is willing to /// hold this adapter again." The second half is H-4 — `activate` treats a /// held-then-absent adapter as refused, and clearing the record here is how /// the owner opts back in. It is the decision `installStale` says a person /// should make rather than a stranger. /// /// AND IT CLEARS EVERY KEY F-16 ADDED, or the owner could not opt back in /// at all — a restraint the owner cannot lift is not a restraint, it is a /// brick. All four keys are cleared for the adapter NAMED HERE: its /// address, its code, and the operations the registry currently says it /// carries. /// /// CLEARING AN OPERATION IS WIDER THAN CLEARING AN ADDRESS, deliberately. /// The operation keys are what a redeployed adapter is caught by, so they /// are also what the owner has to lift to accept one — and lifting `swap` /// lifts it for every adapter that carries `swap`, not only this one. That /// is the same statement in both directions: "this account is willing to /// hold this capability again." /// /// A DELISTED ADAPTER CLEARS NOTHING BUT ITS OWN TWO KEYS, because the /// registry answers `""` for it and this contract has no other record of /// what it carried. That costs nothing: a delisted adapter cannot be /// installed by any path here, so there is no restraint left to lift. The /// owner lifts the operations by naming the adapter they actually want, /// which is the whitelisted one. function allowAdapter(address adapter) external { bytes32 codehash = adapter.codehash; bytes32 id = _identity(adapter); delete refused[msg.sender][adapter]; delete refusedCode[msg.sender][codehash]; delete refusedIdentity[msg.sender][id]; // THE HOLDER RECORDS ONLY GO WHEN THE ADAPTER IS NOT HELD — #5 of the // 2026-10-08 audit. They are what makes a LATER uninstall stick: an // owner who called this for an adapter they still held (to lift a // refusal of its code or its name, say) wiped `appliedInstall` and the // `heldBy` / `codeHeldBy` that point at it, and the uninstall that // followed read as "never held", so any stranger's `activate` put it // straight back. Consent to hold an adapter AGAIN means something only // once it has gone. if (!_installedOn(msg.sender, adapter)) { delete appliedInstall[msg.sender][adapter]; delete everHeld[msg.sender][adapter]; delete codeHeldBy[msg.sender][codehash]; delete heldBy[msg.sender][id]; } bytes32[] memory operations = _operationsOf(registry.installDataFor(adapter)); for (uint256 i = 0; i < operations.length; i++) { delete revokedOperation[msg.sender][operations[i]]; delete operationHeldBy[msg.sender][operations[i]]; } emit AdapterAllowed(msg.sender, adapter, codehash, id); } /// Re-apply an adapter's curation to an account that already has it. /// /// THE CASE INSTALLATION CANNOT COVER. Curation travels with the install, so /// an account installs the Morpho adapter and gets the markets curated at /// that moment. A market added afterwards reaches nobody: `activate` skips /// an adapter already installed, and skipping it skips its curation too. The /// account would hold a perfectly good adapter that has never heard of the /// new market, and the step would revert UnknownMarket — which reads as a /// broken plan. /// /// The same shape as an adapter added later, and it needs the same answer: /// permissionless, idempotent, and bounded by the registry rather than by /// the caller. Curating is not additive to the adapter's authority — it only /// resolves names the account already trusts that adapter to act on. The /// installer itself is not such an adapter; `_curate` refuses it. /// /// V CURATES ITSELF. These calls are made by the ACCOUNT, and they write to /// the adapter's per-account storage, because the position is the account's. /// This contract only drives it — it has no curation of its own and cannot /// invent one. /// /// Replace-set, not upsert. Morpho / Property / AaveV4 / Rent wipe their /// name table first so a name the new blob omitted is not still /// agent-callable. Adapters without that selector revert empty and we /// continue; a failed wipe does not. /// /// AN INEFFECTIVE ADAPTER IS SKIPPED, NOT WIPED, and that guard is the whole /// difference between a replace-set and a delete. /// /// The registry answers `curationFor` with `[]` and `curationVersion` with /// `0` for anything inside its three-day delay — which is EVERY adapter for /// three days after the guardian rewrites its config, not only a brand new /// one. Without this check the sequence was: `curationStale` says yes /// (`applied != 0`), anyone — or the worker's own stale sweep — calls here, /// `_curate` sends `resetCuration` and then applies nothing. Morpho markets, /// Aave v4 reserve ids, property venues and rent delegatees go EMPTY on /// accounts that are already live, for the length of the delay, and a /// position with debt against it cannot be repaid or adjusted by name in /// that window. /// /// A delisted adapter reads the same way and gets the same answer: it is /// still installed on the account, and the account keeps the names it was /// curated with until its owner uninstalls it. This contract was never /// allowed to reach into an installed adapter and take authority away. function syncCuration(address account, address[] calldata adapters) external { for (uint256 i = 0; i < adapters.length; i++) { address adapter = adapters[i]; // Only for an adapter the account actually has. Curating one it does // not hold would revert inside the adapter — every curate-shaped // function checks installed first — and it would be a strange thing // to ask for. if (!IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "")) { continue; } // M-3 OF THE ROUND-2 AUDIT, BOTH HALVES. // // REFUSED IS HONOURED. `activate` has checked it since M-10 and // this did not, so an account that had explicitly disowned an // adapter could still have its name table rewritten from the // registry by a stranger. // // AND IT ONLY RUNS WHEN THERE IS SOMETHING TO APPLY. Without the // staleness gate this was a permissionless call that wiped and // rewrote a live account's curated names FOR GAS, repeatably — // front-runnable against a `repay` intent, which then reverts // `UnknownMarket`, repeated until the position is liquidated. // Repair needs an OWNER transaction, because the setters are // account-only, and the attacker undoes it in the next block. // Gated, a second call in the same version is a no-op, so the loop // costs the attacker everything and achieves one wipe that the // guardian's own version bump had already licensed. // // ALL THREE KEYS — #11 of the 2026-10-08 audit. This honoured the // ADDRESS key alone, so an owner who refused an adapter by its // CODE or by its NAME (`refuseAdapter` on a redeploy, which writes // both) still had the installed one's name table rewritten by a // stranger. A refusal is recorded three ways and any one binds; // `refusedFor` is the same question asked by `PolicyGate`. if (_refusedAny(account, adapter)) continue; if (!curationStale(account, adapter)) continue; _curate(account, adapter); } } /// Whether the registry has curation this contract may apply right now. /// /// `curationVersion` is zero for an adapter that is pending, delisted or /// never listed, and a zero version is the registry saying "I have nothing /// to hand out" — never "hand out nothing". /// /// AND AN EMPTY BLOB IS ALSO "NOTHING TO HAND OUT" (M-1 of the 2026-09-10 /// audit). `whitelistAdapter` bumps `_curationVersion` on EVERY write, /// including one whose `curation_` array is empty — so an adapter /// whitelisted with no curation calls read as version-non-zero with nothing /// behind it, and this function said yes. `_curate` then sent /// `resetCuration()` and applied nothing: a permissionless call that WIPED /// an account's curated name tables. RentAdapter shipped exactly that way — /// `cmd/curate/curation.go` has cases for Morpho, Property and AaveV4 and /// none for Rent — and it was repeatable for gas, and destroyed owner-set /// entries in the other three too. /// /// Which contradicted this contract's own sentence: "This contract was /// never allowed to reach into an installed adapter and take authority /// away." It is again. function _curatable(address adapter) internal view returns (bool) { return registry.curationVersion(adapter) != 0 && registry.curationCount(adapter) != 0; } /// Whether an account's curation has fallen behind the registry's. /// /// The question a caller needs before spending a transaction: curation is /// idempotent, so re-applying blindly is harmless — but it is still a /// transaction, and the ordinary case is an account that is already current. /// /// FALSE WHILE THE REGISTRY HAS NOTHING TO GIVE. `curationVersion` reads /// zero for a pending or delisted adapter, which differs from any version an /// account has applied and would report every live account as stale for the /// three days after a config change. This view is what the worker's stale /// sweep keys off, so saying "yes" here is what would send it to /// `syncCuration` to wipe a live name table — the two must agree on what is /// curatable, and they read the same predicate. function curationStale(address account, address adapter) public view returns (bool) { if (!IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "")) { return false; } if (!_curatable(adapter)) return false; return appliedCuration[account][adapter] != registry.curationVersion(adapter); } /// Whether the guardian has changed this adapter's install data since this /// account applied it. /// /// THE INSTALL DATA IS THE ACCOUNT'S AUTHORITY: the venues it may reach and /// the OPERATIONS it may perform. `onInstall` is the only place any of it is written, and it /// refuses a second call — so an operation added to an adapter after an /// account installed it is unreachable, and the step reverts /// `OperationNotAllowed(account, operation)`, which reads as a broken plan. /// (A per-asset cap table had the same shape and was worse — a reinstall /// also reset what had been spent. It was removed on 2026-09-20.) /// /// A QUESTION, NOT AN ACTION, and deliberately so. Applying it is a /// REINSTALL, and a reinstall wipes the account's authority and writes it /// again from the registry. That is a change to what an executor may do /// with somebody's money, and nobody should discover it happened. So this /// contract reports /// and the person decides; there is no automatic sync here and there should /// not be one. /// /// ZERO IS UNKNOWN AND ANSWERS FALSE. Accounts installed before /// `appliedInstall` existed recorded no hash, and calling those stale would /// mark every account on the chain as needing an update the moment this /// shipped. They are left alone; a reinstall for any other reason records a /// hash and brings them into the scheme. function installStale(address account, address adapter) public view returns (bool) { if (!IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "")) { return false; } bytes32 applied = appliedInstall[account][adapter]; if (applied == bytes32(0)) { return false; } // M-18 OF THE ROUND-2 AUDIT: ZERO MEANS "I HAVE NOTHING TO HAND OUT", // NEVER "HAND OUT NOTHING" — the same confusion M-1 fixed in // `_curatable`, left in the sibling read. // // `installDataFor` answers `""` for anything not currently effective, // and `whitelistAdapter` RE-ARMS the three-day clock on every write // including a byte-identical rewrite. So this compared a real hash // against `keccak256("")` and answered true — marking EVERY live // account on the chain stale for three days after any routine curate // run, and permanently after a delist. The remedy a stale install // recommends is a REINSTALL, which restates the account's whole // authority: this contract's own natspec is why that must not be // triggered by a bookkeeping artefact. bytes memory current = registry.installDataFor(adapter); if (current.length == 0) return false; return applied != keccak256(current); } /// Applies the registry's curation and records the version applied. /// /// THE INSTALLER IS NEVER A CURATION TARGET. It is an executor on every /// account, so the installed-check in `syncCuration` would pass for it, and /// the calls below would then reach this contract as the account — which is /// exactly the caller `allowAdapter` accepts. Curation is supposed to write /// an adapter's name tables, not this contract's bookkeeping. function _curate(address account, address adapter) internal { if (adapter == address(this)) revert CannotCurateInstaller(); // Belt and braces on the wipe. `syncCuration` already refuses an // ineffective adapter and `_install` only ever reaches here with a // payload the registry just handed out — but this is the function that // sends `resetCuration`, so the check that it has something to write // back lives next to it. A future caller that forgets the gate above // gets a no-op rather than an empty name table. if (!_curatable(adapter)) return; // Replace-set: the adapter drops the names a PREVIOUS SYNC wrote, so // one this blob omits is not still agent-callable. // // AN EMPTY REVERT IS A FAILURE TOO — #18 of the 2026-10-08 audit. It // used to be read as "this adapter has no table to reset" and the // sync carried on. But an empty revert is exactly what a MISSING // `resetSyncedCuration` returns, and an adapter that has curation // (`_curatable` above: there are calls to apply) and no reset is one // whose previous sync's names all stay live: a delegatee the guardian // removed is still one the keeper can name. Nothing reaches here // without curation to apply, so an adapter with none is never asked; // one with curation must be able to reset it, or it is not curated. // // `resetSyncedCuration`, NOT `resetCuration` — M-3 of the round-2 // audit. The latter clears the whole table including every name the // owner curated through their own root validator, which made this // permissionless call a destructive one: the repair needs an owner // transaction, because the setters are account-only, and the attacker // undoes it in the next block. See BaseAdapter._curatedBySync. bytes memory reset = abi.encodeWithSignature("resetSyncedCuration()"); try IERC7579Execution(account) .executeFromExecutor(EXEC_SINGLE, abi.encodePacked(adapter, uint256(0), reset)) {} catch (bytes memory reason) { if (reason.length == 0) revert ResetSyncedCurationFailed(adapter); assembly { revert(add(reason, 32), mload(reason)) } } bytes[] memory calls = registry.curationFor(adapter); for (uint256 i = 0; i < calls.length; i++) { // ONLY A NAME-TABLE WRITER — #24 of the 2026-10-08 audit. These // calls run AS THE ACCOUNT, and every account-gated setter on an // adapter trusts `msg.sender == account`: forwarding the guardian's // bytes unread let a curation blob call `resetAuthority`, or any // other account-only function an adapter has or gains, on every // account that syncs. Curation exists to fill name tables, so the // selector must be one that does. _requireCurationCall(adapter, calls[i]); IERC7579Execution(account) .executeFromExecutor(EXEC_SINGLE, abi.encodePacked(adapter, uint256(0), calls[i])); } appliedCuration[account][adapter] = registry.curationVersion(adapter); emit Curated(account, adapter, calls.length); } /// WHAT A CURATION BLOB MAY CALL, AS THE ACCOUNT — #24. /// /// The account-gated setters that fill a per-account NAME TABLE, and /// nothing else. In gen 7 that is one: `RentAdapter.allowDelegatee`. The /// Morpho markets, Aave v4 reserves and properties that were the other /// three moved to `AdapterCuration`, one chain-global table the guardian /// writes directly, and `cmd/curate` sends no per-adapter curation at all /// (cmd/curate/registrycuration.go). A future name-table writer is added /// HERE, deliberately and in review, which is the point: the guardian's /// blob can no longer reach a setter nobody listed. /// /// `resetSyncedCuration` is not in it: this contract sends that itself, /// first, and a blob that sent it again could only wipe what it had just /// written. bytes4 public constant CURATION_SELECTOR_ALLOW_DELEGATEE = bytes4(keccak256("allowDelegatee(bytes32,address)")); function _requireCurationCall(address adapter, bytes memory data) internal pure { bytes4 selector; if (data.length >= 4) { assembly ("memory-safe") { selector := mload(add(data, 32)) } } if (data.length < 4 || selector != CURATION_SELECTOR_ALLOW_DELEGATEE) { revert CurationCallNotAllowed(adapter, selector); } } // `setLtvCeilingEverywhere` WAS HERE, and it is gone with the reason it // existed. The owner's LTV ceiling was a row in each adapter's storage, so // stating it meant one call per adapter and this looped them — skipping // any adapter still inside its re-whitelist delay (finding 10 of the // 2026-09-26 audit) and every adapter the account gained later (finding 6). // It is one row on `AgentRegistry.setLtvCeiling` now, which every adapter // reads, so there is nothing to fan out and nothing to skip. /// Whether this account still has adapters to install. /// /// A view so a caller can skip the transaction entirely rather than sending /// one that does nothing. The worker reaches a chain on every group; paying /// gas each time to discover there was nothing to do is a cost with no /// corresponding fact. function pending(address account) external view returns (uint256 count) { address[] memory adapters = registry.adapters(); for (uint256 i = 0; i < adapters.length; i++) { address adapter = adapters[i]; if (refused[account][adapter]) continue; // Match activate: a held-then-uninstalled adapter is not pending. M-2. if (_held(account, adapter)) continue; // And match it on F-16's three keys too, for exactly M-2's reason: // a worker that loops on `pending > 0` loops for ever on an adapter // `activate` will always skip. bytes32 codehash = adapter.codehash; if (refusedCode[account][codehash]) continue; if (_codeUninstalled(account, adapter, codehash)) continue; if (IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, adapter, "")) { continue; } bool revoked = _identityRefused(account, adapter); if (revoked) continue; count++; } } /// The account calls ITSELF. That is the whole mechanism: inside /// installModule, msg.sender is the account, which is what /// onlyEntryPointOrSelfOrRoot accepts. /// /// One place, so activate and activateOnly cannot drift into two different /// call shapes — and the shape is the security-relevant part. /// The install payload each account actually applied, by hash. /// /// WHY A HASH AND NOT THE CURATION COUNTER. `curationVersion` bumps on ANY /// `whitelistAdapter`, because that call replaces the whole entry — so it /// cannot tell "the markets moved on" from "the operations changed", and /// reinstalling on every bump would restate every account's authority for a /// change that did not need it. The payload's hash answers the exact /// question and nothing else. /// /// ZERO MEANS UNKNOWN, NOT STALE. Accounts installed before this existed /// have no recorded hash, and treating that as stale would reinstall every /// account on this chain the first time anything synced. They keep what they /// have until something else reinstalls them; see installStale. mapping(address account => mapping(address adapter => bytes32)) public appliedInstall; function _install(address account, address adapter, bytes memory payload) internal { // No hook and no hookData for the adapter: 20 zero bytes, then the two // empty tails. bytes memory initData = abi.encodePacked(bytes20(0), abi.encode(payload, bytes(""))); IERC7579Execution(account) .executeFromExecutor( EXEC_SINGLE, abi.encodePacked( account, uint256(0), abi.encodeCall( IERC7579Modules.installModule, (MODULE_TYPE_EXECUTOR, adapter, initData) ) ) ); appliedInstall[account][adapter] = keccak256(payload); everHeld[account][adapter] = true; // THE CODEHASH RECORD STAYS, and the argument for dropping it was // wrong. A name does catch a redeploy -- but only when the guardian // names the replacement the same, and this key exists to protect the // owner from what the GUARDIAN does. Making it conditional on the // guardian's diligence removes it exactly where it is needed: identical // code whitelisted at another address is caught here with nobody having // to have labelled anything. One slot per adapter, ~449k across Base's // twenty-two, for a protection that does not depend on anyone being // careful. codeHeldBy[account][adapter.codehash] = adapter; // ONE RECORD PER ADAPTER, KEYED BY WHAT SURVIVES A REDEPLOY. // // F-16 wrote one slot per OPERATION here -- 20,411 gas x 58 on Base, // 1,183,838 per account -- because an adapter's address and codehash // both move when its code changes, leaving the operations it carried as // the only stable handle. `AgentRegistry.adapterName` is a better one: // it is what the owner meant. "I removed revert_fi" is a sentence about // an adapter, not about the six operations it happens to carry. // // 22 slots instead of 102 on a Base bring-up, and the semantics get // sharper rather than looser: two adapters that merely share an // operation name are no longer treated as the same thing, and a // genuine redeploy is caught whatever its operations became. // // UNNAMED FALLS BACK TO THE ADDRESS, so an adapter nobody has named // behaves exactly as it did before this -- address-keyed, redeploy // exposed, and no worse. heldBy[account][_identity(adapter)] = adapter; // CURATION, immediately, in the same transaction. // // An installed adapter is not a usable one. A Morpho supply against an // uncurated market reverts UnknownMarket; an Aave v4 step with no mapped // reserves cannot name one. Those calls are only accepted from the // account, which is the same problem installModule has, and leaving them // to the owner would put A back on every chain — the one thing this // exists to prevent. // // Same transaction rather than a second call so an account is never // observable in the half-state where it holds an adapter it cannot use. // What is sent comes from the registry, so the caller still names // nothing. _curate(account, adapter); emit Activated(account, adapter); } // --- IExecutor --------------------------------------------------------- // // Installed at genesis and never configured: there is nothing per-account to // remember, because everything this contract knows comes from the registry. function onInstall(bytes calldata) external payable {} function onUninstall(bytes calldata) external payable {} function isModuleType(uint256 moduleType) external pure returns (bool) { return moduleType == MODULE_TYPE_EXECUTOR; } /// Always initialised, because installation writes no state here. /// /// Returning false would make Kernel's uninstall path believe there is /// something to tear down that does not exist. function isInitialized(address) external pure returns (bool) { return true; } // --- APPEND ONLY BELOW --------------------------------------------------- // // This contract is behind an ERC-1967 proxy that `UpgradeTimelock` replaces // the implementation of, and its ADDRESS is inside every account's CREATE2 // preimage — so the code changes and the storage stays, for ever. A variable // INSERTED rather than appended moves every mapping after it: `refused` is // slot 2 and `appliedInstall` is slot 3, and an account's uninstall record // would start being read out of a mapping that has never heard of it — // which reads, silently, as "this account never held that adapter" and is // precisely the bypass F-16 is about. AdapterInstallerLayout.t.sol fails if // new state does not go here. /// F-16: `refused`, keyed by the adapter's CODE rather than its address. /// /// WHAT A CODEHASH PROVES, and it is worth being exact because /// `AccountKycForwarder`'s natspec is: that the runtime bytecode at this /// address is byte-identical to something this account has already refused. /// It does NOT prove the constructor arguments matched, and it does not /// prove the storage behind it is the same. It answers the one question /// asked here — "have I seen this before" — where the address cannot, /// because an address is landable by anybody (C-1) and a codehash is not. mapping(address account => mapping(bytes32 codehash => bool)) public refusedCode; /// F-16: `appliedInstall`, keyed by the adapter's CODE. /// /// THE ADAPTER, NOT A HASH OF THE PAYLOAD, because the question this has to /// answer is the one the address-keyed record answers with the loop's own /// `isModuleInstalled`: held, and now absent. The candidate is at a /// DIFFERENT address by construction here, so its own installed-ness says /// nothing, and the holder has to be asked directly. /// /// It also keeps the restraint off an account that legitimately holds two /// adapters compiled to the same runtime code: the recorded holder is still /// installed, so nothing is blocked. /// /// ZERO MEANS UNKNOWN, NOT STALE — the same rule `appliedInstall` is read /// under, and the reason this is safe to ship onto a live proxy. Every /// account that exists today has zero here, which blocks nothing that was /// not already blocked: the address-keyed record is still checked first and /// still carries H-4 on its own. mapping(address account => mapping(bytes32 codehash => address adapter)) public codeHeldBy; /// F-16: the adapter that last granted this account an operation. /// /// The index that makes the restraint survive a redeploy AND a delist. By /// the time a stranger can activate the rebuilt adapter, `cmd/curate` has /// delisted the old one — `AgentRegistry._delist` pops it out of /// `_adapters` and empties its install data — so there is nothing left to /// ask what the owner actually removed. This is recorded at install time, /// when the answer is still in hand. /// /// ONE HOLDER, AND THE LAST WRITER WINS, WHICH IS A HOLE — stated here /// because it is not visible from the call sites. Two adapters can carry /// the same operation name: on Base `bridge` is both `across` and `cctp`, /// and `split` is both `uniswap` and `revert_range`. This slot remembers /// one of them, so where the account holds both and the one recorded here /// is the one still installed, uninstalling the OTHER and redeploying it is /// not caught by this key. `revokedOperation` closes that whenever an /// `activate` runs between the uninstall and the delist, which is the /// ordinary case — the relayer calls it on every group that reaches a /// chain. Closing it outright needs a SET per operation, and a set is not /// enumerable in storage without paying for one slot per carrier. mapping(address account => mapping(bytes32 operation => address adapter)) public operationHeldBy; /// F-16: operations this account has revoked, and will not be handed back. /// /// Written when `activate` SEES the uninstall — the last moment the /// uninstalled adapter is still listed and can still say what it carried — /// and by `refuseAdapter`, where the owner says it outright. Sticky /// afterwards, so it does not matter that the evidence is gone by the time /// the replacement arrives. /// /// DEAD. NOTHING SETS THIS, AND NOTHING HAS SINCE THE NAME KEY REPLACED IT. /// /// F-16 keyed revocation per OPERATION, because an address is CREATE2 over /// initcode and a codehash is the code, so both move when a line changes /// and the operations were the only stable handle. That cost a slot per /// operation per account — 20,411 gas x 58 on Base — and /// `AgentRegistry.adapterName` turned out to be a better handle and a truer /// one: "I removed revert_fi" is a sentence about an adapter, not about the /// six operations it happens to carry this quarter. /// /// So the writer was deleted and `refusedIdentity` took over. What was left /// behind is this mapping, a `delete` of it in `allowAdapter` that clears /// what was never written, and the deleted function's natspec, which sat /// above `_identityRefused` describing something it does not do. /// /// IT STAYS BECAUSE THE SLOT STAYS. This contract is an ERC-1967 proxy /// behind a 7-day `UpgradeTimelock` and there is state declared after it; /// removing the declaration would shift every later slot under live /// accounts. It reads false for every key, for ever — which is correct, and /// is why anything reconstructing an account's revocations must read /// `refusedFor` per adapter and not this. mapping(address account => mapping(bytes32 operation => bool)) public revokedOperation; /// account -> adapter identity -> the adapter that held it. /// /// The identity is `AgentRegistry.adapterName` when the adapter has one and /// its address when it does not. Written once per adapter at install, and /// it is what replaces the per-operation index -- see `_install`. mapping(address account => mapping(bytes32 identity => address holder)) public heldBy; /// What this account refused, by the same identity. mapping(address account => mapping(bytes32 identity => bool)) public refusedIdentity; /// What survives a redeploy: the name if it has one, else the address. function _identity(address adapter) internal view returns (bytes32) { bytes32 named = registry.adapterName(adapter); return named == bytes32(0) ? bytes32(uint256(uint160(adapter))) : named; } /// Whether this account held `codehash` at some OTHER address and let it go. /// /// The address-keyed half of H-4 reads the loop's own `isModuleInstalled` /// for the adapter in hand; this one cannot, because the whole point is that /// the code is now somewhere else. So it asks the recorded holder. function _codeUninstalled(address account, address adapter, bytes32 codehash) internal view returns (bool) { address holder = codeHeldBy[account][codehash]; if (holder == address(0) || holder == adapter) return false; return !IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, holder, ""); } /// Has this account refused the adapter BY NAME, or let go of whatever /// holds that name? /// /// (The four paragraphs that used to open this block described /// `_revokeOperations`, which was deleted when the name key replaced the /// per-operation one. See `revokedOperation` above.) /// /// ONE KEY, AND IT IS THE ONE THE OWNER MEANT. F-16 asked this per /// OPERATION, because an adapter's address is CREATE2 over its initcode and /// its codehash IS its code, so both move the moment a line changes -- /// leaving the operations it carried as the only stable handle. That cost a /// slot per operation per account: 20,411 gas x 58 on Base. /// /// `AgentRegistry.adapterName` is a better handle and a truer one. "I /// removed revert_fi" is a sentence about an adapter, not about the six /// operations it happens to carry this quarter -- and two adapters that /// merely share an operation name are no longer treated as the same thing, /// which was F-16's multi-carrier residual. /// /// UNNAMED FALLS BACK TO THE ADDRESS, so an adapter nobody has named /// behaves exactly as it did before names existed. function _identityRefused(address account, address adapter) internal view returns (bool) { bytes32 id = _identity(adapter); if (refusedIdentity[account][id]) return true; address holder = heldBy[account][id]; if (holder == address(0) || holder == adapter) return false; return !IERC7579Modules(account).isModuleInstalled(MODULE_TYPE_EXECUTOR, holder, ""); } function _operationsOf(bytes memory payload) internal view returns (bytes32[] memory) { if (payload.length == 0) return new bytes32[](0); try this.operationsIn(payload) returns (bytes32[] memory operations) { return operations; } catch { return new bytes32[](0); } } /// `abi.decode` behind an external boundary, so `_operationsOf` can catch it. /// /// The shape is `BaseAdapter.onInstall`'s, which is the only consumer that /// matters: whatever this reads is what the account is about to be granted. function operationsIn(bytes calldata payload) external pure returns (bytes32[] memory) { (,, bytes32[] memory operations) = abi.decode(payload, (bytes32[], address[], bytes32[])); return operations; } // ── WHICH REVOCATIONS ARE THE ACCOUNT'S ──────────────────────────────── // // APPEND ONLY, and it is the last slot. The seventh public input. // // `refusedIdentity` is a mapping and cannot be enumerated, so a prover // rebuilds the set from `AdapterRefused` events — which is why those events // now carry the DERIVED keys, two of which are computed at write time from // state that moves afterwards. // // What the chain could not say is WHICH reconstruction is the account's. // The account commits to one here and `PolicyGate` compares a proof's // `revokeRoot` against it. // // NOT DERIVED ON CHAIN, and that is forced rather than lazy: the circuit // hashes with Poseidon because keccak costs ~502 constraints a byte — // measured, `zk/circuits/sha_probe.circom` — and this contract cannot // compute a Poseidon root at any sensible price. So the account asserts it // and anyone may check it from the events. // // A WRONG ROOT ONLY BREAKS THE OWNER'S OWN PROOFS. The mappings above are // what actually refuses an install, so this can be weaker than the chain // and never stronger — which is the property that makes an asserted root // acceptable at all. /// Has this account committed a capability root — i.e. does it run on /// proofs rather than on installs? /// /// ASKED THROUGH A `try`, BECAUSE A REGISTRY MAY NOT HAVE THE FUNCTION. /// This installer's address is in every account's genesis and it outlives /// registry implementations; `opsRootOf` arrived on this branch, so against /// any earlier one a plain call reverts with an unrecognised selector and /// takes `activate` down with it — permissionless bring-up, dead, for the /// length of an upgrade window. A registry that cannot answer has no v2 /// accounts to protect, so the honest reading of silence is "v1". function _policyGated(address account) internal view returns (bool) { try registry.opsRootOf(account) returns (bytes32 root) { return root != bytes32(0); } catch { return false; } } /// IS THIS ADAPTER REFUSED FOR THIS ACCOUNT — all three keys, one call. /// /// A refusal is recorded THREE WAYS and any one of them binds: by the /// adapter's ADDRESS, by its CODEHASH, and by its NAME. F-16 is why: an /// address is CREATE2 over initcode and a codehash is the code itself, so /// both move the moment a line changes, and a refusal keyed only by address /// is a refusal a redeploy walks straight past. /// /// EXPOSED BECAUSE THE CALL PATH NEEDS IT. `AdapterInstaller` checks these /// when an adapter is INSTALLED, which bounds what an account holds. It /// says nothing about a step already in flight through an adapter the owner /// has since refused, and `PolicyGate.consume` is the place that can. /// /// NO `isModuleInstalled` CALL, unlike `_identityRefused`. That branch /// exists to notice an uninstall that happened outside this contract, and /// it costs a call into the account; on a path taken once per STEP it is /// the wrong trade, and the three explicit records are what an owner /// actually wrote. function refusedFor(address account, address adapter) external view returns (bool) { return _refusedAny(account, adapter); } function _refusedAny(address account, address adapter) internal view returns (bool) { if (refused[account][adapter]) return true; if (refusedCode[account][adapter.codehash]) return true; return refusedIdentity[account][_identity(adapter)]; } mapping(address account => bytes32 root) public revokeRootOf; event RevokeRootSet(address indexed account, bytes32 previous, bytes32 root); function setRevokeRoot(bytes32 root) external { bytes32 previous = revokeRootOf[msg.sender]; revokeRootOf[msg.sender] = root; emit RevokeRootSet(msg.sender, previous, root); } // ── EVER HELD — #5 of the 2026-10-08 audit ───────────────────────────── // // APPEND ONLY, slot 11, after `revokeRootOf`. // // H-4's record that an account HAS held an adapter was `appliedInstall`, // a hash of the payload this contract installed it with. Two ways left it // zero for an adapter the account had held, and zero reads "never held", // so the owner's uninstall was undone by the next stranger's `activate`: // // - an adapter the account got some other way than this contract — the // owner's own root sending `installModule` — never had a payload hash // written at all; // - `allowAdapter` cleared it while the adapter was still installed. // // This is a plain bit, written by `_install` AND whenever this contract // SEES the adapter installed (`activate` / `activateOnly` find it // present), and cleared only by `allowAdapter` once the adapter is gone. // What it cannot see is an adapter installed outside this contract and // uninstalled again before any `activate` or `activateOnly` named that // account: nothing here ever observed it held. The product's uninstall is // `refuseAdapter` and then `uninstallModule` in one owner batch // (internal/account/denylist.go), which `refused` covers on its own. mapping(address account => mapping(address adapter => bool)) public everHeld; /// Held now, or held before and let go: either way not this contract's /// to install. function _held(address account, address adapter) internal view returns (bool) { return everHeld[account][adapter] || appliedInstall[account][adapter] != bytes32(0); } /// `isModuleInstalled`, asked of a caller that may not answer it. The /// caller of `allowAdapter` writes only its own rows and may be anything; /// one that cannot answer holds nothing, and must not be unable to lift /// its own refusals for want of the function. function _installedOn(address account, address adapter) internal view returns (bool) { (bool ok, bytes memory ret) = account.staticcall( abi.encodeCall(IERC7579Modules.isModuleInstalled, (MODULE_TYPE_EXECUTOR, adapter, "")) ); return ok && ret.length >= 32 && abi.decode(ret, (bool)); } /// Record an adapter seen installed, once. function _sawHeld(address account, address adapter) internal { if (!everHeld[account][adapter]) everHeld[account][adapter] = true; } }