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 plain-string reason duplicate selector: <selector> — this one is not a typed error).

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] = "aokrw";
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 plain-string reason duplicate selector: <selector>; unlike most PCL failures this is not a typed error, so decode it as a string rather than with decodeErrorResult.

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 transaction's 4-byte function selector, if any. At most one selector-matched global entry runs per transaction (the one whose selector bytes equal msg.data[:4]).
  • Contract config — same rule, scoped to the target proxy. The empty-selector entry runs before the selector-matched entry.


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