PCL Policy Enforcement

mechanism compliance

PCL evaluates active policies against every transaction: global rules run for all transactions before execution and again after it, and contract-scoped rules run through the PCL proxy hook path during execution. Rejections at any of these points surface as ABI-decodable typed reverts.

Every Maroo transaction is checked by PCL. Global policies (a GlobalPolicyConfig set by the policy admin) are evaluated for all transactions ahead of execution, and again after a successful execution. Contract-scoped policies (a ContractPolicyConfig registered against a PCL-wrapped proxy) run during execution through the proxy's preCall / postCall hooks. A rejection at any of these points 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/>global policies"}
  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 the global rules again after execution, over the transaction's calls and its native and ERC20 transfers. A rejection at RPC submission means the transaction is never broadcast; a rejection during or after execution reverts the whole transaction, which is still mined and charged for its gas.

Two enforcement scopes

PCL enforces policies in two scopes — there is no single-call entry point that both evaluates policy and executes a call.

  • Global scope — GlobalPolicyConfig is evaluated for every transaction before execution (at RPC submission and again in the chain's pre-execution checks, against the transaction's sender, to, value, and 4-byte function selector), and again after a successful execution, over the calls and transfers the transaction made. It applies regardless of the target contract. A violation found after execution still fails the transaction: it is mined, its state is reverted, and its gas is charged. Typical contents: a chain-wide denylist, a periodic-volume cap on unattested users, a KYC gate.
  • Contract scope — ContractPolicyConfig is evaluated during execution when a transaction routes through a PCL-wrapped proxy (see pcl-proxy-hook); a trusted ERC-4337 EntryPoint can also call preCall / postCall, and the policy-aware Privacy precompile evaluates its own contract policies at its own boundary. 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, another error IPcl declares or inherits from IPrecompile. This holds at both enforcement points: the pre-broadcast check on eth_sendRawTransaction and the on-chain execution boundary produce identical revert shapes, so one ABI decodes both: iPclAbi from @maroo-chain/contracts carries the IPcl errors and the IPrecompile ones it inherits.
import { BaseError, decodeErrorResult } from "viem";
import { iPclAbi } from "@maroo-chain/contracts/abi/precompiles/pcl/IPcl";

try {
  await walletClient.sendRawTransaction({ serializedTransaction: signedTx });
} catch (err) {
  // The node's revert data is on the RPC error in the cause chain; viem may wrap that error.
  const rpcError = err instanceof BaseError ? err.walk((e) => typeof (e as { data?: unknown }).data === "string") : null;
  const data = (rpcError as { data?: `0x${string}` } | null)?.data;
  if (data) {
    // iPclAbi lists every IPcl ReasonCode and the IPrecompile errors it inherits, in one ABI
    const decoded = decodeErrorResult({ abi: iPclAbi, data });
    console.error(`PCL ReasonCode: ${decoded.errorName}`, decoded.args);
  } else {
    throw err;
  }
}

Pre-broadcast rejection at RPC submission

The RPC mempool now runs the global policies' before-execution check on the transaction's own call (sender, to, value, and 4-byte function selector) before broadcasting it. 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:

  • The same decoder handles a submission-time and an execution-time policy rejection, but they arrive differently: a submission-time rejection is thrown by eth_sendRawTransaction, while an execution-time rejection (in the proxy hook or after execution) is mined as a failed transaction whose gas is charged, so the client sees it in the receipt status.
  • 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. Global config again, in preCall, for the proxy call's principal and its own selector (empty-selector entries first).
6. Implementation call runs.
7. postCall hook re-enters PCL to check the transfers the call made against the contract and global policies and to record them (e.g. periodic-volume increment).
8. After the transaction succeeds, the chain's post-transaction hook evaluates the global config again over the transaction's calls and transfers.

A failure at any step aborts the transaction; a failure at step 3 or later happens on-chain, so the transaction is still mined as failed and its gas is charged. A call that reaches the implementation directly (bypassing the proxy) goes through steps 1–2 and 8 only; contract-scoped policies are not evaluated.

What causes a plain-string revert instead

Most PCL failures produce a typed error. That includes a duplicate selector (InvalidParameter(bytes input), where input is the selector as text: lowercase hex without 0x, or __all__ for the empty selector), a caller that is not the required admin (Unauthorized()), and an empty policy list for a policy-aware precompile (CannotEmpty(string field)). A plain-string revert is the exception: deployPclProxy reports a proxy that could not be constructed (an initializer that reverts — or is empty, for a Transparent or UUPS proxy — or a logic address without code) only as Solidity's Error(string) with the reason execution reverted. viem's decodeErrorResult decodes that shape too, as errorName: "Error" with the reason in args[0], so one catch branch handles both.

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