PCL Contract Admin Binding
Every contract-scoped policy set has an on-chain admin. The binding is created once when the first ContractPolicyConfig for a proxy is written, and can only be handed over — never re-initialized.
PCL enforces contract-scoped policies only against PCL-registered proxies — contracts deployed through IPcl.deployPclProxy — and the binary-supported policy-aware precompiles. Each such target has exactly one contract policy admin: the address authorized to call changeContractPolicies and removeContractPolicies against it. The admin is set when the target is created, not by a later call: deployPclProxy records its caller (the deployer or factory) as the proxy's admin in the same call, and a policy-aware precompile's admin is installed by the chain. From then on, every changeContractPolicies / removeContractPolicies must come from the stored admin; any other sender gets Unauthorized. To rotate authority, the current admin calls changeContractPolicies again with a different admin in the payload — the policy replacement and the admin handover happen atomically in the same call.
Architecture
flowchart LR
D["dApp / factory"] -->|"deployPclProxy(kind, 0, initData)"| P{"PCL precompile"}
P -->|"deploy proxy bytecode"| X["PCL-wrapped proxy"]
P -->|"write admin=msg.sender"| R[("Contract-admin record")]
P -->|"sync projection"| E[("pclProxy registry entry<br/>kind + admin + proxy")]
A["Current admin"] -->|"changeContractPolicies(newAdmin, ...)"| P
P -->|"update admin"| R
P -->|"sync projection"| E
classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff;
class D,A,X,R,E evm;
class P precompile; The contract-admin record is the primary source of truth; the proxy registry entry mirrors the admin for PCL-wrapped proxies and is refreshed on every admin change.
Only PCL-registered proxies can carry a ContractPolicyConfig
changeContractPolicies and removeContractPolicies both look up the target's contract policy binding before touching policy state. A raw implementation address, an EOA, an agent wallet or a contract deployed through some other mechanism has none, so a call against it reverts with the typed error ContractPolicyNotRegistered(address contractAddress). Deploy the contract behind IPcl.deployPclProxy first — the returned proxy address is what dApps publish to users, and it is the address every subsequent policy management call must target.Initialization is single-assignment
deployPclProxy itself: in the same call that deploys the proxy, PCL records the caller (msg.sender — your wallet, or the factory contract that deployed it) as the proxy's contract admin. There is no moment in which the slot is empty and a first caller could claim it: the first changeContractPolicies for a new proxy must already come from that admin, and any other sender reverts with Unauthorized. If a factory contract deploys the proxy, the factory is the admin, so it makes the first changeContractPolicies itself — typically handing the admin to its owner in that call. Re-initializing an already-bound proxy — even with the same admin — is not allowed: the low-level InitializeContractPolicyAdmin path reverts with PolicyAlreadyRegistered(contractAddress) when an admin is already stored. The anti-takeover guarantee is intentional: if a contract is destroyed and redeployed at the same CREATE2 address by a different deployer, the new deployer is locked out unless the current admin explicitly hands over authority.Handover through changeContractPolicies
transferAdmin method. To rotate the admin, the current admin calls changeContractPolicies from its own key with a payload whose admin field is the new address. The call replaces the entire PolicySet[] and writes the new admin in a single state transition, so at no point does the proxy have two admins. From a contract, make the call low-level as below: PCL has no EVM bytecode, and Solidity checks the target's code size before a high-level call to a function that returns nothing, so the usual PCL.changeContractPolicies(...) syntax reverts with empty data before PCL sees it.// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.28;
import {
IPcl,
ContractPolicyConfig,
PolicySet
} from "@maroo-chain/contracts/precompiles/pcl/IPcl.sol";
contract PolicyOwner {
address constant PCL = 0x1000000000000000000000000000000000000005;
address public immutable owner;
constructor() {
owner = msg.sender;
}
/// @notice Hand policy authority for `proxy` over to `newAdmin` while
/// keeping the existing policies. Only this contract's owner may
/// call it; PCL accepts the change because this contract is the
/// proxy's current admin.
function handOver(
address proxy,
address newAdmin,
PolicySet[] calldata keepPolicies
) external {
require(msg.sender == owner, "PolicyOwner: not owner");
// Low-level call: PCL has no EVM bytecode, and Solidity checks the
// target's code size before a high-level call to a function that
// returns nothing — `PCL.changeContractPolicies(...)` would revert
// with empty data before reaching the precompile.
(bool ok, bytes memory ret) = PCL.call(abi.encodeCall(
IPcl.changeContractPolicies,
(ContractPolicyConfig({
_contract: proxy,
admin: newAdmin,
policies: keepPolicies
}))
));
if (!ok) {
assembly { revert(add(ret, 32), mload(ret)) } // bubble PCL's own error
}
}
} removeContractPolicies clears policies, not authority
removeContractPolicies(proxy) from the current admin drops every PolicySet bound to proxy, but the admin binding survives. A follow-up changeContractPolicies from the same admin can re-populate policies without any separate re-registration step. The same precondition applies: removeContractPolicies reverts with ContractPolicyNotRegistered(address) if the target has no contract policy binding, and with Unauthorized if the caller is not the current admin.Reading the current admin
contractPolicies(address) is a pure view: it returns the current ContractPolicyConfig (admin + policies) for the target address. For an address that has no admin — anything not deployed through deployPclProxy and not a policy-aware precompile — it returns an empty struct (a zero admin and an empty policies array) rather than reverting, and a policy call against such an address reverts with ContractPolicyNotRegistered. Use this to see which account controls a proxy before sending a policy change.import { createPublicClient, http } from "viem";
import { iPclAbi as pclAbi } from "@maroo-chain/contracts/abi/precompiles/pcl/IPcl";
const PCL = "0x1000000000000000000000000000000000000005" as const;
// TODO: replace with the real proxy address.
const proxyAddress = "0x8F3ac2B1d9E74c05A6B18FE27Dc4913e5A0F7b62";
const publicClient = createPublicClient({ transport: http("https://rpc-testnet.maroo.io") });
const { admin, policies } = await publicClient.readContract({
address: PCL,
abi: pclAbi,
functionName: "contractPolicies",
args: [proxyAddress],
});
if (admin === "0x0000000000000000000000000000000000000000") {
console.log("no policy admin: not a PCL proxy or a policy-aware precompile — changeContractPolicies reverts ContractPolicyNotRegistered.");
} else {
console.log("current admin:", admin, "policy count:", policies.length);
}