--- eip: 8192 title: Mandated Execution for Tokenized Vaults description: A minimal interface for non-custodial, risk-constrained delegated strategy execution for ERC-4626 vaults. author: Tabilabs (@tabilabs) discussions-to: https://ethereum-magicians.org/t/erc-8192-mandated-execution-for-tokenized-vaults/27877 status: Draft type: Standards Track category: ERC created: 2026-02-15 requires: 20, 165, 712, 1271, 4626 --- ## Abstract This ERC defines a minimal, interoperable interface for **delegated strategy execution** on **[ERC-4626][1] tokenized vaults**, allowing external executors (e.g., AI agents or solvers) to submit on-chain executions **without custody of private keys**, while the vault enforces **hard risk constraints** on-chain. Where ERC-4626 standardizes custody and accounting (deposit, withdraw, `totalAssets`), this ERC standardizes the **execution layer** — how vault assets can be safely deployed by authorized executors under on-chain risk constraints. ([Ethereum Improvement Proposals][1]) The core primitive is a signed **Mandate** ([EIP-712][5]) specifying: (i) an adapter allowlist commitment (address + code fingerprint), (ii) a maximum allowable single-execution drawdown bound, (iii) a maximum cumulative drawdown bound over a rolling epoch, and (iv) an optional binding to an intended payload. The vault executes a batch of adapter calls (`CALL`) and then enforces a **circuit breaker** by comparing pre- and post-execution `totalAssets()` (ERC-4626). > **Applicability note:** This ERC's circuit breaker relies on `totalAssets()` being resistant to manipulation within the same transaction. Vaults whose `totalAssets()` can be atomically manipulated by an executor (e.g., via flash-loan-inflated spot prices) MUST NOT rely solely on this mechanism and SHOULD employ additional safeguards (e.g., TWAP-based valuation or an oracle extension). ## Motivation DeFi is shifting from “user manually calls contracts” toward “agents/solvers execute strategies”. ERC-4626 standardizes pooled custody and accounting, but it does not standardize a trust-minimized way for a vault to grant **bounded execution power** to third-party executors. ([Ethereum Improvement Proposals][1]) Existing standards cover adjacent layers: * Operator authorization (e.g., [ERC-7741][2]) focuses on setting operators, not standardized vault strategy execution and post-execution risk enforcement. ([Ethereum Improvement Proposals][2]) * General delegation frameworks (e.g., [ERC-7710][3]) define delegation plumbing, but not vault-native risk constraints using ERC-4626 accounting semantics. ([Ethereum Improvement Proposals][3]) * Intent frameworks (e.g., [ERC-7521][4], [ERC-7683][10]) standardize intent/order expression and settlement, not pooled vault execution semantics and circuit breakers. ([Ethereum Improvement Proposals][4], [Ethereum Improvement Proposals][10]) * Account abstraction ([ERC-4337][11]) and modular smart accounts ([ERC-7579][12]) standardize individual account execution and module composition, but do not address **pooled capital vaults** where multiple depositors share a single asset pool managed by a delegated executor under risk constraints. Protocol-specific approaches exist (e.g., Sommelier's Strategist model, Enzyme's fund management framework, Brahma's SubAccount system), but each defines a closed execution interface tightly coupled to its own vault implementation. This ERC abstracts the common pattern — signed mandate, adapter allowlist, circuit breaker — into an open, interoperable interface that any ERC-4626 vault can adopt. This ERC fills the gap by defining a "thin waist" for **risk-constrained mandated execution** for tokenized vaults. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. ### Terminology * **Vault**: an ERC-4626 tokenized vault implementing this ERC. ([Ethereum Improvement Proposals][1]) * **Authority**: the address whose signatures authorize mandates for the vault. * **Executor**: the external actor submitting `execute` transactions (could be an AI agent/solver). * **Adapter**: an on-chain contract that the vault calls to perform strategy steps. * **Mandate**: [EIP-712][5] signed authorization defining constraints and optional extensions. * **Action**: a single `CALL` to an adapter with calldata and value. * **Circuit Breaker**: the post-execution enforcement comparing pre/post `totalAssets()` against `maxDrawdownBps` (single-execution) and cumulative loss from epoch high-water mark against `maxCumulativeDrawdownBps`. * **Epoch**: a rolling accounting period during which cumulative drawdown is tracked. The epoch's reference value (`epochAssets`) is maintained as a high-water mark of `totalAssets()` observed across executions. ### Design goals (Core) A vault compliant with this ERC MUST: 1. Validate mandates via **[EIP-712][5]** and support **[ERC-1271][6]** authorities. ([Ethereum Improvement Proposals][5]) 2. Provide a standardized `execute` entrypoint with replay protection (nonce/deadline) and adapter allowlist enforcement. 3. Enforce a **max single-execution drawdown** bound and a **max cumulative drawdown** bound (epoch-based high-water mark) using ERC-4626 `totalAssets()` pre/post, reverting on violation. ([Ethereum Improvement Proposals][1]) Everything else (slippage rules, valuation oracles, complex fee accounting, etc.) is out of scope for Core and can be implemented via extensions. --- ### Data Types #### `Action` ```solidity struct Action { address adapter; // adapter contract to call uint256 value; // native value sent with the call bytes data; // calldata for adapter } ``` * The vault MUST perform each action as a low-level `CALL` (not `DELEGATECALL` or `CALLCODE`) to `adapter` with `(value, data)`. * The vault MUST treat each action's `adapter` as the target checked against the allowlist, and MUST reject adapters with empty code (`adapter.code.length == 0`). * **Core baseline:** `action.value` MUST be `0` for every action. Sending native ETH through actions is out of scope for Core and MAY only be enabled by an explicit extension. The `execute` function is not `payable`; executors cannot attach ETH to the call. #### `Mandate` ```solidity struct Mandate { // Who may submit execute (optional) address executor; // if address(0), any caller MAY execute // Replay protection / expiry uint256 nonce; // unique per authority per vault uint48 deadline; // unix timestamp; 0 means no expiry uint64 authorityEpoch; // MUST match vault authorityEpoch() at execution time // Hard constraints uint16 maxDrawdownBps; // max allowed drop in totalAssets() for this execution (1 bps = 0.01%) uint16 maxCumulativeDrawdownBps; // max allowed cumulative drop from epoch high-water mark (1 bps = 0.01%) // Adapter allowlist commitment bytes32 allowedAdaptersRoot; // merkle root of allowed adapters // Optional binding to an intended payload bytes32 payloadDigest; // if nonzero, MUST equal keccak256(abi.encode(actions)) // Extension mechanism bytes32 extensionsHash; // MUST equal keccak256(extensions) } ``` Constraints: * `maxDrawdownBps` MUST be in `[0, 10_000]`. * `maxCumulativeDrawdownBps` MUST be in `[0, 10_000]` and MUST be `>= maxDrawdownBps`. * `allowedAdaptersRoot` MUST be nonzero. * `authorityEpoch` MUST equal the current on-chain `authorityEpoch()` at execution time. > **Semantic note on `maxCumulativeDrawdownBps`:** This value is per-mandate, but the epoch state (`epochAssets`) is global to the vault. When multiple mandates with different `maxCumulativeDrawdownBps` values are active, the effective cumulative drawdown limit for any given execution is determined by the mandate used for that execution. Consequently, the **most permissive** mandate signed by the authority sets the practical upper bound on cumulative loss within an epoch. Authorities SHOULD ensure all concurrently valid mandates share consistent cumulative drawdown limits, or use `invalidateNoncesBelow` to retire mandates with overly permissive values. --- ### EIP-712 Signed Mandate Mandates MUST be signed as EIP-712 typed structured data. ([Ethereum Improvement Proposals][5]) #### EIP-712 domain The domain separator MUST bind signatures to a single vault instance and chain: * `name`: `"MandatedExecution"` * `version`: `"1"` * `chainId`: current chain id * `verifyingContract`: the vault address #### Typed data definition Type string: ``` Mandate( address executor, uint256 nonce, uint48 deadline, uint64 authorityEpoch, uint16 maxDrawdownBps, uint16 maxCumulativeDrawdownBps, bytes32 allowedAdaptersRoot, bytes32 payloadDigest, bytes32 extensionsHash ) ``` The signed struct hash MUST follow EIP-712 `hashStruct` rules. ([Ethereum Improvement Proposals][5]) #### Authority signature validation The vault MUST define the authority via: ```solidity function mandateAuthority() external view returns (address); ``` When validating a mandate signature: * If `mandateAuthority()` is an EOA, the vault MUST recover and compare the signer to `mandateAuthority()`. * If `mandateAuthority()` is a contract, the vault MUST validate via ERC-1271 `isValidSignature(hash, signature)`. ([Ethereum Improvement Proposals][6]) --- ### Adapter Allowlist (Merkle) This ERC standardizes adapter allowlisting through a Merkle root commitment `allowedAdaptersRoot`. #### Leaf encoding The leaf for adapter `A` MUST be: ``` leaf = keccak256(abi.encode(A, codeHash)) where codeHash = extcodehash(A) (i.e., A.codehash) ``` #### Tree construction The Merkle tree MUST be constructed using **sorted pairs** (i.e., for each internal node, the two children are sorted before hashing: `keccak256(abi.encodePacked(min(a,b), max(a,b)))`). This matches the OpenZeppelin `MerkleProof` library convention and ensures deterministic root computation across implementations. #### Proof verification For each `Action`, the caller provides a `bytes32[] proof` to prove membership of `action.adapter` in the Merkle tree committed by `allowedAdaptersRoot`. * The vault MUST revert if any action’s adapter fails verification against `allowedAdaptersRoot`. * When verifying, the vault MUST compute `codeHash` from the *current* `action.adapter` (i.e., `action.adapter.codehash`) and verify membership of `leaf = keccak256(abi.encode(action.adapter, codeHash))`. > Note: This leaf scheme pins runtime bytecode at `adapter` (via `codeHash`). Upgradeable proxy adapters can still change behavior without changing the proxy bytecode, and therefore MUST NOT be allowlisted in Core unless an additional implementation-pinning scheme is used (see Security Considerations). > Practical note: The EVM does not provide a way for a vault contract to read arbitrary storage of another contract. As a result, generic on-chain verification of an [EIP-1967][14] proxy's current implementation is not possible without the adapter exposing an explicit introspection method. Core therefore treats upgradeable proxy adapters as unsafe to allowlist by default. --- ### Off-chain Construction Guide (informative) #### Digests * `actionsDigest = keccak256(abi.encode(actions))` * `payloadDigest`: * If the authority wants to bind an exact action batch: set `payloadDigest = actionsDigest`. * Otherwise set `payloadDigest = bytes32(0)` (but Core forbids unbounded open mandates where `executor == address(0)` and `payloadDigest == 0`). #### `extensions` and `extensionsHash` * `extensionsHash = keccak256(extensions)` and MUST be committed in the signed mandate. * No extensions: * `extensions` MUST be an empty byte sequence (`""`, length 0). * `extensionsHash` MUST be `keccak256("")`. * With extensions: * Build `Extension[]` with strictly ascending `id` (no duplicates). * Encode as `extensions = abi.encode(Extension[])`. * Compute `extensionsHash = keccak256(extensions)`. #### Merkle roots and proofs Adapter allowlist: * Leaf: `leaf = keccak256(abi.encode(adapter, extcodehash(adapter)))` * Tree construction MUST use sorted pairs: * `parent = keccak256(abi.encodePacked(min(left,right), max(left,right)))` * The executor supplies `adapterProofs[i]` for each `actions[i]` (1:1 aligned). Selector allowlist (optional `SelectorAllowlist@v1` extension): * For each action `i`, read `selector = bytes4(actions[i].data[0:4])` (requires `actions[i].data.length >= 4`). * Leaf: `leaf = keccak256(abi.encode(actions[i].adapter, selector))` * Proofs are supplied via `Extension.data = abi.encode(root, proofs)` where `proofs.length == actions.length` and `proofs[i]` aligns to `actions[i]`. ### Core Interface A vault compliant with this ERC MUST implement [ERC-165][7] and return `true` for `type(IERC8192MandatedVault).interfaceId`. ([Ethereum Improvement Proposals][7]) > **interfaceId:** `0x25cb08f6` (computed as the XOR of all function selectors in `IERC8192MandatedVault`). ```solidity interface IERC8192MandatedVault /* is IERC4626, IERC165 */ { // --------- Structs --------- struct Action { address adapter; uint256 value; bytes data; } struct Mandate { address executor; uint256 nonce; uint48 deadline; uint64 authorityEpoch; uint16 maxDrawdownBps; uint16 maxCumulativeDrawdownBps; bytes32 allowedAdaptersRoot; bytes32 payloadDigest; bytes32 extensionsHash; } struct Extension { bytes4 id; bool required; bytes data; } // --------- Events --------- event MandateExecuted( bytes32 indexed mandateHash, address indexed authority, address indexed executor, bytes32 actionsDigest, uint256 preAssets, uint256 postAssets ); event MandateRevoked(bytes32 indexed mandateHash, address indexed authority); event NonceInvalidated(address indexed authority, uint256 indexed nonce); event NoncesInvalidatedBelow(address indexed authority, uint256 threshold); event AuthorityTransferred(address indexed previousAuthority, address indexed newAuthority); event AuthorityProposed(address indexed currentAuthority, address indexed proposedAuthority); event EpochReset(address indexed authority, uint256 newEpochAssets, uint48 newEpochStart); // --------- Errors --------- error NotAuthority(); error UnauthorizedExecutor(); error MandateExpired(); error AuthorityEpochMismatch(); error InvalidSignature(); error NonceAlreadyUsed(); error NonceBelowThreshold(); error ThresholdNotIncreased(); error MandateIsRevoked(); error ExtensionsHashMismatch(); error PayloadDigestMismatch(); error AdapterNotAllowed(); error UnsupportedRequiredExtension(bytes4 id); error InvalidDrawdownBps(); error InvalidCumulativeDrawdownBps(); error InvalidAdaptersRoot(); error DrawdownExceeded(); error CumulativeDrawdownExceeded(); error UnboundedOpenMandate(); error EmptyActions(); error NonZeroActionValue(); error ActionCallFailed(uint256 index, bytes reason); error ZeroAddressAuthority(); error InvalidExtensionsEncoding(); error ExtensionsNotCanonical(); error VaultBusy(); // --------- Views --------- function mandateAuthority() external view returns (address); function authorityEpoch() external view returns (uint64); function isNonceUsed(address authority, uint256 nonce) external view returns (bool); function isMandateRevoked(bytes32 mandateHash) external view returns (bool); function nonceThreshold(address authority) external view returns (uint256); function hashMandate(Mandate calldata mandate) external view returns (bytes32); function hashActions(Action[] calldata actions) external pure returns (bytes32); /// @notice Returns the timestamp when the current epoch started. function epochStart() external view returns (uint48); /// @notice Returns the high-water mark of totalAssets() for the current epoch. function epochAssets() external view returns (uint256); /// @notice Returns true if the vault supports the given extension ID. function supportsExtension(bytes4 id) external view returns (bool); // --------- Authority management --------- /// @notice Returns the pending authority (zero if no transfer is in progress). function pendingAuthority() external view returns (address); function proposeAuthority(address newAuthority) external; function acceptAuthority() external; // --------- Epoch management --------- /// @notice Resets the epoch, setting epochAssets to current totalAssets() and epochStart to block.timestamp. function resetEpoch() external; // --------- Revocation --------- function revokeMandate(bytes32 mandateHash) external; function invalidateNonce(uint256 nonce) external; function invalidateNoncesBelow(uint256 threshold) external; // --------- Execution --------- function execute( Mandate calldata mandate, Action[] calldata actions, bytes calldata signature, bytes32[][] calldata adapterProofs, bytes calldata extensions ) external returns (uint256 preAssets, uint256 postAssets); } ``` #### Execution flow (informative) ``` [Authority] --signs (EIP-712)--> [Mandate + extensionsHash + optional payloadDigest] | v [Executor] --submits--> execute(mandate, actions, signature, adapterProofs, extensions) | v [Vault] validate mandate + proofs + extensions | v [Vault] snapshot preAssets = totalAssets() | v [Vault] CALL adapters in-order (Core: action.value == 0) | v [Vault] snapshot postAssets = totalAssets() | v [Vault] enforce circuit breaker (single + cumulative) -> emit MandateExecuted ``` #### Execution semantics (MUST) When `execute` is called, the vault MUST: 1. **Validate mandate expiry** * If `mandate.deadline != 0`, it MUST satisfy `block.timestamp <= mandate.deadline`. 2. **Validate executor restriction** * If `mandate.executor != address(0)`, then `msg.sender MUST equal mandate.executor`. 3. **Validate open mandate safety** * If `mandate.executor == address(0)` AND `mandate.payloadDigest == bytes32(0)`, the vault MUST revert with `UnboundedOpenMandate`. An open mandate (any caller) without a bound payload is considered unsafe because any party could execute arbitrary actions within the adapter allowlist. 4. **Validate authority epoch** * MUST revert with `AuthorityEpochMismatch` if `mandate.authorityEpoch != authorityEpoch()`. 5. **Validate mandate constraints** * MUST revert with `InvalidDrawdownBps` if `mandate.maxDrawdownBps > 10_000`. * MUST revert with `InvalidCumulativeDrawdownBps` if `mandate.maxCumulativeDrawdownBps > 10_000` OR `mandate.maxCumulativeDrawdownBps < mandate.maxDrawdownBps`. * MUST revert with `InvalidAdaptersRoot` if `mandate.allowedAdaptersRoot == bytes32(0)`. 6. **Validate extensions hash** * MUST require `keccak256(extensions) == mandate.extensionsHash`. * If `extensions.length != 0`, MUST decode `extensions` as `abi.decode(extensions, (Extension[]))`, and MUST enforce required/optional semantics as defined in the Extension mechanism section (reverting on invalid encoding). 7. **Compute mandate hash & check revocation** * MUST compute `mandateHash` as the EIP-712 digest for the mandate. ([Ethereum Improvement Proposals][5]) * MUST revert with `MandateIsRevoked` if the mandate hash has been revoked. 8. **Validate authority signature** * MUST validate `mandateHash` against `mandateAuthority()` (EOA recovery or ERC-1271). ([Ethereum Improvement Proposals][5]) 9. **Replay protection** * MUST revert if `mandate.nonce < nonceThreshold(authority)`. * MUST revert if `isNonceUsed(authority, mandate.nonce)` is true. * MUST mark the nonce as used before any state-modifying external calls (to prevent reentrancy replay). Note: read-only calls such as `staticcall` for ERC-1271 signature validation (step 7) are exempt from this ordering requirement. * MUST make `execute` reentrancy-safe (e.g., a reentrancy guard / mutex) to prevent reentering `execute` during action execution. * MUST also prevent share mint/burn entrypoints (`deposit`/`mint`/`withdraw`/`redeem`) from being callable during `execute` (e.g., shared mutex or execution-phase guard). 10. **Optional payload binding** * Let `actionsDigest = keccak256(abi.encode(actions))`. * If `mandate.payloadDigest != bytes32(0)`, MUST require `mandate.payloadDigest == actionsDigest`. 11. **Validate actions non-empty** * MUST require `actions.length > 0`, reverting with `EmptyActions` otherwise. 12. **Adapter allowlist checks** * MUST require `actions.length == adapterProofs.length`. * For each `i`, MUST verify the Merkle proof of `leaf = keccak256(abi.encode(actions[i].adapter, actions[i].adapter.codehash))` against `mandate.allowedAdaptersRoot`. 13. **Pre-state snapshot & epoch initialization** * MUST read `preAssets = totalAssets()` (ERC-4626). ([Ethereum Improvement Proposals][1]) * If `epochStart() == 0` (no epoch initialized), the vault MUST initialize the epoch by setting `epochStart = block.timestamp` and `epochAssets = preAssets`. 14. **Execute actions** * For each action in order: * In Core, the vault MUST revert with `NonZeroActionValue` if `action.value != 0`. * The vault MUST perform a low-level `CALL` (not `DELEGATECALL` or `CALLCODE`) to `action.adapter` with `(value, data)`. * If any action call fails, MUST revert. 15. **Post-state snapshot** * MUST read `postAssets = totalAssets()`. 16. **Circuit breaker (single-execution and cumulative)** * If `preAssets == 0`, the drawdown check MAY be skipped. * Otherwise, MUST revert with `DrawdownExceeded` if: ``` preAssets > postAssets AND (preAssets - postAssets) * 10_000 > preAssets * mandate.maxDrawdownBps ``` * **Cumulative drawdown check (epoch-based high-water mark):** * Let `eAssets = epochAssets()`. * If `eAssets == 0`, the cumulative check MAY be skipped. * Otherwise, MUST revert with `CumulativeDrawdownExceeded` if: ``` eAssets > postAssets AND (eAssets - postAssets) * 10_000 > eAssets * mandate.maxCumulativeDrawdownBps ``` * **High-water mark update:** After the cumulative check passes, if `postAssets > epochAssets`, the vault MUST update `epochAssets = postAssets`. This ensures the cumulative drawdown is always measured from the peak value within the epoch, preventing the "death by a thousand cuts" attack where vault growth renders the cumulative check ineffective. * The vault MUST provide `resetEpoch()` callable only by `mandateAuthority()`, which sets `epochAssets = totalAssets()` and `epochStart = block.timestamp`. This allows the authority to start a fresh epoch after legitimate drawdowns or strategy changes. 17. **Emit events** (see below). #### Input size limits (RECOMMENDED) To reduce gas-griefing and make execution/simulation behavior more predictable, implementations SHOULD enforce reasonable upper bounds on calldata inputs. The following values are RECOMMENDED (and used by the reference implementation in `reference/src/MandatedVault.sol`): * `actions.length <= 32` * `extensions.length <= 128 KiB` * Decoded `Extension[]` length `<= 16` (when `extensions.length != 0`) * `adapterProofs[i].length <= 64` * For `SelectorAllowlist@v1`, `proofs[i].length <= 64` Implementations SHOULD fail fast (before decoding large ABI blobs or hashing large `extensions`) when inputs exceed limits. --- ### Events ```solidity event MandateExecuted( bytes32 indexed mandateHash, address indexed authority, address indexed executor, bytes32 actionsDigest, uint256 preAssets, uint256 postAssets ); event MandateRevoked(bytes32 indexed mandateHash, address indexed authority); event NonceInvalidated(address indexed authority, uint256 indexed nonce); event NoncesInvalidatedBelow(address indexed authority, uint256 threshold); event AuthorityTransferred(address indexed previousAuthority, address indexed newAuthority); event AuthorityProposed(address indexed currentAuthority, address indexed proposedAuthority); event EpochReset(address indexed authority, uint256 newEpochAssets, uint48 newEpochStart); ``` * `actionsDigest` MUST equal `keccak256(abi.encode(actions))`. --- ### Errors A compliant vault MUST define the following custom errors: ```solidity error NotAuthority(); error UnauthorizedExecutor(); error MandateExpired(); error AuthorityEpochMismatch(); error InvalidSignature(); error NonceAlreadyUsed(); error NonceBelowThreshold(); error ThresholdNotIncreased(); error MandateIsRevoked(); error ExtensionsHashMismatch(); error PayloadDigestMismatch(); error AdapterNotAllowed(); error UnsupportedRequiredExtension(bytes4 id); error InvalidDrawdownBps(); error InvalidCumulativeDrawdownBps(); error InvalidAdaptersRoot(); error DrawdownExceeded(); error CumulativeDrawdownExceeded(); error UnboundedOpenMandate(); error EmptyActions(); error NonZeroActionValue(); error ActionCallFailed(uint256 index, bytes reason); error ZeroAddressAuthority(); error InvalidExtensionsEncoding(); error ExtensionsNotCanonical(); error VaultBusy(); ``` --- ### Authority Management The vault MUST implement a **two-step authority transfer** to prevent accidental loss of authority: * `proposeAuthority(newAuthority)` callable only by `mandateAuthority()`. If `newAuthority == address(0)`, the vault MUST clear the pending authority (cancelling any in-progress transfer) and emit `AuthorityProposed(currentAuthority, address(0))`. Otherwise, sets the pending authority and emits `AuthorityProposed`. * `acceptAuthority()` callable only by the pending authority. Completes the transfer, sets the new authority, clears the pending authority, increments `authorityEpoch`, and emits `AuthorityTransferred`. * `pendingAuthority()` returns the address of the pending authority (zero if no transfer is in progress). * `authorityEpoch()` returns the current authority epoch used for mandate replay-domain separation across authority rotations. > **Rationale:** A single-step `setAuthority` risks permanently locking the vault if the wrong address is provided. The two-step pattern (similar to OpenZeppelin's `Ownable2Step`) ensures the new authority can actually accept the role before the transfer completes. --- ### Revocation & Nonce invalidation The vault MUST support: * `revokeMandate(mandateHash)` callable only by `mandateAuthority()` (or governance logic) which marks the mandate hash as revoked. * `invalidateNonce(nonce)` callable only by `mandateAuthority()` to proactively invalidate a nonce without revealing future mandates. * `invalidateNoncesBelow(threshold)` callable only by `mandateAuthority()` which sets a threshold value. Any mandate whose `nonce < threshold` MUST be rejected during execution. This enables bulk invalidation of all previously issued mandates without enumerating individual nonces. The threshold MUST only increase; if `threshold <= currentThreshold`, the vault MUST revert with `ThresholdNotIncreased`. A vault MAY also provide batched versions (out of scope for Core). --- ### Extension mechanism (Core-level hook) Core requires that `extensions` are **hash-committed** via `extensionsHash` and supplied to `execute`. A vault MAY support zero or more extensions; however, it MUST implement the canonical encoding and required/optional handling defined below. Canonical encoding for `extensions`: ```solidity struct Extension { bytes4 id; bool required; bytes data; } // extensions = abi.encode(Extension[]) ``` Unknown extensions MUST be handled as follows: * If an unknown extension has `required == true`, the vault MUST revert. * If an unknown extension has `required == false`, the vault MAY ignore it. If `extensions` is not a valid ABI encoding of `Extension[]`, the vault MUST revert. > **Decoding note:** Solidity `try/catch` can only wrap external calls. Implementations that wish to treat ABI decoding failures as a specific error (e.g., `InvalidExtensionsEncoding`) may use an external self-call helper (e.g., `this.decodeExtensions(extensions)`) and wrap it in `try/catch`. This pattern is used in the reference implementation. If `extensions.length != 0`, decoded `Extension[]` MUST be canonical: * Entries MUST be strictly sorted by `id` in ascending order. * Duplicate `id` entries MUST revert. * Non-canonical ordering MUST revert with `ExtensionsNotCanonical`. > **Canonical "no extensions" encoding:** When no extensions are used, the caller MUST pass `extensions` as an empty byte sequence (`""`, i.e., zero length). The corresponding `extensionsHash` in the mandate MUST be `keccak256("")` (= `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`). Note that `abi.encode(new Extension[](0))` produces a non-empty 32-byte ABI offset and differs from the empty byte sequence; implementations MUST NOT conflate the two. Extension IDs SHOULD be globally unique. A recommended convention is to derive IDs as the first 4 bytes of `keccak256` of a canonical identifier string (for example, the string "mandated-vault-price-validation@v1") and publish them in a registry (out of scope for Core). ### SelectorAllowlist@v1 (optional extension) This extension allows the authority to further constrain each `Action` to an allowlist of `(adapter, function selector)` pairs. This reduces the attack surface when an allowlisted adapter exposes multiple entrypoints but only a subset should be callable under a given mandate. #### Extension ID The extension ID is: * the first 4 bytes of `keccak256` over the UTF-8 string "mandated-vault-selector-allowlist@v1" #### Data encoding `Extension.data` MUST be ABI-encoded as: ```solidity // data = abi.encode(bytes32 root, bytes32[][] proofs) ``` Where `proofs[i]` is the Merkle proof for `actions[i]` and the proof list MUST be aligned 1:1 with `actions`. #### Leaf encoding For each action `i`, the vault MUST: * Require `actions[i].data.length >= 4`. * Read `selector` as the first 4 bytes of `actions[i].data`. * Compute `leaf = keccak256(abi.encode(actions[i].adapter, selector))`. #### Tree construction The Merkle tree MUST be constructed using **sorted pairs**, matching the OpenZeppelin `MerkleProof` convention described in the Adapter Allowlist section. #### Verification If this extension is present, the vault MUST: * Require `proofs.length == actions.length` (otherwise revert with `InvalidExtensionsEncoding`). * For every action `i`, require `MerkleProof.verify(proofs[i], root, leaf)` returns true; otherwise revert. Implementations SHOULD revert with specific errors to aid debugging (not part of Core), e.g.: ```solidity error InvalidActionData(uint256 index); error SelectorNotAllowed(uint256 index, address adapter, bytes4 selector); ``` --- ## Rationale ### Why anchor the circuit breaker to ERC-4626 `totalAssets()`? It yields a universal, composable accounting metric for tokenized vaults. By using `totalAssets()` for pre/post snapshots, the drawdown bound is enforceable at EVM level without introducing new valuation interfaces. ([Ethereum Improvement Proposals][1]) ### Why allow `payloadDigest == 0`? Many executor workflows require flexibility where the authority signs only constraints (e.g., allowlist + drawdown) and lets the executor choose exact steps. Optional binding provides both modes: * **Bounded intent**: authority signs exact action batch * **Open mandate**: authority signs constraints only ### Why not standardize slippage/oracles/valuation in Core? Those constraints depend heavily on strategy classes and asset types, and prematurely standardizing them risks fragmentation and slow adoption. Related work exists for user-vault slippage protections ([ERC-5143][8]) and for performance fee accounting ([ERC-8113][9]), which can be integrated as optional extensions. ([Ethereum Improvement Proposals][8], [Ethereum Improvement Proposals][9]) ### Why use an epoch-based high-water mark for cumulative drawdown? A simple per-execution drawdown limit is insufficient: an attacker (or buggy executor) can drain the vault through many small losses, each within the per-execution bound ("death by a thousand cuts"). The epoch-based high-water mark tracks the peak `totalAssets()` within an epoch and measures cumulative loss from that peak. This ensures that vault growth does not silently raise the absolute loss tolerance. The authority can reset the epoch via `resetEpoch()` after legitimate drawdowns or strategy rotations. ### Why require `payloadDigest` for open mandates (`executor == address(0)`)? An open mandate without payload binding allows any party to execute arbitrary actions (within the adapter allowlist) using the authority's signature. While the circuit breaker limits damage, the combination of open executor + open payload creates an unnecessarily large attack surface. Requiring `payloadDigest != 0` for open mandates ensures the authority explicitly commits to the intended action batch when allowing any caller to submit. ### Why two-step authority transfer? The authority role controls all mandate signing, revocation, and epoch management. A single-step transfer (`setAuthority`) risks permanently locking the vault if the wrong address is provided (e.g., typo, wrong chain address). The two-step pattern (`proposeAuthority` + `acceptAuthority`) ensures the new authority can actually transact before the transfer completes, following the established `Ownable2Step` pattern. ### Why include `authorityEpoch` in `Mandate`? Without epoch binding, signatures created by a previously active authority can become valid again if authority rotates away and later rotates back to the same address. `authorityEpoch` creates a monotonic replay domain boundary across authority rotations. ### Why forbid non-zero `action.value` in Core? Most ERC-4626 `totalAssets()` implementations account only for the [ERC-20][13] asset. Allowing ETH outflows through `action.value` would create an untracked loss channel outside the circuit breaker. Core forbids this by default; ETH-enabled behavior belongs in explicit extensions with dedicated accounting controls. ### Why Merkle tree over on-chain mapping for adapter allowlists? A Merkle root commitment keeps the allowlist off-chain, reducing storage costs and enabling large allowlists without proportional gas costs. The authority can sign mandates with different allowlist roots for different strategies without on-chain state changes. The trade-off is that callers must provide proofs, but this is acceptable for the executor-driven execution model. --- ## Backwards Compatibility This ERC is additive. Existing ERC-4626 vaults can implement this interface without changing deposit/withdraw behavior. ([Ethereum Improvement Proposals][1]) --- ## Test Cases Normal flows: * Basic execution with a single allowlisted adapter call (`payloadDigest == 0`, `executor != address(0)`). * Bounded intent execution (`payloadDigest != 0`) where `payloadDigest == keccak256(abi.encode(actions))`. * Multiple actions execute in-order; partial failures revert the whole transaction. * Unknown optional extensions are ignored (hash-committed, `required == false`). * Two-step authority transfer: `proposeAuthority()` by current authority, then `acceptAuthority()` by proposed authority succeeds; subsequent mandates must be signed by the new authority. * `acceptAuthority()` increments `authorityEpoch`, and newly signed mandates use the updated epoch. * Epoch initializes on first execution (`epochStart == 0` before, nonzero after). * Cumulative drawdown within bounds across multiple executions in the same epoch succeeds. * High-water mark updates: after a profitable execution (`postAssets > epochAssets`), `epochAssets` is updated to `postAssets`. * `resetEpoch()` by authority resets `epochAssets` to current `totalAssets()` and `epochStart` to `block.timestamp`. Boundary / failure cases: * Expired mandate (`deadline != 0` and `block.timestamp > deadline`) reverts. * Executor restriction mismatch reverts. * Invalid ECDSA signature (EOA authority) reverts. * Invalid ERC-1271 signature (contract authority) reverts; ERC-1271 returning less than 32 bytes reverts. * Nonce replay reverts; nonce is marked used before any external calls. * Nonce below threshold reverts (`mandate.nonce < nonceThreshold(authority)`). * `invalidateNoncesBelow` with a threshold <= current threshold reverts with `ThresholdNotIncreased`. * Revoked mandate hash reverts. * `adapterProofs.length != actions.length` reverts. * Adapter allowlist proof fails for `leaf = keccak256(abi.encode(adapter, adapter.codehash))` reverts. * Adapter has empty code (`adapter.code.length == 0`) reverts. * `keccak256(extensions) != extensionsHash` reverts. * Non-canonical `extensions` ordering (unsorted or duplicate `id`) reverts with `ExtensionsNotCanonical`. * Unknown required extension (`required == true`) reverts. * Single-execution drawdown exceeded reverts; `preAssets == 0` may skip drawdown enforcement. * Cumulative drawdown exceeded reverts: multiple small losses each within `maxDrawdownBps` but cumulative loss exceeds `maxCumulativeDrawdownBps` from `epochAssets`. * Open mandate without payload binding (`executor == address(0)` AND `payloadDigest == bytes32(0)`) reverts with `UnboundedOpenMandate`. * Empty actions array (`actions.length == 0`) reverts with `EmptyActions`. * Any action with `action.value != 0` reverts with `NonZeroActionValue` in Core. * Mandate constraint validation: `maxDrawdownBps > 10_000` reverts with `InvalidDrawdownBps`; `maxCumulativeDrawdownBps > 10_000` or `< maxDrawdownBps` reverts with `InvalidCumulativeDrawdownBps`; `allowedAdaptersRoot == bytes32(0)` reverts with `InvalidAdaptersRoot`. * Zero drawdown boundary: `maxDrawdownBps == 0` causes even 1 wei of loss to revert with `DrawdownExceeded`. * Authority transfer invalidates old signatures: a mandate signed by the previous authority MUST fail after `acceptAuthority()` completes. * `mandate.authorityEpoch != authorityEpoch()` reverts with `AuthorityEpochMismatch`. * `invalidateNonce(n)` followed by `execute` with nonce `n` reverts with `NonceAlreadyUsed`. * `proposeAuthority(address(0))` cancels a pending transfer (clears `pendingAuthority`). * `proposeAuthority()` called by non-authority reverts. * `acceptAuthority()` called by non-pending-authority reverts. * `resetEpoch()` called by non-authority reverts. Security regressions: * Reentrancy: an adapter attempting to reenter `execute` MUST fail. * Reentrancy: an adapter attempting to call `deposit`/`mint`/`withdraw`/`redeem` during `execute` MUST fail. * Codehash pinning: changing adapter runtime code requires a new allowlist root; old proofs must fail. * "Death by a thousand cuts": N sequential executions each losing `maxDrawdownBps - 1` bps MUST eventually trigger `CumulativeDrawdownExceeded` when cumulative loss from `epochAssets` (high-water mark) exceeds `maxCumulativeDrawdownBps`. * High-water mark effectiveness: after vault growth (e.g., profitable execution), the cumulative drawdown is measured from the new peak, not the original epoch start value. Verify that `epochAssets` updates to `postAssets` when `postAssets > epochAssets`. * Authority transfer safety: proposing a new authority does not immediately change `mandateAuthority()`; only `acceptAuthority()` completes the transfer. --- ## Reference Implementation The following reference implementation is intended to be compileable (with OpenZeppelin Contracts v5.x) and to demonstrate the full Core semantics (EIP-712, ERC-1271, Merkle allowlist with codehash pinning, reentrancy safety, epoch-based cumulative drawdown tracking, authority transfer, nonce threshold invalidation). It also includes some non-Core but recommended/optional behaviors described in this document (e.g., `SelectorAllowlist@v1`, input size limits, and an authority-only native ETH escape hatch). > **Compilation note:** This reference implementation inherits from `IERC8192MandatedVault`, which is the interface defined in the Core Interface section above. To compile, the interface MUST be defined in a separate file (e.g., `IERC8192MandatedVault.sol`) and imported by the implementation contract. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.20; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; import {IERC8192MandatedVault} from "./interfaces/IERC8192MandatedVault.sol"; contract MandatedVault is ERC4626, ERC165, EIP712, ReentrancyGuard, IERC8192MandatedVault { bytes4 internal constant _ERC1271_MAGICVALUE = 0x1626ba7e; bytes32 internal constant _EMPTY_CODEHASH = keccak256(""); bytes4 internal constant _SELECTOR_ALLOWLIST_ID = bytes4(keccak256("mandated-vault-selector-allowlist@v1")); bytes32 internal constant _MANDATE_TYPEHASH = keccak256( "Mandate(address executor,uint256 nonce,uint48 deadline,uint64 authorityEpoch,uint16 maxDrawdownBps,uint16 maxCumulativeDrawdownBps,bytes32 allowedAdaptersRoot,bytes32 payloadDigest,bytes32 extensionsHash)" ); // --------- Recommended input limits (DoS/gas grief mitigation) --------- // These limits are not part of Core semantics, but are included in the reference implementation // to make extreme inputs fail fast and predictably. uint256 public constant MAX_ACTIONS = 32; uint256 public constant MAX_EXTENSIONS = 16; uint256 public constant MAX_ADAPTER_PROOF_DEPTH = 64; uint256 public constant MAX_SELECTOR_PROOF_DEPTH = 64; uint256 public constant MAX_EXTENSIONS_BYTES = 131_072; // 128 KiB event NativeSwept(address indexed to, uint256 amount); error InvalidActionData(uint256 index); error SelectorNotAllowed(uint256 index, address adapter, bytes4 selector); error NativeSweepFailed(); error ZeroAddressRecipient(); error TooManyActions(uint256 count); error TooManyExtensions(uint256 count); error ExtensionsTooLarge(uint256 length); error AdapterProofTooDeep(uint256 index, uint256 depth); error SelectorProofTooDeep(uint256 index, uint256 depth); address private _authority; address private _pendingAuthority; uint64 private _authorityEpoch; mapping(address => mapping(uint256 => bool)) private _nonceUsed; mapping(address => uint256) private _nonceThreshold; mapping(bytes32 => bool) private _mandateRevoked; uint48 private _epochStart; uint256 private _epochAssets; constructor(IERC20 asset_, string memory name_, string memory symbol_, address authority_) ERC20(name_, symbol_) ERC4626(asset_) EIP712("MandatedExecution", "1") { if (authority_ == address(0)) revert ZeroAddressAuthority(); _authority = authority_; } // --------- Views --------- function mandateAuthority() public view returns (address) { return _authority; } function authorityEpoch() external view returns (uint64) { return _authorityEpoch; } function pendingAuthority() external view returns (address) { return _pendingAuthority; } function isNonceUsed(address authority, uint256 nonce) external view returns (bool) { return _nonceUsed[authority][nonce]; } function nonceThreshold(address authority) external view returns (uint256) { return _nonceThreshold[authority]; } function isMandateRevoked(bytes32 mandateHash) external view returns (bool) { return _mandateRevoked[mandateHash]; } function epochStart() external view returns (uint48) { return _epochStart; } function epochAssets() external view returns (uint256) { return _epochAssets; } function supportsExtension(bytes4 id) public view returns (bool) { return _supportsExtension(id); } function supportsInterface(bytes4 interfaceId) public view override returns (bool) { return interfaceId == type(IERC8192MandatedVault).interfaceId || interfaceId == type(IERC4626).interfaceId || super.supportsInterface(interfaceId); } function hashActions(Action[] calldata actions) external pure returns (bytes32) { return keccak256(abi.encode(actions)); } function hashMandate(Mandate calldata mandate) public view returns (bytes32) { bytes32 structHash = keccak256( abi.encode( _MANDATE_TYPEHASH, mandate.executor, mandate.nonce, mandate.deadline, mandate.authorityEpoch, mandate.maxDrawdownBps, mandate.maxCumulativeDrawdownBps, mandate.allowedAdaptersRoot, mandate.payloadDigest, mandate.extensionsHash ) ); return _hashTypedDataV4(structHash); } // --------- Authority management (2-step) --------- function proposeAuthority(address newAuthority) external { if (msg.sender != _authority) revert NotAuthority(); _pendingAuthority = newAuthority; emit AuthorityProposed(_authority, newAuthority); } function acceptAuthority() external { if (msg.sender != _pendingAuthority) revert NotAuthority(); address prev = _authority; _authority = _pendingAuthority; _pendingAuthority = address(0); unchecked { _authorityEpoch++; } emit AuthorityTransferred(prev, _authority); } // --------- Epoch management --------- function resetEpoch() external { if (msg.sender != _authority) revert NotAuthority(); _epochAssets = totalAssets(); _epochStart = uint48(block.timestamp); emit EpochReset(_authority, _epochAssets, _epochStart); } // --------- Revocation --------- function invalidateNonce(uint256 nonce) external { if (msg.sender != _authority) revert NotAuthority(); _nonceUsed[_authority][nonce] = true; emit NonceInvalidated(_authority, nonce); } function invalidateNoncesBelow(uint256 threshold) external { if (msg.sender != _authority) revert NotAuthority(); if (threshold <= _nonceThreshold[_authority]) revert ThresholdNotIncreased(); _nonceThreshold[_authority] = threshold; emit NoncesInvalidatedBelow(_authority, threshold); } function revokeMandate(bytes32 mandateHash) external { if (msg.sender != _authority) revert NotAuthority(); _mandateRevoked[mandateHash] = true; emit MandateRevoked(mandateHash, _authority); } // --------- Execution --------- function execute( Mandate calldata mandate, Action[] calldata actions, bytes calldata signature, bytes32[][] calldata adapterProofs, bytes calldata extensions ) external nonReentrant returns (uint256 preAssets, uint256 postAssets) { // 1. Validate mandate expiry if (mandate.deadline != 0 && block.timestamp > mandate.deadline) revert MandateExpired(); // 2. Validate executor restriction if (mandate.executor != address(0) && msg.sender != mandate.executor) revert UnauthorizedExecutor(); // 3. Validate open mandate safety (spec step 3) if (mandate.executor == address(0) && mandate.payloadDigest == bytes32(0)) { revert UnboundedOpenMandate(); } // 4. Validate authority epoch (spec step 4) if (mandate.authorityEpoch != _authorityEpoch) revert AuthorityEpochMismatch(); // 5. Validate mandate constraints if (mandate.maxDrawdownBps > 10_000) revert InvalidDrawdownBps(); if (mandate.maxCumulativeDrawdownBps > 10_000 || mandate.maxCumulativeDrawdownBps < mandate.maxDrawdownBps) { revert InvalidCumulativeDrawdownBps(); } if (mandate.allowedAdaptersRoot == bytes32(0)) revert InvalidAdaptersRoot(); // 5a. Input size limits (reference implementation; includes spec step 11: EmptyActions) uint256 actionsLen = actions.length; if (actionsLen == 0) revert EmptyActions(); if (actionsLen > MAX_ACTIONS) revert TooManyActions(actionsLen); if (adapterProofs.length != actionsLen) revert AdapterNotAllowed(); if (extensions.length > MAX_EXTENSIONS_BYTES) revert ExtensionsTooLarge(extensions.length); // 6. Validate extensions hash (spec step 6) if (keccak256(extensions) != mandate.extensionsHash) revert ExtensionsHashMismatch(); // 6a. Decode extensions (spec step 6 continued) bool hasSelectorAllowlist; bytes32 selectorRoot; bytes32[][] memory selectorProofs; if (extensions.length != 0) { try this.decodeExtensions(extensions) returns (Extension[] memory exts) { if (exts.length > MAX_EXTENSIONS) revert TooManyExtensions(exts.length); for (uint256 i = 1; i < exts.length;) { if (exts[i - 1].id >= exts[i].id) { revert ExtensionsNotCanonical(); } unchecked { ++i; } } for (uint256 i = 0; i < exts.length;) { if (exts[i].required && !_supportsExtension(exts[i].id)) { revert UnsupportedRequiredExtension(exts[i].id); } if (exts[i].id == _SELECTOR_ALLOWLIST_ID) { hasSelectorAllowlist = true; try this.decodeSelectorAllowlist(exts[i].data) returns (bytes32 root, bytes32[][] memory proofs) { selectorRoot = root; selectorProofs = proofs; } catch { revert InvalidExtensionsEncoding(); } } unchecked { ++i; } } } catch { revert InvalidExtensionsEncoding(); } } // 7. Compute mandate hash & check revocation (spec step 7) bytes32 mandateHash_ = hashMandate(mandate); if (_mandateRevoked[mandateHash_]) revert MandateIsRevoked(); // 8. Validate authority signature (spec step 8) address authority_ = _authority; _verifyAuthoritySig(authority_, mandateHash_, signature); // 9. Replay protection (spec step 9) if (mandate.nonce < _nonceThreshold[authority_]) revert NonceBelowThreshold(); if (_nonceUsed[authority_][mandate.nonce]) revert NonceAlreadyUsed(); _nonceUsed[authority_][mandate.nonce] = true; // 10. Optional payload binding (spec step 10) bytes32 actionsDigest = keccak256(abi.encode(actions)); if (mandate.payloadDigest != bytes32(0) && mandate.payloadDigest != actionsDigest) { revert PayloadDigestMismatch(); } // 12. Adapter allowlist checks (spec step 12) for (uint256 i = 0; i < actionsLen;) { address adapter = actions[i].adapter; bytes32 codeHash = adapter.codehash; // Reject EOAs / precompiles / non-existent accounts (empty code). if (codeHash == bytes32(0) || codeHash == _EMPTY_CODEHASH) revert AdapterNotAllowed(); if (actions[i].value != 0) revert NonZeroActionValue(); if (adapterProofs[i].length > MAX_ADAPTER_PROOF_DEPTH) { revert AdapterProofTooDeep(i, adapterProofs[i].length); } bytes32 leaf = keccak256(abi.encode(adapter, codeHash)); if (!MerkleProof.verifyCalldata(adapterProofs[i], mandate.allowedAdaptersRoot, leaf)) { revert AdapterNotAllowed(); } unchecked { ++i; } } // 12a. Selector allowlist checks (optional extension) if (hasSelectorAllowlist) { _enforceSelectorAllowlist(actions, selectorRoot, selectorProofs); } // 13. Pre-state snapshot & epoch initialization (spec step 13) preAssets = totalAssets(); if (_epochStart == 0) { _epochStart = uint48(block.timestamp); _epochAssets = preAssets; } // 14. Execute actions (spec step 14) for (uint256 i = 0; i < actionsLen;) { (bool ok,) = actions[i].adapter.call{value: actions[i].value}(actions[i].data); if (!ok) { revert ActionCallFailed(i, _copyReturnData()); } unchecked { ++i; } } // 15. Post-state snapshot (spec step 15) postAssets = totalAssets(); // 16. Circuit breaker (spec step 16) // Single-execution drawdown check if (preAssets != 0 && preAssets > postAssets) { uint256 loss = preAssets - postAssets; if (loss * 10_000 > preAssets * uint256(mandate.maxDrawdownBps)) revert DrawdownExceeded(); } // Cumulative drawdown check (epoch-based high-water mark) uint256 eAssets = _epochAssets; if (eAssets != 0 && eAssets > postAssets) { uint256 cumulativeLoss = eAssets - postAssets; if (cumulativeLoss * 10_000 > eAssets * uint256(mandate.maxCumulativeDrawdownBps)) { revert CumulativeDrawdownExceeded(); } } // High-water mark update if (postAssets > eAssets) { _epochAssets = postAssets; } // 17. Emit events (spec step 17) emit MandateExecuted(mandateHash_, authority_, msg.sender, actionsDigest, preAssets, postAssets); } // --------- Internal --------- function _supportsExtension(bytes4 id) internal view virtual returns (bool) { return id == _SELECTOR_ALLOWLIST_ID; } /// @dev External helper for try/catch decoding of extensions. function decodeExtensions(bytes calldata extensions) external pure returns (Extension[] memory) { return abi.decode(extensions, (Extension[])); } /// @dev External helper for try/catch decoding of selector allowlist extension data. function decodeSelectorAllowlist(bytes calldata data) external pure returns (bytes32 root, bytes32[][] memory proofs) { (root, proofs) = abi.decode(data, (bytes32, bytes32[][])); } function _enforceSelectorAllowlist(Action[] calldata actions, bytes32 root, bytes32[][] memory proofs) internal pure { if (proofs.length != actions.length) revert InvalidExtensionsEncoding(); for (uint256 i = 0; i < actions.length;) { if (proofs[i].length > MAX_SELECTOR_PROOF_DEPTH) { revert SelectorProofTooDeep(i, proofs[i].length); } bytes calldata callData = actions[i].data; if (callData.length < 4) revert InvalidActionData(i); bytes4 selector; assembly ("memory-safe") { selector := calldataload(callData.offset) } bytes32 leaf = keccak256(abi.encode(actions[i].adapter, selector)); if (!MerkleProof.verify(proofs[i], root, leaf)) { revert SelectorNotAllowed(i, actions[i].adapter, selector); } unchecked { ++i; } } } function _copyReturnData() internal pure returns (bytes memory ret) { assembly ("memory-safe") { let size := returndatasize() ret := mload(0x40) mstore(ret, size) let dst := add(ret, 0x20) returndatacopy(dst, 0, size) mstore(0x40, add(dst, and(add(size, 0x1f), not(0x1f)))) } } function _verifyAuthoritySig(address authority_, bytes32 digest, bytes calldata signature) internal view { if (authority_.code.length == 0) { (address signer, ECDSA.RecoverError err,) = ECDSA.tryRecoverCalldata(digest, signature); if (err != ECDSA.RecoverError.NoError || signer != authority_) revert InvalidSignature(); return; } bytes4 magic = _ERC1271_MAGICVALUE; uint256 length = signature.length; bool ok; assembly ("memory-safe") { // Encoded calldata is: // [ 0x00 - 0x03 ] // [ 0x04 - 0x23 ] // [ 0x24 - 0x43 ] (0x40) // [ 0x44 - 0x63 ] // [ 0x64 - ... ] let ptr := mload(0x40) mstore(ptr, magic) mstore(add(ptr, 0x04), digest) mstore(add(ptr, 0x24), 0x40) mstore(add(ptr, 0x44), length) calldatacopy(add(ptr, 0x64), signature.offset, length) // Write only the first 32 bytes of returndata to 0x00. ok := staticcall(gas(), authority_, ptr, add(length, 0x64), 0x00, 0x20) ok := and(ok, and(gt(returndatasize(), 0x1f), eq(mload(0x00), magic))) } if (!ok) revert InvalidSignature(); } receive() external payable {} function deposit(uint256 assets, address receiver) public override returns (uint256) { if (_reentrancyGuardEntered()) revert VaultBusy(); return super.deposit(assets, receiver); } function mint(uint256 shares, address receiver) public override returns (uint256) { if (_reentrancyGuardEntered()) revert VaultBusy(); return super.mint(shares, receiver); } function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) { if (_reentrancyGuardEntered()) revert VaultBusy(); return super.withdraw(assets, receiver, owner); } function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) { if (_reentrancyGuardEntered()) revert VaultBusy(); return super.redeem(shares, receiver, owner); } /// @notice Authority-only escape hatch for native ETH accidentally/forcibly sent to the vault. function sweepNative(address payable to, uint256 amount) external { if (msg.sender != _authority) revert NotAuthority(); if (to == address(0)) revert ZeroAddressRecipient(); (bool ok,) = to.call{value: amount}(""); if (!ok) revert NativeSweepFailed(); emit NativeSwept(to, amount); } } ``` ## Security Considerations 1. **`totalAssets()` correctness and applicability scope** This ERC's circuit breaker is designed for vaults whose `totalAssets()` **cannot be atomically manipulated by the executor within the same transaction**. Vaults whose `totalAssets()` derives from spot AMM prices, rebasing token balances, or other values that an executor can influence within a single transaction MUST NOT rely solely on this circuit breaker and MUST employ additional safeguards (e.g., TWAP-based valuation via an oracle extension, or a time-delayed settlement pattern). A compliant vault MUST ensure that the `totalAssets()` value used for the pre/post snapshots is *manipulation-resistant over the execution window* (typically: within the same transaction / same block). Vaults relying on external pricing MUST use time-weighted or otherwise manipulation-resistant valuation, and MUST implement staleness/validity checks appropriate for the oracle(s) used. ([Ethereum Improvement Proposals][1]) 2. **Adapter allowlist strength** Using `leaf = keccak256(abi.encode(adapter, codeHash))` pins runtime bytecode at `adapter`. However, upgradeable proxy adapters can change behavior without changing the proxy bytecode, so vaults MUST NOT allowlist upgradeable proxy adapters in Core unless an implementation-pinning scheme is also enforced (e.g., via an extension that commits to and validates the implementation identity). Vaults MUST also reject adapters with empty code (`adapter.code.length == 0`). 3. **Reentrancy** Vaults MUST mark nonces used before any external calls and MUST make `execute` reentrancy-safe (e.g., by using a mutex / reentrancy guard). 4. **ERC-1271 gas costs** ERC-1271 signature validation can be significantly more expensive than ECDSA recovery depending on the authority contract. Implementations SHOULD consider worst-case gas and DoS surfaces (e.g., authority reverts, complex multisig paths). 5. **Fees and dilution (if implemented via extensions)** Any fee mechanism that mints shares will dilute depositors. If implementing performance fees, vaults targeting stronger fairness properties SHOULD consider series-based accounting approaches described in ERC-8113. ([Ethereum Improvement Proposals][9]) 6. **Nonce management** Authorities that wish to sign multiple mandates in parallel should adopt a nonce strategy that avoids collisions (e.g., randomized nonces, or partitioned nonce spaces encoded into the high bits of the `nonce`). Vaults MAY document recommended nonce strategies for their authority model. 7. **Cross-chain / fork replay** The EIP-712 domain binds `chainId` and `verifyingContract`, which prevents replay across chains with different `chainId` and vault addresses. Implementations SHOULD still consider edge cases such as chain forks where `chainId` does not change, and deployment schemes that reuse addresses across environments. 8. **Deposit/withdraw reentrancy during execution** The vault MUST ensure `execute` and ERC-4626 share-changing entrypoints (`deposit`/`mint`/`withdraw`/`redeem`) are mutually excluded during execution (shared mutex or execution-phase guard). Without this, adapter callbacks can trigger share changes that distort pre/post `totalAssets()` snapshots. 9. **Native ETH and circuit breaker scope** Core forbids `action.value != 0` to avoid an accounting mismatch where ETH leaves the vault but is not reflected in ERC-4626 `totalAssets()`. Implementations that require ETH transfer in actions MUST enable it through an explicit extension and MUST define how ETH exposure is bounded/accounted. Even when `execute` is not payable, native ETH can be forcibly sent to the vault (e.g., via `SELFDESTRUCT`); implementations MAY include an authority-only escape hatch (e.g., `sweepNative(to, amount)`) to recover such ETH. 10. **Per-mandate cumulative drawdown semantics** The `maxCumulativeDrawdownBps` field is per-mandate, but the epoch state (`epochAssets`) is global. When multiple mandates with different cumulative limits coexist, the most permissive mandate determines the practical upper bound. Authorities SHOULD ensure consistency across concurrently valid mandates, or use `invalidateNoncesBelow` to retire overly permissive mandates. 11. **Authority transfer operational security** `acceptAuthority()` increments `authorityEpoch`, so mandates signed under previous epochs are automatically invalid after transfer finalization. Authorities SHOULD still call `invalidateNoncesBelow` before rotation when emergency revocation latency matters. 12. **DELEGATECALL prohibition** Vaults MUST use `CALL` (not `DELEGATECALL` or `CALLCODE`) for adapter invocations. Using `DELEGATECALL` would allow adapters to execute in the vault's storage context, enabling them to modify any vault state (authority, nonces, epoch data) and bypass all safety mechanisms. 13. **Zero-drawdown rounding sensitivity** When `maxDrawdownBps == 0`, any decrease in `totalAssets()` — even by 1 wei — will trigger `DrawdownExceeded`. Some ERC-4626 implementations may have `totalAssets()` values that fluctuate by small amounts due to rounding in underlying protocol accounting. Authorities SHOULD set `maxDrawdownBps` to at least 1 when interacting with vaults whose `totalAssets()` is subject to rounding-induced micro-fluctuations. 14. **Epoch reset abuse potential** The `resetEpoch()` function allows the authority to reset cumulative drawdown tracking at any time. A compromised or coerced authority could repeatedly call `resetEpoch()` to circumvent cumulative drawdown limits. Governance layers or multisig authorities SHOULD consider implementing cooldown periods or rate-limiting for `resetEpoch()` calls. The `EpochReset` event provides an on-chain audit trail for monitoring. 15. **High-water mark griefing via deposit/withdraw** The epoch high-water mark (`epochAssets`) updates whenever `postAssets > epochAssets` after an execution. A griefing attacker can deposit a large amount (inflating `totalAssets()`), wait for a legitimate execution (which updates the high-water mark to the inflated value), then withdraw. After withdrawal, `epochAssets` remains at the inflated value while `totalAssets()` drops back, causing subsequent executions to see a large apparent cumulative loss and potentially revert with `CumulativeDrawdownExceeded`. This is a liveness griefing vector (not a fund-loss vector). Mitigation: the authority can call `resetEpoch()` to restore normal operation. Core's shared execution mutex requirement blocks same-transaction variants of this attack. 16. **Epoch expiry and long-running vaults** The epoch has no automatic expiry mechanism. If the authority never calls `resetEpoch()`, cumulative drawdown accumulates indefinitely from the high-water mark. Over long periods, even small rounding losses or legitimate micro-fluctuations in `totalAssets()` can accumulate and eventually exhaust the cumulative drawdown budget, effectively locking the vault from further executions. Authorities SHOULD periodically call `resetEpoch()` (e.g., daily or weekly) to prevent stale epoch state from blocking legitimate operations. Governance layers MAY implement automated epoch rotation based on time or execution count. 17. **Arithmetic overflow in drawdown calculations** The drawdown checks compute `loss * 10_000` and `preAssets * maxDrawdownBps` (and similarly for cumulative checks). These multiplications will revert with an arithmetic overflow if `totalAssets()` exceeds approximately `type(uint256).max / 10_000 ≈ 1.15 × 10^73`. While this value is astronomically large and unlikely in practice, implementations that use non-standard asset denominations or extremely high-precision tokens SHOULD be aware of this boundary. Vaults whose `totalAssets()` may approach this range MUST use checked arithmetic or alternative comparison methods (e.g., dividing instead of multiplying). --- ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md). [1]: ./eip-4626 "ERC-4626: Tokenized Vaults" [2]: ./eip-7741 "ERC-7741: Authorize Operator" [3]: ./eip-7710 "ERC-7710: Smart Contract Delegation" [4]: ./eip-7521 "ERC-7521: General Intents for Smart Contract Wallets" [5]: ./eip-712 "EIP-712: Typed structured data hashing and signing" [6]: ./eip-1271 "ERC-1271: Standard Signature Validation Method for Contracts" [7]: ./eip-165 "ERC-165: Standard Interface Detection" [8]: ./eip-5143 "ERC-5143: Slippage Protection for Tokenized Vault" [9]: ./eip-8113 "ERC-8113: Series Accounting for Incentivized Vaults" [10]: ./eip-7683 "ERC-7683: Cross Chain Intents Standard" [11]: ./eip-4337 "ERC-4337: Account Abstraction via Entry Point Contract Specification" [12]: ./eip-7579 "ERC-7579: Minimal Modular Smart Accounts" [13]: ./eip-20 "ERC-20: Token Standard" [14]: ./eip-1967 "ERC-1967: Standard Proxy Storage Slots"