PCL Proxy Hook
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
preCall or postCall is invoked, PCL first classifies the immediate caller by inspecting its on-chain code:| Kind | Detection | Admitted? |
|---|---|---|
| Plain EOA | Empty code hash | ❌ Rejected — Unauthorized |
| EIP-7702 delegated EOA | Code parses as a 7702 delegation designator | ❌ Rejected — Unauthorized |
| Registered PCL-wrapped proxy | Present in the chain-side proxy registry | ✅ Only if targetContract == msg.sender |
| Trusted entrypoint | Address 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
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
principal argument. PCL evaluates policies against principal, not against the immediate caller of preCall / postCall. This is critical:- For a proxy call,
principalis the EOA (or AA smart account) that called the proxy. - For an entrypoint call,
principalis 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
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
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);