From 1a391f4993381abaacefebfad24ef4ca2d3d99e1 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 22 Jul 2026 17:48:21 -0400 Subject: [PATCH 01/23] Propose composite policy interface (UNION/INTERSECT) Interface-only proposal for composite policies on IPolicyRegistry: - Extend PolicyType with UNION and INTERSECT (append-only; leaf discriminants 0/1 unchanged, gate rides the policy ID top byte). - Add createCompositePolicy(admin, gate, operands) and updateCompositeOperands(policyId, operands). Update is a full-set overwrite (replace-all), re-validated as at creation. - Add CompositePolicyCreated and CompositeOperandsUpdated events, and EmptyOperandSet / InvalidOperand errors. - Note on createPolicy/createPolicyWithAccounts that composite gates revert IncompatiblePolicyType. Operands are flat-only (leaf ALLOWLIST/BLOCKLIST), capped at the composite operand limit, and the set may not be empty. This changes only the interface; MockPolicyRegistry and the Rust precompile implementation follow in a separate PR, so the mock will not compile against this interface until then. Generated with Claude Code Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 90 ++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index b312d2d..b3f13c5 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -10,13 +10,19 @@ interface IPolicyRegistry { TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Policy type discriminator. + /// @notice Policy type discriminator. Discriminants are append-only: BLOCKLIST and ALLOWLIST + /// keep values 0 and 1, so the composite gates ride the policy ID top byte without + /// shifting the existing leaf types. /// - /// @param BLOCKLIST Authorized unless in the policy's set. - /// @param ALLOWLIST Authorized only if in the policy's set. + /// @param BLOCKLIST Leaf. Authorized unless in the policy's set. + /// @param ALLOWLIST Leaf. Authorized only if in the policy's set. + /// @param UNION Composite. Authorized if authorized under ANY operand (logical OR). + /// @param INTERSECT Composite. Authorized only if authorized under EVERY operand (logical AND). enum PolicyType { BLOCKLIST, - ALLOWLIST + ALLOWLIST, + UNION, + INTERSECT } /*////////////////////////////////////////////////////////////// @@ -48,6 +54,16 @@ interface IPolicyRegistry { /// @notice `finalizeUpdateAdmin` was called with no pending admin staged. error NoPendingAdmin(); + /// @notice A composite policy was created or updated with an empty operand set. A composite must + /// reference at least one operand, so the never-revert fold evaluator need not define + /// empty-UNION / empty-INTERSECT semantics. + error EmptyOperandSet(); + + /// @notice A composite operand is not a flat leaf. Operands must be existing ALLOWLIST or + /// BLOCKLIST policies; composites of composites are rejected (flat-only). + /// @param operandId The offending operand policy ID. + error InvalidOperand(uint64 operandId); + /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ @@ -68,13 +84,25 @@ interface IPolicyRegistry { /// @notice One or more accounts had their BLOCKLIST membership set to `blocked` in a single batch. event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); + /// @notice A composite policy (UNION or INTERSECT) was created over `operands`. + event CompositePolicyCreated( + uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] operands + ); + + /// @notice A composite policy's operand set was replaced in full with `operands`. The event + /// carries the complete post-update set, so an indexer reconstructs current state from a + /// single log with no delta folding. + event CompositeOperandsUpdated(uint64 indexed policyId, address indexed updater, uint64[] operands); + /*////////////////////////////////////////////////////////////// POLICY CREATION //////////////////////////////////////////////////////////////*/ - /// @notice Creates a new policy with no initial members. Permissionless. + /// @notice Creates a new leaf policy with no initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. + /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or + /// INTERSECT); use `createCompositePolicy` for those. /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// /// @param admin Initial admin authorized to modify membership and transfer or renounce administration. @@ -83,9 +111,11 @@ interface IPolicyRegistry { /// @return newPolicyId The newly assigned policy ID. function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); - /// @notice Creates a new policy seeded with `accounts` as initial members. Permissionless. + /// @notice Creates a new leaf policy seeded with `accounts` as initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`. + /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or + /// INTERSECT); use `createCompositePolicy` for those. Takes precedence over `BatchSizeTooLarge`. /// @dev Reverts with `BatchSizeTooLarge` when `accounts.length` exceeds the registry limit. /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// @@ -98,6 +128,30 @@ interface IPolicyRegistry { external returns (uint64 newPolicyId); + /// @notice Creates a new composite policy that combines existing leaf policies under a logic gate. + /// Permissionless. A UNION authorizes an account if ANY operand authorizes it; an INTERSECT + /// authorizes only if EVERY operand does. `isAuthorized` folds the operands and never reverts. + /// + /// @dev Operands are flat-only: each must be an existing ALLOWLIST or BLOCKLIST policy, never another + /// composite. Because operands reference smaller, already-assigned IDs, the reference graph is a + /// DAG and cannot cycle. The operand set is capped at the composite operand limit. + /// @dev Reverts with `IncompatiblePolicyType` when `gate` is not UNION or INTERSECT. + /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. + /// @dev Reverts with `EmptyOperandSet` when `operands` is empty. + /// @dev Reverts with `BatchSizeTooLarge` when `operands.length` exceeds the composite operand limit. + /// @dev Reverts with `PolicyNotFound` when any operand does not exist. + /// @dev Reverts with `InvalidOperand` when any operand is itself a composite (not a flat leaf). + /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. + /// + /// @param admin Initial admin authorized to update operands and transfer or renounce administration. + /// @param gate UNION or INTERSECT. + /// @param operands Existing leaf policy IDs to combine. + /// + /// @return newPolicyId The newly assigned composite policy ID. + function createCompositePolicy(address admin, PolicyType gate, uint64[] calldata operands) + external + returns (uint64 newPolicyId); + /*////////////////////////////////////////////////////////////// POLICY ADMINISTRATION //////////////////////////////////////////////////////////////*/ @@ -154,6 +208,30 @@ interface IPolicyRegistry { /// @param accounts Accounts to update. function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; + /// @notice Replaces a composite policy's operand set in full with `operands`. This is a whole-set + /// overwrite, not an incremental edit: the caller sends the complete desired operand list + /// and it is re-validated exactly as at creation. The gate (UNION or INTERSECT) is fixed in + /// the policy ID and cannot change; a different gate requires a new composite. + /// + /// @dev Overwrite semantics are last-write-wins. The function carries no expected-version guard, so + /// concurrent full-set submissions by shared admins silently clobber one another; serialize + /// edits through the governing multisig or approval flow. Read the current operands before + /// editing. + /// @dev Guard order: `PolicyNotFound` -> `IncompatiblePolicyType` -> `Unauthorized` -> + /// `EmptyOperandSet` / `BatchSizeTooLarge` -> per-operand `PolicyNotFound` / `InvalidOperand`. + /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. + /// @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 `EmptyOperandSet` when `operands` is empty; there is no clear-the-list path. + /// @dev Reverts with `BatchSizeTooLarge` when `operands.length` exceeds the composite operand limit. + /// @dev Reverts with `PolicyNotFound` when any operand does not exist. + /// @dev Reverts with `InvalidOperand` when any operand is itself a composite (not a flat leaf). + /// + /// @param policyId Composite policy to update. + /// @param operands Complete new operand set of existing leaf policy IDs. + function updateCompositeOperands(uint64 policyId, uint64[] calldata operands) external; + /*////////////////////////////////////////////////////////////// AUTHORIZATION QUERIES //////////////////////////////////////////////////////////////*/ From e685897fac2e574cb8a7faf06777da5eeb771e5a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 22 Jul 2026 17:57:31 -0400 Subject: [PATCH 02/23] Tighten composite NatSpec wording Drop the "not an incremental edit" binary contrast and the "silently" adverb; make the flat-only note active. Wording only, no signature or behavior change. Generated with Claude Code Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index b3f13c5..f1a7390 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -60,7 +60,7 @@ interface IPolicyRegistry { error EmptyOperandSet(); /// @notice A composite operand is not a flat leaf. Operands must be existing ALLOWLIST or - /// BLOCKLIST policies; composites of composites are rejected (flat-only). + /// BLOCKLIST policies; a composite may not reference another composite (flat-only). /// @param operandId The offending operand policy ID. error InvalidOperand(uint64 operandId); @@ -208,15 +208,15 @@ interface IPolicyRegistry { /// @param accounts Accounts to update. function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; - /// @notice Replaces a composite policy's operand set in full with `operands`. This is a whole-set - /// overwrite, not an incremental edit: the caller sends the complete desired operand list - /// and it is re-validated exactly as at creation. The gate (UNION or INTERSECT) is fixed in - /// the policy ID and cannot change; a different gate requires a new composite. + /// @notice Replaces a composite policy's operand set in full with `operands`. The caller sends the + /// complete desired operand list, and it is re-validated as at creation. The gate (UNION or + /// INTERSECT) is fixed in the policy ID and cannot change; a different gate requires a new + /// composite. /// /// @dev Overwrite semantics are last-write-wins. The function carries no expected-version guard, so - /// concurrent full-set submissions by shared admins silently clobber one another; serialize - /// edits through the governing multisig or approval flow. Read the current operands before - /// editing. + /// concurrent full-set submissions by shared admins overwrite one another with no conflict + /// signal; serialize edits through the governing multisig or approval flow, and read the + /// current operands before editing. /// @dev Guard order: `PolicyNotFound` -> `IncompatiblePolicyType` -> `Unauthorized` -> /// `EmptyOperandSet` / `BatchSizeTooLarge` -> per-operand `PolicyNotFound` / `InvalidOperand`. /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. From 0c29a7e634b36061a46196e6e8b29b2edf25c974 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 10:08:50 -0400 Subject: [PATCH 03/23] Apply suggestion from @stevieraykatz Co-authored-by: katzman --- src/interfaces/IPolicyRegistry.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index f1a7390..0a886fe 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -10,9 +10,7 @@ interface IPolicyRegistry { TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Policy type discriminator. Discriminants are append-only: BLOCKLIST and ALLOWLIST - /// keep values 0 and 1, so the composite gates ride the policy ID top byte without - /// shifting the existing leaf types. + /// @notice Policy type discriminator. Discriminants are append-only. /// /// @param BLOCKLIST Leaf. Authorized unless in the policy's set. /// @param ALLOWLIST Leaf. Authorized only if in the policy's set. From 5c14492873ebb37ed5845c7261c477f3f64cd2f3 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 10:09:27 -0400 Subject: [PATCH 04/23] Apply suggestion from @ilikesymmetry Co-authored-by: Conner Swenberg --- src/interfaces/IPolicyRegistry.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 0a886fe..6e7cb76 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -14,8 +14,8 @@ interface IPolicyRegistry { /// /// @param BLOCKLIST Leaf. Authorized unless in the policy's set. /// @param ALLOWLIST Leaf. Authorized only if in the policy's set. - /// @param UNION Composite. Authorized if authorized under ANY operand (logical OR). - /// @param INTERSECT Composite. Authorized only if authorized under EVERY operand (logical AND). + /// @param UNION Authorized if ANY operand policy is authorized (logical OR). + /// @param INTERSECT Authorized only if EVERY operand policy is authorized (logical AND). enum PolicyType { BLOCKLIST, ALLOWLIST, From 0f599adc49d98751523f671fc1360ec557811199 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 10:11:50 -0400 Subject: [PATCH 05/23] Apply suggestion from @stevieraykatz Co-authored-by: katzman --- src/interfaces/IPolicyRegistry.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 6e7cb76..50c97b1 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -57,8 +57,7 @@ interface IPolicyRegistry { /// empty-UNION / empty-INTERSECT semantics. error EmptyOperandSet(); - /// @notice A composite operand is not a flat leaf. Operands must be existing ALLOWLIST or - /// BLOCKLIST policies; a composite may not reference another composite (flat-only). + /// @notice Operands must be existing ALLOWLIST or BLOCKLIST policies. /// @param operandId The offending operand policy ID. error InvalidOperand(uint64 operandId); From 57e6caac40edc8327985a32f8ad13344edb97a28 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 10:13:35 -0400 Subject: [PATCH 06/23] Apply suggestion from @ilikesymmetry Co-authored-by: Conner Swenberg --- src/interfaces/IPolicyRegistry.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 50c97b1..c9aa708 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -145,7 +145,7 @@ interface IPolicyRegistry { /// @param operands Existing leaf policy IDs to combine. /// /// @return newPolicyId The newly assigned composite policy ID. - function createCompositePolicy(address admin, PolicyType gate, uint64[] calldata operands) + function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata operandPolicies) external returns (uint64 newPolicyId); From 2594d6277567a0f4778b15f662224d9b4140f499 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 10:19:27 -0400 Subject: [PATCH 07/23] feat: poloicy registry --- src/interfaces/IPolicyRegistry.sol | 88 ++++++++++++++++-------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index c9aa708..55e6223 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -14,8 +14,8 @@ interface IPolicyRegistry { /// /// @param BLOCKLIST Leaf. Authorized unless in the policy's set. /// @param ALLOWLIST Leaf. Authorized only if in the policy's set. - /// @param UNION Authorized if ANY operand policy is authorized (logical OR). - /// @param INTERSECT Authorized only if EVERY operand policy is authorized (logical AND). + /// @param UNION Authorized if ANY child policy is authorized (logical OR). + /// @param INTERSECT Authorized only if EVERY child policy is authorized (logical AND). enum PolicyType { BLOCKLIST, ALLOWLIST, @@ -52,14 +52,19 @@ interface IPolicyRegistry { /// @notice `finalizeUpdateAdmin` was called with no pending admin staged. error NoPendingAdmin(); - /// @notice A composite policy was created or updated with an empty operand set. A composite must - /// reference at least one operand, so the never-revert fold evaluator need not define - /// empty-UNION / empty-INTERSECT semantics. - error EmptyOperandSet(); + /// @notice A composite policy was created or updated with an empty child set. + error EmptyChildSet(); - /// @notice Operands must be existing ALLOWLIST or BLOCKLIST policies. - /// @param operandId The offending operand policy ID. - error InvalidOperand(uint64 operandId); + /// @notice A composite policy was created or updated with fewer than the minimum number of + /// children. A composite must reference at least two leaf child policies. + /// @param provided Number of child policy IDs supplied. + /// @param minimum Minimum required (2). + error TooFewChildren(uint256 provided, uint256 minimum); + + /// @notice A composite child is not a flat leaf. Children must be existing ALLOWLIST or + /// BLOCKLIST policies; a composite may not reference another composite (flat-only). + /// @param childPolicyId The offending child policy ID. + error InvalidChildPolicy(uint64 childPolicyId); /*////////////////////////////////////////////////////////////// EVENTS @@ -81,15 +86,15 @@ interface IPolicyRegistry { /// @notice One or more accounts had their BLOCKLIST membership set to `blocked` in a single batch. event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); - /// @notice A composite policy (UNION or INTERSECT) was created over `operands`. + /// @notice A composite policy (UNION or INTERSECT) was created over `childPolicyIds`. event CompositePolicyCreated( - uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] operands + uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] childPolicyIds ); - /// @notice A composite policy's operand set was replaced in full with `operands`. The event + /// @notice A composite policy's child set was replaced in full with `childPolicyIds`. The event /// carries the complete post-update set, so an indexer reconstructs current state from a /// single log with no delta folding. - event CompositeOperandsUpdated(uint64 indexed policyId, address indexed updater, uint64[] operands); + event CompositeChildrenUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); /*////////////////////////////////////////////////////////////// POLICY CREATION @@ -126,26 +131,27 @@ interface IPolicyRegistry { returns (uint64 newPolicyId); /// @notice Creates a new composite policy that combines existing leaf policies under a logic gate. - /// Permissionless. A UNION authorizes an account if ANY operand authorizes it; an INTERSECT - /// authorizes only if EVERY operand does. `isAuthorized` folds the operands and never reverts. + /// Permissionless. A UNION authorizes an account if ANY child authorizes it; an INTERSECT + /// authorizes only if EVERY child does. `isAuthorized` folds the children and never reverts. /// - /// @dev Operands are flat-only: each must be an existing ALLOWLIST or BLOCKLIST policy, never another - /// composite. Because operands reference smaller, already-assigned IDs, the reference graph is a - /// DAG and cannot cycle. The operand set is capped at the composite operand limit. + /// @dev Children are flat-only: each must be an existing ALLOWLIST or BLOCKLIST policy, never another + /// composite. Because children reference smaller, already-assigned IDs, the reference graph is a + /// DAG and cannot cycle. The child set is capped at the composite child limit. /// @dev Reverts with `IncompatiblePolicyType` when `gate` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. - /// @dev Reverts with `EmptyOperandSet` when `operands` is empty. - /// @dev Reverts with `BatchSizeTooLarge` when `operands.length` exceeds the composite operand limit. - /// @dev Reverts with `PolicyNotFound` when any operand does not exist. - /// @dev Reverts with `InvalidOperand` when any operand is itself a composite (not a flat leaf). + /// @dev Reverts with `EmptyChildSet` when `childPolicyIds` is empty. + /// @dev Reverts with `TooFewChildren` when `childPolicyIds.length == 1`. + /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite child limit. + /// @dev Reverts with `PolicyNotFound` when any child does not exist. + /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a flat leaf). /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// - /// @param admin Initial admin authorized to update operands and transfer or renounce administration. - /// @param gate UNION or INTERSECT. - /// @param operands Existing leaf policy IDs to combine. + /// @param admin Initial admin authorized to update children and transfer or renounce administration. + /// @param gate UNION or INTERSECT. + /// @param childPolicyIds Existing leaf policy IDs to combine. /// /// @return newPolicyId The newly assigned composite policy ID. - function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata operandPolicies) + function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) external returns (uint64 newPolicyId); @@ -205,29 +211,31 @@ interface IPolicyRegistry { /// @param accounts Accounts to update. function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; - /// @notice Replaces a composite policy's operand set in full with `operands`. The caller sends the - /// complete desired operand list, and it is re-validated as at creation. The gate (UNION or - /// INTERSECT) is fixed in the policy ID and cannot change; a different gate requires a new - /// composite. + /// @notice Replaces a composite policy's child set in full with `childPolicyIds`. The caller sends + /// the complete desired child list, and it is re-validated as at creation. The gate (UNION + /// or INTERSECT) is fixed in the policy ID and cannot change; a different gate requires a + /// new composite. /// /// @dev Overwrite semantics are last-write-wins. The function carries no expected-version guard, so /// concurrent full-set submissions by shared admins overwrite one another with no conflict /// signal; serialize edits through the governing multisig or approval flow, and read the - /// current operands before editing. + /// current children before editing. /// @dev Guard order: `PolicyNotFound` -> `IncompatiblePolicyType` -> `Unauthorized` -> - /// `EmptyOperandSet` / `BatchSizeTooLarge` -> per-operand `PolicyNotFound` / `InvalidOperand`. + /// `EmptyChildSet` / `TooFewChildren` / `BatchSizeTooLarge` -> + /// per-child `PolicyNotFound` / `InvalidChildPolicy`. /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. /// @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 `EmptyOperandSet` when `operands` is empty; there is no clear-the-list path. - /// @dev Reverts with `BatchSizeTooLarge` when `operands.length` exceeds the composite operand limit. - /// @dev Reverts with `PolicyNotFound` when any operand does not exist. - /// @dev Reverts with `InvalidOperand` when any operand is itself a composite (not a flat leaf). - /// - /// @param policyId Composite policy to update. - /// @param operands Complete new operand set of existing leaf policy IDs. - function updateCompositeOperands(uint64 policyId, uint64[] calldata operands) external; + /// @dev Reverts with `EmptyChildSet` when `childPolicyIds` is empty; there is no clear-the-list path. + /// @dev Reverts with `TooFewChildren` when `childPolicyIds.length == 1`. + /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite child limit. + /// @dev Reverts with `PolicyNotFound` when any child does not exist. + /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a flat leaf). + /// + /// @param policyId Composite policy to update. + /// @param childPolicyIds Complete new child set of existing leaf policy IDs. + function updateCompositeChildren(uint64 policyId, uint64[] calldata childPolicyIds) external; /*////////////////////////////////////////////////////////////// AUTHORIZATION QUERIES From 6f3091529e5767d1f239c2ff61aea5fd534ff1f4 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 10:26:32 -0400 Subject: [PATCH 08/23] feaet: more clean up --- src/interfaces/IPolicyRegistry.sol | 43 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 55e6223..a583f37 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -12,10 +12,14 @@ interface IPolicyRegistry { /// @notice Policy type discriminator. Discriminants are append-only. /// - /// @param BLOCKLIST Leaf. Authorized unless in the policy's set. - /// @param ALLOWLIST Leaf. Authorized only if in the policy's set. - /// @param UNION Authorized if ANY child policy is authorized (logical OR). - /// @param INTERSECT Authorized only if EVERY child policy is authorized (logical AND). + /// @dev Two kinds of policy: + /// - Membership (BLOCKLIST, ALLOWLIST): decide from an address set. + /// - Composite (UNION, INTERSECT): decide by combining child membership policies. + /// + /// @param BLOCKLIST Membership. Account is authorized unless it is in the set. + /// @param ALLOWLIST Membership. Account is authorized only if it is in the set. + /// @param UNION Composite (OR). Account is authorized if any child policy authorizes it. + /// @param INTERSECT Composite (AND). Account is authorized only if every child policy authorizes it. enum PolicyType { BLOCKLIST, ALLOWLIST, @@ -56,13 +60,13 @@ interface IPolicyRegistry { error EmptyChildSet(); /// @notice A composite policy was created or updated with fewer than the minimum number of - /// children. A composite must reference at least two leaf child policies. + /// children. A composite must reference at least two membership policies. /// @param provided Number of child policy IDs supplied. /// @param minimum Minimum required (2). error TooFewChildren(uint256 provided, uint256 minimum); - /// @notice A composite child is not a flat leaf. Children must be existing ALLOWLIST or - /// BLOCKLIST policies; a composite may not reference another composite (flat-only). + /// @notice A composite child is not a membership policy. Children must be existing ALLOWLIST or + /// BLOCKLIST policies; a composite may not reference another composite. /// @param childPolicyId The offending child policy ID. error InvalidChildPolicy(uint64 childPolicyId); @@ -100,7 +104,7 @@ interface IPolicyRegistry { POLICY CREATION //////////////////////////////////////////////////////////////*/ - /// @notice Creates a new leaf policy with no initial members. Permissionless. + /// @notice Creates a new membership policy with no initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or @@ -113,7 +117,7 @@ interface IPolicyRegistry { /// @return newPolicyId The newly assigned policy ID. function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); - /// @notice Creates a new leaf policy seeded with `accounts` as initial members. Permissionless. + /// @notice Creates a new membership policy seeded with `accounts` as initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or @@ -130,25 +134,26 @@ interface IPolicyRegistry { external returns (uint64 newPolicyId); - /// @notice Creates a new composite policy that combines existing leaf policies under a logic gate. - /// Permissionless. A UNION authorizes an account if ANY child authorizes it; an INTERSECT - /// authorizes only if EVERY child does. `isAuthorized` folds the children and never reverts. + /// @notice Creates a new composite policy that combines existing membership policies under a logic + /// gate. Permissionless. A UNION authorizes an account if any child authorizes it; an + /// INTERSECT authorizes only if every child does. `isAuthorized` folds the children and + /// never reverts. /// - /// @dev Children are flat-only: each must be an existing ALLOWLIST or BLOCKLIST policy, never another - /// composite. Because children reference smaller, already-assigned IDs, the reference graph is a - /// DAG and cannot cycle. The child set is capped at the composite child limit. + /// @dev Children must be membership policies (ALLOWLIST or BLOCKLIST), never another composite. + /// Because children reference smaller, already-assigned IDs, the reference graph is a DAG and + /// cannot cycle. The child set is capped at the composite child limit. /// @dev Reverts with `IncompatiblePolicyType` when `gate` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `EmptyChildSet` when `childPolicyIds` is empty. /// @dev Reverts with `TooFewChildren` when `childPolicyIds.length == 1`. /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite child limit. /// @dev Reverts with `PolicyNotFound` when any child does not exist. - /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a flat leaf). + /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a membership policy). /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// /// @param admin Initial admin authorized to update children and transfer or renounce administration. /// @param gate UNION or INTERSECT. - /// @param childPolicyIds Existing leaf policy IDs to combine. + /// @param childPolicyIds Existing membership policy IDs to combine. /// /// @return newPolicyId The newly assigned composite policy ID. function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) @@ -231,10 +236,10 @@ interface IPolicyRegistry { /// @dev Reverts with `TooFewChildren` when `childPolicyIds.length == 1`. /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite child limit. /// @dev Reverts with `PolicyNotFound` when any child does not exist. - /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a flat leaf). + /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a membership policy). /// /// @param policyId Composite policy to update. - /// @param childPolicyIds Complete new child set of existing leaf policy IDs. + /// @param childPolicyIds Complete new child set of existing membership policy IDs. function updateCompositeChildren(uint64 policyId, uint64[] calldata childPolicyIds) external; /*////////////////////////////////////////////////////////////// From bc683b16954f41d8786585dad700da3c153ba0b7 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 12:31:20 -0400 Subject: [PATCH 09/23] chore: clean up --- src/interfaces/IPolicyRegistry.sol | 79 +++++++++++++----------------- 1 file changed, 35 insertions(+), 44 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index a583f37..50aebe1 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -56,17 +56,17 @@ interface IPolicyRegistry { /// @notice `finalizeUpdateAdmin` was called with no pending admin staged. error NoPendingAdmin(); - /// @notice A composite policy was created or updated with an empty child set. - error EmptyChildSet(); + /// @notice A composite policy was created or updated with an empty child-policy set. + error EmptyChildPolicySet(); /// @notice A composite policy was created or updated with fewer than the minimum number of - /// children. A composite must reference at least two membership policies. + /// child policies. A composite must reference at least two membership policies. /// @param provided Number of child policy IDs supplied. /// @param minimum Minimum required (2). - error TooFewChildren(uint256 provided, uint256 minimum); + error TooFewChildPolicies(uint256 provided, uint256 minimum); - /// @notice A composite child is not a membership policy. Children must be existing ALLOWLIST or - /// BLOCKLIST policies; a composite may not reference another composite. + /// @notice A composite child is not a membership policy. Child policies must be existing + /// ALLOWLIST or BLOCKLIST policies; a composite may not reference another composite. /// @param childPolicyId The offending child policy ID. error InvalidChildPolicy(uint64 childPolicyId); @@ -96,9 +96,10 @@ interface IPolicyRegistry { ); /// @notice A composite policy's child set was replaced in full with `childPolicyIds`. The event - /// carries the complete post-update set, so an indexer reconstructs current state from a - /// single log with no delta folding. - event CompositeChildrenUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); + /// carries the complete post-update set. + event CompositeChildPoliciesUpdated( + uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds + ); /*////////////////////////////////////////////////////////////// POLICY CREATION @@ -135,24 +136,22 @@ interface IPolicyRegistry { returns (uint64 newPolicyId); /// @notice Creates a new composite policy that combines existing membership policies under a logic - /// gate. Permissionless. A UNION authorizes an account if any child authorizes it; an - /// INTERSECT authorizes only if every child does. `isAuthorized` folds the children and - /// never reverts. - /// - /// @dev Children must be membership policies (ALLOWLIST or BLOCKLIST), never another composite. - /// Because children reference smaller, already-assigned IDs, the reference graph is a DAG and - /// cannot cycle. The child set is capped at the composite child limit. - /// @dev Reverts with `IncompatiblePolicyType` when `gate` is not UNION or INTERSECT. - /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. - /// @dev Reverts with `EmptyChildSet` when `childPolicyIds` is empty. - /// @dev Reverts with `TooFewChildren` when `childPolicyIds.length == 1`. - /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite child limit. - /// @dev Reverts with `PolicyNotFound` when any child does not exist. - /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a membership policy). + /// gate. + /// @dev Child policies must be membership policies (ALLOWLIST or BLOCKLIST), never another composite. The child-policy set is capped at the composite child-policy limit. + /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. + /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. + /// @dev Reverts with `EmptyChildPolicySet` when `childPolicyIds` is empty. + /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length == 1`. + /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite + /// child-policy 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 membership policy). /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// - /// @param admin Initial admin authorized to update children and transfer or renounce administration. - /// @param gate UNION or INTERSECT. + /// @param admin Initial admin authorized to update child policies and transfer or renounce + /// administration. + /// @param policyType UNION or INTERSECT. /// @param childPolicyIds Existing membership policy IDs to combine. /// /// @return newPolicyId The newly assigned composite policy ID. @@ -216,31 +215,23 @@ interface IPolicyRegistry { /// @param accounts Accounts to update. function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; - /// @notice Replaces a composite policy's child set in full with `childPolicyIds`. The caller sends - /// the complete desired child list, and it is re-validated as at creation. The gate (UNION - /// or INTERSECT) is fixed in the policy ID and cannot change; a different gate requires a - /// new composite. - /// - /// @dev Overwrite semantics are last-write-wins. The function carries no expected-version guard, so - /// concurrent full-set submissions by shared admins overwrite one another with no conflict - /// signal; serialize edits through the governing multisig or approval flow, and read the - /// current children before editing. - /// @dev Guard order: `PolicyNotFound` -> `IncompatiblePolicyType` -> `Unauthorized` -> - /// `EmptyChildSet` / `TooFewChildren` / `BatchSizeTooLarge` -> - /// per-child `PolicyNotFound` / `InvalidChildPolicy`. + /// @notice Replaces a composite policy's child-policy set in full with `childPolicyIds`. /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. /// @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 `EmptyChildSet` when `childPolicyIds` is empty; there is no clear-the-list path. - /// @dev Reverts with `TooFewChildren` when `childPolicyIds.length == 1`. - /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite child limit. - /// @dev Reverts with `PolicyNotFound` when any child does not exist. - /// @dev Reverts with `InvalidChildPolicy` when any child is itself a composite (not a membership policy). + /// @dev Reverts with `EmptyChildPolicySet` when `childPolicyIds` is empty; there is no clear-the-list + /// path. + /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length == 1`. + /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite + /// child-policy 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 membership policy). /// /// @param policyId Composite policy to update. - /// @param childPolicyIds Complete new child set of existing membership policy IDs. - function updateCompositeChildren(uint64 policyId, uint64[] calldata childPolicyIds) external; + /// @param childPolicyIds Complete new set of existing membership policy IDs. + function updateCompositeChildPolicies(uint64 policyId, uint64[] calldata childPolicyIds) external; /*////////////////////////////////////////////////////////////// AUTHORIZATION QUERIES From 35e816ac89068bd751b44f39c5dbdb010ddba6ef Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 13:08:21 -0400 Subject: [PATCH 10/23] feat: rename to simple --- src/interfaces/IPolicyRegistry.sol | 36 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 50aebe1..e07ba0c 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.20 <0.9.0; /// @title IPolicyRegistry /// -/// @notice Singleton registry of address-membership policies. Policies are referenced by +/// @notice Singleton registry of simple and composite policies. Policies are referenced by /// `uint64 policyId` and queried via `isAuthorized(policyId, account)`. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// @@ -13,13 +13,13 @@ interface IPolicyRegistry { /// @notice Policy type discriminator. Discriminants are append-only. /// /// @dev Two kinds of policy: - /// - Membership (BLOCKLIST, ALLOWLIST): decide from an address set. - /// - Composite (UNION, INTERSECT): decide by combining child membership policies. + /// - Simple (BLOCKLIST, ALLOWLIST): decide from an address set. + /// - Composite (UNION, INTERSECT): decide by combining child simple policies. /// - /// @param BLOCKLIST Membership. Account is authorized unless it is in the set. - /// @param ALLOWLIST Membership. Account is authorized only if it is in the set. - /// @param UNION Composite (OR). Account is authorized if any child policy authorizes it. - /// @param INTERSECT Composite (AND). Account is authorized only if every child policy authorizes it. + /// @param BLOCKLIST Account is authorized unless it is in the set. + /// @param ALLOWLIST Account is authorized only if it is in the set. + /// @param UNION (OR). Account is authorized if any child policy authorizes it. + /// @param INTERSECT (AND). Account is authorized only if every child policy authorizes it. enum PolicyType { BLOCKLIST, ALLOWLIST, @@ -60,13 +60,13 @@ interface IPolicyRegistry { error EmptyChildPolicySet(); /// @notice A composite policy was created or updated with fewer than the minimum number of - /// child policies. A composite must reference at least two membership policies. + /// child policies. A composite must reference at least two simple policies. /// @param provided Number of child policy IDs supplied. /// @param minimum Minimum required (2). error TooFewChildPolicies(uint256 provided, uint256 minimum); - /// @notice A composite child is not a membership policy. Child policies must be existing - /// ALLOWLIST or BLOCKLIST policies; a composite may not reference another composite. + /// @notice Composite policies are not simple policies. Child policies must be existing + /// ALLOWLIST or BLOCKLIST policies; /// @param childPolicyId The offending child policy ID. error InvalidChildPolicy(uint64 childPolicyId); @@ -105,7 +105,7 @@ interface IPolicyRegistry { POLICY CREATION //////////////////////////////////////////////////////////////*/ - /// @notice Creates a new membership policy with no initial members. Permissionless. + /// @notice Creates a new simple policy with no initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or @@ -118,7 +118,7 @@ interface IPolicyRegistry { /// @return newPolicyId The newly assigned policy ID. function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); - /// @notice Creates a new membership policy seeded with `accounts` as initial members. Permissionless. + /// @notice Creates a new simple policy seeded with `accounts` as initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or @@ -135,9 +135,9 @@ interface IPolicyRegistry { external returns (uint64 newPolicyId); - /// @notice Creates a new composite policy that combines existing membership policies under a logic + /// @notice Creates a new composite policy that combines existing simple policies under a logic /// gate. - /// @dev Child policies must be membership policies (ALLOWLIST or BLOCKLIST), never another composite. The child-policy set is capped at the composite child-policy limit. + /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite. The child-policy set is capped at the composite child-policy limit. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `EmptyChildPolicySet` when `childPolicyIds` is empty. @@ -146,13 +146,13 @@ interface IPolicyRegistry { /// child-policy 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 membership policy). + /// (not a simple 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 /// administration. /// @param policyType UNION or INTERSECT. - /// @param childPolicyIds Existing membership policy IDs to combine. + /// @param childPolicyIds Existing simple policy IDs to combine. /// /// @return newPolicyId The newly assigned composite policy ID. function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) @@ -227,10 +227,10 @@ interface IPolicyRegistry { /// child-policy 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 membership policy). + /// (not a simple policy). /// /// @param policyId Composite policy to update. - /// @param childPolicyIds Complete new set of existing membership policy IDs. + /// @param childPolicyIds Complete new set of existing simple policy IDs. function updateCompositeChildPolicies(uint64 policyId, uint64[] calldata childPolicyIds) external; /*////////////////////////////////////////////////////////////// From fe6710a34d1c3b24c452c16771d19f837e0e4cf6 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 13:11:57 -0400 Subject: [PATCH 11/23] chore: clean up --- src/interfaces/IPolicyRegistry.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index e07ba0c..eaf7a48 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -10,7 +10,7 @@ interface IPolicyRegistry { TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Policy type discriminator. Discriminants are append-only. + /// @notice Policy type discriminator. /// /// @dev Two kinds of policy: /// - Simple (BLOCKLIST, ALLOWLIST): decide from an address set. From 0682ec3ca5b5e01c2ba89a6f71ac7565c4d1132f Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 15:24:25 -0400 Subject: [PATCH 12/23] chore: update error types --- src/interfaces/IPolicyRegistry.sol | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index eaf7a48..c1daa60 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -56,9 +56,6 @@ interface IPolicyRegistry { /// @notice `finalizeUpdateAdmin` was called with no pending admin staged. error NoPendingAdmin(); - /// @notice A composite policy was created or updated with an empty child-policy set. - error EmptyChildPolicySet(); - /// @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. /// @param provided Number of child policy IDs supplied. @@ -140,8 +137,7 @@ interface IPolicyRegistry { /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite. The child-policy set is capped at the composite child-policy limit. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. - /// @dev Reverts with `EmptyChildPolicySet` when `childPolicyIds` is empty. - /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length == 1`. + /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2` (including empty). /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite /// child-policy limit. /// @dev Reverts with `PolicyNotFound` when any child policy does not exist. @@ -220,9 +216,7 @@ 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 `EmptyChildPolicySet` when `childPolicyIds` is empty; there is no clear-the-list - /// path. - /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length == 1`. + /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2`; there is no clear-the-list path. /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite /// child-policy limit. /// @dev Reverts with `PolicyNotFound` when any child policy does not exist. From 050a4b050f47d23905f99ccee3f493a6b71cb285 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 15:28:45 -0400 Subject: [PATCH 13/23] feat: update policy registry --- src/interfaces/IPolicyRegistry.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index c1daa60..33d3eec 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -94,9 +94,7 @@ interface IPolicyRegistry { /// @notice A composite policy's child set was replaced in full with `childPolicyIds`. The event /// carries the complete post-update set. - event CompositeChildPoliciesUpdated( - uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds - ); + event CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); /*////////////////////////////////////////////////////////////// POLICY CREATION @@ -225,7 +223,7 @@ interface IPolicyRegistry { /// /// @param policyId Composite policy to update. /// @param childPolicyIds Complete new set of existing simple policy IDs. - function updateCompositeChildPolicies(uint64 policyId, uint64[] calldata childPolicyIds) external; + function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external; /*////////////////////////////////////////////////////////////// AUTHORIZATION QUERIES From 02e8f22abba668bae97f17bf6c8db34aeae3805d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 15:40:50 -0400 Subject: [PATCH 14/23] refactor: remove CompositePolicyCreated event Use the canonical PolicyCreated event for every policy type and surface composite child policies via CompositePolicyUpdated (now emitted on both creation and update). Gives indexers one creation path and one per-field update path. Addresses PR #174 review. Generated with Claude Code Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 33d3eec..afe8622 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -87,13 +87,8 @@ interface IPolicyRegistry { /// @notice One or more accounts had their BLOCKLIST membership set to `blocked` in a single batch. event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); - /// @notice A composite policy (UNION or INTERSECT) was created over `childPolicyIds`. - event CompositePolicyCreated( - uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] childPolicyIds - ); - - /// @notice A composite policy's child set was replaced in full with `childPolicyIds`. The event - /// carries the complete post-update set. + /// @notice A composite policy's child set was set or replaced in full with `childPolicyIds`. Emitted + /// on composite creation and on every subsequent update; carries the complete post-update set. event CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); /*////////////////////////////////////////////////////////////// From 4d27af7bcb0a448bf165caf7d764740e4a049d8c Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 15:43:57 -0400 Subject: [PATCH 15/23] chore: update dev docs --- src/interfaces/IPolicyRegistry.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index afe8622..651dc14 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -127,12 +127,12 @@ interface IPolicyRegistry { /// @notice Creates a new composite policy that combines existing simple policies under a logic /// gate. - /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite. The child-policy set is capped at the composite child-policy limit. + /// @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` (including empty). - /// @dev Reverts with `BatchSizeTooLarge` when `childPolicyIds.length` exceeds the composite - /// child-policy limit. + /// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 4` /// @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). @@ -210,8 +210,8 @@ interface IPolicyRegistry { /// @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` when `childPolicyIds.length` exceeds the composite - /// child-policy limit. + /// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 4` + /// (composite child-policy cap; not the account membership batch limit of 64). /// @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). From 2bd24c211517323c324b53f5fe9d2d7413603126 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 16:28:03 -0400 Subject: [PATCH 16/23] Apply suggestion from @stevieraykatz Co-authored-by: katzman --- src/interfaces/IPolicyRegistry.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 651dc14..64e8914 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -134,8 +134,7 @@ interface IPolicyRegistry { /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2` (including empty). /// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 4` /// @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). + /// @dev Reverts with `InvalidChildPolicy` when any child policy is not a simple 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 From 03230f2d6197edb1020c0622e712fb98ab44d0a6 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 16:29:12 -0400 Subject: [PATCH 17/23] Apply suggestion from @stevieraykatz Co-authored-by: katzman --- src/interfaces/IPolicyRegistry.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 64e8914..3787655 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -127,6 +127,7 @@ interface IPolicyRegistry { /// @notice Creates a new composite policy that combines existing simple policies under a logic /// gate. + /// /// @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. From 500e269ca68cbfadfe4b15d7a9a27cdd39a99c75 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 16:29:31 -0400 Subject: [PATCH 18/23] Apply suggestion from @stevieraykatz Co-authored-by: katzman --- src/interfaces/IPolicyRegistry.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 3787655..7eaa028 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -111,8 +111,7 @@ interface IPolicyRegistry { /// @notice Creates a new simple policy seeded with `accounts` as initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`. - /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or - /// INTERSECT); use `createCompositePolicy` for those. Takes precedence over `BatchSizeTooLarge`. + /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite policyType. /// @dev Reverts with `BatchSizeTooLarge` when `accounts.length` exceeds the registry limit. /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// From 14c9bb52134949c003953b88f698218a5bbd2d09 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 16:33:34 -0400 Subject: [PATCH 19/23] Apply suggestion from @stevieraykatz Co-authored-by: katzman --- src/interfaces/IPolicyRegistry.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 7eaa028..2435bae 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -98,8 +98,7 @@ interface IPolicyRegistry { /// @notice Creates a new simple policy with no initial members. Permissionless. /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. - /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate (UNION or - /// INTERSECT); use `createCompositePolicy` for those. + /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate. /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// /// @param admin Initial admin authorized to modify membership and transfer or renounce administration. From aa0dc53f0e68dae55379cf840d5610e8aab6b87d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 23 Jul 2026 16:33:49 -0400 Subject: [PATCH 20/23] feat: policy registry --- src/interfaces/IPolicyRegistry.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 64e8914..a69fb22 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -131,7 +131,7 @@ interface IPolicyRegistry { /// 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` (including empty). + /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2`. /// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 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. From fb594b22771389cfca9e70837aa0be56b498988f Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 24 Jul 2026 10:25:27 -0400 Subject: [PATCH 21/23] chore: clean up --- src/interfaces/IPolicyRegistry.sol | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index bd05dd5..85493a5 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -58,9 +58,7 @@ interface IPolicyRegistry { /// @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. - /// @param provided Number of child policy IDs supplied. - /// @param minimum Minimum required (2). - error TooFewChildPolicies(uint256 provided, uint256 minimum); + error TooFewChildPolicies(); /// @notice Composite policies are not simple policies. Child policies must be existing /// ALLOWLIST or BLOCKLIST policies; @@ -99,7 +97,6 @@ interface IPolicyRegistry { /// /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate. - /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. /// /// @param admin Initial admin authorized to modify membership and transfer or renounce administration. /// @param policyType BLOCKLIST or ALLOWLIST. From 09a87924ece6d0cb2c562b115cd9469bfe08952d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 24 Jul 2026 15:54:18 -0400 Subject: [PATCH 22/23] feat: composite-policy support in MockPolicyRegistry (#175) * feat: create composite polocies * feat: create mock polocy * test: reject built-in sentinels as composite children Add tests asserting createCompositePolicy and updateComposite revert InvalidChildPolicy when a child is a built-in sentinel (ALWAYS_ALLOW_ID / ALWAYS_BLOCK_ID), and align the IPolicyRegistry natspec accordingly. Generated with Claude Code Co-Authored-By: Claude * style: forge fmt Generated with Claude Code Co-Authored-By: Claude * refactor: replace composite count errors with ChildPoliciesOutsideOfRange Collapse the TooFewChildPolicies (< 2) and BatchSizeTooLarge(4) (> 4) composite child-count checks into a single ChildPoliciesOutsideOfRange(min, max) error reverting with (2, 4). Remove the now-unused TooFewChildPolicies error; BatchSizeTooLarge is retained for account-membership batches. Add MIN_CHILD_POLICIES and update the composite tests + revert-order walks accordingly. Generated with Claude Code Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/interfaces/IPolicyRegistry.sol | 21 +- test/lib/PolicyRegistryTest.sol | 68 +++- test/lib/mocks/MockPolicyRegistry.sol | 146 +++++++- test/lib/mocks/MockPolicyRegistryStorage.sol | 15 + .../createCompositePolicy.t.sol | 318 ++++++++++++++++++ .../createCompositePolicy_revertOrder.t.sol | 91 +++++ test/unit/PolicyRegistry/isAuthorized.t.sol | 92 +++++ .../unit/PolicyRegistry/updateComposite.t.sol | 195 +++++++++++ .../updateComposite_revertOrder.t.sol | 99 ++++++ 9 files changed, 1021 insertions(+), 24 deletions(-) create mode 100644 test/unit/PolicyRegistry/createCompositePolicy.t.sol create mode 100644 test/unit/PolicyRegistry/createCompositePolicy_revertOrder.t.sol create mode 100644 test/unit/PolicyRegistry/updateComposite.t.sol create mode 100644 test/unit/PolicyRegistry/updateComposite_revertOrder.t.sol diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 85493a5..84003de 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -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; @@ -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 @@ -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). diff --git a/test/lib/PolicyRegistryTest.sol b/test/lib/PolicyRegistryTest.sol index fe0fa04..a463137 100644 --- a/test/lib/PolicyRegistryTest.sol +++ b/test/lib/PolicyRegistryTest.sol @@ -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 @@ -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; + } } diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 03fbfcc..a9974f5 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -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 // ============================================================ @@ -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 // ============================================================ @@ -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); } // ============================================================ @@ -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); } diff --git a/test/lib/mocks/MockPolicyRegistryStorage.sol b/test/lib/mocks/MockPolicyRegistryStorage.sol index 7c260ee..01f5d01 100644 --- a/test/lib/mocks/MockPolicyRegistryStorage.sol +++ b/test/lib/mocks/MockPolicyRegistryStorage.sol @@ -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)) @@ -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) { @@ -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 @@ -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 // ============================================================ diff --git a/test/unit/PolicyRegistry/createCompositePolicy.t.sol b/test/unit/PolicyRegistry/createCompositePolicy.t.sol new file mode 100644 index 0000000..a63f7af --- /dev/null +++ b/test/unit/PolicyRegistry/createCompositePolicy.t.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; + +import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; +import {MockPolicyRegistryStorage} from "base-std-test/lib/mocks/MockPolicyRegistryStorage.sol"; + +contract PolicyRegistryCreateCompositePolicyTest is PolicyRegistryTest { + // ============================================================ + // REVERTS + // ============================================================ + + /// @notice Verifies createCompositePolicy reverts when admin is the zero address + /// @dev Required-field guard; checks ZeroAddress() error. Takes precedence over every later check. + function test_createCompositePolicy_revert_zeroAdmin(address caller, uint8 typeIdx) public { + _assumeValidCaller(caller); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + uint64[] memory children = _makeSimpleChildren(2); + vm.expectRevert(IPolicyRegistry.ZeroAddress.selector); + vm.prank(caller); + policyRegistry.createCompositePolicy(address(0), pt, children); + } + + /// @notice Verifies createCompositePolicy reverts when policyType is a simple gate + /// @dev Type guard: composites must be UNION or INTERSECT; a simple ALLOWLIST/BLOCKLIST + /// type reverts with IncompatiblePolicyType(). + function test_createCompositePolicy_revert_incompatiblePolicyType(address caller, address admin_, uint8 typeIdx) + public + { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatablePolicyType(typeIdx); // ALLOWLIST or BLOCKLIST + uint64[] memory children = _makeSimpleChildren(2); + vm.expectRevert(IPolicyRegistry.IncompatiblePolicyType.selector); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } + + /// @notice Verifies createCompositePolicy reverts when the child count is outside [2, 4] + /// @dev A composite must reference between MIN_CHILD_POLICIES (2) and MAX_CHILD_POLICIES (4) + /// simple policies, inclusive; checks ChildPoliciesOutsideOfRange(2, 4). Exercises both + /// under-range (0 and 1 children) and over-range (5..8 children) cases. The composite + /// child-policy range is distinct from the 64-account membership limit. + function test_createCompositePolicy_revert_childPoliciesOutsideOfRange( + address caller, + address admin_, + uint8 typeIdx, + uint8 overflow + ) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + bytes memory expectedRevert = abi.encodeWithSelector( + IPolicyRegistry.ChildPoliciesOutsideOfRange.selector, MIN_CHILD_POLICIES, MAX_CHILD_POLICIES + ); + + // Under range: 0 children. + uint64[] memory none = new uint64[](0); + vm.expectRevert(expectedRevert); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, none); + + // Under range: 1 child (a real, existing simple policy) — still below the minimum. + uint64[] memory one = _makeSimpleChildren(1); + vm.expectRevert(expectedRevert); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, one); + + // Over range: 5..8 valid simple children — only the count condition is broken. + uint256 n = MAX_CHILD_POLICIES + 1 + (uint256(overflow) % 4); // 5..8 + uint64[] memory tooMany = _makeSimpleChildren(n); + vm.expectRevert(expectedRevert); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, tooMany); + } + + /// @notice Verifies createCompositePolicy reverts when a child policy does not exist + /// @dev Checks PolicyNotFound(). Both children are well-formed but never-created IDs, so the + /// existence check fails before the simple-vs-composite check. + function test_createCompositePolicy_revert_policyNotFound( + address caller, + address admin_, + uint8 typeIdx, + uint64 seed + ) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + uint64[] memory children = new uint64[](2); + children[0] = _wellFormedUncreatedPolicyId(seed); + children[1] = _wellFormedUncreatedPolicyId(seed ^ 0xdead); + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } + + /// @notice Verifies createCompositePolicy reverts when a child is itself a composite + /// @dev Child policies must be simple; a composite child reverts with + /// InvalidChildPolicy(childPolicyId) carrying the offending ID. + function test_createCompositePolicy_revert_invalidChildPolicy(address caller, address admin_, uint8 typeIdx) + public + { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + + // A composite policy to use as an (invalid) child. + uint64 compositeChild = _createComposite(IPolicyRegistry.PolicyType.UNION, 2); + // A valid simple sibling so the child set has the required size and the composite child + // is the sole offending entry. + uint64 simpleChild = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + + uint64[] memory children = _childIds(simpleChild, compositeChild); + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, compositeChild)); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } + + /// @notice Verifies createCompositePolicy reverts when a child is a built-in sentinel + /// @dev Built-in sentinels (ALWAYS_ALLOW_ID / ALWAYS_BLOCK_ID) are reserved and may not be + /// composed; each reverts with InvalidChildPolicy(childPolicyId) carrying the offending ID. + function test_createCompositePolicy_revert_builtinChild(address caller, address admin_, uint8 typeIdx) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + + // A valid simple sibling; creating it also lazily initializes the built-in sentinels so + // they pass the existence check and the built-in guard is the sole offending entry. + uint64 simpleChild = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + + // ALWAYS_ALLOW_ID as a child. + uint64[] memory withAllow = _childIds(simpleChild, PolicyRegistryConstants.ALWAYS_ALLOW_ID); + vm.expectRevert( + abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, PolicyRegistryConstants.ALWAYS_ALLOW_ID) + ); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, withAllow); + + // ALWAYS_BLOCK_ID as a child. + uint64[] memory withBlock = _childIds(simpleChild, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + vm.expectRevert( + abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, PolicyRegistryConstants.ALWAYS_BLOCK_ID) + ); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, withBlock); + } + + /// @notice Verifies createCompositePolicy panics with arithmetic overflow at the counter max + /// @dev Slot-writes nextCounter to type(uint56).max after seeding children to avoid iterating + /// 2^56 times. Mock-only: vm.store cannot write to native precompile addresses. + /// Matches the Rust precompile which reverts with Panic(UnderOverflow) = Panic(0x11). + function test_createCompositePolicy_revert_counterOverflow(address caller, address admin_, uint8 typeIdx) public { + vm.skip(livePrecompiles); + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + + // Seed valid children first (advances the counter past the built-ins), then force overflow. + uint64[] memory children = _makeSimpleChildren(2); + vm.store( + address(policyRegistry), MockPolicyRegistryStorage.nextCounterSlot(), bytes32(uint256(type(uint56).max)) + ); + + vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } + + // ============================================================ + // SUCCESS + // ============================================================ + + /// @notice Verifies createCompositePolicy assigns a fresh UNION policy id + /// @dev Paired slot: admin lane matches, exists bit set, ID top byte = UNION. + function test_createCompositePolicy_success_union(address caller, address admin_) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + uint64[] memory children = _makeSimpleChildren(2); + + uint64 predicted = _predictNextPolicyId(IPolicyRegistry.PolicyType.UNION); + vm.prank(caller); + uint64 policyId = policyRegistry.createCompositePolicy(admin_, IPolicyRegistry.PolicyType.UNION, children); + assertEq(policyId, predicted); + assertTrue(policyRegistry.policyExists(policyId)); + assertEq(policyRegistry.policyAdmin(policyId), admin_); + + uint256 packed = uint256(vm.load(address(policyRegistry), MockPolicyRegistryStorage.policySlot(policyId))); + assertEq( + MockPolicyRegistryStorage.policyAdminFromPacked(packed), + admin_, + "policies[id] slot admin must reflect the composite admin" + ); + assertTrue(MockPolicyRegistryStorage.policyExistsFromPacked(packed), "policies[id] slot exists bit must be set"); + assertEq( + MockPolicyRegistryStorage.policyTypeFromId(policyId), + uint8(IPolicyRegistry.PolicyType.UNION), + "policy ID high byte must encode UNION" + ); + } + + /// @notice Verifies createCompositePolicy assigns a fresh INTERSECT policy id + /// @dev Paired slot: admin lane matches, exists bit set, ID top byte = INTERSECT. + function test_createCompositePolicy_success_intersect(address caller, address admin_) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + uint64[] memory children = _makeSimpleChildren(2); + + uint64 predicted = _predictNextPolicyId(IPolicyRegistry.PolicyType.INTERSECT); + vm.prank(caller); + uint64 policyId = policyRegistry.createCompositePolicy(admin_, IPolicyRegistry.PolicyType.INTERSECT, children); + assertEq(policyId, predicted); + assertTrue(policyRegistry.policyExists(policyId)); + assertEq(policyRegistry.policyAdmin(policyId), admin_); + + uint256 packed = uint256(vm.load(address(policyRegistry), MockPolicyRegistryStorage.policySlot(policyId))); + assertEq( + MockPolicyRegistryStorage.policyAdminFromPacked(packed), + admin_, + "policies[id] slot admin must reflect the composite admin" + ); + assertTrue(MockPolicyRegistryStorage.policyExistsFromPacked(packed), "policies[id] slot exists bit must be set"); + assertEq( + MockPolicyRegistryStorage.policyTypeFromId(policyId), + uint8(IPolicyRegistry.PolicyType.INTERSECT), + "policy ID high byte must encode INTERSECT" + ); + } + + /// @notice Verifies createCompositePolicy accepts a child set exactly at the cap + /// @dev Boundary check: MAX_CHILD_POLICIES (4) children is inclusive. + function test_createCompositePolicy_success_atMaxChildren(address caller, address admin_, uint8 typeIdx) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + uint64[] memory children = _makeSimpleChildren(MAX_CHILD_POLICIES); + vm.prank(caller); + uint64 policyId = policyRegistry.createCompositePolicy(admin_, pt, children); + assertTrue(policyRegistry.policyExists(policyId)); + } + + /// @notice Verifies sequential composite creates each consume a fresh id from the global counter + /// @dev Each create's ID matches the prediction taken from the live counter immediately before it. + function test_createCompositePolicy_success_advancesNextPolicyId(address admin_, uint8 typeIdxA, uint8 typeIdxB) + public + { + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType ptA = _creatableCompositeType(typeIdxA); + IPolicyRegistry.PolicyType ptB = _creatableCompositeType(typeIdxB); + + uint64[] memory childrenA = _makeSimpleChildren(2); + uint64 predictedA = _predictNextPolicyId(ptA); + uint64 idA = policyRegistry.createCompositePolicy(admin_, ptA, childrenA); + assertEq(idA, predictedA); + + uint64[] memory childrenB = _makeSimpleChildren(2); + uint64 predictedB = _predictNextPolicyId(ptB); + uint64 idB = policyRegistry.createCompositePolicy(admin_, ptB, childrenB); + assertEq(idB, predictedB); + + assertTrue(idA != idB); + } + + /// @notice Verifies createCompositePolicy emits PolicyCreated with the correct args + function test_createCompositePolicy_success_emitsPolicyCreated(address caller, address admin_, uint8 typeIdx) + public + { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + uint64[] memory children = _makeSimpleChildren(2); + + uint64 expectedId = _predictNextPolicyId(pt); + vm.expectEmit(address(policyRegistry)); + emit IPolicyRegistry.PolicyCreated(expectedId, caller, pt); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } + + /// @notice Verifies createCompositePolicy emits PolicyAdminUpdated(previousAdmin = 0) on initial assignment + function test_createCompositePolicy_success_emitsInitialPolicyAdminUpdated( + address caller, + address admin_, + uint8 typeIdx + ) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + uint64[] memory children = _makeSimpleChildren(2); + + uint64 expectedId = _predictNextPolicyId(pt); + vm.expectEmit(address(policyRegistry)); + emit IPolicyRegistry.PolicyAdminUpdated(expectedId, address(0), admin_); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } + + /// @notice Verifies createCompositePolicy emits CompositePolicyUpdated carrying the full child set + /// @dev The event is emitted on creation as well as on every subsequent update; the payload is the + /// complete post-create child set. + function test_createCompositePolicy_success_emitsCompositePolicyUpdated( + address caller, + address admin_, + uint8 typeIdx + ) public { + _assumeValidCaller(caller); + vm.assume(admin_ != address(0)); + IPolicyRegistry.PolicyType pt = _creatableCompositeType(typeIdx); + uint64[] memory children = _makeSimpleChildren(2); + + uint64 expectedId = _predictNextPolicyId(pt); + vm.expectEmit(address(policyRegistry)); + emit IPolicyRegistry.CompositePolicyUpdated(expectedId, caller, children); + vm.prank(caller); + policyRegistry.createCompositePolicy(admin_, pt, children); + } +} diff --git a/test/unit/PolicyRegistry/createCompositePolicy_revertOrder.t.sol b/test/unit/PolicyRegistry/createCompositePolicy_revertOrder.t.sol new file mode 100644 index 0000000..03f861e --- /dev/null +++ b/test/unit/PolicyRegistry/createCompositePolicy_revertOrder.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; + +import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; + +/// @title Sequential revert-order test for `createCompositePolicy`. +/// +/// @notice **Canonical order:** +/// 1. ZERO-ADMIN (`admin == address(0)`) → `ZeroAddress` +/// 2. INCOMPATIBLE-TYPE (`policyType` not UNION/INTERSECT) → `IncompatiblePolicyType` +/// 3. COUNT-OUTSIDE-RANGE (`childPolicyIds.length` not in `[2, 4]`) → `ChildPoliciesOutsideOfRange(2, 4)` +/// 4. CHILD-NOT-FOUND (a child does not exist) → `PolicyNotFound` +/// 5. INVALID-CHILD (a child is itself a composite) → `InvalidChildPolicy` +/// +/// Walks from all conditions broken to success, fixing one per step. ZeroAddress-first +/// precedence mirrors `createPolicy` / `createPolicyWithAccounts`. +contract PolicyRegistryCreateCompositePolicyRevertOrderTest is PolicyRegistryTest { + /// @notice Walks through every revert in canonical order, fixing one per step, ending at success. + function test_createCompositePolicy_revertOrder(uint8 typeIdx) public { + IPolicyRegistry.PolicyType composite = _creatableCompositeType(typeIdx); + + // Fixtures. + uint64 validSimpleA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 validSimpleB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); + uint64 compositeChild = _createComposite(IPolicyRegistry.PolicyType.UNION, 2); + + // Ghost (well-formed, never-created) child IDs at counters far above anything created. + uint64 neverCreatedWellFormedPolicy = + (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(2_000_000); + uint64[] memory tooManyNeverCreatedWellFormedPolicyList = new uint64[](MAX_CHILD_POLICIES + 1); // 5, all nonexistent + for (uint256 i = 0; i < tooManyNeverCreatedWellFormedPolicyList.length; ++i) { + tooManyNeverCreatedWellFormedPolicyList[i] = + (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(1_000_000 + i); + } + + // 1. ZERO-ADMIN: admin==0 AND type simple AND oversized child set → ZeroAddress fires first. + vm.expectRevert(IPolicyRegistry.ZeroAddress.selector); + policyRegistry.createCompositePolicy( + address(0), IPolicyRegistry.PolicyType.ALLOWLIST, tooManyNeverCreatedWellFormedPolicyList + ); + + // Fix: use a non-zero admin. + + // 2. INCOMPATIBLE-TYPE: valid admin, but simple type (ALLOWLIST) AND oversized child set + // → IncompatiblePolicyType fires before the count/size checks. + vm.expectRevert(IPolicyRegistry.IncompatiblePolicyType.selector); + policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.ALLOWLIST, tooManyNeverCreatedWellFormedPolicyList + ); + + // Fix: use a composite gate. + + // 3. COUNT-OUTSIDE-RANGE: composite type, but a child count outside [2, 4] (all nonexistent) + // → ChildPoliciesOutsideOfRange fires before the existence check. Exercises both the + // under-range (1 child) and over-range (> MAX_CHILD_POLICIES) ends. + bytes memory outsideRange = abi.encodeWithSelector( + IPolicyRegistry.ChildPoliciesOutsideOfRange.selector, MIN_CHILD_POLICIES, MAX_CHILD_POLICIES + ); + uint64[] memory one = new uint64[](1); + one[0] = neverCreatedWellFormedPolicy; + vm.expectRevert(outsideRange); + policyRegistry.createCompositePolicy(admin, composite, one); + + vm.expectRevert(outsideRange); + policyRegistry.createCompositePolicy(admin, composite, tooManyNeverCreatedWellFormedPolicyList); + + // Fix: bring the child count within [2, 4]. + + // 4. CHILD-NOT-FOUND: in-range child set, but one child never existed → PolicyNotFound + // fires before the simple-vs-composite check. + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + policyRegistry.createCompositePolicy( + admin, + composite, + _childIds(tooManyNeverCreatedWellFormedPolicyList[0], tooManyNeverCreatedWellFormedPolicyList[1]) + ); + + // Fix: replace the ghost with an existing simple policy. + + // 5. INVALID-CHILD: all children exist, but one is itself a composite → InvalidChildPolicy. + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, compositeChild)); + policyRegistry.createCompositePolicy(admin, composite, _childIds(validSimpleA, compositeChild)); + + // Fix: replace the composite child with a simple policy. + + // Success. + policyRegistry.createCompositePolicy(admin, composite, _childIds(validSimpleA, validSimpleB)); + } +} diff --git a/test/unit/PolicyRegistry/isAuthorized.t.sol b/test/unit/PolicyRegistry/isAuthorized.t.sol index 5f47555..0d03762 100644 --- a/test/unit/PolicyRegistry/isAuthorized.t.sol +++ b/test/unit/PolicyRegistry/isAuthorized.t.sol @@ -79,4 +79,96 @@ contract PolicyRegistryIsAuthorizedTest is PolicyRegistryTest { uint64 policyId = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); assertTrue(policyRegistry.isAuthorized(policyId, account)); } + + // ============================================================ + // COMPOSITE POLICY SEMANTICS + // ============================================================ + // UNION is OR: authorized if ANY child authorizes. + // INTERSECT is AND: authorized only if EVERY child authorizes. + // Composites reference their children live — evaluation reads current + // child membership, not a snapshot taken at composite-creation time. + + /// @notice Sets membership of a single `account` on a simple policy, as `admin`. + function _setAllowlistMember(uint64 policyId, address account, bool member) private { + address[] memory accounts = new address[](1); + accounts[0] = account; + vm.prank(admin); + policyRegistry.updateAllowlist(policyId, member, accounts); + } + + /// @notice Verifies UNION authorizes an account that is a member of any single child + /// @dev OR semantics: `account` is on allowlist A only, yet the UNION authorizes it. + function test_isAuthorized_success_union_anyChildAuthorizes(address account) public { + uint64 childA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(childA, childB)); + _setAllowlistMember(childA, account, true); + assertTrue(policyRegistry.isAuthorized(composite, account)); + } + + /// @notice Verifies UNION denies an account that no child authorizes + /// @dev OR semantics: `account` is on neither empty allowlist, so the UNION denies it. + function test_isAuthorized_success_union_noChildAuthorizes(address account) public { + uint64 childA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(childA, childB)); + assertFalse(policyRegistry.isAuthorized(composite, account)); + } + + /// @notice Verifies INTERSECT authorizes an account only when every child authorizes it + /// @dev AND semantics: `account` is on both allowlists, so the INTERSECT authorizes it. + function test_isAuthorized_success_intersect_allChildrenAuthorize(address account) public { + uint64 childA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(childA, childB) + ); + _setAllowlistMember(childA, account, true); + _setAllowlistMember(childB, account, true); + assertTrue(policyRegistry.isAuthorized(composite, account)); + } + + /// @notice Verifies INTERSECT denies an account when even one child denies it + /// @dev AND semantics: `account` is on allowlist A only, so the INTERSECT denies it. + function test_isAuthorized_success_intersect_oneChildDenies(address account) public { + uint64 childA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(childA, childB) + ); + _setAllowlistMember(childA, account, true); + assertFalse(policyRegistry.isAuthorized(composite, account)); + } + + /// @notice Verifies composite gates combine mixed ALLOWLIST + BLOCKLIST children correctly + /// @dev With `account` absent from both children, the allowlist denies and the blocklist allows: + /// UNION → true (OR), INTERSECT → false (AND). Exercises both simple semantics under a gate. + function test_isAuthorized_success_composite_mixedChildTypes(address account) public { + uint64 allow = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 block_ = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); + uint64 union = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(allow, block_)); + uint64 intersect = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(allow, block_)); + + // account absent: allowlist denies, blocklist allows. + assertTrue(policyRegistry.isAuthorized(union, account), "UNION(deny, allow) must be true"); + assertFalse(policyRegistry.isAuthorized(intersect, account), "INTERSECT(deny, allow) must be false"); + } + + /// @notice Verifies composite evaluation tracks live child membership changes + /// @dev A UNION over two empty allowlists denies `account`; adding `account` to a child + /// flips the composite to authorize — composites reference children, not snapshots. + function test_isAuthorized_success_composite_reflectsChildMembershipChange(address account) public { + uint64 childA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(childA, childB)); + + assertFalse(policyRegistry.isAuthorized(composite, account)); + _setAllowlistMember(childB, account, true); + assertTrue(policyRegistry.isAuthorized(composite, account)); + } } diff --git a/test/unit/PolicyRegistry/updateComposite.t.sol b/test/unit/PolicyRegistry/updateComposite.t.sol new file mode 100644 index 0000000..eb82a6f --- /dev/null +++ b/test/unit/PolicyRegistry/updateComposite.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; + +import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; + +contract PolicyRegistryUpdateCompositeTest is PolicyRegistryTest { + // ============================================================ + // REVERTS + // ============================================================ + + /// @notice Verifies updateComposite reverts for an unknown composite id + /// @dev The target policy must exist; checks PolicyNotFound(). Fires before any child validation. + function test_updateComposite_revert_policyNotFound(address caller, uint64 seed) public { + _assumeValidCaller(caller); + uint64 ghostComposite = _wellFormedUncreatedPolicyId(seed); + uint64[] memory children = + _childIds(_wellFormedUncreatedPolicyId(seed ^ 1), _wellFormedUncreatedPolicyId(seed ^ 2)); + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + vm.prank(caller); + policyRegistry.updateComposite(ghostComposite, children); + } + + /// @notice Verifies updateComposite reverts when the target is a simple policy + /// @dev Type guard: updateComposite only operates on UNION/INTERSECT policies; a simple + /// ALLOWLIST/BLOCKLIST target reverts with IncompatiblePolicyType(). + function test_updateComposite_revert_incompatiblePolicyType(uint8 typeIdx) public { + IPolicyRegistry.PolicyType simple = _creatablePolicyType(typeIdx); + uint64 simpleId = policyRegistry.createPolicy(admin, simple); + uint64[] memory children = _makeSimpleChildren(2); + // Call as the policy admin so the type guard, not the auth guard, is under test. + vm.expectRevert(IPolicyRegistry.IncompatiblePolicyType.selector); + vm.prank(admin); + policyRegistry.updateComposite(simpleId, children); + } + + /// @notice Verifies updateComposite reverts when called by a non-admin + /// @dev Access control: only the current admin may replace the child set; checks Unauthorized(). + function test_updateComposite_revert_unauthorized(address caller, uint8 typeIdx) public { + _assumeValidCaller(caller); + vm.assume(caller != admin); + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + uint64[] memory children = _makeSimpleChildren(2); + vm.expectRevert(IPolicyRegistry.Unauthorized.selector); + vm.prank(caller); + policyRegistry.updateComposite(composite, children); + } + + /// @notice Verifies a renounced composite can never be updated + /// @dev After renounceAdmin the admin lane is zero, so every updateComposite reverts with + /// Unauthorized() — the policy is frozen but still observable. + function test_updateComposite_revert_renouncedComposite(uint8 typeIdx) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + vm.prank(admin); + policyRegistry.renounceAdmin(composite); + + uint64[] memory children = _makeSimpleChildren(2); + vm.expectRevert(IPolicyRegistry.Unauthorized.selector); + vm.prank(admin); // former admin — no longer authorized + policyRegistry.updateComposite(composite, children); + } + + /// @notice Verifies updateComposite reverts when the new child count is outside [2, 4] + /// @dev A composite must reference between MIN_CHILD_POLICIES (2) and MAX_CHILD_POLICIES (4) + /// simple policies, inclusive; checks ChildPoliciesOutsideOfRange(2, 4). There is no + /// clear-the-list path. Exercises both under-range (0 and 1 children) and over-range + /// (5..8 children) cases. + function test_updateComposite_revert_childPoliciesOutsideOfRange(uint8 typeIdx, uint8 overflow) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + bytes memory expectedRevert = abi.encodeWithSelector( + IPolicyRegistry.ChildPoliciesOutsideOfRange.selector, MIN_CHILD_POLICIES, MAX_CHILD_POLICIES + ); + + // Under range: 0 children. + uint64[] memory none = new uint64[](0); + vm.expectRevert(expectedRevert); + vm.prank(admin); + policyRegistry.updateComposite(composite, none); + + // Under range: 1 child. + uint64[] memory one = _makeSimpleChildren(1); + vm.expectRevert(expectedRevert); + vm.prank(admin); + policyRegistry.updateComposite(composite, one); + + // Over range: 5..8 valid simple children. + uint256 n = MAX_CHILD_POLICIES + 1 + (uint256(overflow) % 4); // 5..8 + uint64[] memory tooMany = _makeSimpleChildren(n); + vm.expectRevert(expectedRevert); + vm.prank(admin); + policyRegistry.updateComposite(composite, tooMany); + } + + /// @notice Verifies updateComposite reverts when a new child policy does not exist + /// @dev Checks PolicyNotFound() for a well-formed but never-created child. + function test_updateComposite_revert_policyNotFoundChild(uint8 typeIdx, uint64 seed) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + uint64[] memory children = + _childIds(_wellFormedUncreatedPolicyId(seed), _wellFormedUncreatedPolicyId(seed ^ 0xbeef)); + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + vm.prank(admin); + policyRegistry.updateComposite(composite, children); + } + + /// @notice Verifies updateComposite reverts when a new child is itself a composite + /// @dev Child policies must be simple; a composite child reverts with + /// InvalidChildPolicy(childPolicyId) carrying the offending ID. + function test_updateComposite_revert_invalidChildPolicy(uint8 typeIdx) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + uint64 otherComposite = _createComposite(IPolicyRegistry.PolicyType.INTERSECT, 2); + uint64 simpleChild = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + + uint64[] memory children = _childIds(simpleChild, otherComposite); + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, otherComposite)); + vm.prank(admin); + policyRegistry.updateComposite(composite, children); + } + + /// @notice Verifies updateComposite reverts when a new child is a built-in sentinel + /// @dev Built-in sentinels (ALWAYS_ALLOW_ID / ALWAYS_BLOCK_ID) are reserved and may not be + /// composed; each reverts with InvalidChildPolicy(childPolicyId) carrying the offending ID. + function test_updateComposite_revert_builtinChild(uint8 typeIdx) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + uint64 simpleChild = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + + uint64[] memory withAllow = _childIds(simpleChild, PolicyRegistryConstants.ALWAYS_ALLOW_ID); + vm.expectRevert( + abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, PolicyRegistryConstants.ALWAYS_ALLOW_ID) + ); + vm.prank(admin); + policyRegistry.updateComposite(composite, withAllow); + + uint64[] memory withBlock = _childIds(simpleChild, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + vm.expectRevert( + abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, PolicyRegistryConstants.ALWAYS_BLOCK_ID) + ); + vm.prank(admin); + policyRegistry.updateComposite(composite, withBlock); + } + + // ============================================================ + // EVENT + REPLACEMENT + // ============================================================ + + /// @notice Verifies updateComposite emits CompositePolicyUpdated with the full new child set + /// @dev One event per call carrying the complete post-update set; topic args match policyId / updater. + function test_updateComposite_success_emitsCompositePolicyUpdated(uint8 typeIdx) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + uint64[] memory newChildren = _makeSimpleChildren(2); + vm.expectEmit(address(policyRegistry)); + emit IPolicyRegistry.CompositePolicyUpdated(composite, admin, newChildren); + vm.prank(admin); + policyRegistry.updateComposite(composite, newChildren); + } + + /// @notice Verifies updateComposite accepts a new child set exactly at the cap + /// @dev Boundary check: MAX_CHILD_POLICIES (4) children is inclusive. + function test_updateComposite_success_atMaxChildren(uint8 typeIdx) public { + uint64 composite = _createComposite(_creatableCompositeType(typeIdx), 2); + uint64[] memory newChildren = _makeSimpleChildren(MAX_CHILD_POLICIES); + vm.prank(admin); + policyRegistry.updateComposite(composite, newChildren); + assertTrue(policyRegistry.policyExists(composite)); + } + + /// @notice Verifies updateComposite replaces the child set in full rather than merging + /// @dev Behavioral proof via isAuthorized: an account authorized under the OLD child set is no + /// longer authorized once the set is swapped for children it does not belong to. + function test_updateComposite_success_replacesChildSet(address account) public { + // UNION over two allowlists; `account` is a member of the first only. + uint64 childA = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childB = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(childA, childB)); + + address[] memory one = new address[](1); + one[0] = account; + vm.prank(admin); + policyRegistry.updateAllowlist(childA, true, one); + assertTrue(policyRegistry.isAuthorized(composite, account), "union should authorize a member of child A"); + + // Swap in a fresh child set that `account` does not belong to. + uint64 childC = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + uint64 childD = policyRegistry.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + vm.prank(admin); + policyRegistry.updateComposite(composite, _childIds(childC, childD)); + + assertFalse( + policyRegistry.isAuthorized(composite, account), + "old child set must no longer govern after full replacement" + ); + } +} diff --git a/test/unit/PolicyRegistry/updateComposite_revertOrder.t.sol b/test/unit/PolicyRegistry/updateComposite_revertOrder.t.sol new file mode 100644 index 0000000..ba5f22a --- /dev/null +++ b/test/unit/PolicyRegistry/updateComposite_revertOrder.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; + +import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; + +/// @title Sequential revert-order test for `updateComposite`. +/// +/// @notice **Canonical order:** +/// 1. POLICY-NOT-FOUND (composite `policyId` does not exist) → `PolicyNotFound` +/// 2. INCOMPATIBLE-TYPE (`policyId` is a simple policy) → `IncompatiblePolicyType` +/// 3. UNAUTHORIZED (caller is not the admin) → `Unauthorized` +/// 4. COUNT-OUTSIDE-RANGE (`childPolicyIds.length` not in `[2, 4]`) → `ChildPoliciesOutsideOfRange(2, 4)` +/// 5. CHILD-NOT-FOUND (a child does not exist) → `PolicyNotFound` +/// 6. INVALID-CHILD (a child is itself a composite) → `InvalidChildPolicy` +/// +/// Walks from all conditions broken to success, fixing one per step. `PolicyNotFound` +/// appears twice: for the composite itself (step 1) and for a child (step 5). +contract PolicyRegistryUpdateCompositeRevertOrderTest is PolicyRegistryTest { + /// @notice Walks through every revert in canonical order, fixing one per step, ending at success. + function test_updateComposite_revertOrder(uint8 typeIdx) public { + IPolicyRegistry.PolicyType gate = _creatableCompositeType(typeIdx); + + // Fixtures. + // Two existing simple policies double as the composite's seed children and the valid + // replacement children used at the end of the walk. + uint64[] memory validSimple = _makeSimpleChildren(2); + uint64 composite = _createComposite(bob, alice, gate, validSimple); // admin = alice + uint64 simpleId = policyRegistry.createPolicy(alice, IPolicyRegistry.PolicyType.ALLOWLIST); // exists, simple + uint64 otherComposite = _createComposite(IPolicyRegistry.PolicyType.INTERSECT, 2); // composite child + + // Ghost (well-formed, never-created) IDs at counters far above anything created. + uint64 ghostComposite = (uint64(uint8(IPolicyRegistry.PolicyType.UNION)) << 56) | uint64(3_000_000); + uint64 ghostChild = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(2_000_000); + uint64[] memory tooManyGhosts = new uint64[](MAX_CHILD_POLICIES + 1); // 5, all nonexistent + for (uint256 i = 0; i < tooManyGhosts.length; ++i) { + tooManyGhosts[i] = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(1_000_000 + i); + } + + // 1. POLICY-NOT-FOUND (self): composite never created, called by a non-admin, oversized child set. + vm.prank(attacker); + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + policyRegistry.updateComposite(ghostComposite, tooManyGhosts); + + // Fix: target an existing policy — but a simple one (wrong type). + + // 2. INCOMPATIBLE-TYPE: target exists but is simple; fires before the auth check. + vm.prank(attacker); + vm.expectRevert(IPolicyRegistry.IncompatiblePolicyType.selector); + policyRegistry.updateComposite(simpleId, tooManyGhosts); + + // Fix: target the composite (correct type); attacker is still not its admin. + + // 3. UNAUTHORIZED: composite, but caller is not the admin (alice); fires before the count/size checks. + vm.prank(attacker); + vm.expectRevert(IPolicyRegistry.Unauthorized.selector); + policyRegistry.updateComposite(composite, tooManyGhosts); + + // Fix: call as the admin (alice). + + // 4. COUNT-OUTSIDE-RANGE: admin, but a child count outside [2, 4] (all nonexistent); fires + // before the existence check. Exercises both the under-range (1 child) and over-range + // (> MAX_CHILD_POLICIES) ends. + bytes memory outsideRange = abi.encodeWithSelector( + IPolicyRegistry.ChildPoliciesOutsideOfRange.selector, MIN_CHILD_POLICIES, MAX_CHILD_POLICIES + ); + uint64[] memory one = new uint64[](1); + one[0] = ghostChild; + vm.prank(alice); + vm.expectRevert(outsideRange); + policyRegistry.updateComposite(composite, one); + + vm.prank(alice); + vm.expectRevert(outsideRange); + policyRegistry.updateComposite(composite, tooManyGhosts); + + // Fix: bring the child count within [2, 4]. + + // 5. CHILD-NOT-FOUND: in-range child set, but one child never existed; fires before the + // simple-vs-composite check. + vm.prank(alice); + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + policyRegistry.updateComposite(composite, _childIds(ghostChild, otherComposite)); + + // Fix: replace the ghost with an existing simple policy. + + // 6. INVALID-CHILD: all children exist, but one is itself a composite → InvalidChildPolicy. + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, otherComposite)); + policyRegistry.updateComposite(composite, _childIds(validSimple[0], otherComposite)); + + // Fix: replace the composite child with a simple policy. + + // Success. + vm.prank(alice); + policyRegistry.updateComposite(composite, _childIds(validSimple[0], validSimple[1])); + } +} From f6f2825c07f9bf7802a0b300a4f9af02834c1b9d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 24 Jul 2026 16:33:33 -0400 Subject: [PATCH 23/23] test: cover childrenSlot storage helper The Interface Coverage CI check flags any test/lib/mocks function with zero test hits. childrenSlot / childrenBaseSlot were added for the composite child set but never exercised. Add a slot-helper test that reads a composite's child array via childrenSlot, matching the existing slot-helper test pattern. Generated with Claude Code Co-Authored-By: Claude --- .../MockPolicyRegistrySlotHelpers.t.sol | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/unit/storage/MockPolicyRegistrySlotHelpers.t.sol b/test/unit/storage/MockPolicyRegistrySlotHelpers.t.sol index 53cb884..eab4feb 100644 --- a/test/unit/storage/MockPolicyRegistrySlotHelpers.t.sol +++ b/test/unit/storage/MockPolicyRegistrySlotHelpers.t.sol @@ -108,6 +108,28 @@ contract MockPolicyRegistrySlotHelpersTest is PolicyRegistryTest { ); } + /// @notice Verifies `childrenSlot(id)` locates a composite's child-policy array. + /// @dev The dynamic array's length lives at `childrenSlot(id)`; its elements + /// pack four `uint64` per 32-byte slot starting at + /// `keccak256(childrenSlot(id))`, so a length-2 set occupies the low + /// 128 bits of that first element slot (elem0 low, elem1 next). + function test_childrenSlot_success_locatesChildArray(uint8 typeIdx) public { + IPolicyRegistry.PolicyType gate = _creatableCompositeType(typeIdx); + uint64[] memory children = _makeSimpleChildren(2); + uint64 composite = _createComposite(admin, admin, gate, children); + + bytes32 lengthSlot = MockPolicyRegistryStorage.childrenSlot(composite); + assertEq( + uint256(vm.load(address(policyRegistry), lengthSlot)), + children.length, + "childrenSlot must hold the child-array length" + ); + + uint256 packed = uint256(vm.load(address(policyRegistry), keccak256(abi.encode(lengthSlot)))); + assertEq(uint64(packed), children[0], "childrenSlot element 0 must match"); + assertEq(uint64(packed >> 64), children[1], "childrenSlot element 1 must match"); + } + /// @notice Verifies `nextCounterSlot()` advances by exactly 1 per /// createPolicy in the steady state. /// @dev The very first `createPolicy` also lazily writes the two