PCL Proxy Hook

mechanism compliance

How PCL-wrapped proxies and trusted entrypoints call preCall / postCall to enforce ContractPolicyConfig around a user transaction.

Contract-scoped PCL policies are enforced through a pair of hook methods on the PCL precompile: preCall runs before the wrapped implementation executes, and postCall runs after. These hooks are not open — the precompile checks the immediate msg.sender against a small allowlist of caller kinds and rejects everything else with Unauthorized. Two caller kinds are admitted: a PCL-wrapped proxy registered by deployPclProxy (which additionally must be the same address as the target contract), and a trusted entrypoint registered in PclParams.entrypoints (with no such constraint, since an entrypoint routes user operations to many targets).

Architecture

flowchart LR
    EOA["User EOA"]:::evm
    AA["AA Smart Account"]:::evm
    EP["Trusted Entrypoint<br/>(in PclParams.entrypoints)"]:::evm
    PX["PCL-wrapped Proxy<br/>(registered)"]:::evm
    IMPL["Target Implementation"]:::evm
    PCL{{"PCL Precompile<br/>preCall / postCall"}}:::precompile

    EOA -->|"call proxy"| PX
    AA -->|"user op"| EP
    PX -->|"preCall/postCall<br/>target == self"| PCL
    EP -->|"preCall/postCall<br/>target = any"| PCL
    PCL -->|"admit → delegate"| IMPL
    PX -->|"delegatecall"| IMPL

    classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
    classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff;

Only two caller kinds can invoke PCL's preCall/postCall: a registered PCL-wrapped proxy (must target itself) or a trusted entrypoint (may target any contract). Plain EOAs and EIP-7702 delegated EOAs are rejected.

The four caller kinds PCL classifies

When preCall or postCall is invoked, PCL first classifies the immediate caller by inspecting its on-chain code:

KindDetectionAdmitted?
Plain EOAEmpty code hash❌ Rejected — Unauthorized
EIP-7702 delegated EOACode parses as a 7702 delegation designator❌ Rejected — Unauthorized
Registered PCL-wrapped proxyPresent in the chain-side proxy registry✅ Only if targetContract == msg.sender
Trusted entrypointAddress in PclParams.entrypoints✅ Any targetContract

Anything else (an arbitrary contract that is neither a registered proxy nor a trusted entrypoint) is also rejected. This is why a dApp cannot call preCall / postCall directly from its own contract to "opt in" to enforcement — the only supported entry paths are through a PCL-wrapped proxy or an ERC-4337-style entrypoint that the policy admin has trusted.

Why the two admitted paths have different constraints

A PCL-wrapped proxy is deployed by deployPclProxy and is one-to-one with a single implementation. When users call the proxy, its wrapper bytecode invokes preCall(proxyAddress, principal, data, value) and later postCall(sessionId, proxyAddress, principal, data, value, workable). Because the proxy targets exactly one implementation and forwards to itself, PCL requires targetContract == msg.sender — a proxy call that claims a different target address is malformed and is rejected with Unauthorized.

An entrypoint, by contrast, routes user operations to many different target contracts. Requiring targetContract == msg.sender would make the entrypoint path unusable. Instead, the policy admin gates entry by adding the entrypoint's address to PclParams.entrypoints, and the entrypoint itself is trusted to pass the correct targetContract and principal (the underlying user, not the entrypoint) to the hooks.

Principal, not msg.sender, is the policy caller

Both hooks take an explicit principal argument. PCL evaluates policies against principal, not against the immediate caller of preCall / postCall. This is critical:

  • For a proxy call, principal is the EOA (or AA smart account) that called the proxy.
  • For an entrypoint call, principal is the underlying user whose operation the entrypoint is executing on behalf of.


Without this parameter, PCL would only see the proxy or entrypoint address in msg.sender, and sender-based checks (denylist, EAS attestation lookup, agent transfer-limit metadata, periodic-volume counters) would all resolve against the wrong account.
// The proxy hook wrapper (conceptual — the real bytecode is embedded in the chain).
// Note that `msg.sender` here is the user (EOA or AA account), and it is
// forwarded to PCL as `principal` — PCL then evaluates policies against it.

address constant PCL = 0x1000000000000000000000000000000000000005;

function _pclWrappedCall(bytes calldata data) external payable {
    bytes32 sessionId = IPcl(PCL).preCall(
        address(this),   // targetContract == msg.sender for a proxy
        msg.sender,      // principal — the user, forwarded explicitly
        data,
        msg.value
    );

    (bool ok, bytes memory ret) = _implementation().delegatecall(data);

    IPcl(PCL).postCall(
        sessionId,
        address(this),
        msg.sender,
        data,
        msg.value,
        ok               // if false, postCall skips policy evaluation
    );

    if (!ok) { assembly { revert(add(ret, 32), mload(ret)) } }
}

Why EIP-7702 delegated EOAs are rejected

EIP-7702 lets an EOA install a 23-byte delegation designator as its account code so that regular calls run against a specified implementation. From the raw code-hash perspective the account looks like a contract, but its address is still the EOA's — so a 7702 account cannot itself be a PCL-registered proxy or a trusted entrypoint. To distinguish this case, PCL reads the account's raw code and checks whether it parses as a 7702 delegation designator; if so the caller is classified as a 7702 EOA and rejected with Unauthorized.

The practical rule for dApp builders: never call preCall / postCall from an EOA or a 7702-delegated account. Route regulated calls through the PCL-wrapped proxy address published for your contract, or through a trusted entrypoint address if you are integrating account abstraction.

Registering a trusted entrypoint

The list of trusted entrypoints is part of PclParams and is managed by the chain-wide policy admin (see pcl-policy-admin). A dApp cannot add itself. To check whether a given entrypoint is currently trusted, read getParams() on the PCL precompile and look for the address in entrypoints:
import { createPublicClient, http } from "viem";

const PCL = "0x1000000000000000000000000000000000000005" as const;
const pclParamsAbi = [{
  name: "getParams", type: "function", stateMutability: "view",
  inputs: [],
  outputs: [{
    type: "tuple", components: [
      { name: "policyAdmin", type: "address" },
      { name: "entrypoints", type: "address[]" },
    ],
  }],
}] as const;

const publicClient = createPublicClient({ transport: http("https://rpc-testnet.maroo.io") });
const params = await publicClient.readContract({
  address: PCL,
  abi: pclParamsAbi,
  functionName: "getParams",
});

// TODO: replace with the entrypoint address your AA stack uses.
const candidate = "0x4337084d9e255ff0702461cf8895ce9e3b5ff108";
const isTrusted = params.entrypoints
  .map((a) => a.toLowerCase())
  .includes(candidate.toLowerCase());
console.log("entrypoint trusted:", isTrusted);
ESC
Type to search