// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { IERC8004IdentityRegistry } from "../identity/IERC8004IdentityRegistry.sol"; import { EIP712BaseV3 } from "../signatures/EIP712BaseV3.sol"; import { SignatureVerifierV3 } from "../signatures/SignatureVerifierV3.sol"; import { IMandateExecutionAdapterV3 } from "./IMandateExecutionAdapterV3.sol"; import { MandateExecutionAdapterV3 } from "./MandateExecutionAdapterV3.sol"; import { AgentTreasuryV3 } from "../finance/AgentTreasuryV3.sol"; /// @title MandateRegistryV3 /// @notice Versioned, identity-bound mandates for delegated V3 actions. /// @dev A mandate's signed data is immutable. Registration snapshots the security-critical /// identity and authorization metadata so revoke and consume never trust user-supplied /// values to identify the principal or operator. contract MandateRegistryV3 is EIP712BaseV3 { bytes32 public constant GOVERNANCE_POLICY_MODULE_ID = keccak256("Vaultfire.BaseV3.MandateRegistry"); // --------------------------------------------------------------------- // Constants // --------------------------------------------------------------------- string public constant PROTOCOL_VERSION = "3.0.0"; bytes32 public constant EXECUTION_DOMAIN = keccak256("VAULTFIRE_V3_MANDATE_NATIVE_EXECUTION"); uint256 public constant MAX_ALLOWED_ACTIONS = 32; uint256 public constant MAX_PROHIBITED_ACTIONS = 32; uint256 public constant MAX_ALLOWED_CONTRACTS = 32; uint256 public constant MAX_ALLOWED_SELECTORS = 32; // --------------------------------------------------------------------- // Types // --------------------------------------------------------------------- struct Mandate { // Identity uint256 identityRegistryChainId; address identityRegistry; uint256 principalAgentId; address principal; uint256 operatorAgentId; address operator; address beneficiary; // Scope bytes32[] allowedActions; bytes32[] prohibitedActions; address[] allowedContracts; bytes4[] allowedSelectors; // Economic caps address spendAsset; uint256 spendCap; uint256 rateLimitPerHour; // Policy bytes32 evidencePolicyId; bytes32 disputePolicyId; // Time / replay uint64 notBefore; uint64 notAfter; uint96 nonce; uint256 chainId; string protocolVersion; } /// @notice Immutable mandate metadata and mutable use state. /// @dev Fields through protocolVersionHash are snapshotted when a valid principal signature /// is registered. `spentSoFar` and `revoked` are the only operationally mutable fields. struct MandateState { bool registered; bool revoked; uint64 registeredAt; uint64 notBefore; uint64 notAfter; uint96 nonce; uint256 spentSoFar; uint256 chainId; uint256 identityRegistryChainId; uint256 principalAgentId; uint256 operatorAgentId; address principal; address operator; address beneficiary; address identityRegistry; bytes32 protocolVersionHash; } // --------------------------------------------------------------------- // Storage // --------------------------------------------------------------------- mapping(bytes32 mandateHash => MandateState) internal _state; /// @notice A principal nonce is reserved on registration and cannot authorize another mandate. mapping(address principal => mapping(uint96 nonce => bytes32 mandateHash)) internal _mandateForNonce; /// @notice Per-mandate rate-limit bucket keyed by (mandateHash, hourBucket). mapping(bytes32 => mapping(uint256 => uint256)) internal _bucketUsage; /// @notice Only owner-approved V3 modules may consume mandates. mapping(address module => bool allowed) public allowedModules; mapping(address module => bool allowed) public allowedTreasuryModules; mapping(address principal => uint256 epoch) public principalExecutionEpoch; mapping(bytes32 mandateHash => uint256 epoch) public registeredPrincipalEpoch; bytes32 public immutable requiredExecutionAdapterCodehash; bytes32 public immutable requiredTreasuryCodehash; address public immutable identityRegistry; bytes32 public immutable identityRegistryCodehash; address public owner; // --------------------------------------------------------------------- // Errors + Events // --------------------------------------------------------------------- error NotOwner(); error ZeroAddress(); error AlreadyRegistered(); error NotRegistered(); error Revoked(); error NotYetActive(); error Expired(); error InvalidTimeWindow(); error BadNonce(); error BadChain(); error UnsupportedProtocolVersion(); error BadSignature(); error UnauthorizedCaller(); error UnauthorizedModule(address module); error NotPrincipal(); error PrincipalMismatch(address expected, address provided); error NonceAlreadyUsed(address principal, uint96 nonce, bytes32 existingMandateHash); error InvalidIdentityRegistry(); error InvalidPrincipalIdentity(); error InvalidOperatorIdentity(); error TooManyAllowedActions(uint256 provided, uint256 maximum); error TooManyProhibitedActions(uint256 provided, uint256 maximum); error TooManyAllowedContracts(uint256 provided, uint256 maximum); error TooManyAllowedSelectors(uint256 provided, uint256 maximum); error ActionNotAllowed(bytes32 action); error ContractNotAllowed(address target); error SelectorNotAllowed(bytes4 selector); error SpendCapExceeded(uint256 requested, uint256 remaining); error RateLimitExceeded(); error LegacyConsumeDisabled(); error InvalidExecutionAdapter(address module); error BadExecutionBinding(); error UnsupportedExecutionAsset(); error InvalidScope(); error BadPrincipalEpoch(); event MandateRegisteredV3( bytes32 indexed mandateHash, address indexed principal, address indexed operator, uint64 notBefore, uint64 notAfter ); event MandateRevokedV3(bytes32 indexed mandateHash, address indexed by); event MandateConsumedV3(bytes32 indexed mandateHash, address indexed operator, bytes32 action, uint256 amount); event ModuleAuthorizationUpdatedV3(address indexed module, bool allowed, address indexed updatedBy); event TreasuryModuleAuthorizationUpdatedV3(address indexed module, bool allowed); event PrincipalMandatesRevokedV3(address indexed principal, uint256 epoch); event TreasuryMandateConsumedV3( bytes32 indexed mandateHash, address indexed module, address indexed asset, address beneficiary, uint256 amount, bytes32 intentCommitment ); event MandateExecutionConsumedV3( bytes32 indexed mandateHash, address indexed operator, address indexed target, bytes32 action, bytes32 resource, bytes4 selector, uint256 amount, bytes32 callDataHash ); // --------------------------------------------------------------------- // EIP-712 typehashes // --------------------------------------------------------------------- bytes32 public constant MANDATE_TYPEHASH = keccak256( "Mandate(uint256 identityRegistryChainId,address identityRegistry,uint256 principalAgentId,address principal,uint256 operatorAgentId,address operator,address beneficiary,bytes32 allowedActionsHash,bytes32 prohibitedActionsHash,bytes32 allowedContractsHash,bytes32 allowedSelectorsHash,address spendAsset,uint256 spendCap,uint256 rateLimitPerHour,bytes32 evidencePolicyId,bytes32 disputePolicyId,uint64 notBefore,uint64 notAfter,uint96 nonce,uint256 chainId,string protocolVersion)" ); bytes32 public constant EPOCH_MANDATE_TYPEHASH = keccak256("EpochMandate(bytes32 mandateHash,uint256 principalEpoch)"); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } modifier onlyAllowedModule() { if (!allowedModules[msg.sender]) revert UnauthorizedModule(msg.sender); _requireExecutionAdapter(msg.sender); _; } constructor(address _owner, address _identityRegistry, bytes32 _identityRegistryCodehash) EIP712BaseV3(PROTOCOL_VERSION) { if (_owner == address(0) || _identityRegistry == address(0)) revert ZeroAddress(); if ( _identityRegistry.code.length == 0 || _identityRegistryCodehash == bytes32(0) || _identityRegistry.codehash != _identityRegistryCodehash ) revert InvalidIdentityRegistry(); owner = _owner; identityRegistry = _identityRegistry; identityRegistryCodehash = _identityRegistryCodehash; requiredExecutionAdapterCodehash = keccak256(type(MandateExecutionAdapterV3).runtimeCode); requiredTreasuryCodehash = keccak256(type(AgentTreasuryV3).runtimeCode); } // --------------------------------------------------------------------- // Module administration // --------------------------------------------------------------------- /// @notice Authorize or remove a trusted V3 module that can consume mandates. function setModuleAuthorization(address module, bool allowed) external onlyOwner { if (module == address(0)) revert ZeroAddress(); if (allowed) _requireExecutionAdapter(module); allowedModules[module] = allowed; emit ModuleAuthorizationUpdatedV3(module, allowed, msg.sender); } /// @notice Separate exact-runtime/domain lane; never relaxes the native /// adapter allowlist or interprets native msg.value as token exposure. function setTreasuryModuleAuthorization(address module, bool allowed) external onlyOwner { if (module == address(0)) revert ZeroAddress(); if (allowed) _requireTreasury(module); allowedTreasuryModules[module] = allowed; emit TreasuryModuleAuthorizationUpdatedV3(module, allowed); } // --------------------------------------------------------------------- // Register // --------------------------------------------------------------------- /// @notice Principal (or their ERC-1271 wallet) signs an identity-bound mandate. /// @dev Anyone may submit a valid signature. Nonces are consumed at registration, not per use, /// because a mandate may legitimately authorize multiple actions until it expires or caps. function registerMandate(Mandate calldata m, bytes calldata principalSig) external returns (bytes32 mandateHash) { if (principalExecutionEpoch[m.principal] != 0) revert BadPrincipalEpoch(); return _registerMandate(m, principalSig, false, 0); } /// @notice Explicit new signed envelope. Old signatures are never silently /// rebound to a post-emergency epoch or revived by a registration nonce. function registerMandateWithEpoch(Mandate calldata m, uint256 epoch, bytes calldata principalSig) external returns (bytes32 mandateHash) { if (epoch != principalExecutionEpoch[m.principal]) revert BadPrincipalEpoch(); return _registerMandate(m, principalSig, true, epoch); } function _registerMandate(Mandate calldata m, bytes calldata principalSig, bool epochAware, uint256 epoch) private returns (bytes32 mandateHash) { _validateMandate(m); mandateHash = hashMandate(m); if (_state[mandateHash].registered) revert AlreadyRegistered(); bytes32 existingMandateHash = _mandateForNonce[m.principal][m.nonce]; if (existingMandateHash != bytes32(0)) { revert NonceAlreadyUsed(m.principal, m.nonce, existingMandateHash); } bytes32 signedHash = epochAware ? keccak256(abi.encode(EPOCH_MANDATE_TYPEHASH, mandateHash, epoch)) : mandateHash; bytes32 digest = _hashTypedData(signedHash); if (!SignatureVerifierV3.verify(m.principal, digest, principalSig)) revert BadSignature(); _mandateForNonce[m.principal][m.nonce] = mandateHash; registeredPrincipalEpoch[mandateHash] = epoch; _state[mandateHash] = MandateState({ registered: true, revoked: false, registeredAt: uint64(block.timestamp), notBefore: m.notBefore, notAfter: m.notAfter, nonce: m.nonce, spentSoFar: 0, chainId: m.chainId, identityRegistryChainId: m.identityRegistryChainId, principalAgentId: m.principalAgentId, operatorAgentId: m.operatorAgentId, principal: m.principal, operator: m.operator, beneficiary: m.beneficiary, identityRegistry: m.identityRegistry, protocolVersionHash: keccak256(bytes(m.protocolVersion)) }); emit MandateRegisteredV3(mandateHash, m.principal, m.operator, m.notBefore, m.notAfter); } /// @notice Invalidates EVERY already registered mandate for this principal. /// Future registrations require fresh, explicitly epoch-bound signatures. function revokeAllMandates() external { _revokeAll(msg.sender); } /// @notice Canonical treasury authenticates its principal/guardian before /// freezing that principal. Its emergency invalidates the native lane too. function revokeAllMandatesFromTreasury(address principal) external { if (!allowedTreasuryModules[msg.sender]) revert UnauthorizedModule(msg.sender); _requireTreasury(msg.sender); if (!AgentTreasuryV3(payable(msg.sender)).principalFrozen(principal)) revert NotPrincipal(); _revokeAll(principal); } function _revokeAll(address principal) private { principalExecutionEpoch[principal] += 1; emit PrincipalMandatesRevokedV3(principal, principalExecutionEpoch[principal]); } /// @notice Revoke a mandate as its snapshotted principal or registry owner. function revokeMandate(bytes32 mandateHash) external { _revokeMandate(mandateHash); } /// @notice Compatibility overload for the pre-hardening API. /// @dev The asserted principal is checked against immutable registered state; it is never used /// for authorization. New integrations should call `revokeMandate(bytes32)`. function revokeMandate(bytes32 mandateHash, address assertedPrincipal) external { MandateState storage s = _state[mandateHash]; if (!s.registered) revert NotRegistered(); if (assertedPrincipal != s.principal) revert PrincipalMismatch(s.principal, assertedPrincipal); _revokeMandate(mandateHash); } // --------------------------------------------------------------------- // Consume // --------------------------------------------------------------------- /// @notice Consume a mandate from an owner-authorized V3 module. /// @dev The module allowlist blocks arbitrary direct calls that could otherwise grief-consume a /// valid mandate. The canonical adapter binds the authenticated operator to immutable /// registered state before invoking the downstream call. function consume(Mandate calldata, bytes32, address, bytes4, uint256, address) external pure returns (bytes32) { revert LegacyConsumeDisabled(); } /// @notice Consume from the canonical adapter after it commits to the exact /// downstream target, selector, calldata, native value, resource and domain. function consumeExecution( bytes calldata encodedMandate, bytes32 action, address target, bytes4 selector, uint256 amount, address operator, bytes32 resource, bytes32 domain, bytes32 callDataHash, uint256 value ) external onlyAllowedModule returns (bytes32 mandateHash) { if (domain != EXECUTION_DOMAIN || resource != action || amount != value || callDataHash == bytes32(0)) { revert BadExecutionBinding(); } Mandate memory m = abi.decode(encodedMandate, (Mandate)); if (m.spendAsset != address(0)) revert UnsupportedExecutionAsset(); mandateHash = _consume(m, action, target, selector, amount, operator, callDataHash); emit MandateExecutionConsumedV3(mandateHash, operator, target, action, resource, selector, amount, callDataHash); } function _consume( Mandate memory m, bytes32 action, address target, bytes4 selector, uint256 amount, address operator, bytes32 callDataHash ) private returns (bytes32 mandateHash) { mandateHash = hashMandate(m); MandateState storage s = _state[mandateHash]; if (!s.registered) revert NotRegistered(); if (s.revoked || registeredPrincipalEpoch[mandateHash] != principalExecutionEpoch[s.principal]) revert Revoked(); // forge-lint: disable-next-line(block-timestamp) if (block.timestamp < s.notBefore) revert NotYetActive(); // forge-lint: disable-next-line(block-timestamp) if (block.timestamp >= s.notAfter) revert Expired(); if (operator != s.operator || m.operator != s.operator) revert UnauthorizedCaller(); if (m.principal != s.principal || m.nonce != s.nonce || m.chainId != s.chainId) revert BadNonce(); _requireAction(m, action, target, selector, callDataHash); _requireContract(m, target); _requireSelector(m, selector); _requireCap(s, m, amount); _requireRate(m, mandateHash); s.spentSoFar += amount; emit MandateConsumedV3(mandateHash, operator, action, amount); } /// @notice Canonical treasury derives amount, asset, beneficiary and exact /// intent from its stored owner-approved quote before calling this method. /// A zero-native-value token payment still consumes its full asset amount. function consumeTreasuryExecution( bytes calldata encodedMandate, bytes32 action, address principal, address asset, address beneficiary, uint256 amount, address operator, bytes32 intentCommitment ) external returns (bytes32 mandateHash) { if (!allowedTreasuryModules[msg.sender]) revert UnauthorizedModule(msg.sender); _requireTreasury(msg.sender); Mandate memory m = abi.decode(encodedMandate, (Mandate)); if (m.principal != principal || m.spendAsset != asset || m.beneficiary != beneficiary || amount == 0 || intentCommitment == bytes32(0)) revert BadExecutionBinding(); mandateHash = _consume(m, action, msg.sender, AgentTreasuryV3.execute.selector, amount, operator, intentCommitment); emit TreasuryMandateConsumedV3(mandateHash, msg.sender, asset, beneficiary, amount, intentCommitment); } // --------------------------------------------------------------------- // Hashing // --------------------------------------------------------------------- function hashMandate(Mandate memory m) public pure returns (bytes32) { return keccak256( abi.encode( MANDATE_TYPEHASH, m.identityRegistryChainId, m.identityRegistry, m.principalAgentId, m.principal, m.operatorAgentId, m.operator, m.beneficiary, keccak256(abi.encodePacked(m.allowedActions)), keccak256(abi.encodePacked(m.prohibitedActions)), keccak256(abi.encodePacked(m.allowedContracts)), keccak256(abi.encodePacked(m.allowedSelectors)), m.spendAsset, m.spendCap, m.rateLimitPerHour, m.evidencePolicyId, m.disputePolicyId, m.notBefore, m.notAfter, m.nonce, m.chainId, keccak256(bytes(m.protocolVersion)) ) ); } // --------------------------------------------------------------------- // Views // --------------------------------------------------------------------- function isActive(bytes32 mandateHash) external view returns (bool) { MandateState memory s = _state[mandateHash]; // forge-lint: disable-next-line(block-timestamp) return s.registered && !s.revoked && registeredPrincipalEpoch[mandateHash] == principalExecutionEpoch[s.principal] && block.timestamp >= s.notBefore && block.timestamp < s.notAfter; } function state(bytes32 mandateHash) external view returns (MandateState memory) { MandateState memory s = _state[mandateHash]; if (registeredPrincipalEpoch[mandateHash] != principalExecutionEpoch[s.principal]) s.revoked = true; return s; } function executionParties(bytes32 mandateHash) external view returns (address principal, address operator, address beneficiary, bool active) { MandateState storage s = _state[mandateHash]; active = s.registered && !s.revoked && registeredPrincipalEpoch[mandateHash] == principalExecutionEpoch[s.principal] && block.timestamp >= s.notBefore && block.timestamp < s.notAfter; return (s.principal, s.operator, s.beneficiary, active); } function mandateForNonce(address principal, uint96 nonce) external view returns (bytes32) { return _mandateForNonce[principal][nonce]; } // --------------------------------------------------------------------- // Internal checks // --------------------------------------------------------------------- function _validateMandate(Mandate calldata m) private view { if (m.chainId != block.chainid) revert BadChain(); if (keccak256(bytes(m.protocolVersion)) != keccak256(bytes(PROTOCOL_VERSION))) { revert UnsupportedProtocolVersion(); } if (m.notAfter <= m.notBefore) revert InvalidTimeWindow(); // forge-lint: disable-next-line(block-timestamp) if (m.notAfter <= block.timestamp) revert Expired(); if (m.principal == address(0) || m.operator == address(0) || m.beneficiary == address(0)) { revert ZeroAddress(); } _validateArrayBounds(m); _validateIdentity(m); } function _validateArrayBounds(Mandate calldata m) private pure { if (m.allowedActions.length == 0 || m.allowedActions.length > MAX_ALLOWED_ACTIONS) { revert TooManyAllowedActions(m.allowedActions.length, MAX_ALLOWED_ACTIONS); } if (m.prohibitedActions.length > MAX_PROHIBITED_ACTIONS) { revert TooManyProhibitedActions(m.prohibitedActions.length, MAX_PROHIBITED_ACTIONS); } if (m.allowedContracts.length == 0 || m.allowedContracts.length > MAX_ALLOWED_CONTRACTS) { revert TooManyAllowedContracts(m.allowedContracts.length, MAX_ALLOWED_CONTRACTS); } if (m.allowedSelectors.length == 0 || m.allowedSelectors.length > MAX_ALLOWED_SELECTORS) { revert TooManyAllowedSelectors(m.allowedSelectors.length, MAX_ALLOWED_SELECTORS); } if (m.spendCap == 0 || m.rateLimitPerHour == 0) revert InvalidScope(); for (uint256 i = 1; i < m.allowedContracts.length; ++i) { if (m.allowedContracts[i] <= m.allowedContracts[i - 1]) revert InvalidScope(); } for (uint256 i = 1; i < m.allowedSelectors.length; ++i) { if (m.allowedSelectors[i] <= m.allowedSelectors[i - 1]) revert InvalidScope(); } } function _validateIdentity(Mandate calldata m) private view { if ( m.identityRegistryChainId != block.chainid || m.identityRegistry != identityRegistry || m.identityRegistry.codehash != identityRegistryCodehash || m.principalAgentId == 0 || m.operatorAgentId == 0 ) revert InvalidIdentityRegistry(); IERC8004IdentityRegistry registry = IERC8004IdentityRegistry(m.identityRegistry); IERC8004IdentityRegistry.Agent memory principalAgent = registry.getAgent(m.principalAgentId); if ( !principalAgent.active || principalAgent.agentId != m.principalAgentId || principalAgent.principal != m.principal || principalAgent.operator != m.operator || principalAgent.beneficiary != m.beneficiary ) revert InvalidPrincipalIdentity(); IERC8004IdentityRegistry.Agent memory operatorAgent = registry.getAgent(m.operatorAgentId); if ( !operatorAgent.active || operatorAgent.agentId != m.operatorAgentId || operatorAgent.agentWallet != m.operator || operatorAgent.operator != m.operator ) revert InvalidOperatorIdentity(); if (!registry.isAuthorizedOperator(m.principalAgentId, m.operator)) revert InvalidOperatorIdentity(); } function _revokeMandate(bytes32 mandateHash) private { MandateState storage s = _state[mandateHash]; if (!s.registered) revert NotRegistered(); if (msg.sender != s.principal && msg.sender != owner) revert NotPrincipal(); s.revoked = true; emit MandateRevokedV3(mandateHash, msg.sender); } function _requireAction(Mandate memory m, bytes32 action, address target, bytes4 selector, bytes32 callDataHash) private pure { bytes32 tuple = keccak256(abi.encode(action, target, selector, m.spendCap, callDataHash)); for (uint256 i; i < m.prohibitedActions.length; ++i) { if (m.prohibitedActions[i] == tuple) revert ActionNotAllowed(action); } for (uint256 i; i < m.allowedActions.length; ++i) { if (m.allowedActions[i] == tuple) return; } revert ActionNotAllowed(action); } function _requireContract(Mandate memory m, address target) private pure { for (uint256 i; i < m.allowedContracts.length; ++i) { if (m.allowedContracts[i] == target) return; } revert ContractNotAllowed(target); } function _requireSelector(Mandate memory m, bytes4 selector) private pure { for (uint256 i; i < m.allowedSelectors.length; ++i) { if (m.allowedSelectors[i] == selector) return; } revert SelectorNotAllowed(selector); } function _requireCap(MandateState storage s, Mandate memory m, uint256 amount) private view { uint256 remaining = m.spendCap - s.spentSoFar; if (amount > remaining) revert SpendCapExceeded(amount, remaining); } function _requireRate(Mandate memory m, bytes32 mandateHash) private { uint256 bucket = block.timestamp / 1 hours; uint256 used = _bucketUsage[mandateHash][bucket] + 1; if (used > m.rateLimitPerHour) revert RateLimitExceeded(); _bucketUsage[mandateHash][bucket] = used; } function _requireExecutionAdapter(address module) private view { if (module.code.length == 0 || module.codehash != requiredExecutionAdapterCodehash) { revert InvalidExecutionAdapter(module); } if ( IMandateExecutionAdapterV3(module).mandateRegistry() != address(this) || IMandateExecutionAdapterV3(module).executionDomain() != EXECUTION_DOMAIN ) revert InvalidExecutionAdapter(module); } function _requireTreasury(address module) private view { if (module.code.length == 0 || module.codehash != requiredTreasuryCodehash) revert InvalidExecutionAdapter(module); AgentTreasuryV3 treasury = AgentTreasuryV3(payable(module)); if (treasury.mandateRegistry() != address(this) || treasury.admin() != owner || treasury.EXECUTION_DOMAIN() != keccak256("VAULTFIRE_V3_MANDATE_TREASURY_EXECUTION_V1")) revert InvalidExecutionAdapter(module); } }