PCL Policy Enforcement

mechanism compliance

PCL evaluates active policies against every transaction: global rules run for all transactions before execution, and contract-scoped rules run through the PCL proxy hook path. Rejections at either boundary surface as ABI-decodable typed reverts.

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 ahead of execution. Contract-scoped policies (a ContractPolicyConfig registered against a PCL-wrapped proxy) run through the proxy's preCall / postCall hooks. A rejection at either boundary aborts the transaction with an ABI-encoded PCL ReasonCode (or, for authorization / encoding failures, one of the shared IPrecompile errors) — the same shape whether the rejection happens at RPC-submission time or at on-chain execution, so a single client decoder handles both.

Architecture

flowchart TD
  Tx["Incoming transaction"] --> Ingress{"Global policies<br/>(ingress)"}
  Ingress -->|"reject"| Revert1["revert IPcl error"]
  Ingress -->|"admit"| Target{"Target address"}
  Target -->|"PCL-wrapped proxy"| Hook["preCall / postCall<br/>(contract-scoped policies)"]
  Target -->|"plain contract"| Exec["EVM execution"]
  Hook -->|"reject"| Revert2["revert IPcl error"]
  Hook -->|"admit"| Exec
  Exec --> Sweep{"Post-execution<br/>ERC20 log sweep"}
  Sweep -->|"reject"| Revert3["revert IPcl error"]
  Sweep -->|"admit"| Commit["State commit"]

  classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
  classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff;
  class Tx,Target,Exec,Commit evm;
  class Ingress,Hook,Sweep,Revert1,Revert2,Revert3 precompile;

PCL evaluates policies at three points: global rules at ingress, contract-scoped rules inside the PCL proxy hook, and a post-execution sweep over ERC20 Transfer logs. Any single rejection reverts the whole transaction.

Two enforcement points

PCL enforces at exactly two boundaries — there is no single-call entry point that both evaluates policy and executes a call.

  • Global scopeGlobalPolicyConfig is evaluated for every transaction before execution. It runs against the transaction's sender and calldata regardless of the target contract. Typical contents: a chain-wide denylist, a periodic-volume cap on unattested users, a KYC gate.
  • Contract scopeContractPolicyConfig is evaluated only when a transaction routes through a PCL-wrapped proxy (see pcl-proxy-hook). The proxy's preCall hook invokes PCL with the effective principal, calldata, and value; on success it forwards to the implementation; on failure the whole transaction reverts.


Both scopes evaluate empty-selector entries first, then any selector-matched entry (see pcl-policy-structure).

Rejection surfaces are typed reverts

When PCL rejects a transaction, the revert data is the ABI encoding of a PCL error selector from IPcl (a ReasonCode such as InDenylist(address sender), ExceededPeriodicVolume(uint256 maxLimit, uint256 value, uint256 resetAt), EasNoAttestationReceived(address sender)) or, for authorization and encoding failures, one of the shared IPrecompile errors. This holds at both enforcement points: the pre-broadcast check on eth_sendRawTransaction and the on-chain execution boundary produce identical revert shapes, so a client can attach both error fragments to a single ABI and decode uniformly.
import { decodeErrorResult } from "viem";

const pclErrorAbi = [
  { type: "error", name: "InDenylist", inputs: [{ name: "sender", type: "address" }] },
  { type: "error", name: "ExceededPeriodicVolume", inputs: [
    { name: "maxLimit", type: "uint256" },
    { name: "value", type: "uint256" },
    { name: "resetAt", type: "uint256" },
  ] },
  { type: "error", name: "EasNoAttestationReceived", inputs: [{ name: "sender", type: "address" }] },
  { type: "error", name: "EasAttestationRevoked", inputs: [{ name: "sender", type: "address" }] },
  // ...append the rest of IPcl ReasonCodes plus IPrecompile shared errors
] as const;

try {
  await walletClient.sendRawTransaction({ serializedTransaction: signedTx });
} catch (err: any) {
  if (err?.data) {
    const decoded = decodeErrorResult({ abi: pclErrorAbi, data: err.data });
    console.error(`PCL ReasonCode: ${decoded.errorName}`, decoded.args);
  } else {
    throw err;
  }
}

Pre-broadcast rejection at RPC submission

The RPC mempool now runs PCL policy evaluation before broadcasting a transaction. If the evaluation rejects the transaction, eth_sendRawTransaction returns the same typed revert data the transaction would have produced on-chain — the resolver wraps the underlying PCL error into an EVM-style typed revert payload so client-side try/catch around sendRawTransaction decodes the reason identically to any other revert. Two practical implications:

  • A client does not need special-case handling for "submission-time policy rejection" vs "execution-time policy rejection" — the same decoder handles both.
  • Because the rejection happens before broadcast, no gas is charged and no block includes the transaction. It never appears in the explorer.

Order of evaluation

For a call that reaches a PCL-wrapped proxy:

1. Global config — empty-selector entries evaluate first (a chain-wide denylist can short-circuit here).
2. Global config — the entry whose selector matches the transaction's 4-byte function selector, if any.
3. Contract config — empty-selector entries on the target proxy.
4. Contract config — the selector-matched entry on the target proxy.
5. Implementation call runs.
6. postCall hook re-enters PCL for any post-execution accounting (e.g. periodic-volume increment).

A failure at any step aborts the transaction. A call that reaches the implementation directly (bypassing the proxy) only goes through steps 1–2; contract-scoped policies are not evaluated.

What causes a plain-string revert instead

Most PCL failures produce a typed error, but a handful are plain-string reverts. duplicate selector: <selector> (when a caller submits two PolicySet entries sharing a selector) is the canonical example. Decode it as a string rather than with decodeErrorResult. Similarly, if the underlying failure is a stock SDK error with no typed mapping ("unauthorized", "cannot be empty: <field>"), the revert is a plain string. Check both shapes in the catch branch.

Who pays for policy evaluation

Policy evaluation is not free and it is not billed separately — it comes out of the caller's gas. The pre-execution check is part of the call you already pay for; the post-execution accounting runs after your call succeeds and draws on whatever your gas limit has left. It used to have a fixed budget of its own, which meant a tight hand-set limit still worked; that budget is gone.

Two consequences worth designing around:

  • Take the gas limit from eth_estimateGas, not from the contract call. The estimate runs the same post-execution evaluation, so it already includes the policy work. A limit computed from the call alone can run out mid-evaluation.
  • A reverting call never reaches it. Post-execution evaluation runs only when execution succeeded, so a failed call is not additionally charged for policy work.
ESC
Type to search