EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8192: Mandated Execution for Tokenized Vaults

A minimal interface for non-custodial, risk-constrained delegated strategy execution for ERC-4626 vaults.

Authors
Created2026-02-15
Discussion Linkhttps://ethereum-magicians.org/t/erc-8192-mandated-execution-for-tokenized-vaults/27877
Requires

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#1597PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationTerminologyDesign goals (Core)Data TypesActionMandateEIP-712 Signed MandateEIP-712 domainTyped data definitionAuthority signature validationAdapter Allowlist (Merkle)Leaf encodingTree constructionProof verificationOff-chain Construction Guide (informative)Digestsextensions and extensionsHashMerkle roots and proofsCore InterfaceExecution flow (informative)Execution semantics (MUST)Input size limits (RECOMMENDED)EventsErrorsAuthority ManagementRevocation & Nonce invalidationExtension mechanism (Core-level hook)SelectorAllowlist@v1 (optional extension)Extension IDData encodingLeaf encodingTree constructionVerificationRationaleWhy anchor the circuit breaker to ERC-4626 totalAssets()?Why allow payloadDigest == 0?Why not standardize slippage/oracles/valuation in Core?Why use an epoch-based high-water mark for cumulative drawdown?Why require payloadDigest for open mandates (executor == address(0))?Why two-step authority transfer?Why include authorityEpoch in Mandate?Why forbid non-zero action.value in Core?Why Merkle tree over on-chain mapping for adapter allowlists?Backwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsCopyright

Abstract

This ERC defines a minimal, interoperable interface for delegated strategy execution on ERC-4626 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)

The core primitive is a signed Mandate (EIP-712) 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)

Existing standards cover adjacent layers:

  • Operator authorization (e.g., ERC-7741) focuses on setting operators, not standardized vault strategy execution and post-execution risk enforcement. (Ethereum Improvement Proposals)
  • General delegation frameworks (e.g., ERC-7710) define delegation plumbing, but not vault-native risk constraints using ERC-4626 accounting semantics. (Ethereum Improvement Proposals)
  • Intent frameworks (e.g., ERC-7521, ERC-7683) standardize intent/order expression and settlement, not pooled vault execution semantics and circuit breakers. (Ethereum Improvement Proposals, Ethereum Improvement Proposals)
  • Account abstraction (ERC-4337) and modular smart accounts (ERC-7579) 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)
  • 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 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 and support ERC-1271 authorities. (Ethereum Improvement Proposals)
  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)

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

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

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)

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)

Authority signature validation

The vault MUST define the authority via:

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)

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 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 and return true for type(IERC8192MandatedVault).interfaceId. (Ethereum Improvement Proposals)

interfaceId: 0x25cb08f6 (computed as the XOR of all function selectors in IERC8192MandatedVault).

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)
    • MUST revert with MandateIsRevoked if the mandate hash has been revoked.
  8. Validate authority signature

  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.
  1. Validate actions non-empty

    • MUST require actions.length > 0, reverting with EmptyActions otherwise.
  2. 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.
  3. Pre-state snapshot & epoch initialization

    • MUST read preAssets = totalAssets() (ERC-4626). (Ethereum Improvement Proposals)
    • If epochStart() == 0 (no epoch initialized), the vault MUST initialize the epoch by setting epochStart = block.timestamp and epochAssets = preAssets.
  4. 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.

  5. Post-state snapshot

    • MUST read postAssets = totalAssets().
  6. 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.

  7. Emit events (see below).

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

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:

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:

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:

// 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.:

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)

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) and for performance fee accounting (ERC-8113), which can be integrated as optional extensions. (Ethereum Improvement Proposals, Ethereum Improvement Proposals)

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 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)


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.

// 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 ] <selector>
            // [ 0x04 - 0x23 ] <hash>
            // [ 0x24 - 0x43 ] <signature offset> (0x40)
            // [ 0x44 - 0x63 ] <signature length>
            // [ 0x64 - ...  ] <signature data>
            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)

  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)

  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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth