PCL Proxy Hook

mechanism compliance

How Maroo wraps upgradeable contracts so every call flows through PCL — a chain-deployed proxy that inserts preCall/postCall hooks around the implementation delegatecall, with no user-callable bypass.

The PCL-wrapped proxy is a TransparentUpgradeableProxy variant deployed exclusively via IPcl.deployPclProxy(...). Its address becomes the PCL policy lookup key: any ContractPolicyConfig registered against the proxy applies uniformly across implementation upgrades. On every non-admin call, the proxy's fallback opens a PCL session via preCall, delegatecalls the current implementation with the original calldata, then closes the session via postCall — surfacing any PCL rejection as a revert. The only admin-privileged path is upgradeToAndCall; any other call from the proxy admin reverts with ProxyDeniedAdminAccess.

Architecture

flowchart TD
    A[Caller EOA / AA] -->|call| P[PclTransparentUpgradeableProxy]
    P -->|msg.sender == admin<br/>and sig == upgradeToAndCall| U[OZ upgrade dispatch<br/>PCL bypassed]
    P -->|every other call| H[hookedDelegate]
    H -->|preCall proxy, principal, data, value| PCL[IPcl precompile<br/>0x1000...0005]
    H -->|delegatecall| I[Implementation]
    H -->|postCall proxy, principal, data, value, ok| PCL
    U -->|delegatecall| I

    classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
    classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff;
    class A,P,U,H,I evm;
    class PCL precompile;

Fallback dispatch inside PclTransparentUpgradeableProxy. The admin's `upgradeToAndCall` is the only path that bypasses PCL; every other call — from any sender — runs through preCall and postCall against the proxy address. There is no user-callable bypass.

Fallback routing

The proxy's _fallback handles two cases in this order:

1. Admin path — when msg.sender == _proxyAdmin(), only upgradeToAndCall is dispatched (standard OZ upgrade flow, PCL bypassed). Any other admin call reverts with ProxyDeniedAdminAccess.
2. User path — every other call goes through PclProxyHook.hookedDelegate, which wraps the implementation delegatecall in preCall / postCall.

There is no permissionless bypass. Earlier iterations exposed a nonPolicyExec(bytes) escape hatch that could delegatecall the implementation while skipping the contract-scope PCL check; that hook has been removed. Every user-side call now goes through the same policy-enforcing path.

Selector reservation

The dispatch reserves the upgradeToAndCall selector for admin use, mirroring the standard transparent-proxy caveat: implementations behind this proxy must not expose a colliding selector.
function _fallback() internal virtual override {
    if (msg.sender == _proxyAdmin()) {
        if (msg.sig != ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
            revert ProxyDeniedAdminAccess();
        }
        _dispatch(); // OZ upgrade path
        return;
    }

    // Every non-admin call — including bare ETH transfers with empty calldata —
    // flows through the PCL-enforcing hook.
    PclProxyHook.hookedDelegate(address(this), ERC1967Utils.getImplementation());
}

The single hook — hookedDelegate

The library PclProxyHook exposes exactly one entry point: hookedDelegate. It captures the principal (the actual EOA / AA caller — the proxy itself is the immediate msg.sender PCL sees, so the principal must be forwarded explicitly for sender / principal denylist checks to fire), opens a session with preCall, delegatecalls the implementation with msg.data, then closes the session with postCall. The ok flag from the delegatecall is forwarded as workable; when the implementation reverts, PCL skips post-call bookkeeping and the proxy bubbles the original revert data.
// Excerpt — PclProxyHook.sol
function hookedDelegate(address addrForPolicy, address impl) internal {
    address principal = msg.sender;
    bytes32 sessionId = PCL_CONTRACT.preCall(
        addrForPolicy, principal, msg.data, msg.value
    );

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

    PCL_CONTRACT.postCall(
        sessionId, addrForPolicy, principal, msg.data, msg.value, ok
    );

    if (!ok) { assembly { revert(add(ret, 0x20), mload(ret)) } }
    assembly { return(add(ret, 0x20), mload(ret)) }
}

No skip flag

The paired preCall and postCall signatures on IPcl no longer carry a checkContractPolicy flag. Contract-scope policy evaluation always runs when a ContractPolicyConfig is registered for the proxy address — there is no in-proxy toggle to skip it.

Why the proxy address is the policy key

Contract-scoped policies are keyed by the proxy address, not the implementation. This means an implementation upgrade never resets or dislocates the policy graph: the same DENYLIST_POLICY, EAS_POLICY, or periodic-volume policy that governed v1 still governs v2 as soon as the proxy points at the new logic. The admin field inside each ContractPolicyConfig is the only key allowed to call changeContractPolicies / removeContractPolicies, and it is separate from the proxy's upgrade admin — policy management and code upgrades are two independent authorities.

Deployment — via the precompile only

You cannot deploy this proxy from your own code. The canonical bytecode is embedded in the chain and instantiated by IPcl.deployPclProxy(kind, value, initData), which also registers the resulting address in the on-chain proxy registry (readable via IPcl.pclProxy(address)). Deploying via any other path yields a contract that is not recognized by PCL as an allowed entrypoint — its preCall / postCall invocations would revert with Unauthorized.
import { encodeAbiParameters } from "viem";

const PCL = "0x1000000000000000000000000000000000000005";

// For Transparent proxies: initData = abi.encode(logic, initialOwner, initializer)
const initData = encodeAbiParameters(
  [
    { type: "address", name: "logic" },
    { type: "address", name: "initialOwner" },
    { type: "bytes",   name: "initializer" },
  ],
  [logicAddress, ownerAddress, "0x"],
);

const { result: proxyAddress } = await publicClient.simulateContract({
  address: PCL,
  abi: pclAbi,
  functionName: "deployPclProxy",
  args: [1 /* PclProxyKind.Transparent */, 0n, initData],
  account: deployer,
});
Source: maroo
ESC
Type to search