diff --git a/src/impls/PolicyRegistry.sol b/src/impls/PolicyRegistry.sol new file mode 100644 index 0000000..f3cba08 --- /dev/null +++ b/src/impls/PolicyRegistry.sol @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {IPolicyRegistry} from "../interfaces/IPolicyRegistry.sol"; +import {PolicySlot} from "./PolicySlot.sol"; + +/// @title PolicyRegistry +/// @notice Implementation of the IPolicyRegistry precompile interface. +/// @author Coinbase +/// +/// @dev Existence sentinel: `_policyData[id] == 0` means the policy was never created. +/// This is safe because WHITELIST/BLACKLIST require a non-zero admin (so packed +/// is never zero), and COMPOUND always has type byte = 2 (so packed is never zero). +/// +/// Authorization cost: exactly 2 SLOADs for any custom policy (zero for built-ins). +/// Compound policies pack each constituent's type bit alongside its ID in the +/// compound slot, so a hot-path check reads only (a) the compound slot and +/// (b) the relevant constituent's member set — the constituent's own policy +/// slot does NOT need to be loaded. Compound constituents cannot themselves be +/// COMPOUND (enforced at creation), so evaluation never goes deeper. +contract PolicyRegistry is IPolicyRegistry { + using PolicySlot for uint256; + + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + mapping(uint64 policyId => uint256 packed) private _policyData; + + // WHITELIST: member == true means the address is allowed. + // BLACKLIST: member == true means the address is restricted. + mapping(uint64 policyId => mapping(address account => bool)) private _members; + + // Pending admin per policy. Set by beginPolicyAdminTransfer, cleared on + // accept/cancel/freeze. Kept in its own mapping (rather than packed into the + // policy slot) so the hot-path read on _policyData stays minimal; admin + // rotation is a cold path and the extra SLOAD on accept is acceptable. + mapping(uint64 policyId => address pendingAdmin) private _pendingAdmins; + + uint64 private _counter = FIRST_USER_POLICY_ID; + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + uint64 private constant ALWAYS_REJECT_ID = 0; + uint64 private constant ALWAYS_ALLOW_ID = 1; + uint64 private constant FIRST_USER_POLICY_ID = 2; + + /*////////////////////////////////////////////////////////////// + POLICY CREATION + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IPolicyRegistry + function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId) { + newPolicyId = _createPolicy(admin, policyType); + } + + /// @inheritdoc IPolicyRegistry + function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) + external + returns (uint64 newPolicyId) + { + newPolicyId = _createPolicy(admin, policyType); + bool isWhitelist = policyType == PolicyType.WHITELIST; + mapping(address => bool) storage members = _members[newPolicyId]; + for (uint256 i = 0; i < accounts.length; ++i) { + address account = accounts[i]; + members[account] = true; + if (isWhitelist) { + emit WhitelistUpdated(newPolicyId, msg.sender, account, true); + } else { + emit BlacklistUpdated(newPolicyId, msg.sender, account, true); + } + } + } + + /// @inheritdoc IPolicyRegistry + function createCompoundPolicy( + uint64 senderPolicyId, + uint64 recipientPolicyId, + uint64 mintRecipientPolicyId, + uint64 redeemerPolicyId + ) external returns (uint64 newPolicyId) { + // Resolve each constituent's type once, at creation, so the hot path can read + // the constituent's type bit directly from the compound slot. + PolicyType senderType = _requireConstituent(senderPolicyId); + PolicyType recipientType = _requireConstituent(recipientPolicyId); + PolicyType mintRecipientType = _requireConstituent(mintRecipientPolicyId); + PolicyType redeemerType = _requireConstituent(redeemerPolicyId); + newPolicyId = _nextPolicyId(); + _policyData[newPolicyId] = PolicySlot.encodeCompound({ + senderField: PolicySlot.encodeField({id: senderPolicyId, constituentType: senderType}), + recipientField: PolicySlot.encodeField({id: recipientPolicyId, constituentType: recipientType}), + mintField: PolicySlot.encodeField({id: mintRecipientPolicyId, constituentType: mintRecipientType}), + redeemerField: PolicySlot.encodeField({id: redeemerPolicyId, constituentType: redeemerType}) + }); + emit CompoundPolicyCreated( + newPolicyId, msg.sender, senderPolicyId, recipientPolicyId, mintRecipientPolicyId, redeemerPolicyId + ); + } + + /*////////////////////////////////////////////////////////////// + POLICY ADMINISTRATION + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IPolicyRegistry + function beginPolicyAdminTransfer(uint64 policyId, address newAdmin) external { + // Permitting newAdmin == address(0) here is intentional: it lets the current + // admin cancel a prior nomination via the same entry point. The pending slot + // is just a nomination; the active admin doesn't change until accept. + uint256 packed = _requireExists(policyId); + PolicyType policyType = packed.decodeType(); + if (policyType == PolicyType.COMPOUND) revert IncompatiblePolicyType(); + if (packed.decodeFrozen()) revert PolicyFrozen(); + if (packed.decodeAdmin() != msg.sender) revert Unauthorized(); + _pendingAdmins[policyId] = newAdmin; + emit PolicyAdminTransferBegun(policyId, msg.sender, newAdmin); + } + + /// @inheritdoc IPolicyRegistry + function acceptPolicyAdminTransfer(uint64 policyId) external { + uint256 packed = _requireExists(policyId); + PolicyType policyType = packed.decodeType(); + if (policyType == PolicyType.COMPOUND) revert IncompatiblePolicyType(); + if (packed.decodeFrozen()) revert PolicyFrozen(); + address pending = _pendingAdmins[policyId]; + if (pending != msg.sender) revert NotPendingAdmin(); + // Preserve the frozen bit on rotation. Currently unreachable because the + // freeze check above short-circuits, but coded defensively in case the + // freeze semantics evolve (e.g. allowing rotation while frozen). + _policyData[policyId] = PolicySlot.encodeSimple({policyType: policyType, policyAdmin: msg.sender}) + | (packed.decodeFrozen() ? PolicySlot.FROZEN_BIT : 0); + delete _pendingAdmins[policyId]; + emit PolicyAdminUpdated(policyId, msg.sender, msg.sender); + } + + /// @inheritdoc IPolicyRegistry + function cancelPolicyAdminTransfer(uint64 policyId) external { + uint256 packed = _requireExists(policyId); + PolicyType policyType = packed.decodeType(); + if (policyType == PolicyType.COMPOUND) revert IncompatiblePolicyType(); + if (packed.decodeAdmin() != msg.sender) revert Unauthorized(); + address pending = _pendingAdmins[policyId]; + if (pending == address(0)) revert NoTransferPending(); + delete _pendingAdmins[policyId]; + emit PolicyAdminTransferCancelled(policyId, msg.sender, pending); + } + + /// @inheritdoc IPolicyRegistry + function freezePolicy(uint64 policyId) external { + uint256 packed = _requireExists(policyId); + PolicyType policyType = packed.decodeType(); + if (policyType == PolicyType.COMPOUND) revert IncompatiblePolicyType(); + if (packed.decodeFrozen()) revert PolicyFrozen(); + if (packed.decodeAdmin() != msg.sender) revert Unauthorized(); + _policyData[policyId] = packed | PolicySlot.FROZEN_BIT; + // Any in-flight nomination is moot once frozen; clear it so the post-freeze + // state is unambiguous (no zombie pending admin lingering in storage). + if (_pendingAdmins[policyId] != address(0)) delete _pendingAdmins[policyId]; + emit PolicyFrozenEvent(policyId, msg.sender); + } + + /// @inheritdoc IPolicyRegistry + function modifyPolicyWhitelist(uint64 policyId, address account, bool allowed) external { + uint256 packed = _requireExists(policyId); + if (packed.decodeType() != PolicyType.WHITELIST) revert IncompatiblePolicyType(); + if (packed.decodeFrozen()) revert PolicyFrozen(); + if (packed.decodeAdmin() != msg.sender) revert Unauthorized(); + _members[policyId][account] = allowed; + emit WhitelistUpdated(policyId, msg.sender, account, allowed); + } + + /// @inheritdoc IPolicyRegistry + function modifyPolicyBlacklist(uint64 policyId, address account, bool restricted) external { + uint256 packed = _requireExists(policyId); + if (packed.decodeType() != PolicyType.BLACKLIST) revert IncompatiblePolicyType(); + if (packed.decodeFrozen()) revert PolicyFrozen(); + if (packed.decodeAdmin() != msg.sender) revert Unauthorized(); + _members[policyId][account] = restricted; + emit BlacklistUpdated(policyId, msg.sender, account, restricted); + } + + /*////////////////////////////////////////////////////////////// + AUTHORIZATION QUERIES + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IPolicyRegistry + function isAuthorized(uint64 policyId, address user) external view returns (bool) { + return + _checkRole(policyId, user, PolicySlot.SENDER_SHIFT) + && _checkRole(policyId, user, PolicySlot.RECIPIENT_SHIFT); + } + + /// @inheritdoc IPolicyRegistry + function isAuthorizedSender(uint64 policyId, address user) external view returns (bool) { + return _checkRole(policyId, user, PolicySlot.SENDER_SHIFT); + } + + /// @inheritdoc IPolicyRegistry + function isAuthorizedRecipient(uint64 policyId, address user) external view returns (bool) { + return _checkRole(policyId, user, PolicySlot.RECIPIENT_SHIFT); + } + + /// @inheritdoc IPolicyRegistry + function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool) { + return _checkRole(policyId, user, PolicySlot.MINT_SHIFT); + } + + /// @inheritdoc IPolicyRegistry + function isAuthorizedRedeemer(uint64 policyId, address user) external view returns (bool) { + return _checkRole(policyId, user, PolicySlot.REDEEM_SHIFT); + } + + /*////////////////////////////////////////////////////////////// + POLICY QUERIES + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IPolicyRegistry + function policyIdCounter() external view returns (uint64) { + return _counter; + } + + /// @inheritdoc IPolicyRegistry + function policyExists(uint64 policyId) external view returns (bool) { + return _exists(policyId); + } + + /// @inheritdoc IPolicyRegistry + function policyData(uint64 policyId) external view returns (PolicyType policyType, address admin) { + if (!_exists(policyId)) revert PolicyNotFound(); + if (policyId == ALWAYS_REJECT_ID) return (PolicyType.ALWAYS_REJECT, address(0)); + if (policyId == ALWAYS_ALLOW_ID) return (PolicyType.ALWAYS_ALLOW, address(0)); + uint256 packed = _policyData[policyId]; + policyType = packed.decodeType(); + admin = policyType == PolicyType.COMPOUND ? address(0) : packed.decodeAdmin(); + } + + /// @inheritdoc IPolicyRegistry + function pendingPolicyAdmin(uint64 policyId) external view returns (address) { + return _pendingAdmins[policyId]; + } + + /// @inheritdoc IPolicyRegistry + function isPolicyFrozen(uint64 policyId) external view returns (bool) { + if (policyId < FIRST_USER_POLICY_ID) return false; + uint256 packed = _policyData[policyId]; + if (packed == 0) return false; + if (packed.decodeType() == PolicyType.COMPOUND) return false; + return packed.decodeFrozen(); + } + + /// @inheritdoc IPolicyRegistry + function compoundPolicyData(uint64 policyId) + external + view + returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId, uint64 redeemerPolicyId) + { + uint256 packed = _requireExists(policyId); + if (packed.decodeType() != PolicyType.COMPOUND) revert IncompatiblePolicyType(); + senderPolicyId = packed.decodeId(PolicySlot.SENDER_SHIFT); + recipientPolicyId = packed.decodeId(PolicySlot.RECIPIENT_SHIFT); + mintRecipientPolicyId = packed.decodeId(PolicySlot.MINT_SHIFT); + redeemerPolicyId = packed.decodeId(PolicySlot.REDEEM_SHIFT); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPERS + //////////////////////////////////////////////////////////////*/ + + function _nextPolicyId() internal returns (uint64 id) { + id = _counter++; + // The on-chain compound-policy packing reserves 61 bits per constituent ID. + // Bounding the counter here guarantees that every policy ID ever created can + // be round-tripped through the compound slot without silent truncation; the + // packed-field decoders elsewhere in this contract can therefore rely on + // ID <= PolicySlot.ID_MASK without re-checking. + if (id > PolicySlot.ID_MASK) revert PolicyIdOverflow(); + } + + function _createPolicy(address admin, PolicyType policyType) internal returns (uint64 newPolicyId) { + if (policyType != PolicyType.WHITELIST && policyType != PolicyType.BLACKLIST) revert InvalidPolicyType(); + if (admin == address(0)) revert ZeroAddress(); + newPolicyId = _nextPolicyId(); + _policyData[newPolicyId] = PolicySlot.encodeSimple({policyType: policyType, policyAdmin: admin}); + emit PolicyCreated(newPolicyId, msg.sender, policyType); + emit PolicyAdminUpdated(newPolicyId, msg.sender, admin); + } + + function _exists(uint64 policyId) internal view returns (bool) { + return policyId < FIRST_USER_POLICY_ID || _policyData[policyId] != 0; + } + + // Loads and returns the packed slot for a custom policy ID, reverting if it does + // not exist. Built-in IDs (0, 1) are excluded: they have no mutable state. + function _requireExists(uint64 policyId) internal view returns (uint256 packed) { + if (policyId < FIRST_USER_POLICY_ID) revert PolicyNotFound(); + packed = _policyData[policyId]; + if (packed == 0) revert PolicyNotFound(); + } + + // Validates that policyId is a legal compound constituent and returns its type. + // Must exist and must be WHITELIST or BLACKLIST. Built-in IDs (0, 1) are always + // valid; their returned type is WHITELIST as a placeholder (the type bit is + // ignored on the hot path for built-ins because evaluation short-circuits on ID). + function _requireConstituent(uint64 policyId) internal view returns (PolicyType) { + if (policyId < FIRST_USER_POLICY_ID) return PolicyType.WHITELIST; + uint256 packed = _policyData[policyId]; + if (packed == 0) revert PolicyNotFound(); + PolicyType policyType = packed.decodeType(); + if (policyType != PolicyType.WHITELIST && policyType != PolicyType.BLACKLIST) revert ConstituentIsCompound(); + return policyType; + } + + // Resolves an authorization check for a single role slot. The shift selects + // which 62-bit constituent field to read from a compound policy's packed slot. + // + // For a compound policyId: 1 SLOAD (compound slot) + 1 SLOAD (member set) = 2 SLOADs + // For a simple policyId: 1 SLOAD (policy slot) + 1 SLOAD (member set) = 2 SLOADs + // For a built-in policyId: 0 SLOADs + function _checkRole(uint64 policyId, address user, uint256 shift) internal view returns (bool) { + if (policyId == ALWAYS_REJECT_ID) return false; + if (policyId == ALWAYS_ALLOW_ID) return true; + + uint256 packed = _policyData[policyId]; + PolicyType policyType = packed.decodeType(); + + if (policyType == PolicyType.COMPOUND) { + (uint64 constituentId, bool isBlacklist) = packed.decodeField(shift); + if (constituentId == ALWAYS_REJECT_ID) return false; + if (constituentId == ALWAYS_ALLOW_ID) return true; + bool member = _members[constituentId][user]; + return isBlacklist ? !member : member; + } + + bool simpleMember = _members[policyId][user]; + return policyType == PolicyType.WHITELIST ? simpleMember : !simpleMember; + } +} diff --git a/src/impls/PolicySlot.sol b/src/impls/PolicySlot.sol new file mode 100644 index 0000000..8a27581 --- /dev/null +++ b/src/impls/PolicySlot.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "../interfaces/IPolicyRegistry.sol"; + +/// @title PolicySlot +/// @notice Internal library for encoding and decoding packed policy storage slots. +/// @author Coinbase +/// +/// @dev Each policy is stored as a single uint256. The low 8 bits are always the +/// PolicyType discriminator; the remaining bits depend on the type: +/// +/// WHITELIST / BLACKLIST: +/// [255:169] unused +/// [168] frozen flag (1 = policy is permanently immutable) +/// [167:8] admin address (160 bits) +/// [7:0] PolicyType +/// +/// COMPOUND: +/// [255:194] redeemerField (62 bits = 1 type bit + 61 ID bits) +/// [193:132] mintRecipientField (62 bits) +/// [131:70] recipientField (62 bits) +/// [69:8] senderField (62 bits) +/// [7:0] PolicyType = 2 +/// +/// Each constituent field carries both the constituent policy ID (61 bits) +/// and a single type bit (0 = WHITELIST or built-in, 1 = BLACKLIST) so that +/// `isAuthorized*` evaluation needs at most one SLOAD to read the compound +/// slot plus one SLOAD to read the relevant constituent's member set — the +/// constituent's policy slot does NOT need to be loaded on the hot path. +/// The type bit is meaningless for built-in constituents (IDs 0 and 1) +/// because evaluation short-circuits on ID before consulting type. +/// +/// Constituent policy IDs are stored as 61 bits. The contract bounds the +/// policy ID counter to never exceed `ID_MASK` so this truncation is +/// structurally impossible. +/// +/// All functions are internal so they are inlined at compile time with no +/// runtime overhead. +library PolicySlot { + uint256 internal constant TYPE_MASK = 0xFF; + + // Simple-policy layout. + uint256 internal constant ADMIN_SHIFT = 8; + uint256 internal constant FROZEN_SHIFT = 168; + uint256 internal constant FROZEN_BIT = uint256(1) << FROZEN_SHIFT; + + // Compound-policy layout. + uint256 internal constant ID_BITS = 61; + uint256 internal constant ID_MASK = (uint256(1) << ID_BITS) - 1; + uint256 internal constant FIELD_BITS = 62; // 1 type bit + 61 ID bits + uint256 internal constant FIELD_MASK = (uint256(1) << FIELD_BITS) - 1; + uint256 internal constant TYPE_BIT_OFFSET = ID_BITS; // type bit sits above the 61-bit ID + uint256 internal constant TYPE_BIT = uint256(1) << TYPE_BIT_OFFSET; + + uint256 internal constant SENDER_SHIFT = 8; + uint256 internal constant RECIPIENT_SHIFT = SENDER_SHIFT + FIELD_BITS; // 70 + uint256 internal constant MINT_SHIFT = RECIPIENT_SHIFT + FIELD_BITS; // 132 + uint256 internal constant REDEEM_SHIFT = MINT_SHIFT + FIELD_BITS; // 194 + + function encodeSimple(IPolicyRegistry.PolicyType policyType, address policyAdmin) internal pure returns (uint256) { + return uint256(policyType) | (uint256(uint160(policyAdmin)) << ADMIN_SHIFT); + } + + /// @dev Packs a constituent (policy ID + type bit) into a single 62-bit field. + /// Built-in IDs (0, 1) can be passed with any `constituentType`; the type + /// bit is ignored at decode time for built-ins. + function encodeField(uint64 id, IPolicyRegistry.PolicyType constituentType) internal pure returns (uint256) { + uint256 typeBit = constituentType == IPolicyRegistry.PolicyType.BLACKLIST ? uint256(1) : uint256(0); + return uint256(id) | (typeBit << TYPE_BIT_OFFSET); + } + + /// @dev Composes a full compound-policy slot from four pre-encoded constituent fields. + function encodeCompound(uint256 senderField, uint256 recipientField, uint256 mintField, uint256 redeemerField) + internal + pure + returns (uint256) + { + return uint256(IPolicyRegistry.PolicyType.COMPOUND) | (senderField << SENDER_SHIFT) + | (recipientField << RECIPIENT_SHIFT) | (mintField << MINT_SHIFT) | (redeemerField << REDEEM_SHIFT); + } + + function decodeType(uint256 packed) internal pure returns (IPolicyRegistry.PolicyType) { + return IPolicyRegistry.PolicyType(packed & TYPE_MASK); + } + + function decodeAdmin(uint256 packed) internal pure returns (address) { + // forge-lint: disable-next-line(unsafe-typecast) + return address(uint160(packed >> ADMIN_SHIFT)); + } + + function decodeFrozen(uint256 packed) internal pure returns (bool) { + return (packed & FROZEN_BIT) != 0; + } + + /// @dev Extracts the constituent policy ID at the given shift offset. + /// Used by view functions that only care about the ID, not the type. + function decodeId(uint256 packed, uint256 shift) internal pure returns (uint64) { + // forge-lint: disable-next-line(unsafe-typecast) + return uint64((packed >> shift) & ID_MASK); + } + + /// @dev Extracts the constituent policy ID and type bit in one operation. + /// Used on the authorization hot path so the constituent's own policy slot + /// does not need to be loaded. + function decodeField(uint256 packed, uint256 shift) internal pure returns (uint64 id, bool isBlacklist) { + uint256 field = (packed >> shift) & FIELD_MASK; + // forge-lint: disable-next-line(unsafe-typecast) + id = uint64(field & ID_MASK); + isBlacklist = (field & TYPE_BIT) != 0; + } +} diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index b3f0733..ed2d013 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -1,19 +1,22 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.20 <0.9.0; +pragma solidity ^0.8.20; /// @title IPolicyRegistry +/// @author Coinbase /// @notice Singleton registry of transfer-authorization policies for B-20 /// tokens. Each B-20 token holds a single `transferPolicyId` -/// pointing into this registry; on every transfer or mint, the -/// token consults the registry to determine whether the involved +/// pointing into this registry; on every transfer, mint, or redeem, +/// the token consults the registry to determine whether the involved /// addresses are authorized. /// -/// Three policy types are supported in v1: +/// Five policy types are defined: /// - WHITELIST: only listed addresses are authorized. /// - BLACKLIST: all addresses except listed ones are authorized. -/// - COMPOUND: references three simple policies, one for senders, -/// one for recipients, one for mint recipients. Lets a single -/// policy ID carry asymmetric rules. +/// - COMPOUND: references four constituent policies, one each for +/// senders, recipients, mint recipients, and redeemers. Lets a +/// single policy ID carry asymmetric per-role rules. +/// - ALWAYS_REJECT: built-in; all authorization queries return false. +/// - ALWAYS_ALLOW: built-in; all authorization queries return true. /// /// @dev Adapted from Tempo TIP-403 + TIP-1015 with three deliberate /// omissions: no virtual-address rejection logic (no TIP-1022 on @@ -41,35 +44,31 @@ interface IPolicyRegistry { TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Policy type discriminator. - /// @param WHITELIST An address is authorized only if it is in the policy's set. - /// @param BLACKLIST An address is authorized unless it is in the policy's set. - /// @param COMPOUND The policy carries no member set of its own. It - /// references three simple policies and delegates the - /// per-role check. + /// @notice Policy type discriminator. ALWAYS_REJECT and ALWAYS_ALLOW are + /// reserved for the built-in IDs (0 and 1) and cannot be assigned + /// to created policies. enum PolicyType { - WHITELIST, - BLACKLIST, - COMPOUND + WHITELIST, // 0: address-set membership; authorized if in set + BLACKLIST, // 1: address-set membership; authorized if NOT in set + COMPOUND, // 2: per-role slots delegating to constituent policies + ALWAYS_REJECT, // 3: built-in; all authorization queries return false + ALWAYS_ALLOW // 4: built-in; all authorization queries return true } - /// @notice Top-level data for any policy (simple or compound). - /// @param policyType The type of the policy. - /// @param admin The address that may modify this policy. Zero for - /// COMPOUND policies (they are structurally immutable). - struct PolicyData { - PolicyType policyType; - address admin; - } - - /// @notice Constituent policy IDs for a compound policy. - /// @param senderPolicyId Policy checked for transfer senders. - /// @param recipientPolicyId Policy checked for transfer recipients. - /// @param mintRecipientPolicyId Policy checked for mint recipients. + /// @notice Constituent policy IDs for a compound policy. Each slot maps to one + /// transfer role; the constituent policy is evaluated against the address + /// fulfilling that role. Slots may reference any simple policy (WHITELIST, + /// BLACKLIST) or a built-in ID. Use ID `1` (always-allow) for any slot + /// with no constraint, or `0` (always-reject) to hard-block a role. struct CompoundPolicyData { + /// @dev Policy checked for transfer senders. uint64 senderPolicyId; + /// @dev Policy checked for transfer recipients. uint64 recipientPolicyId; + /// @dev Policy checked for mint recipients. uint64 mintRecipientPolicyId; + /// @dev Policy checked for redeem callers. + uint64 redeemerPolicyId; } /*////////////////////////////////////////////////////////////// @@ -82,10 +81,10 @@ interface IPolicyRegistry { /// @notice The referenced policy ID does not exist (and is not built-in). error PolicyNotFound(); - /// @notice A compound policy attempted to reference another compound - /// policy as a constituent. Only simple policies (WHITELIST, - /// BLACKLIST) and the built-in IDs (0, 1) are valid constituents. - error PolicyNotSimple(); + /// @notice A compound policy attempted to reference another compound policy + /// as a constituent. Only WHITELIST, BLACKLIST, and built-in IDs + /// (0, 1) are valid constituents. + error ConstituentIsCompound(); /// @notice The operation is incompatible with the policy's type. For /// example, calling `modifyPolicyWhitelist` on a BLACKLIST @@ -100,6 +99,22 @@ interface IPolicyRegistry { /// @notice A required address argument was the zero address. error ZeroAddress(); + /// @notice The policy ID counter has been exhausted. Custom policy IDs are + /// bounded by the on-chain packing format (61 bits, or ~2.3e18 IDs); + /// creating one more policy would overflow that bound. + error PolicyIdOverflow(); + + /// @notice The caller is not the pending admin for this policy. + error NotPendingAdmin(); + + /// @notice There is no admin transfer in progress for this policy. + error NoTransferPending(); + + /// @notice The policy has been permanently frozen and can no longer be + /// modified (no membership changes, no admin transfers, no further + /// freezing). This is a one-way state set by `freezePolicy`. + error PolicyFrozen(); + /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ @@ -114,13 +129,29 @@ interface IPolicyRegistry { address indexed creator, uint64 senderPolicyId, uint64 recipientPolicyId, - uint64 mintRecipientPolicyId + uint64 mintRecipientPolicyId, + uint64 redeemerPolicyId ); /// @notice Emitted when a policy's admin is updated (including initial - /// assignment at creation). + /// assignment at creation and on `acceptPolicyAdminTransfer`). event PolicyAdminUpdated(uint64 indexed policyId, address indexed updater, address indexed admin); + /// @notice Emitted when the current admin nominates a new admin via + /// `beginPolicyAdminTransfer`. The transfer does not take effect until + /// `pendingAdmin` calls `acceptPolicyAdminTransfer`. + event PolicyAdminTransferBegun(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin); + + /// @notice Emitted when an in-flight admin transfer is cancelled by the current + /// admin (clearing the pending admin without changing the active admin). + event PolicyAdminTransferCancelled( + uint64 indexed policyId, address indexed currentAdmin, address indexed cancelledPendingAdmin + ); + + /// @notice Emitted when a policy is permanently frozen. After this event, the + /// policy's membership and admin can no longer be modified. + event PolicyFrozenEvent(uint64 indexed policyId, address indexed frozenBy); + /// @notice Emitted when an account's whitelist status is updated for a /// WHITELIST policy. event WhitelistUpdated(uint64 indexed policyId, address indexed updater, address indexed account, bool allowed); @@ -134,11 +165,14 @@ interface IPolicyRegistry { //////////////////////////////////////////////////////////////*/ /// @notice Creates a new simple (WHITELIST or BLACKLIST) policy. - /// @dev Permissionless. Reverts with `InvalidPolicyType` if - /// `policyType` is `COMPOUND` (use `createCompoundPolicy`), - /// and with `ZeroAddress` if `admin` is `address(0)`. + /// + /// @dev Permissionless. Reverts with `InvalidPolicyType` if `policyType` + /// is `COMPOUND` (use `createCompoundPolicy`), and with `ZeroAddress` + /// if `admin` is `address(0)`. + /// /// @param admin The address authorized to modify this policy. /// @param policyType WHITELIST or BLACKLIST. + /// /// @return newPolicyId The newly assigned policy ID. function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); @@ -149,39 +183,71 @@ interface IPolicyRegistry { external returns (uint64 newPolicyId); - /// @notice Creates a new compound policy referencing three constituent - /// simple policies. Compound policies are structurally - /// immutable: the constituent IDs cannot be changed after - /// creation, and there is no admin. To rotate the configuration, - /// create a new compound policy and re-point the consuming + /// @notice Creates a new compound policy referencing four constituent policy IDs, + /// one per transfer role. Compound policies are immutable: constituent IDs + /// cannot be changed after creation, and there is no admin. To rotate + /// the configuration, create a new compound policy and re-point the /// token's `transferPolicyId`. - /// @dev Permissionless. Each constituent MUST exist and MUST be a - /// simple policy (WHITELIST, BLACKLIST) OR a built-in (IDs 0 - /// or 1). Reverts with `PolicyNotFound` for unknown IDs and - /// `PolicyNotSimple` if any constituent is itself COMPOUND. - function createCompoundPolicy(uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId) - external - returns (uint64 newPolicyId); + /// + /// @dev Permissionless. Each constituent MUST exist and MUST NOT be COMPOUND. + /// Built-in IDs (0 and 1) are always valid. Reverts with + /// `PolicyNotFound` for unknown IDs and `ConstituentIsCompound` if + /// any constituent is itself COMPOUND. + function createCompoundPolicy( + uint64 senderPolicyId, + uint64 recipientPolicyId, + uint64 mintRecipientPolicyId, + uint64 redeemerPolicyId + ) external returns (uint64 newPolicyId); /*////////////////////////////////////////////////////////////// POLICY ADMINISTRATION //////////////////////////////////////////////////////////////*/ - /// @notice Transfers admin rights for a simple policy. Caller must be - /// the current admin. Reverts on COMPOUND policies (they have - /// no admin). - function setPolicyAdmin(uint64 policyId, address newAdmin) external; + /// @notice Nominates a new admin for a simple policy. The transfer is two-step: + /// this call records `newAdmin` as the pending admin without changing the + /// active admin. The nominee must then call `acceptPolicyAdminTransfer`. + /// + /// @dev Caller must be the current admin. Reverts on COMPOUND policies (they + /// have no admin) and on frozen policies. Calling this again overwrites + /// any previously pending admin for this policy. Pass `address(0)` to + /// clear a previously nominated pending admin (equivalent to + /// `cancelPolicyAdminTransfer`). + function beginPolicyAdminTransfer(uint64 policyId, address newAdmin) external; + + /// @notice Completes a two-step admin transfer. Caller must be the address + /// previously nominated via `beginPolicyAdminTransfer`. On success, + /// the caller becomes the new admin and the pending admin slot is + /// cleared. + function acceptPolicyAdminTransfer(uint64 policyId) external; + + /// @notice Cancels a pending admin transfer without changing the active admin. + /// Caller must be the current admin. + function cancelPolicyAdminTransfer(uint64 policyId) external; + + /// @notice Permanently freezes a policy: after this call, the policy's + /// membership cannot be modified, its admin cannot be transferred, + /// and it cannot be unfrozen. Compound policies that reference this + /// policy as a constituent continue to work; only this policy's own + /// membership state is locked. + /// + /// @dev Caller must be the current admin. Reverts on COMPOUND policies and + /// on already-frozen policies. Any in-flight admin transfer is + /// cleared as a side effect. + function freezePolicy(uint64 policyId) external; /// @notice Adds or removes an account from a WHITELIST policy. Caller /// must be the policy admin. + /// /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// WHITELIST. + /// WHITELIST, and with `PolicyFrozen` if the policy has been frozen. function modifyPolicyWhitelist(uint64 policyId, address account, bool allowed) external; /// @notice Adds or removes an account from a BLACKLIST policy. Caller /// must be the policy admin. + /// /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// BLACKLIST. + /// BLACKLIST, and with `PolicyFrozen` if the policy has been frozen. function modifyPolicyBlacklist(uint64 policyId, address account, bool restricted) external; /*////////////////////////////////////////////////////////////// @@ -212,6 +278,11 @@ interface IPolicyRegistry { /// the same result as `isAuthorizedRecipient`. function isAuthorizedMintRecipient(uint64 policyId, address user) external view returns (bool); + /// @notice Whether `user` is authorized as a redeemer under `policyId`. + /// For compound policies, delegates to the policy's `redeemerPolicyId`. + /// For simple policies, equivalent to `isAuthorizedRecipient`. + function isAuthorizedRedeemer(uint64 policyId, address user) external view returns (bool); + /*////////////////////////////////////////////////////////////// POLICY QUERIES //////////////////////////////////////////////////////////////*/ @@ -225,20 +296,28 @@ interface IPolicyRegistry { /// exist; custom IDs (>=2) exist iff they have been created. function policyExists(uint64 policyId) external view returns (bool); - /// @notice Returns the type and admin of `policyId`. - /// @dev For COMPOUND policies, `admin` is `address(0)`. For built-in - /// policies, `admin` is `address(0)` and `policyType` is - /// implementation-defined (the built-ins are not categorized as - /// WHITELIST or BLACKLIST since they have no member set). + /// @notice Returns the type and admin of `policyId`. For COMPOUND policies, + /// `admin` is `address(0)`. For built-in IDs, `admin` is `address(0)` + /// and `policyType` is `ALWAYS_REJECT` (ID 0) or `ALWAYS_ALLOW` (ID 1). /// Reverts with `PolicyNotFound` for unknown policy IDs. function policyData(uint64 policyId) external view returns (PolicyType policyType, address admin); + /// @notice The pending admin nominated via `beginPolicyAdminTransfer`, or + /// `address(0)` if no transfer is in flight. Always `address(0)` + /// for compound and built-in policies. + function pendingPolicyAdmin(uint64 policyId) external view returns (address); + + /// @notice Whether `policyId` has been permanently frozen via `freezePolicy`. + /// Always false for compound and built-in policies. + function isPolicyFrozen(uint64 policyId) external view returns (bool); + /// @notice Returns the constituent policy IDs of a compound policy. + /// /// @dev Reverts with `IncompatiblePolicyType` if the policy is not /// COMPOUND, and with `PolicyNotFound` if the policy does not /// exist. function compoundPolicyData(uint64 policyId) external view - returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId); + returns (uint64 senderPolicyId, uint64 recipientPolicyId, uint64 mintRecipientPolicyId, uint64 redeemerPolicyId); }