// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {LibString} from "solady/utils/LibString.sol"; /// PROVE THE PLAN ONCE, SPEND IT ONE STEP AT A TIME. /// /// A proof about a plan is verified in one transaction. The plan EXECUTES in /// several — `internal/plan` compiles a DAG into intents, each of which reaches /// its own adapter in its own transaction, and `BaseAdapter._consume` sees one /// of them. So "check the policy off chain and prove it" is only half a design: /// the other half is what makes the proof bind the transactions that come /// after it, possibly hours later and in ANY order: neither this contract nor /// circuit v1 orders the steps. /// /// That is this contract. `admit` verifies once and records the plan; `consume` /// is what an adapter calls per step, and it answers one question — is this /// step part of a plan this account's owner had proved, and has it run before. /// /// WHY A COMMITMENT AND NOT A LIST. Recording the steps would put the whole /// plan in storage, which is the per-account copying ACCOUNT_CONFIG_V2 spent /// three phases removing. `planHash` is a Poseidon chain over the steps /// (`zk/circuits/policy.circom` R6), so a step proves membership by naming its /// index and the accumulator before it — one hash, no storage. /// /// WHAT THIS DOES NOT DO. It does not replace the adapters' own checks and must /// not be read as doing so. `_requireCuratedAsset`, `venueOf` and the epoch cap /// bound what a SINGLE intent does, and they hold whether or not a plan was /// ever proved — an intent that reaches an adapter outside any plan is exactly /// the case they exist for. This adds a bound the adapters cannot express: that /// the step belongs to a plan whose every step is a capability THIS OWNER /// approved, spent once, by the adapter and operation the plan named. NOT IN /// ORDER: steps settle in any order at all, and nothing here orders them - /// circuit v1's `needs` (R4) is in no hash and no public signal, so the proof /// cannot say what runs before what. /// /// IT DOES NOT BOUND THE SHAPE, and an earlier version of this sentence said it /// did. `planHash` is emitted and never stored, never compared and never /// reaches `consume`; the only owner-authored comparands are `opsRootOf` — a /// capability SET — and `revokeRootOf`. Everything a prover needs is public by /// design, and `admit` is permissionless, so nothing here distinguishes a DAG /// the owner asked for from one the keeper composed this morning out of /// capabilities the owner curated. Binding the shape would mean the owner /// signing every plan, which is the thing this system exists not to require. /// The boundary is the capability set, and it is worth stating as what it is. /// A PROOF AGGREGATOR, in the shape the services actually expose. /// /// One aggregate proof is verified once and covers N application proofs; a /// consumer then ASKS whether its own (circuit, public inputs) was in that /// batch. NEBRA UPA reports ~366 L2 gas a proof at batch 1024 -- one ~250k /// aggregate verification divided 1024 ways plus per-proof bookkeeping -- and /// EIP-8288, merged as a draft on 2026-09-09, is the protocol pursuing the same /// idea in the mempool because EIP-8141's VERIFY budget is ~79k against /// Groth16's ~194k. /// /// WHAT IT COSTS IN TRUST, and it is not nothing. The aggregator becomes a /// second thing that can refuse: a batch that never fills is a plan that never /// admits, and an aggregator that stops is an account that cannot act. Against /// that, `admit` stays permissionless and the direct `admit` below remains, so /// falling back to paying 242,353 yourself is always available. /// The two roots a proof's policy claim is checked against. /// /// SPLIT BECAUSE THEIR OWNERS ARE. The capability commitment is the account's /// statement about which tree of curated capabilities it approved, and lives on /// the registry beside the set it describes. The revocation commitment is the /// account's statement about what it has taken away, and lives on the installer /// beside the mappings that actually enforce it. interface IPolicyRoots { /// The registry's incremental Poseidon tree over the adapters it holds. /// R10 proves each step's target is a leaf of it; this is how the chain /// says WHICH tree that claim was about — and, since the leaf is /// `Poseidon(address, name, opsHash)`, it is also how the chain notices an /// adapter being redeployed, renamed or recurated. One SLOAD. function adapterRoot() external view returns (uint256); function opsRootOf(address account) external view returns (bytes32); /// The owner's commitment whole: the root, and the circuit version it was /// committed under. See `AgentRegistry.setOpsRoot`. function policyOf(address account) external view returns (bytes32 root, uint8 version); /// Who may add and retire circuit versions. function guardian() external view returns (address); /// Whether an adapter is past ACTIVATION_DELAY now, and when it will be. /// The gate no longer asks the first (it asks `policyMayUse`, which /// contains it); `consume` names the second when it refuses for the delay. /// See `AgentRegistry.isEffectiveAdapter`. function isEffectiveAdapter(address adapter) external view returns (bool); function effectiveAt(address adapter) external view returns (uint64); /// The v2 usability question, whole: the ZK layer is on, the adapter has /// served its delay, and it is not suspended. `consume` asks it of its /// caller and `authorizes` asks it again of an open window. function policyMayUse(address adapter) external view returns (bool); /// The registry's ZK-layer switch. `_record` refuses every admission while /// it is off, and `consume` names it first when it refuses. function policyLayerOn() external view returns (bool); /// Whether the guardian or pauser has stopped this adapter everywhere. /// Asked by `consume` only on its failure path, to say why. function isSuspended(address adapter) external view returns (bool); /// Charge a plan's whole allowance for one token against the account's /// epoch cap. Gate-only on the registry's side. function reserve(address account, address asset, uint256 amount) external returns (uint256 charged); /// Give back to the epoch what a settled plan reserved and did not spend. /// Gate-only on the registry's side, and a no-op once the epoch the /// reservation was charged to has rolled over. function release(address account, address asset, uint256 amount, uint256 reservedAt) external; /// The owner's cap for one token, and the epoch it is in. `draw` reads the /// epoch's start to tell a reservation that still covers a spend from one /// that has rolled over. function capOf(address account, address asset) external view returns (uint96 perEpoch, uint96 spent, uint48 epochStart); /// The epoch's length — read, never restated, so the gate and the registry /// cannot disagree about when a day ends. function CAP_EPOCH() external view returns (uint48); } interface IRevokeRoots { function revokeRootOf(address account) external view returns (bytes32); /// All three refusal keys — address, codehash and name — in one call. See /// `AdapterInstaller.refusedFor`. function refusedFor(address account, address adapter) external view returns (bool); } interface IProofAggregator { function isProofVerified(bytes32 circuitId, uint256[] calldata pubSignals) external view returns (bool); } /// Version 1's verifier, as snarkjs generates it. A later version's verifier /// takes `uint256[n]` for its own n and is called by selector, not through /// this type — see `PolicyGate._verify`. interface IPolicyVerifier { function verifyProof( uint256[2] calldata a, uint256[2][2] calldata b, uint256[2] calldata c, uint256[32] calldata pubSignals ) external view returns (bool); } /// ONE GATE FOR EVERY CIRCUIT, AT ONE ADDRESS FOR EVER. /// /// WHY THE ADDRESS MUST NOT MOVE. `PolicyExecutor` — the door every v2 account /// installs in genesis — holds this contract's address immutably, and genesis /// bytes are the CREATE2 preimage of the account. So the gate's address is /// part of every v2 account's address. When the verifier was a constructor /// argument, a new circuit (a deeper capability tree, a new public input, a /// fix) meant a new gate, a new door, every undeployed account's address /// moving — deposit addresses already shown to people among them — and every /// deployed one needing its owner to install a new door. /// /// So this is an ERC1967 proxy at a fixed salt, admin'd by `UpgradeTimelock` /// like the registry — whose upgrade authority can already rewrite /// `opsRootOf` and so already governs every v2 account; upgrading this adds /// no party — and the CIRCUIT is not part of it at all. Circuits are /// VERSIONS: added by the guardian, never overwritten, retirable, and /// usable by an account only after its owner commits to one. /// /// WHAT REPLACED "THE VERIFIER IS IMMUTABLE". That rule existed because "a /// verifier that can be repointed is a policy that can be replaced by whoever /// can repoint it". Versions keep what it protected. No version is ever /// repointed. A new one admits nothing for an account until that account's /// owner commits to it in `AgentRegistry.setOpsRoot(root, version)`, and the /// gate refuses any admission under a version the owner did not name — so a /// broken or malicious verifier added tomorrow cannot speak for an owner who /// never chose it, whatever it is willing to accept. contract PolicyGate { /// The aggregator. Zero disables the batch path, which is what a chain /// with no aggregator looks like. An implementation immutable: changing it /// is an upgrade, through the timelock. IProofAggregator public immutable aggregator; /// Where the owner's two commitments live. Implementation immutables: /// whoever could repoint these could replace the policy every proof is /// checked against, so only an upgrade — through the timelock — can. IPolicyRoots public immutable registry; IRevokeRoots public immutable installer; /// THE NULLIFIER IS THE FIRING, and it is what everything is keyed by. /// /// `nullifier = Poseidon(planHash, account, fire)`, computed INSIDE the /// circuit so a prover cannot choose it. Two things follow, and the design /// was wrong without either. /// /// ONE: A PROOF CANNOT BE MOVED TO ANOTHER ACCOUNT. Before this the public /// signals were (planHash, policyRoot, nSteps) and named no account at all, /// so a proof published for one account could be admitted against any /// other. Comparing policy roots at `consume` looks like it saves that and /// does not — the ordinary case is two accounts running the same standard /// policy, where the roots are equal by construction. /// /// TWO: A PROOF CANNOT BE SPENT TWICE. A plan that re-fires is real here — /// `One run per firing`, where a resting order takes a second bite, and /// where identical task ids once had the dedupe hand back the FIRST /// transaction and report five fills against money that moved once. `fire` /// is inside the nullifier, so a genuine second firing needs a genuine /// second proof rather than a replay of the first. mapping(uint256 => uint256) public admittedUnder; /// nullifier => the account the proof named. Zero until admitted. mapping(uint256 => address) public accountOf; /// nullifier => step index => spent. /// /// Per STEP, because a DAG's steps settle independently and a plan is not /// finished until all of them have — a per-firing flag would either block /// step two or let step one run twice. mapping(uint256 => mapping(uint256 => bool)) public spent; event Admitted( address indexed account, uint256 indexed nullifier, uint256 planHash, uint256 policyRoot, uint256 steps ); event Consumed(address indexed account, uint256 indexed nullifier, uint256 index); error ProofRejected(); error NullifierAlreadyUsed(uint256 nullifier); error ProofNamesAnotherAccount(address named, address given); /// The account signal does not fit 160 bits. See `_checkRoots`. error AccountNotAnAddress(uint256 account); error ProofForAnotherChain(uint256 named, uint256 here); error AccountHasNoPolicy(address account); error PolicyRootNotTheAccounts(uint256 claimed, uint256 committed); error RevokeRootNotTheAccounts(uint256 claimed, uint256 committed); /// The proof was built against a different adapter tree than the registry /// holds — so R10 proved membership in a set nobody here agreed to. error AdapterRootNotTheRegistrys(uint256 claimed, uint256 onChain); /// The adapter list re-presented at `consume` is not the one the proof /// committed to at `admit`. error NotThePlansAdapters(uint256 nullifier); /// The step belongs to this adapter and to a different operation of it. error StepIsForAnotherOperation(uint256 nullifier, uint256 index, bytes32 operation); /// The step's operation field is zero: the padding leaf Poseidon(0, 0) is in /// every owner's capability tree, so a zero operation is never a capability. error StepIsTheSentinel(uint256 nullifier, uint256 index); /// The curated set moved between admitting this plan and spending it. The /// names the owner approved still stand; the contracts behind them changed. error RegistryMoved(bytes32 admittedAgainst, bytes32 now_); error NotAdmitted(uint256 nullifier); error PolicyMoved(uint256 admittedUnderRoot, uint256 nowRoot); error StepAlreadySpent(uint256 nullifier, uint256 index); error StepNotInPlan(uint256 nullifier, uint256 index); /// The caller is not the adapter this step was proved for. `msg.sender` is /// in the taskId's preimage, so this is the one claim a caller cannot fake. error StepNotThisAdapters(uint256 nullifier, uint256 index, address caller); /// The owner refused this adapter after the plan was admitted. Their /// remedy is meant to bite immediately, including mid-plan. error AdapterRefusedByOwner(address account, address adapter); /// The step's adapter has not served ACTIVATION_DELAY. Zero means the /// registry does not list it. error AdapterNotYetEffective(address adapter, uint64 effectiveAt); /// The registry's ZK-layer switch is off: no plan is admitted and no step /// opens until the guardian switches it back on. error PolicyLayerOff(); /// The step's adapter is suspended on this chain. Same selector as the /// registry's and `Agent`'s. Resuming restores it, and a plan admitted /// before the suspension still runs — nothing it was proved against moved. error AdapterIsSuspended(address adapter); error IndexOutOfRange(uint256 index, uint256 steps); error NotTheRegistry(address caller); error AllowanceNotAToken(uint256 bucket, uint256 asset); error AllowanceTooLarge(uint256 bucket, uint256 total); error OverAllowance(uint256 nullifier, address asset, uint256 want, uint256 left); error PlanSettled(uint256 nullifier); error PlanStillRunning(uint256 nullifier, uint256 unspentStep); event AllowanceReserved(address indexed account, uint256 indexed nullifier, address indexed asset, uint256 total); event AllowanceDrawn(address indexed account, uint256 indexed nullifier, address indexed asset, uint256 amount, uint256 left); event AllowanceReturned(address indexed account, uint256 indexed nullifier, address indexed asset, uint256 amount, uint256 left); event Settled(address indexed account, uint256 indexed nullifier, address by); event AllowanceReleased( address indexed account, uint256 indexed nullifier, address indexed asset, uint256 unspent, uint256 toEpoch ); /// nullifier => how many steps the admitted plan had. mapping(uint256 => uint256) public stepCount; // ── THE PLAN'S ALLOWANCE ──────────────────────────────────────────────── // // WHAT THE PROOF COULD NOT DO. The circuit's R2 compared the plan's // amounts with a budget, and both were private: the amounts never reached // the chain, so a keeper proved small and ran large, and every fresh // `fire` was a fresh budget. The proof now PUBLISHES one (token, total) per // bucket, and this contract makes it binding in two moves. // // RESERVED WHEN THE FIRST STEP OPENS, NOT AT ADMIT. `admit` is // permissionless and everything a prover needs is public, so reserving // there would let a stranger admit plans that exhaust an owner's epoch cap // — a day's denial of service on their own money, for the price of a // proof. `consume` is reached only through `BaseAdapter.openStep`, which // is agent-only, so only the account's own agent can spend its cap. The // reservation is charged to `AgentRegistry`'s cap for that token, and over // it the whole plan is refused before step 1 moves anything. // // DRAWN ON BY EVERY CHARGE A STEP MAKES. The registry routes an adapter's // `charge` here while that adapter holds an open step, so the plan cannot // spend past what it declared, of any token, in any step — whether or not // the owner set a cap for it. A token the plan declared nothing for has an // allowance of zero. /// nullifier => bucket => `(asset << 96) | total`, written at admit and /// KEPT: `settle` reads the tokens back from here. Packed so a bucket /// costs one slot. mapping(uint256 => mapping(uint256 => uint256)) private _declared; /// nullifier => the plan's state, in ONE slot, because it is read by every /// step and written at most three times in the plan's life. /// /// bits 0-7 buckets still to reserve — non-zero from admit until /// the first step opens, and "is there anything to /// reserve" is one SLOAD that is almost always no; /// bits 8-15 the circuit version the plan was admitted under, so a /// retired version stops its plans with no extra read; /// bits 16.. the timestamp the reservation was charged at, which is /// what tells `release` whether the epoch it was charged /// to is still the current one. mapping(uint256 => uint256) private _reservation; uint256 private constant _UNRESERVED_MASK = 0xff; uint256 private constant _VERSION_SHIFT = 8; uint256 private constant _AT_SHIFT = 16; uint256 private constant _KEEP_VERSION = 0xff00; /// nullifier => token => what the plan may still spend of it. mapping(uint256 => mapping(address => uint256)) public allowanceLeft; /// nullifier => token => what the reservation actually charged to the /// owner's cap. Zero for an uncapped token, and that is the point: a /// settle may give back at most THIS, so an allowance reserved while the /// token was uncapped cannot, once the owner caps it, un-spend charges /// that other plans made. mapping(uint256 => mapping(address => uint256)) private _charged; /// nullifier => settled. A settled plan runs no further step. mapping(uint256 => bool) public settled; /// What a plan declared for one bucket. The backend reads this with /// `reservedAtOf` and `allowanceLeft` to tell a plan waiting to start from /// one that started, and one that finished from one that was abandoned. function declaredOf(uint256 nullifier, uint256 bucket) external view returns (address asset, uint256 total) { uint256 d = _declared[nullifier][bucket]; return (address(uint160(d >> 96)), uint96(d)); } /// When the plan's allowance was reserved against the cap; zero before its /// first step opens. function reservedAtOf(uint256 nullifier) external view returns (uint256) { return _reservation[nullifier] >> _AT_SHIFT; } /// The circuit version a plan was admitted under; zero if never admitted. function versionOf(uint256 nullifier) external view returns (uint8) { return uint8(_reservation[nullifier] >> _VERSION_SHIFT); } /// nullifier => the registry fingerprint the plan was admitted against. /// /// THE PROOF BINDS NAMES; THIS BINDS THE DEPLOYMENT. A proof proves the /// owner approved `(uniswap, mint)` and cannot say that the contract /// answering to `uniswap` today is the one they approved — the guardian may /// redeploy it, delist and re-add it, or rename something else into the /// slot between admission and execution. /// /// Recorded HERE rather than inside the proof, because a commitment that /// moved with every curate run would be one the owner re-signs on every /// curate run. The owner signs names, which survive; the gate remembers the /// deployment, which does not. mapping(uint256 => bytes32) public admittedAgainst; /// The IMPLEMENTATION's constructor. What it sets is compiled into the /// implementation's code, so the proxy reads it without storage — and the /// proxy's address does not depend on it. constructor(IPolicyRoots registry_, IRevokeRoots installer_, IProofAggregator aggregator_) { registry = registry_; installer = installer_; aggregator = aggregator_; } // ── CIRCUIT VERSIONS ──────────────────────────────────────────────────── // // APPEND ONLY. Everything above is the gate's per-plan state; this is the // one piece of configuration it has, and it only grows. struct Circuit { address verifier; // How many public signals its proofs carry. At least 32, and the first // 32 always mean what the S_* constants say: a later circuit may only // APPEND signals, because this contract reads them by position. uint16 signals; // Admits nothing before this. The first version is active at once; a // later one waits CIRCUIT_DELAY, so a verifier nobody has examined is // on chain, visible, and useless for that long before any owner can be // asked to commit to it. uint64 activeAt; // `verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[signals])`. // snarkjs types the signal array by its length, so the selector // differs per circuit and is computed once, here. bytes4 selector; // The prover's "nothing revoked": the root of an empty revocation tree // AT THIS CIRCUIT'S DEPTH. `AdapterInstaller.revokeRootOf` reads 0 for // an owner who never revoked anything, and the circuit's value for the // same fact is not 0 — see `_record`. uint256 emptyRevokeRoot; } mapping(uint8 version => Circuit) private _circuits; mapping(address verifier => uint8 version) public versionOfVerifier; uint8 public circuitCount; /// Bit v set: version v is retired. One word, so `consume` pays one SLOAD /// to ask it for any plan. uint256 private _retired; uint64 public constant CIRCUIT_DELAY = 3 days; event CircuitAdded(uint8 indexed version, address verifier, uint16 signals, uint64 activeAt); event CircuitRetired(uint8 indexed version); error AlreadyInitialized(); error NotGuardian(address caller); error NotAVerifier(address verifier); error VerifierAlreadyRegistered(address verifier, uint8 version); error TooFewSignals(uint256 signals); error NoCircuitVersionsLeft(); error UnknownCircuit(uint8 version); error CircuitNotYetActive(uint8 version, uint64 activeAt); error CircuitIsRetired(uint8 version); error WrongSignalCount(uint8 version, uint256 got, uint256 want); error NotTheAccountsCircuit(address account, uint8 version, uint8 committed); /// solady's canonical ERC1967Factory — same address Agent.initialize trusts. address internal constant ERC1967_FACTORY = 0x0000000000006396FF2a80c067f99B3d2Ab4Df24; /// Called once, by the factory (or the guardian), in the transaction that /// creates the proxy: version 1, active at once. Nothing depends on it yet, /// and every owner still has to commit to it before it admits anything for /// them. Permissionless init would let an attacker freeze a malicious v1 /// verifier before the honest deploy call. function initialize(IPolicyVerifier verifier, uint16 signals, uint256 emptyRevokeRoot) external { if (msg.sender != ERC1967_FACTORY && msg.sender != registry.guardian()) { revert NotGuardian(msg.sender); } if (circuitCount != 0) revert AlreadyInitialized(); _addCircuit(address(verifier), signals, emptyRevokeRoot, uint64(block.timestamp)); } /// Add a circuit version. Guardian-only, and it waits CIRCUIT_DELAY. /// /// WHAT THE GUARDIAN CANNOT DO WITH IT: make any account accept it. The /// owner names the version in `setOpsRoot`, and `_record` refuses every /// other. Nor can it change a version once added — there is no setter. function addCircuit(IPolicyVerifier verifier, uint16 signals, uint256 emptyRevokeRoot) external returns (uint8 version) { if (msg.sender != registry.guardian()) revert NotGuardian(msg.sender); return _addCircuit(address(verifier), signals, emptyRevokeRoot, uint64(block.timestamp) + CIRCUIT_DELAY); } /// Stop a version, immediately and for good: no new admissions under it, /// and no further step of a plan admitted under it. Guardian-only. /// /// The brake for a verifier found unsound. A forged proof admits a plan /// that looks exactly like an honest one, so stopping only NEW admissions /// would leave every forged plan already admitted free to run. /// Owners move by committing to another version; nothing is moved for /// them. It can only fail closed. function retireCircuit(uint8 version) external { if (msg.sender != registry.guardian()) revert NotGuardian(msg.sender); if (version == 0 || version > circuitCount) revert UnknownCircuit(version); _retired |= uint256(1) << version; emit CircuitRetired(version); } function circuitOf(uint8 version) external view returns (address verifier, uint16 signals, uint64 activeAt, bool retired, uint256 emptyRevokeRoot) { Circuit storage k = _circuits[version]; return (k.verifier, k.signals, k.activeAt, _isRetired(version), k.emptyRevokeRoot); } function _addCircuit(address verifier, uint16 signals, uint256 emptyRevokeRoot, uint64 activeAt) private returns (uint8 version) { if (verifier.code.length == 0) revert NotAVerifier(verifier); uint8 had = versionOfVerifier[verifier]; if (had != 0) revert VerifierAlreadyRegistered(verifier, had); if (signals < FIXED_SIGNALS) revert TooFewSignals(signals); // 255 versions, because the retired set is one word and a plan's // version is one byte of `_reservation`. if (circuitCount == type(uint8).max) revert NoCircuitVersionsLeft(); version = circuitCount + 1; circuitCount = version; _circuits[version] = Circuit({ verifier: verifier, signals: signals, activeAt: activeAt, selector: bytes4( keccak256( abi.encodePacked( "verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[", LibString.toString(uint256(signals)), "])" ) ) ), emptyRevokeRoot: emptyRevokeRoot }); versionOfVerifier[verifier] = version; emit CircuitAdded(version, verifier, signals, activeAt); } /// A version that may admit now. function _live(uint8 version) private view returns (Circuit storage k) { if (version == 0 || version > circuitCount) revert UnknownCircuit(version); k = _circuits[version]; if (block.timestamp < k.activeAt) revert CircuitNotYetActive(version, k.activeAt); if (_isRetired(version)) revert CircuitIsRetired(version); } function _isRetired(uint8 version) private view returns (bool) { return (_retired >> version) & 1 == 1; } /// Ask a version's verifier, by the selector its signal count implies. /// /// Fixed-size arrays are ABI-encoded inline, so `(a, b, c)` followed by the /// signal words IS the calldata snarkjs's `uint256[n]` verifier expects, /// for any n. A verifier that reverts, returns nothing or returns anything /// but exactly `true` is a rejection. function _verify( Circuit storage k, uint256[2] calldata a, uint256[2][2] calldata b, uint256[2] calldata c, uint256[] calldata p ) private view returns (bool) { (bool ok, bytes memory ret) = k.verifier.staticcall(abi.encodePacked(k.selector, abi.encode(a, b, c), p)); return ok && ret.length == 32 && abi.decode(ret, (uint256)) == 1; } error NoAggregator(); error NotInABatch(uint256 nullifier); error LengthMismatch(); /// ADMIT N PLANS AGAINST ONE AGGREGATED PROOF. /// /// The aggregate is verified by the aggregator, once, for the whole batch; /// this only asks whether each plan's public signals were in it. That is /// the difference between 242,353 a plan and a share of one verification. /// /// Every check `admit` makes is made here too -- the proof names the /// account, the proof names this chain, the nullifier has not been spent -- /// because a batch is a cheaper way to VERIFY and not a cheaper way to /// trust. Dropping one of them here would make the batch path the hole. function admitBatch(address[] calldata accounts, uint8 version, uint256[][] calldata pubSignals) external { if (address(aggregator) == address(0)) revert NoAggregator(); if (accounts.length != pubSignals.length) revert LengthMismatch(); Circuit storage k = _live(version); // THE CIRCUIT ID IS THE VERIFIER'S CODEHASH: the verifying key is // compiled into its bytecode, so the id changes exactly when the // circuit does, and a hand-chosen name cannot outlive what it named. bytes32 circuitId = k.verifier.codehash; for (uint256 i = 0; i < accounts.length; i++) { uint256[] calldata p = pubSignals[i]; if (p.length != k.signals) revert WrongSignalCount(version, p.length, k.signals); if (!aggregator.isProofVerified(circuitId, p)) revert NotInABatch(p[S_NULLIFIER]); // ALREADY ADMITTED IS A SKIP, NOT A REVERT. // // `admitBatch` is permissionless and its public signals are public // by construction — that is what the aggregator is asked about. So // anyone could copy the LAST entry of a 1024-plan batch, front-run // it with a one-entry `admit`, and make the batch revert at index // 1023: the submitter burns a block of gas for nothing, repeatably, // and the path whose whole point is "a share of one verification" // becomes strictly worse than admitting one at a time. if (accountOf[p[S_NULLIFIER]] != address(0)) continue; _record(accounts[i], version, k, p); } } /// The bookkeeping both admission paths share, so the batch path cannot /// drift from the single one -- which is exactly how a cheap path becomes /// the insecure one. /// WHERE EACH PUBLIC SIGNAL ACTUALLY IS. /// /// snarkjs orders public signals by the order their `signal input` lines /// appear IN THE TEMPLATE, not by the order of the `public [...]` list in /// the main component. Those two disagree here — `adapterAddr` is declared /// at policy.circom:109 and `revokeRoot` at :129, so the addresses come out /// BEFORE the revoke root however the main component lists them. /// /// This contract had it wrong in a way no test could catch, because every /// test built the array by hand in the order this file assumed and no test /// ever fed it a real prover's output. Against a real proof the account /// check read the chain id, the chain check read the nullifier, and every /// admission reverted. `test_TheRealProversOrderIsWhatThisContractReads` /// drives `admit` from `zk/out/8p_public.json` so the mapping is pinned by /// the prover rather than by this comment. uint256 private constant S_PLAN = 0; uint256 private constant S_POLICY = 1; uint256 private constant S_NSTEPS = 2; uint256 private constant S_ACCOUNT = 3; uint256 private constant S_CHAIN = 4; uint256 private constant S_NULLIFIER = 5; uint256 private constant S_ADAPTER_ROOT = 6; /// `operation` is declared at policy.circom:88 and `adapterAddr` at :109, /// so the operations come out FIRST however the main component lists them. /// Verified against a real proof in `PolicyGateRealProof.t.sol`, not read /// off the `public [...]` line, which is what got this wrong before. uint256 private constant S_OPERATIONS = 7; // ..14, one per step uint256 private constant S_ADAPTERS = 15; // ..22, one per step uint256 private constant S_REVOKE = 23; uint256 private constant S_SPEND_ASSET = 24; // ..27, one per bucket uint256 private constant S_SPEND_TOTAL = 28; // ..31, one per bucket uint256 internal constant SPEND_BUCKETS = 4; uint256 internal constant MAX_STEPS = 8; /// Signals 0..31 mean what the S_* constants say in EVERY version. uint256 internal constant FIXED_SIGNALS = 32; /// THE PROVER'S ENCODING OF "NOTHING REVOKED", WHICH IS NOT ZERO — and it /// is now each circuit's own `emptyRevokeRoot`, because it depends on the /// tree's depth and the depth is a property of the circuit. /// /// `AdapterInstaller.revokeRootOf` is a plain mapping, so an account that /// has never revoked anything reads 0. The circuit's value for the same /// fact is the Poseidon root of an empty tree of its depth. The two /// encodings of "nothing" disagreed and nothing reconciled them, so /// `_record` once refused every proof for every v2 account with /// `RevokeRootNotTheAccounts`. /// /// The ops half handles its own zero explicitly (`AccountHasNoPolicy`) /// because an uncommitted POLICY is a real error; an uncommitted /// REVOCATION set is the ordinary state of a new account. /// Whose, which chain, and which of the owner's commitments — every check /// that reads the proof against the chain before anything is written. function _checkRoots(address account, uint8 version, Circuit storage k, uint256[] calldata p) private view { // THE ACCOUNT SIGNAL MUST BE AN ADDRESS, NOT A FIELD ELEMENT. The // circuit never range-checks it - it enters only the nullifier's // Poseidon - so `A + k * 2^160` proves as account A under a different // nullifier, and the 20-byte comparison below would admit every such // alias as A: one firing, about 2^93 nullifiers, each with its own // spendable steps. `spendAsset` gets the same `>> 160` check in // `_declareAllowance`. A dedicated error, because // `ProofNamesAnotherAccount(A, A)` would read as nonsense. // docs/audits/2026-10-01-zk-circuit-v1.md, F-09. if (p[S_ACCOUNT] >> 160 != 0) revert AccountNotAnAddress(p[S_ACCOUNT]); address named = address(uint160(p[S_ACCOUNT])); if (named != account) revert ProofNamesAnotherAccount(named, account); if (p[S_CHAIN] != block.chainid) revert ProofForAnotherChain(p[S_CHAIN], block.chainid); // AND WHICH POLICY IT IS ABOUT — the last two of the seven, and the gap // an end-to-end attempt found. A prover can assemble the capability // tree from `AgentRegistry.capabilitySet()` and the revocation set from // `AdapterRefused` events, then claim ANY roots it likes. Without this // the gate verified a proof and admitted it: valid mathematics, about a // policy nobody had agreed to. // // AN UNCOMMITTED ACCOUNT REFUSES THE PROOF, NOT THE ACCOUNT. Zero means // the owner has not committed, and every account already on chain is in // that state. They keep working exactly as they do; it is only a PROOF // that cannot be admitted for them. (bytes32 root, uint8 committed) = registry.policyOf(account); uint256 wantOps = uint256(root); if (wantOps == 0) revert AccountHasNoPolicy(account); // AND UNDER THE CIRCUIT THE OWNER NAMED. This is the check the whole // versioning rests on: the root alone does not stop a new verifier, // because a verifier is only as honest as its circuit and a broken one // accepts a "proof" of whatever root it is shown — this one included. if (version != committed) revert NotTheAccountsCircuit(account, version, committed); if (p[S_POLICY] != wantOps) revert PolicyRootNotTheAccounts(p[S_POLICY], wantOps); // The revocation half goes STALE rather than being chosen: an owner who // refuses an adapter moves this root, and a proof built before that // still carries the old one. Refusing it is what makes a revocation // bite a plan that is PROVED AFTER it. // // IT DOES NOT REACH A PLAN ALREADY ADMITTED. This runs at `admit` and // nowhere else: `consume` re-reads `opsRootOf`, the adapter root and // `refusedFor` live and never `revokeRootOf`, so an OPERATION-level // revocation written after admission does not stop that plan - a // revoked operation keeps running, inside the allowance and the caps, // until the plan settles. The owner's remedies for a plan in flight are // `refuseAdapter` (live, and the console writes every revocation // root in the same batch as its refusal), narrowing `opsRoot` (`PolicyMoved`) and `settle`. A gate // check was weighed and not taken: it would stop every in-flight plan // on ANY revocation write. Pinned by // `test_AnOperationRevocationBitesAtAdmissionAndSettleIsTheMidPlanRemedy`. // docs/audits/2026-10-01-zk-circuit-v1.md, F-03. // AND THE ADAPTER TREE IS THE REGISTRY'S OWN. // // R10 proves every step's target address is a leaf of `adapterRoot` — // which bound NOTHING on chain until now, because the verifier in use // took seven public signals and the circuit's eighth never left the // prover. A proof could be built against any tree it liked and the gate // could not tell. `zk/out/8a_final.zkey` has had `nPublic = 8` since it // was created; only the Solidity verifier was missing. uint256 wantAdapters = registry.adapterRoot(); if (p[S_ADAPTER_ROOT] != wantAdapters) { revert AdapterRootNotTheRegistrys(p[S_ADAPTER_ROOT], wantAdapters); } uint256 stored = uint256(installer.revokeRootOf(account)); uint256 wantRevoke = stored == 0 ? k.emptyRevokeRoot : stored; if (p[S_REVOKE] != wantRevoke) revert RevokeRootNotTheAccounts(p[S_REVOKE], wantRevoke); } /// WHICH ADAPTER RUNS WHICH STEP, kept as ONE hash — see `stepAdapters`. /// /// AN ACTIVE STEP'S OPERATION IS NEVER ZERO. Every owner's capability tree /// is padded with the leaf Poseidon(0, 0), and circuit v1 lets an active /// step prove that pair (R1 asks only for membership). Refused here, on /// public signals, for every index below `nSteps` - the padding past it is /// zero by design and is left alone. `consume` refuses it again, for a plan /// admitted before this check existed. /// docs/audits/2026-10-01-zk-circuit-v1.md, F-01. function _recordSteps(uint256 nullifier, uint256[] calldata p) private { uint256[MAX_STEPS] memory who; uint256[MAX_STEPS] memory what; uint256 active = p[S_NSTEPS] < MAX_STEPS ? p[S_NSTEPS] : MAX_STEPS; for (uint256 i = 0; i < MAX_STEPS; i++) { who[i] = p[S_ADAPTERS + i]; what[i] = p[S_OPERATIONS + i]; if (i < active && what[i] == 0) revert StepIsTheSentinel(nullifier, i); } stepAdapters[nullifier] = _listHash(who, what); // THE ALLOWANCE IS DECLARED HERE AND RESERVED AT THE FIRST STEP — see // `_declared`. Validated now so a malformed one fails the admission // rather than a step, hours later, with money already moved. } /// The plan's allowance, validated and declared; reserved at its first /// step. Returns the buckets still to reserve. function _declareAllowance(uint256 nullifier, uint256[] calldata p) private returns (uint256 mask) { for (uint256 b = 0; b < SPEND_BUCKETS; b++) { uint256 total = p[S_SPEND_TOTAL + b]; if (total == 0) continue; uint256 asset = p[S_SPEND_ASSET + b]; if (asset == 0 || asset >> 160 != 0) revert AllowanceNotAToken(b, asset); // uint96 is what `AgentRegistry.Cap` holds, and the circuit bounds // each amount to 96 bits for the same reason. if (total > type(uint96).max) revert AllowanceTooLarge(b, total); _declared[nullifier][b] = (asset << 96) | total; mask |= 1 << b; } // Always written now: the plan's version rides in the same word, so // `consume` learns it — and whether it was retired — from the read it // already makes. } function _record(address account, uint8 version, Circuit storage k, uint256[] calldata p) internal { // NO NEW PROVED PLANS WHILE THE ZK LAYER IS OFF. Here, in the one // function `admit` and `admitBatch` share, so the batch path cannot // drift from the single one: a batch reverts whole. if (!registry.policyLayerOn()) revert PolicyLayerOff(); _checkRoots(account, version, k, p); uint256 nullifier = p[S_NULLIFIER]; if (accountOf[nullifier] != address(0)) revert NullifierAlreadyUsed(nullifier); accountOf[nullifier] = account; admittedUnder[nullifier] = p[S_POLICY]; stepCount[nullifier] = p[S_NSTEPS]; admittedAgainst[nullifier] = bytes32(registry.adapterRoot()); // WHICH ADAPTER RUNS WHICH STEP, kept as ONE hash rather than N slots. // // This is the bound that was missing entirely: `consume` did not ask // who was calling, so any curated adapter could spend a step of // somebody else's plan. The plan always named an adapter per step — // `adapterAddr[i]` is in the step hash and R10 ties it to the // registry's tree — but the value was PRIVATE, so the chain saw a root // and never the list. // // Making them public costs no constraints, because the circuit already // computes them; it costs 7,160 gas each at verification. Storing the // list would cost 8 cold SSTOREs, so the list stays in calldata and // only its hash is kept: one slot, and `consume` proves membership by // re-presenting it. _recordSteps(nullifier, p); uint256 mask = _declareAllowance(nullifier, p); _reservation[nullifier] = mask | (uint256(version) << _VERSION_SHIFT); emit Admitted(account, nullifier, p[S_PLAN], p[S_POLICY], p[S_NSTEPS]); } /// Verify a plan against the owner's policy, once. /// /// PERMISSIONLESS ON PURPOSE, and it is safe for the same reason `activate` /// is: the proof's public signals name the policy root, and a proof that /// does not correspond to THIS account's committed policy admits nothing /// the account will accept at `consume`. Anyone may pay the gas to admit a /// plan the owner approved; nobody can admit one they did not. /// /// The caller names the circuit VERSION the proof is for — the one the /// account's owner committed to, or it is refused — and passes the proof /// and exactly that version's public signals. function admit( address account, uint8 version, uint256[2] calldata a, uint256[2][2] calldata b, uint256[2] calldata c, uint256[] calldata pubSignals ) external { Circuit storage k = _live(version); if (pubSignals.length != k.signals) revert WrongSignalCount(version, pubSignals.length, k.signals); if (!_verify(k, a, b, c, pubSignals)) revert ProofRejected(); // THE PROOF SAYS WHOSE IT IS AND WHICH CHAIN IT IS FOR. Shared with the // batch path so the two cannot drift. _record(account, version, k, pubSignals); } /// EIP-1153 WAS TRIED HERE AND MEASURED WORSE. See /// `PolicyGateTest.test_TransientStorageLosesToPlainWarmSlots`: caching the /// admission in transient storage costs 2,265 a later step against 1,922 /// for plain slots, because EIP-2929 ALREADY makes the second read of a /// slot warm at 100 gas and the two `keccak256`s needed to derive transient /// keys cost more than the warm reads they replace. The harness that proves /// it lives in the test file rather than here, because an unused path on a /// contract that gates money is a liability and not documentation. /// The taskId an adapter must use for a given step of an admitted plan. /// /// THE ADAPTER IS IN THE PREIMAGE, and that is what ties the proof's claim /// to the address actually being called. /// /// Everything else binds a plan to a POLICY: the owner approved /// `(uniswap, mint)`, and `adapterRoot` says which contract answers to /// `uniswap`. Neither says that the contract executing step 3 right now is /// the one step 3 was proved for — a second curated adapter could spend a /// step assigned to the first, and every check above would pass. /// /// The fix costs one keccak. A prover computes each step's taskId /// including the adapter's ADDRESS, so the plan commits to it; at execution /// the gate rederives it from `msg.sender`, which an adapter cannot lie /// about. A mismatch means this is not that adapter's step. /// /// WHY NOT VERIFY THE STEP AGAINST `planHash` INSTEAD. It is a Poseidon /// CHAIN — `acc[i+1] = H(acc[i], step[i])` — so checking step i in /// isolation means supplying `acc[i]`, and nothing pins `acc[i]` without /// walking the whole chain. A Merkle tree over steps would fix that at /// ceil(log2 N) Poseidon hashes a step, which is 89,000 gas at eight steps /// against the 36 this costs. Measured: PoseidonOnChain.t.sol. /// /// Published so `internal/plan`'s compiler, the adapters and the prover /// agree on ONE derivation. It is also what makes /// `BaseAdapter._consume`'s existing `spent[account][taskId]` write double /// as this plan's replay guard. function taskIdFor(uint256 nullifier, uint256 index, address adapter) public pure returns (bytes32) { return keccak256(abi.encode(nullifier, index, adapter)); } /// Spend one step of an admitted plan. /// /// `currentRoot` is what the ACCOUNT says its policy is now, read by the /// caller from wherever the owner's commitment lives. Passing it in rather /// than reading it here keeps this contract ignorant of that storage, which /// is the difference between a gate and a second registry. /// /// Returns nothing: it reverts or it does not. /// `stepAdapters[nullifier]` is `keccak256` over the eight public adapter /// signals, exactly as they sit in the proof's calldata. mapping(uint256 nullifier => bytes32) public stepAdapters; /// ONE expression, used by both sides. `admit` hashes the list out of the /// proof and `consume` hashes the list out of calldata; if those two ever /// hashed differently the bound would be silently absent rather than /// loudly broken, which is the failure this codebase keeps finding. function _listHash(uint256[MAX_STEPS] memory who, uint256[MAX_STEPS] memory what) private pure returns (bytes32) { return keccak256(abi.encodePacked(who, what)); } /// The caller re-presents the list the proof committed to, and the gate /// checks that this step's entry is the caller. See `_record`. function consume( address account, uint256 nullifier, uint256 index, uint256 currentRoot, bytes32 operation, uint256[MAX_STEPS] calldata who, uint256[MAX_STEPS] calldata what ) external { // ADMISSION FIRST, so an unadmitted plan says THAT rather than blaming // the adapter list — a wrong-looking error costs more debugging time // than the check it saves, which `A check that reads state the run // creates` cost six plan revisions to learn. if (accountOf[nullifier] != account) revert NotAdmitted(nullifier); // A RETIRED CIRCUIT STOPS ITS PLANS, not only its admissions — see // `retireCircuit`. The version rides in the word the reservation check // below reads anyway; the retired set is one more SLOAD. uint256 plan = _reservation[nullifier]; _refuseRetired(plan); if (_listHash(who, what) != stepAdapters[nullifier]) revert NotThePlansAdapters(nullifier); if (index >= MAX_STEPS || address(uint160(who[index])) != msg.sender) { revert StepNotThisAdapters(nullifier, index, msg.sender); } // NOT THE SENTINEL. `what[index] == 0` is the padding pair's operation, // and the padding leaf Poseidon(0, 0) is in every owner's capability // tree, so circuit v1 lets a proof claim it for an ACTIVE step (R1 asks // only for membership). Refused whatever the registry holds: this is // what stops an adapter with a zero name field from spending as every // account's capability. A plan admitted before `_recordSteps` refused // it meets this check, and nothing else. // docs/audits/2026-10-01-zk-circuit-v1.md, F-01. if (what[index] == 0) revert StepIsTheSentinel(nullifier, index); // AND WHICH OPERATION, not just which adapter. // // R1 has always proved the PAIR — `(adapterName, operation)` against // the owner's capability tree — and only the adapter half reached the // chain. So a keeper holding a step for `uni.mint` could run any other // operation UniswapAdapter implements: ten of them, all curated, none // of them the one the owner's plan asked for. // // THE SHIFT IS THE FIELD ENCODING, not decoration. An operation is a // left-aligned ASCII `bytes32`, about 2^254, and BN254's order is // 2^253.9 — a name that does not fit is a name that wraps, so both // sides drop the low byte. `zk/js/policy.mjs`'s `toField` does the // same, and `AgentRegistry._capUpdate` does it to the adapter name for // exactly this reason. if (what[index] != uint256(operation) >> 8) { revert StepIsForAnotherOperation(nullifier, index, operation); } // THE POLICY MOVED, so firings approved under the old one stop. An // owner who narrows their policy expects the narrowing to bite // immediately, and a plan admitted yesterday is what it has to bite. // READ LIVE. `currentRoot` USED TO BE THE COMPARAND AND IT CAME FROM // THE CALLER. // // The caller is `BaseAdapter.openStep`, which is agent-only — the key // this entire scheme assumes stolen — and `admittedUnder` is a public // mapping, so the value that satisfied the check was a free read. The // comparison was `stored == the caller's copy of stored`: it could not // fail, for any nullifier, ever. An owner who narrowed their policy on // discovering a compromised keeper stopped nothing. // // The natspec justified the parameter as keeping this contract // "ignorant of that storage" — and `_record` 250 lines up already calls // `registry.opsRootOf(account)`. It was never ignorant of it. The // parameter stays so the caller must ALSO agree, which turns a stale // caller into a loud `PolicyMoved` rather than a silent no-op. uint256 live = uint256(registry.opsRootOf(account)); uint256 under = admittedUnder[nullifier]; if (under != live) revert PolicyMoved(under, live); if (currentRoot != live) revert PolicyMoved(currentRoot, live); // AND THE CIRCUIT VERSION THE OWNER COMMITTED TO. Root alone is not // enough: setOpsRoot(R, v2) with the same R left admitted v1 plans // runnable because only opsRootOf was re-read. Same refusal admit uses. (, uint8 committedVersion) = registry.policyOf(account); uint8 admittedVersion = uint8(plan >> _VERSION_SHIFT); if (admittedVersion != committedVersion) { revert NotTheAccountsCircuit(account, admittedVersion, committedVersion); } // AND THE REGISTRY IS THE ONE THE PLAN WAS PROVED AGAINST. Names // survive a redeploy; the contracts behind them do not, and a step is // about to hand money to one of them. // ONE SLOAD, AND IT MOVES ONLY WHEN SOMEBODY WRITES. // // This was `registry.capabilityDigest()`, which walks every effective // adapter, loads each one's whole install blob out of storage and // hashes the lot — measured at 361,198 gas for Base's 22 adapters and // 1,046,356 at 64, once PER STEP. The registry's own note beside the // adapter tree says exactly that is unaffordable here and that the // tree exists to replace it; `consume` called the walk anyway. // // AND THE DIGEST CHANGED WITH THE CLOCK. `capabilitySet()` filters on // `_isEffective`, which is `block.timestamp >= at` — so an unrelated // adapter maturing three days after a plan was admitted moved the // fingerprint with NO transaction from anyone, and every remaining step // reverted `RegistryMoved` for ever. A half-executed DAG was stranded // holding whatever step 0 had bought. `adapterRoot` is maintained by // `_capUpdate` on whitelist, rename and delist — writes, all of them. bytes32 nowRoot = bytes32(registry.adapterRoot()); if (admittedAgainst[nullifier] != nowRoot) { revert RegistryMoved(admittedAgainst[nullifier], nowRoot); } uint256 n = stepCount[nullifier]; if (index >= n) { // `settle` zeroes the count, so a settled plan lands here — and // is told so, rather than that step 0 is out of a range of 0. The // extra read is on the failure path only. if (settled[nullifier]) revert PlanSettled(nullifier); revert IndexOutOfRange(index, n); } if (spent[nullifier][index]) revert StepAlreadySpent(nullifier, index); // The owner's refusal reaches a plan already admitted. See // `bindingFloor` for why this is checked here and not only at install. // It is the ADAPTER-level half: an operation-level revocation // (`revokeRootOf`) is not read here and bites at `admit` only - see // `_checkRoots`. if (installer.refusedFor(account, msg.sender)) { revert AdapterRefusedByOwner(account, msg.sender); } // THE ADAPTER HAS SERVED ITS NOTICE — THE DELAY, ASKED WHERE A v2 // ACCOUNT MEETS IT. // // `whitelistAdapter` starts the clock and a real config change restarts // it. v1 honours it at install: `installDataFor` is empty until the // adapter is effective. v2 installs nothing, and the tree takes a named // adapter's new leaf at once while R1 proves a NAME — so a contract // whitelisted under a name the owner granted, or a new venue on one // already there, was usable on every v2 account the moment it landed, // and the three days owners have to refuse it were skipped. So the // clock is asked here, of the caller, the only address a window is // ever opened for — and again by `authorizes` each time the window is // read, for a rewrite later in this same transaction. // // NOT AT ADMIT. Effectiveness can be lost after admission without the // root moving: a venue-only or curation-only rewrite leaves the leaf, // which is address, name and operations, as it was. And the root // cannot carry this either, because it moves on writes and not on the // clock — which is why it replaced the digest above. // // AND THE SUSPENSION AND THE ZK-LAYER SWITCH, IN THE SAME QUESTION. // `policyMayUse` is effective AND not suspended AND the layer on, so // the success path stays one call. Only a refusal asks why, and names // the causes in a fixed order — layer, suspension, then the delay — // because the first is the one the remedy has to start with. // Suspension deletes nothing, so a plan refused here runs unchanged, // with the same nullifier and no new proof, once it is lifted. if (!registry.policyMayUse(msg.sender)) { if (!registry.policyLayerOn()) revert PolicyLayerOff(); if (registry.isSuspended(msg.sender)) revert AdapterIsSuspended(msg.sender); revert AdapterNotYetEffective(msg.sender, registry.effectiveAt(msg.sender)); } // THE FIRST STEP TO OPEN RESERVES THE WHOLE PLAN. Steps settle in ANY // order - the gate does not order them, and circuit v1's `needs` (R4) // is in no hash and no public signal, so the keeper picks the order // within the plan it proved - so "first" is whichever consume finds // buckets still unreserved. Over the owner's cap the registry reverts // here and the plan never starts. if (plan & _UNRESERVED_MASK != 0) _reserve(account, nullifier, plan); // OPEN THE WINDOW WITHOUT MARKING THE STEP SPENT. // // Marking `spent` here let a lone `openStep` burn the step: the // EIP-1153 window died at tx end and a later intent hit // `StepAlreadySpent`. `commitStep` (from draw / adapter `_consume`) // is the first real use and the only write to `spent`. _openStep(account, nullifier, index); } /// Mark the open step spent. First real use of the window — called from /// `draw` and from `BaseAdapter._consume` on the v2 path. function commitStep(address account, address adapter) external { if (msg.sender != adapter && msg.sender != address(registry)) { revert NotTheRegistry(msg.sender); } _commitStep(account, adapter); } function _refuseRetired(uint256 plan) private view { uint8 version = uint8(plan >> _VERSION_SHIFT); if (_isRetired(version)) revert CircuitIsRetired(version); } /// The transient half of `consume`: the step's task, its plan, its index, /// and the window. All four die with the transaction. function _openStep(address account, uint256 nullifier, uint256 index) private { bytes32 t = _taskKey(account, msg.sender); bytes32 id = taskIdFor(nullifier, index, msg.sender); bytes32 nk = _planKey(account, msg.sender); bytes32 ik = _indexKey(account, msg.sender); assembly ("memory-safe") { tstore(t, id) tstore(nk, nullifier) tstore(ik, index) } bytes32 k = _window(account, msg.sender); assembly ("memory-safe") { tstore(k, 1) } } function _commitStep(address account, address adapter) private { (bool open, uint256 nullifier) = _openPlan(account, adapter); if (!open) return; uint256 index; bytes32 ik = _indexKey(account, adapter); assembly ("memory-safe") { index := tload(ik) } if (spent[nullifier][index]) return; spent[nullifier][index] = true; emit Consumed(account, nullifier, index); } /// HAS THIS ADAPTER SPENT A STEP FOR THIS ACCOUNT, IN THIS TRANSACTION? /// /// `PolicyExecutor` is the one module a v2 account installs, and it will /// hand the account's own `executeFromExecutor` to whichever adapter asks. /// What stops that being arbitrary execution is this: an adapter may ask /// only while a step it proved is open, and a step opens here and nowhere /// else. /// /// TRANSIENT, AND THIS TIME THAT IS THE SEMANTICS RATHER THAN A SAVING. /// The transient-cache experiment lost because it cached facts that were /// already cheap to reread; this is the opposite case. What is recorded is /// "a step was spent in THIS transaction", which is false the instant the /// transaction ends — and a window that outlived its transaction would be a /// standing grant to call the account. EIP-1153 gives exactly that lifetime /// with no code to close it, so there is no path where a revert, an /// out-of-gas or a forgotten cleanup leaves one open. /// /// SCOPED TO THE PAIR, not to the plan. One `consume` authorises the /// adapter that consumed it to compose as many calls as the step needs — /// an approve and a swap are two calls of one step — and authorises nobody /// else. A second adapter in the same transaction opens its own window by /// spending its own step. /// /// AND ONLY WHILE THE ADAPTER IS STILL USABLE — effective, not suspended, /// and the ZK layer on (`policyMayUse`). This one line is how a suspension /// or a layer-off stops `PolicyExecutor.executeFor` without touching the /// executor, whose address is in every v2 genesis. `consume` asks the clock /// when it opens the window, but the window lives for the whole /// transaction and the guardian can write in the middle of one: a multisig /// batch, or a delegated guardian EOA, that spends a step and then /// rewrites the adapter's payload with a new venue. Asked at `consume` /// alone, the window opened before the rewrite spent through the venue /// added after it, and the notice was skipped inside one transaction. /// /// NOT H-6. Only a write in this same transaction can flip the answer: a /// window never outlives its transaction, and the next one has to pass /// `consume`, which asks the same question. `_consume` reaches this only /// for an adapter the account did not install. function authorizes(address account, address adapter) external view returns (bool open) { bytes32 k = _window(account, adapter); assembly ("memory-safe") { open := tload(k) } if (open) open = registry.policyMayUse(adapter); } /// The task this account's open step is for. Zero when none is open. function taskOpenFor(address account, address adapter) external view returns (bytes32 id) { bytes32 t = _taskKey(account, adapter); assembly ("memory-safe") { id := tload(t) } } /// Spend `amount` of the open plan's allowance for `asset`. /// /// CALLED BY THE REGISTRY, from `charge`, with the adapter that charged. /// `planned` is false when that adapter holds no open step for this /// account — a v1 charge, which the registry then takes against the epoch /// cap as it always has. When true the plan paid, and `nullifier` says /// which plan, so the registry can hand it back to a later refund as the /// charge's tag. /// /// `covered` SAYS WHETHER THE RESERVATION STILL PAYS THE EPOCH. It was /// assumed to, always, and nothing asked which epoch it was charged to. /// `openStep` -> `consume` -> `_reserve` charges the whole allowance to /// the epoch the first step opened in and keeps it alive whether or not /// that step ever commits; so a keeper reserved on day 1 and on day 2 spent /// the stale allowance AND a full fresh cap, and N plans reserved on N days /// moved N caps at once. Covered means: the reservation charged this token /// at all (`_charged`, zero when it was uncapped at the time), and was /// charged in the epoch the cap is in NOW — the same two questions /// `AgentRegistry.release` asks before giving a reservation back, so a /// reservation `settle` could still release is exactly one that still /// covers a draw. Otherwise the registry charges today's epoch as well. /// /// THE OLD EPOCH IS LEFT ALONE. When a stale reservation is drawn, its /// amount still sits in `spent` of an epoch that has ended (or, if the /// token was uncapped then, nowhere). An ended epoch's `spent` is reset by /// the next `_spendEpoch` and bounds nothing, so there is nothing to give /// back — and `release` refuses it for the same reason. function draw(address account, address adapter, address asset, uint256 amount) external returns (bool planned, bool covered, uint256 nullifier) { if (msg.sender != address(registry)) revert NotTheRegistry(msg.sender); (planned, nullifier) = _openPlan(account, adapter); if (!planned) return (false, false, 0); _commitStep(account, adapter); uint256 left = allowanceLeft[nullifier][asset]; if (amount > left) revert OverAllowance(nullifier, asset, amount, left); unchecked { left -= amount; // Bounded by the declared total, itself below 2^96 a bucket. _drawnBy[nullifier][adapter][asset] += amount; } allowanceLeft[nullifier][asset] = left; emit AllowanceDrawn(account, nullifier, asset, amount, left); if (_charged[nullifier][asset] != 0) { (,, uint48 start) = registry.capOf(account, asset); covered = (_reservation[nullifier] >> _AT_SHIFT) >= start && block.timestamp < uint256(start) + registry.CAP_EPOCH(); } } /// Give back what a venue did not take, to THE PLAN THAT PAID FOR IT. /// /// The registry has already refused `unused > charged` (M-6), and names the /// plan from the charge's tag. It used to credit whichever plan was OPEN at /// refund time, bounded by that plan's `declared - allowanceLeft` — and the /// one refund that runs in a later transaction than its charge is a CoW /// cancel. An order charged outside any plan, or to plan P1, cancelled /// while P2 was open and had drawn, landed in P2: P2 spent 800 against a /// proved total of 400. /// /// SO IT CREDITS `nullifier`, bounded by what THIS adapter drew from it for /// this token (`_drawnBy`) — which is never more than the plan drew, so /// the allowance cannot rise past the proof's total. A plan of another /// account, a plan that has settled, or nothing drawn: the refund is /// dropped. Dropping over-charges the plan's owner, conservatively; the /// alternative is a grant. function undraw(address account, address adapter, address asset, uint256 unused, uint256 nullifier) external { if (msg.sender != address(registry)) revert NotTheRegistry(msg.sender); _undraw(account, adapter, asset, unused, nullifier); } /// THE PREVIOUS REGISTRY'S `undraw`, kept for the one window it is needed. /// /// A registry implementation from before tagged charges calls this /// selector from every `refundCharge`, and reverts that refund — and the /// step making it — if the gate does not answer. So on a chain where a gate /// is already wired, THIS gate is upgraded FIRST: it answers the old /// registry here, and the old registry reads only the first word of the /// new `draw`'s return. The registry follows; nothing calls this after. /// /// It credits the plan open NOW, as the old one did — the old registry /// cannot name another — but only up to what this adapter drew from it /// under this gate (`_drawnBy`), and returns true whenever a plan is open /// so the old registry never falls through to forgiving the epoch. function undraw(address account, address adapter, address asset, uint256 unused) external returns (bool planned) { if (msg.sender != address(registry)) revert NotTheRegistry(msg.sender); uint256 nullifier; (planned, nullifier) = _openPlan(account, adapter); if (planned) _undraw(account, adapter, asset, unused, nullifier); } function _undraw(address account, address adapter, address asset, uint256 unused, uint256 nullifier) private { // Settled: `allowanceLeft` was zeroed and released to the epoch, and // nothing can spend it again. if (accountOf[nullifier] != account || settled[nullifier]) return; uint256 drawn = _drawnBy[nullifier][adapter][asset]; if (unused > drawn) unused = drawn; if (unused == 0) return; uint256 left; unchecked { _drawnBy[nullifier][adapter][asset] = drawn - unused; left = allowanceLeft[nullifier][asset] + unused; } allowanceLeft[nullifier][asset] = left; emit AllowanceReturned(account, nullifier, asset, unused, left); } function _reserve(address account, uint256 nullifier, uint256 plan) private { _reservation[nullifier] = (block.timestamp << _AT_SHIFT) | (plan & _KEEP_VERSION); for (uint256 b = 0; b < SPEND_BUCKETS; b++) { uint256 d = _declared[nullifier][b]; if (d == 0) continue; address asset = address(uint160(d >> 96)); uint256 total = uint96(d); // Two buckets may name one token; each is charged, and the // allowance is their sum. uint256 charged = registry.reserve(account, asset, total); if (charged != 0) _charged[nullifier][asset] += charged; allowanceLeft[nullifier][asset] += total; emit AllowanceReserved(account, nullifier, asset, total); } } /// CLOSE A PLAN AND GIVE ITS OWNER BACK WHAT IT DID NOT SPEND. /// /// A reservation charges the whole declared allowance against the epoch /// cap up front, and a plan rarely spends all of it — a swap fills below /// its bound, a runtime amount comes in under the headroom it was declared /// with. Without this the difference stayed charged until the epoch rolled /// over, and a plan sized with any margin at all starved the owner's other /// plans for the rest of the day. /// /// WHO MAY CALL IT. /// /// Anyone, once every step is spent. The plan can do nothing more, and /// giving back what it did not use harms nobody — so the backend, a /// keeper or the owner may pay for it. /// /// The account, at any time. That is how an owner stops a plan that is /// stuck or that they no longer want: the remaining steps are refused /// `PlanSettled`, and its reservation comes back. The keeper cannot do /// it early: this is keyed by msg.sender, and neither the keeper nor B is /// the account. Nor can B make the account call it — B holds no validator /// and so no `execute`, and `Agent._callOne` reaches only whitelisted /// adapters, refusing the account, itself and the registry as targets. /// /// WHAT COMES BACK is the allowance left, bounded by what the reservation /// actually charged for that token (`_charged`) — and only while the epoch /// it was charged to is still current, which the registry decides. After /// a rollover the budget has already come back by itself. function settle(uint256 nullifier) external { address account = accountOf[nullifier]; if (account == address(0)) revert NotAdmitted(nullifier); if (settled[nullifier]) revert PlanSettled(nullifier); uint256 n = stepCount[nullifier]; if (msg.sender != account) { for (uint256 i = 0; i < n; i++) { if (!spent[nullifier][i]) revert PlanStillRunning(nullifier, i); } } settled[nullifier] = true; // NO FURTHER STEP. `consume` refuses an index at or past the count, // and says `PlanSettled` when it does. stepCount[nullifier] = 0; uint256 r = _reservation[nullifier]; // A plan settled before its first step never reserved, and never will. _reservation[nullifier] = r & ~_UNRESERVED_MASK; uint256 at = r >> _AT_SHIFT; for (uint256 b = 0; b < SPEND_BUCKETS; b++) { uint256 d = _declared[nullifier][b]; if (d == 0) continue; address asset = address(uint160(d >> 96)); // Zeroed as it is released, so a second bucket naming the same // token finds nothing left and is skipped. uint256 left = allowanceLeft[nullifier][asset]; if (left == 0) continue; allowanceLeft[nullifier][asset] = 0; uint256 charged = _charged[nullifier][asset]; uint256 back = left < charged ? left : charged; if (back != 0) registry.release(account, asset, back, at); emit AllowanceReleased(account, nullifier, asset, left, back); } emit Settled(account, nullifier, msg.sender); } function _openPlan(address account, address adapter) private view returns (bool open, uint256 nullifier) { bytes32 t = _taskKey(account, adapter); bytes32 nk = _planKey(account, adapter); bytes32 id; assembly ("memory-safe") { id := tload(t) nullifier := tload(nk) } open = id != bytes32(0); } function _planKey(address account, address adapter) private pure returns (bytes32) { return keccak256(abi.encode("policygate.plan", account, adapter)); } function _taskKey(address account, address adapter) private pure returns (bytes32) { return keccak256(abi.encode("policygate.task", account, adapter)); } function _indexKey(address account, address adapter) private pure returns (bytes32) { return keccak256(abi.encode("policygate.index", account, adapter)); } function _window(address account, address adapter) private pure returns (bytes32) { return keccak256(abi.encode("policygate.window", account, adapter)); } /// nullifier => adapter => token => what that adapter has drawn from the /// plan's allowance and not had back. The bound on `undraw`. /// /// APPENDED, and declared last for that reason: this is a proxy's storage /// and every slot above it is live. mapping(uint256 => mapping(address => mapping(address => uint256))) private _drawnBy; }