PCL Policy Structure

component compliance

Three-tier hierarchy — PolicyTemplate (registered template type) → PolicySet (template + ABI-encoded params + optional selector) → PolicyConfig (the bag of PolicySets that get applied).

PCL stores compliance rules as Solidity-defined ABI tuples, not as JSON objects. The hierarchy has three tiers: a PolicyTemplate (the type of rule, registered by the policy admin) is instantiated as a PolicySet (the type ID plus an ABI-encoded parameters blob plus an optional function selector) and bundled into a PolicyConfig (either the global config or a per-contract config). PolicyTemplate itself is metadata-only — it carries templateId, name, and description, so the shape of each template's parameters must come from the template-specific struct in IPcl.sol.

The Solidity structs

Straight from IPcl.sol:
struct PolicyTemplate {
    string templateId;          // e.g. "DENYLIST_POLICY"
    string name;
    string description;
}

struct PolicySet {
    string templateId;          // which template this is an instance of
    bytes policy;               // abi.encode(<template-specific struct>)
    bytes selector;             // optional 4-byte function selector; empty bytes = applies to all calls
}

struct GlobalPolicyConfig {
    PolicySet[] policies;
}

struct ContractPolicyConfig {
    address _contract;          // the contract this applies to
    address admin;              // who can change this config later
    PolicySet[] policies;
}
The key field is PolicySet.policy — it is bytes carrying the ABI encoding of the template-specific parameter struct. Don't construct it as JSON; use abi.encode(<struct>) from the Solidity / ethers / viem side. Each PolicySet binds exactly one policy instance; to apply multiple rules to the same scope, submit multiple PolicySet entries in the enclosing PolicyConfig.policies array. PolicyTemplate is a pure descriptor: templateId (the machine-readable ID), a human-readable name, and a description. It does not embed a parameter schema; consult the template-specific struct in IPcl.sol to learn the parameter shape for each templateId.

How callers construct a PolicySet

Each template defines its own parameter struct (see pcl-policy-templates for the full list). To register a policy you:

1. Build the template-specific struct with concrete values.
2. Encode it as bytes via abi.encode.
3. Wrap in a PolicySet with the template id and (optionally) a 4-byte function selector.

Example — denylisting two addresses:
import { IPcl, PolicySet, ContractPolicyConfig, DenylistPolicy } from "@maroo-chain/contracts/precompiles/pcl/IPcl.sol";

DenylistPolicy memory dl = DenylistPolicy({
    addresses: new address[](2)
});
dl.addresses[0] = 0x8F3ac2B1d9E74c05A6B18FE27Dc4913e5A0F7b62;
dl.addresses[1] = 0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B;

PolicySet memory ps = PolicySet({
    templateId: "DENYLIST_POLICY",
    policy: abi.encode(dl),
    selector: ""   // empty → applies to all calls on the target contract
});
Then attach to a ContractPolicyConfig and submit via IPcl.changeContractPolicies(...). Multiple PolicySet entries can share the same enclosing config — but each selector value may appear at most once within a single config (a duplicate selector reverts with the typed error InvalidParameter(bytes input); see the selector field below).

Global vs Contract policies

Two scopes:

  • GlobalPolicyConfig — applied to every transaction on the chain. Managed by the chain-wide policy admin (see pcl-policy-admin). Typical contents: a denylist, a periodic-volume cap on un-attested users, etc.
  • ContractPolicyConfig — applied only when a transaction targets a specific contract address through the PCL-wrapped proxy hook path. Each contract config carries its own admin so the contract owner can update its own policies independently of the chain-wide admin.


When a transaction comes in: PCL evaluates the applicable policies in the global config (see the selector section below for which entries apply), AND if the call targets a contract with a registered ContractPolicyConfig and uses the regulated path, the applicable policies in that contract config too. Any single failure rejects the whole transaction with the corresponding ReasonCode.

The selector field

Each PolicySet carries an optional selector (4-byte function selector encoded as bytes). When non-empty, the policy only applies to calls invoking a function with that selector on the target contract. This lets a single scope apply different rules to different functions:
import {
    EasPolicy, PolicySet, LogicalPolicy, ForEachPolicy, LogicalQuantifier, ForEachQuantifier,
    ForEachSubject, VolumePolicy, VolumeUnitPolicy, PeriodicVolumePolicy, UnitPeriodicVolumePolicy,
    ContractPolicyConfig, GlobalPolicyConfig, DenylistPolicy
} from "@maroo-chain/contracts/precompiles/pcl/IPcl.sol";

// the policy this selector rule carries (see the VOLUME_POLICY page for the fields):
string[] memory toks = new string[](1);
toks[0] = "atokrw";
VolumeUnitPolicy[] memory lims = new VolumeUnitPolicy[](1);
lims[0] = VolumeUnitPolicy({ minLimit: 0, maxLimit: 1_000_000 ether });
VolumePolicy memory volumePolicy = VolumePolicy({ tokens: toks, limits: lims });

// rule applies only to calls of `foo(uint256)`:
bytes memory fooSel = abi.encodePacked(bytes4(keccak256("foo(uint256)")));
PolicySet memory ps = PolicySet({
    templateId: "VOLUME_POLICY",
    policy: abi.encode(volumePolicy),
    selector: fooSel
});
Empty selector ("") means "any call in this scope." Within a single config, every PolicySet must use a distinct selector value — reusing the same selector across two entries reverts with the typed error InvalidParameter(bytes input), where input is that selector as text — its lowercase hex without 0x (for example the bytes of "a9059cbb"), or __all__ for the empty selector. Decode it with decodeErrorResult like any other PCL error, then read input as text (viem's hexToString).

Selector matching applies to both scopes:

  • Global config — PCL first evaluates the empty-selector PolicySet (a chain-wide rule such as a universal denylist), then the entry whose selector matches the 4-byte function selector of the call it is checking, if any. For the transaction's own call that selector is msg.data[:4]. A call into a PCL-wrapped proxy (or a UserOperation's call through a trusted EntryPoint) is checked again in preCall / postCall with that call's own selector, so a transaction that reaches a proxy through another contract (Multicall3, a router, handleOps) can run the selector-matched global entries for both its outer and its inner call.
  • Contract config — same rule, scoped to the target proxy: the empty-selector entry runs before the entry for the proxy call's selector.


Order matters: the empty-selector entry runs first, so a coarse chain-wide check (e.g. denylist) can short-circuit before a per-function rule is even considered.
ESC
Type to search