Policy-Aware Precompile

mechanism privacy

A precompile that carries its own ContractPolicyConfig — bound directly, without a PCL proxy — and whose policy set can never be empty or cleared.

Most contract-scope PCL enforcement runs through a PCL-wrapped proxy: the proxy's preCall / postCall hooks evaluate the ContractPolicyConfig bound to the proxy address. A policy-aware precompile is different — it is a binary-supported precompile whose code evaluates PCL policy internally at its own precompile boundary. Currently the only policy-aware precompile is the Privacy precompile at 0x100000000000000000000000000000000000000b. Because a precompile is not a deployed contract, it cannot be registered as a PCL proxy and does not go through the proxy hook path; instead its address is bound directly to a ContractPolicyConfig through IPcl.changeContractPolicies, and the precompile reads that configuration on every state-changing call.

Architecture

flowchart LR
  U["User / dApp"]:::evm
  PRIVACY["Privacy precompile<br/>0x…000b"]:::precompile
  PCL["PCL precompile<br/>0x…0005"]:::precompile
  CFG["ContractPolicyConfig<br/>keyed by 0x…000b"]:::precompile
  STATE["Shielded state"]:::precompile

  U -->|"deposit / transfer / withdraw"| PRIVACY
  PRIVACY -->|"read policies"| PCL
  PCL --> CFG
  PRIVACY -->|"apply if all rules pass"| STATE

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

The Privacy precompile reads its own ContractPolicyConfig from PCL on every state-changing call. There is no proxy hop.

What makes a precompile policy-aware

Two things: (1) the chain binary explicitly lists the precompile address as policy-aware, and (2) the precompile's own code reads its ContractPolicyConfig at call time and enforces the resulting rules before performing state changes. In the current release, the sole policy-aware precompile is the Privacy precompile — every state-changing privacy call (deposit, transfer, withdraw, their WithAuthorization variants, and both batch variants) passes PCL policy evaluation at that boundary.

Registration rules that differ from PCL proxies

The ContractPolicyConfig entry point (IPcl.changeContractPolicies) accepts a policy-aware precompile address as _contract — you do NOT call deployPclProxy for a precompile, and attempts to register a policy-aware precompile as a PCL proxy are rejected at the chain layer. Two additional rules apply that do not apply to ordinary PCL proxies:

  • The policies array must be non-empty. Submitting policies: [] reverts with CannotEmpty("policy-aware precompile policies").
  • removeContractPolicies on a policy-aware precompile address reverts with the same CannotEmpty error. The only way to change the policies is to submit a replacement non-empty set through changeContractPolicies.


These rules exist because a policy-aware precompile is always in the active call path — leaving it without a policy would silently disable an enforcement point that the chain expects to always be evaluated.
// Bind rules to the Privacy precompile. First call: the payload's `admin`
// becomes the future gatekeeper. Subsequent calls must come from that admin.
const PCL     = "0x1000000000000000000000000000000000000005";
const PRIVACY = "0x100000000000000000000000000000000000000b";

await wallet.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "changeContractPolicies",
  args: [{
    _contract: PRIVACY,
    admin:     privacyAdmin,           // TODO: replace with real admin
    policies:  [nonEmptyPolicySet],    // must be non-empty
  }],
});

Admin authority is separate from the chain-wide policy admin

The admin field stored under a policy-aware precompile's ContractPolicyConfig is the sole gatekeeper for future updates to that config. The chain-wide policyAdmin — the address returned by IPcl.policyAdmin() — does not automatically have authority here. Only the currently stored contract admin (initially the first caller who submitted the config) can rotate the admin or replace the policy set, and the two happen atomically in the same changeContractPolicies call.

Global policy still applies

A policy-aware precompile's contract-scope ContractPolicyConfig runs in addition to the chain-wide GlobalPolicyConfig. Every call to the precompile passes global policy evaluation first (the same way any other transaction does), and then the precompile evaluates its own contract-scope policies. Either scope can reject the call independently.

Why the precompile boundary, not a proxy

The privacy precompile handles shielded value flows — deposits, note-spending transfers, and withdrawals — and its input format is not an ordinary Solidity call. Wrapping it in a proxy would either force every caller through an extra hop for a hot-path operation or would silently bypass rules for calls that skip the proxy. Binding policy at the precompile boundary makes enforcement unavoidable: there is no way to reach the shielded state without going through the same precompile that evaluates the policy.
ESC
Type to search