Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions src/interfaces/IPolicyRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ interface IPolicyRegistry {
/// @notice `finalizeUpdateAdmin` was called with no pending admin staged.
error NoPendingAdmin();

/// @notice A composite policy was created or updated with fewer than the minimum number of
/// child policies. A composite must reference at least two simple policies.
error TooFewChildPolicies();
/// @notice A composite policy was created or updated with a child-policy count outside the
/// permitted range. A composite must reference between `min` and `max` simple policies,
/// inclusive.
/// @param min Minimum number of child policies permitted.
/// @param max Maximum number of child policies permitted.
error ChildPoliciesOutsideOfRange(uint256 min, uint256 max);

/// @notice Composite policies are not simple policies. Child policies must be existing
/// ALLOWLIST or BLOCKLIST policies;
Expand Down Expand Up @@ -126,11 +129,10 @@ interface IPolicyRegistry {
/// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite.
/// The child-policy set is capped at 4.
/// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT.
/// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`.
/// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2`.
/// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 4`
/// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`.
/// @dev Reverts with `ChildPoliciesOutsideOfRange(2, 4)` when `childPolicyIds.length` is not in `[2, 4]`.
/// @dev Reverts with `PolicyNotFound` when any child policy does not exist.
/// @dev Reverts with `InvalidChildPolicy` when any child policy is not a simple policy.
/// @dev Reverts with `InvalidChildPolicy` when any child policy is not a simple policy or a built-in policy.
/// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value.
///
/// @param admin Initial admin authorized to update child policies and transfer or renounce
Expand Down Expand Up @@ -204,9 +206,8 @@ interface IPolicyRegistry {
/// @dev Reverts with `IncompatiblePolicyType` when `policyId` is not a composite (UNION or INTERSECT).
/// @dev Reverts with `Unauthorized` when the caller is not the current admin. A renounced composite
/// (admin `address(0)`) can never be updated.
/// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2`; there is no clear-the-list path.
/// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 4`
/// (composite child-policy cap; not the account membership batch limit of 64).
/// @dev Reverts with `ChildPoliciesOutsideOfRange(2, 4)` when `childPolicyIds.length` is not in `[2, 4]`;
/// there is no clear-the-list path (the composite child-policy range, not the 64-account batch limit).
/// @dev Reverts with `PolicyNotFound` when any child policy does not exist.
/// @dev Reverts with `InvalidChildPolicy` when any child policy is itself a composite
/// (not a simple policy).
Expand Down
68 changes: 66 additions & 2 deletions test/lib/PolicyRegistryTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,20 @@ contract PolicyRegistryTest is BaseTest {
// ============================================================
// POLICY-TYPE FUZZ HELPERS
// ============================================================
// The `PolicyType` enum has two values (BLOCKLIST = 0, ALLOWLIST = 1),
// both creatable. The helper picks one from a fuzz seed.
// The `PolicyType` enum has two SIMPLE values (BLOCKLIST = 0,
// ALLOWLIST = 1) and two COMPOSITE gates (UNION = 2, INTERSECT = 3).
// The helpers below pick one from a fuzz seed.

/// @notice Maps a fuzz seed to ALLOWLIST or BLOCKLIST.
function _creatablePolicyType(uint8 idx) internal pure returns (IPolicyRegistry.PolicyType) {
return idx % 2 == 0 ? IPolicyRegistry.PolicyType.ALLOWLIST : IPolicyRegistry.PolicyType.BLOCKLIST;
}

/// @notice Maps a fuzz seed to a composite gate: UNION or INTERSECT.
function _creatableCompositeType(uint8 idx) internal pure returns (IPolicyRegistry.PolicyType) {
return idx % 2 == 0 ? IPolicyRegistry.PolicyType.UNION : IPolicyRegistry.PolicyType.INTERSECT;
}

/// @notice Predict the ID the next `createPolicy(_, policyType)` would assign.
/// @dev Reads `nextCounter` directly via `vm.load`. When the registry
/// has not yet been initialized (counter == 0), the next
Expand Down Expand Up @@ -107,4 +113,62 @@ contract PolicyRegistryTest is BaseTest {
accounts[i] = address(uint160(0x1000 + i));
}
}

// ============================================================
// COMPOSITE-POLICY HELPERS
// ============================================================

/// @notice Minimum number of child policies a composite must reference.
/// @dev Mirrors the registry's composite child-policy floor. Kept as a
/// test-side literal so fork tests against the real precompile use
/// the same compile-time constant.
uint256 internal constant MIN_CHILD_POLICIES = 2;

/// @notice Maximum number of child policies a composite may reference.
/// @dev Mirrors the registry's composite child-policy cap. Distinct
/// from `MAX_BATCH_SIZE` (64), which caps account membership
/// batches; a composite created or updated with a child count
/// outside `[MIN_CHILD_POLICIES, MAX_CHILD_POLICIES]` reverts with
/// `ChildPoliciesOutsideOfRange(MIN_CHILD_POLICIES, MAX_CHILD_POLICIES)`.
/// Kept as a test-side literal so fork tests against the real
/// precompile use the same compile-time constant.
uint256 internal constant MAX_CHILD_POLICIES = 4;

/// @notice Create `n` simple ALLOWLIST policies (admin = `admin`) and
/// return their IDs. Used to seed valid child sets for
/// composite creation / update tests.
function _makeSimpleChildren(uint256 n) internal returns (uint64[] memory childPolicyIds) {
childPolicyIds = new uint64[](n);
for (uint256 i = 0; i < n; ++i) {
childPolicyIds[i] = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST);
}
}

/// @notice Create a composite policy with explicit caller, admin, gate
/// type, and child set.
function _createComposite(
address caller,
address policyAdmin,
IPolicyRegistry.PolicyType policyType,
uint64[] memory childPolicyIds
) internal returns (uint64 policyId) {
vm.prank(caller);
policyId = policyRegistry.createCompositePolicy(policyAdmin, policyType, childPolicyIds);
}

/// @notice Create a composite policy as the default admin over a fresh
/// set of `n` simple ALLOWLIST children. Returns the composite
/// ID; use `_makeSimpleChildren` directly when the child IDs
/// are needed at the call site.
function _createComposite(IPolicyRegistry.PolicyType policyType, uint256 n) internal returns (uint64 policyId) {
uint64[] memory childPolicyIds = _makeSimpleChildren(n);
policyId = policyRegistry.createCompositePolicy(admin, policyType, childPolicyIds);
}

/// @notice Pack two policy IDs into a length-2 `uint64[]`.
function _childIds(uint64 a, uint64 b) internal pure returns (uint64[] memory ids) {
ids = new uint64[](2);
ids[0] = a;
ids[1] = b;
}
}
146 changes: 134 additions & 12 deletions test/lib/mocks/MockPolicyRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ contract MockPolicyRegistry is IPolicyRegistry {
/// precompile.
uint256 internal constant MAX_BATCH_SIZE = 64;

/// @notice Minimum number of child policies a composite must reference.
/// `createCompositePolicy` and `updateComposite` revert with
/// `ChildPoliciesOutsideOfRange(MIN_CHILD_POLICIES, MAX_CHILD_POLICIES)`
/// when `childPolicyIds.length` is below this value.
uint256 internal constant MIN_CHILD_POLICIES = 2;

/// @notice Maximum number of child policies a composite may reference.
/// `createCompositePolicy` and `updateComposite` revert with
/// `ChildPoliciesOutsideOfRange(MIN_CHILD_POLICIES, MAX_CHILD_POLICIES)`
/// when `childPolicyIds.length` is outside `[MIN_CHILD_POLICIES, MAX_CHILD_POLICIES]`.
/// Distinct from `MAX_BATCH_SIZE` (64), which caps account-membership batches.
/// Mirrors the Rust PolicyRegistry precompile.
uint256 internal constant MAX_CHILD_POLICIES = 4;

// ============================================================
// POLICY CREATION
// ============================================================
Expand Down Expand Up @@ -106,6 +120,23 @@ contract MockPolicyRegistry is IPolicyRegistry {
_batchSetMembers({policyId: newPolicyId, policyType: policyType, value: true, accounts: accounts});
}

/// @inheritdoc IPolicyRegistry
function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds)
external
returns (uint64 newPolicyId)
{
if (admin == address(0)) revert ZeroAddress();
if (policyType != PolicyType.UNION && policyType != PolicyType.INTERSECT) revert IncompatiblePolicyType();
if (childPolicyIds.length < MIN_CHILD_POLICIES || childPolicyIds.length > MAX_CHILD_POLICIES) {
revert ChildPoliciesOutsideOfRange(MIN_CHILD_POLICIES, MAX_CHILD_POLICIES);
}
_requireCreatedSimplePolicies(childPolicyIds);

newPolicyId = _create(admin, policyType);
MockPolicyRegistryStorage.layout().children[newPolicyId] = childPolicyIds;
emit CompositePolicyUpdated(newPolicyId, msg.sender, childPolicyIds);
}

// ============================================================
// POLICY ADMINISTRATION
// ============================================================
Expand Down Expand Up @@ -163,24 +194,34 @@ contract MockPolicyRegistry is IPolicyRegistry {
_batchSetMembers({policyId: policyId, policyType: PolicyType.BLOCKLIST, value: blocked, accounts: accounts});
}

/// @inheritdoc IPolicyRegistry
function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external {
// Canonical check precedence (see updateComposite_revertOrder test):
// self-not-found → incompatible-type → unauthorized → too-few → batch-size →
// child-not-found → invalid-child. The type guard precedes the auth guard, and
// a renounced composite (admin zero) fails the auth guard for every caller.
uint256 packed = _requireCustom(policyId);
if (!_isComposite(policyId)) revert IncompatiblePolicyType();
if (_decodeAdmin(packed) != msg.sender) revert Unauthorized();
if (childPolicyIds.length < MIN_CHILD_POLICIES || childPolicyIds.length > MAX_CHILD_POLICIES) {
revert ChildPoliciesOutsideOfRange(MIN_CHILD_POLICIES, MAX_CHILD_POLICIES);
}
_requireCreatedSimplePolicies(childPolicyIds);

// Full replacement: assigning a memory array to the storage array resets its
// length and overwrites elements. Child sets are ≤ MAX_CHILD_POLICIES, so there
// is no stale-tail concern.
MockPolicyRegistryStorage.layout().children[policyId] = childPolicyIds;
emit CompositePolicyUpdated(policyId, msg.sender, childPolicyIds);
}

// ============================================================
// AUTHORIZATION QUERIES
// ============================================================

/// @inheritdoc IPolicyRegistry
function isAuthorized(uint64 policyId, address account) external view returns (bool) {
// Built-in short-circuits precede any SLOAD; sentinels have no
// storage entry.
if (policyId == ALWAYS_ALLOW_ID) return true;
if (policyId == ALWAYS_BLOCK_ID) return false;
// Short-circuit malformed IDs so the `_typeOf` enum cast can't panic.
if (!_isWellFormed(policyId)) return false;
// Hot path: one SLOAD (the membership bit). No existence check —
// callers pre-validate via `policyExists` at write time. For
// non-existent IDs the result collapses to empty-member-set
// semantics (ALLOWLIST → false, BLOCKLIST → true).
bool member = MockPolicyRegistryStorage.layout().members[policyId][account];
return _typeOf(policyId) == PolicyType.ALLOWLIST ? member : !member;
return _isAuthorized(policyId, account);
}

// ============================================================
Expand Down Expand Up @@ -292,6 +333,87 @@ contract MockPolicyRegistry is IPolicyRegistry {
if (packed == 0) revert PolicyNotFound();
}

/// @dev Core authorization logic shared by the external view and composite
/// child evaluation. Never reverts.
///
/// Composites evaluate their children LIVE (reading each child's current
/// membership, not a snapshot). Children are validated to be simple at
/// write time, so the recursion terminates at depth 1: a composite calls
/// `_isAuthorized` per child, each of which resolves via the simple path
/// (or a built-in short-circuit).
function _isAuthorized(uint64 policyId, address account) internal view returns (bool) {
// Built-in short-circuits precede any SLOAD; sentinels have no
// storage entry.
if (policyId == ALWAYS_ALLOW_ID) return true;
if (policyId == ALWAYS_BLOCK_ID) return false;
// Short-circuit malformed IDs so the `_typeOf` enum cast can't panic.
if (!_isWellFormed(policyId)) return false;

PolicyType policyType = _typeOf(policyId);
if (policyType == PolicyType.UNION) return _isAuthorizedUnion(policyId, account);
if (policyType == PolicyType.INTERSECT) return _isAuthorizedIntersect(policyId, account);

// Simple hot path: one SLOAD (the membership bit). No existence check —
// callers pre-validate via `policyExists` at write time. For non-existent
// IDs the result collapses to empty-member-set semantics (ALLOWLIST →
// false, BLOCKLIST → true).
bool member = MockPolicyRegistryStorage.layout().members[policyId][account];
return policyType == PolicyType.ALLOWLIST ? member : !member;
}

/// @dev Evaluates a UNION (OR) composite over its LIVE child set: authorized if ANY
/// child authorizes;
function _isAuthorizedUnion(uint64 policyId, address account) internal view returns (bool) {
uint64[] storage childPolicyIds = MockPolicyRegistryStorage.layout().children[policyId];
uint256 childCount = childPolicyIds.length;
for (uint256 i = 0; i < childCount; ++i) {
if (_isAuthorized(childPolicyIds[i], account)) return true;
}
return false;
}

/// @dev Evaluates an INTERSECT (AND) composite over its LIVE child set: authorized only
/// if EVERY child authorizes;
function _isAuthorizedIntersect(uint64 policyId, address account) internal view returns (bool) {
uint64[] storage childPolicyIds = MockPolicyRegistryStorage.layout().children[policyId];
uint256 childCount = childPolicyIds.length;
for (uint256 i = 0; i < childCount; ++i) {
if (!_isAuthorized(childPolicyIds[i], account)) return false;
}
return true;
}

/// @dev Requires every composite child to be a created, custom, SIMPLE policy:
/// it must exist, must not be a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK),
/// and must not itself be a composite. Two passes so `PolicyNotFound` takes
/// precedence over `InvalidChildPolicy` across the whole set (matches the
/// canonical revert order the Rust precompile mirrors).
function _requireCreatedSimplePolicies(uint64[] calldata childPolicyIds) internal view {
MockPolicyRegistryStorage.Layout storage $ = MockPolicyRegistryStorage.layout();
// Pass 1: existence.
for (uint256 i = 0; i < childPolicyIds.length; ++i) {
if ($.policies[childPolicyIds[i]] == 0) revert PolicyNotFound();
}
// Pass 2: must be a simple policy
for (uint256 i = 0; i < childPolicyIds.length; ++i) {
uint64 child = childPolicyIds[i];
if (_isBuiltin(child) || _isComposite(child)) revert InvalidChildPolicy(child);
}
}

/// @dev True iff `policyId`'s top byte encodes a composite gate (UNION or INTERSECT).
/// Assumes a well-formed ID; composite children come from stored/created policies.
function _isComposite(uint64 policyId) internal pure returns (bool) {
PolicyType policyType = _typeOf(policyId);
return policyType == PolicyType.UNION || policyType == PolicyType.INTERSECT;
}

/// @dev True iff `policyId` is a built-in (ALWAYS_ALLOW / ALWAYS_BLOCK).
/// Sentinels are reserved and may not be used as composite children.
function _isBuiltin(uint64 policyId) internal pure returns (bool) {
return policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID;
}

function _makeId(PolicyType policyType, uint56 counter) internal pure returns (uint64) {
return (uint64(uint8(policyType)) << POLICY_ID_TYPE_SHIFT) | uint64(counter);
}
Expand Down
15 changes: 15 additions & 0 deletions test/lib/mocks/MockPolicyRegistryStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ library MockPolicyRegistryStorage {
// into counters 0 and 1 before consuming counter 2 for the new
// custom policy.
uint56 nextCounter;
// Child policy IDs of a composite (UNION/INTERSECT); empty for simple policies.
// The full set is replaced on every `createCompositePolicy` / `updateComposite`;
// capped at MAX_CHILD_POLICIES entries.
mapping(uint64 policyId => uint64[] childPolicyIds) children;
}

// keccak256(abi.encode(uint256(keccak256("base.policy_registry")) - 1)) & ~bytes32(uint256(0xff))
Expand All @@ -58,6 +62,7 @@ library MockPolicyRegistryStorage {
uint256 internal constant MEMBERS_OFFSET = 1;
uint256 internal constant PENDING_ADMINS_OFFSET = 2;
uint256 internal constant NEXT_COUNTER_OFFSET = 3;
uint256 internal constant CHILDREN_OFFSET = 4;

/// @notice Absolute slot for a top-level field of `Layout`.
function slotOf(uint256 offset) internal pure returns (bytes32) {
Expand Down Expand Up @@ -88,6 +93,7 @@ library MockPolicyRegistryStorage {
function membersBaseSlot() internal pure returns (bytes32) { return slotOf(MEMBERS_OFFSET); }
function pendingAdminsBaseSlot() internal pure returns (bytes32) { return slotOf(PENDING_ADMINS_OFFSET); }
function nextCounterSlot() internal pure returns (bytes32) { return slotOf(NEXT_COUNTER_OFFSET); }
function childrenBaseSlot() internal pure returns (bytes32) { return slotOf(CHILDREN_OFFSET); }

// forgefmt: disable-end

Expand Down Expand Up @@ -116,6 +122,15 @@ library MockPolicyRegistryStorage {
return keccak256(abi.encode(policyId, pendingAdminsBaseSlot()));
}

/// @notice Slot of the length word of the dynamic array `children[policyId]`.
/// @dev The `childPolicyIds` elements are packed contiguously starting at
/// `keccak256(childrenSlot(policyId))`, 4 `uint64` entries per 32-byte
/// slot (Solidity dynamic-array element layout). The Rust impl mirrors
/// this: length here, elements at the hashed base.
function childrenSlot(uint64 policyId) internal pure returns (bytes32) {
return keccak256(abi.encode(policyId, childrenBaseSlot()));
}

// ============================================================
// PACKED-SLOT CODECS
// ============================================================
Expand Down
Loading
Loading