PCL Contract Admin Binding

mechanism compliance

How a contract's PCL admin slot is initialized — automatically at PCL proxy deploy time, or once via legacy registerContractPolicies — and why the binding is single-assignment.

Every ContractPolicyConfig on PCL has an admin field: the only address allowed to later call changeContractPolicies or removeContractPolicies on that contract. This admin slot is bound once per contract address. There are two ways it gets bound: (1) automatically, when the contract is deployed via IPcl.deployPclProxy — the EVM msg.sender of the deploy call is written as admin before the deploy returns; (2) manually, on the legacy path, by the first successful call to registerContractPolicies (now deprecated — prefer changeContractPolicies once the slot is bound). Once written, the slot is anti-takeover: an unrelated caller cannot overwrite it. Handover only happens through changeContractPolicies where the current admin sets a new admin in the config.

Two initialization paths

PCL binds a contract's admin exactly once. Which call performs that binding depends on how the contract came into existence.

  • Auto-bind on proxy deploy (recommended). IPcl.deployPclProxy(kind, value, initData) deploys a PCL-wrapped proxy and, before returning, writes the deploy caller (msg.sender at the precompile) into the proxy's admin slot. No separate registerContractPolicies call is required to establish the admin — you go straight to changeContractPolicies to attach policies.
  • Legacy explicit registration. For contracts deployed without the PCL proxy factory, the first successful registerContractPolicies call binds the admin from the config.admin field. Subsequent updates must use changeContractPolicies. This path is deprecated and will be removed.

Auto-bind at deploy: what actually happens

During deployPclProxy, the precompile deploys the canonical proxy bytecode, then calls the chain-side InitializeContractPolicyAdmin(proxyAddress, msg.sender) before emitting PclProxyDeployed. The admin field on the resulting ContractPolicyConfig is the proxy deployer, not the proxy's logic contract owner and not the initializer target — those are separate concepts.
// Deploying the proxy from an EOA binds that EOA as PCL admin.
IPcl pcl = IPcl(0x1000000000000000000000000000000000000005);
address proxy = pcl.deployPclProxy(
    PclProxyKind.Transparent,
    0,
    abi.encode(logicImpl, initialOwner, initializerCalldata)
);
// pcl.contractPolicies(proxy).admin == msg.sender (the EOA that called deployPclProxy)
// Policies are NOT attached yet — call changeContractPolicies next.
pcl.changeContractPolicies(ContractPolicyConfig({
    _contract: proxy,
    admin:     msg.sender,       // keep or hand over
    policies:  policies
}));

Factory contracts: the admin becomes the factory

Because auto-binding uses the EVM msg.sender of the deployPclProxy call, deploying via a factory contract makes the factory the admin — not the end user who invoked the factory. A factory that wants the end user to own the admin slot has three options:

  • Have the end user call deployPclProxy directly (skip the factory).
  • Deploy from the factory, then have the factory call changeContractPolicies to hand over admin to the end user in the same transaction.
  • Build a delegation layer in the factory that owns admin permanently and mediates policy changes on the user's behalf.


There is deliberately no admin parameter on deployPclProxy — the design fixes the initial admin to msg.sender to eliminate spoofing at deploy time.

Single-assignment invariant and CREATE2 pre-registration

Once bound, the admin slot is anti-takeover: InitializeContractPolicyAdmin will only ever write the slot if it is empty, or as a no-op if the caller passes the same admin that is already stored. Any other caller trying to initialize a different admin gets PolicyAlreadyRegistered.

The same-admin idempotency exists to support CREATE2 pre-registration: a deployer can bind admin against a precomputed proxy address before the proxy exists, and the auto-bind at actual deploy time is a harmless no-op. It also means a contract destroyed and redeployed at the same CREATE2 address by a different deployer is intentionally locked out — the original admin retains authority unless it explicitly hands over via changeContractPolicies.

Handover: change vs. register

Once the slot is bound, use changeContractPolicies for every subsequent modification, including admin handover. Set config.admin to the new admin address in the same call that replaces policies; the current admin must be the transaction sender for the check to pass.
// Current admin hands over control to a new address in the same call
// that replaces the policy set.
pcl.changeContractPolicies(ContractPolicyConfig({
    _contract: proxy,
    admin:     newAdmin,        // handover target
    policies:  updatedPolicies
}));
// From this point on, only newAdmin can call changeContractPolicies /
// removeContractPolicies on `proxy`.

Client-side decision: which entrypoint to call

For a proxy deployed via deployPclProxy, the admin slot is already bound at deploy time — go directly to changeContractPolicies to attach or update policies. Calling registerContractPolicies against such a proxy reverts with PolicyAlreadyRegistered unless the caller is the bound admin — and in that case it performs a full legacy re-registration (policies replaced, the slot set to config.admin), not a no-op. Either way the path is deprecated. For portability, code that manages a contract you own can query IPcl.contractPolicies(contract) first: if admin == address(0), use the legacy registerContractPolicies; otherwise use changeContractPolicies.
Source: maroo
ESC
Type to search