PCL Policy Enforcement

mechanism compliance

How PCL evaluates active policies against every transaction — pre-execution for global rules, and via the regulated EVM path for contract-scoped rules.

Every Maroo transaction is filtered through PCL before any state-changing work runs. Global policies (a GlobalPolicyConfig set by the policy admin) are evaluated for all transactions pre-execution, before they are included in a block. Contract-scoped policies (a ContractPolicyConfig registered by a contract admin) only apply when a transaction targets that contract through the regulated EVM call path (runOnPcl / preCall + postCall). ERC-4337 userOps are unwrapped so PCL sees the principal (the smart account whose UserOperation is being bundled), not just the bundler.

Architecture

flowchart TD
    A[Client submits tx] --> B{Pre-execution global check}
    B -->|Evaluate GlobalPolicyConfig| C{Global policies pass?}
    C -->|No| R1[Broadcast reject<br/>typed ReasonCode]
    C -->|Yes| D{ERC-4337 handleOps?}
    D -->|Yes| E[Unwrap userOp<br/>principal = userOp.sender]
    D -->|No| F[principal = tx signer]
    E --> G{Regulated EVM path?}
    F --> G
    G -->|runOnPcl / preCall| H[Evaluate<br/>ContractPolicyConfig]
    G -->|Direct call| I[Skip contract policies]
    H -->|Fail| R2[On-chain revert<br/>typed ReasonCode<br/>status=0]
    H -->|Pass| J[Execute target]
    I --> J

    classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
    classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff;
    class A,C,D,E,F,G,I,J,R1,R2 evm;
    class B,H precompile;

Two enforcement points: the pre-execution global check runs global policies before broadcast; the regulated EVM path runs contract-scoped policies at execution. ERC-4337 userOps are unwrapped so the smart-account principal (not the bundler) is the policy subject.

Two enforcement points

PCL runs at two distinct points in the transaction lifecycle:

  • Pre-execution global check — checks the GlobalPolicyConfig. If any global policy rejects, the transaction never enters a block. Wallets see a broadcast-level rejection with the ReasonCode ABI-encoded in the error payload.
  • Regulated EVM path (runOnPcl / preCall+postCall) — checks the target contract's ContractPolicyConfig. Contract-scoped policies are opt-in per call: only transactions that go through this path are subject to them. A contract's owner attaches policies via registerContractPolicies.

ERC-4337 (Account Abstraction) principal resolution

For ERC-4337 handleOps bundles, PCL does not treat the bundler as the caller. It unwraps each UserOperation and evaluates policies against the userOp's sender (the smart account executing the call) as the principal. This applies to both global and contract-scoped checks.

A consequence: if the smart account (userOp.sender) is on a global denylist, the entire bundled transaction is rejected by the pre-execution global check before broadcast — wallets see a broadcast-level reject with no on-chain receipt, exactly as with a directly denylisted EOA. This is true regardless of whether the userOp targets a plain call, an ERC20 transfer, runOnPcl, or an inner call. The bundler itself does not need to be denylisted for this rejection to fire.
// Detecting AA broadcast-level denylist rejection with viem
import { decodeErrorResult } from "viem";

try {
  await walletClient.writeContract({
    address: entryPoint,           // ERC-4337 EntryPoint
    abi: entryPointAbi,
    functionName: "handleOps",
    args: [[userOp], beneficiary],
  });
} catch (err: any) {
  // Broadcast reject: no receipt; the revert payload is the ABI-encoded IPcl error.
  // Decode it as a typed error — never match on the message string.
  const decoded = decodeErrorResult({ abi: pclAbi, data: err.data });
  if (decoded.errorName === "InDenylist") {
    console.log("userOp.sender is globally denylisted — bundle never left the mempool.");
  } else {
    console.log("PCL ReasonCode:", decoded.errorName, decoded.args);
  }
}

Global evaluation before execution

Global evaluation happens pre-execution — a transaction that fails a global policy is rejected with a typed ReasonCode before it is included. PCL loads the current GlobalPolicyConfig and evaluates every PolicySet in it against the transaction. If any single policy rejects, the transaction is dropped with the corresponding typed error from IPcl (see pcl-reason-codes). The client never sees a receipt — the rejection surfaces as a broadcast error whose payload is the ABI-encoded IPcl error.

Contract-scoped evaluation via runOnPcl

Contract-scoped policies are only evaluated when a call reaches the target through the regulated EVM path. Two entry shapes:

  • runOnPcl(contractAddress, data, value) — a one-shot regulated invocation from an EOA or contract. PCL evaluates the target's ContractPolicyConfig, then forwards the call to the target.
  • preCall / postCall (used by PCL-wrapped proxies) — for wrapped proxy patterns, the proxy's hook forwards the real principal explicitly (see pcl-proxy-hook). PCL uses principal — not the immediate msg.sender — as the subject for sender-side checks.


A direct call to the contract that bypasses this path is not filtered by the contract's PolicySets — but is still filtered by the global config.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.22;

import { IPcl } from "@maroo-chain/contracts/precompiles/pcl/IPcl.sol";

contract RegulatedCaller {
    address constant PCL = 0x1000000000000000000000000000000000000005;

    // Route a call to `token.transfer(to, amount)` through the regulated path
    // so both global and contract-scoped policies apply.
    function compliantTransfer(address token, address to, uint256 amount) external {
        bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", to, amount);
        IPcl(PCL).runOnPcl(token, data, 0);
    }
}

Combined semantics

For any transaction:

1. The pre-execution global check evaluates every policy in the GlobalPolicyConfig. Any single failure → transaction rejected before execution, with the typed ReasonCode.
2. If the transaction targets a contract with a ContractPolicyConfig and enters via the regulated path, every policy in that config is evaluated as well. Any single failure → the whole call reverts with the typed ReasonCode.
3. For ERC-4337 bundles, steps 1 and 2 use the userOp's principal (smart account) — not the bundler — as the subject for sender-based checks.

Steps 1 and 2 are ANDed: to succeed, a call must pass every applicable policy. There is no override or bypass short of unregistering the offending policy.

Client-side detection

Two failure surfaces to handle:

  • Broadcast-level (pre-execution) rejections — no receipt, no tx hash. The revert data carries the typed ReasonCode. Applies to global-policy violations and to AA bundles whose principal is globally denylisted.
  • On-chain reverts — for contract-scoped rejections reached via runOnPcl, the call produces a receipt with status = 0 and the revert data contains the typed IPcl error. Decode against the IPcl ABI with decodeErrorResult.


For pre-flight simulation without spending gas or risking a broadcast reject, call runOnPcl via eth_call — see simulating-pcl-checks.
Source: maroo
ESC
Type to search