Building a Compliant ERC20 Token with PCL — End-to-End
An end-to-end walk-through: issue a KYC attestation via EAS, deploy a stock ERC20 behind a PCL-wrapped proxy, attach a ContractPolicyConfig that requires the attestation, then verify rejections.
What You Will Learn
- ✓Set up a Hardhat project against a Maroo testnet RPC.
- ✓Issue a KYC attestation via the canonical EAS contract preinstall.
- ✓Deploy a stock OpenZeppelin ERC20 behind a PCL-wrapped proxy so contract-scoped policies are enforced.
- ✓Encode an `EAS_POLICY` parameter struct and bind a ContractPolicyConfig using `changeContractPolicies` (the sole registration entry point).
- ✓Verify that PCL blocks transfers from non-attested senders via the proxy's preCall hook.
Prerequisites
- Access to a Maroo testnet RPC and a funded account (for gas).
- Basic Solidity and ERC20 familiarity.
- An address that can issue attestations under the schema you choose (issuer key).
Tools Needed
Hardhat (or Foundry)Node.js 20+viem or ethers v6@maroo-chain/contracts (Solidity interfaces + TypeScript ABIs for IOkrw / IPcl)@ethereum-attestation-service/eas-sdkMetaMask
We're going to build a compliant ERC20 — not by writing whitelist code in Solidity, but by deploying a stock token behind a PCL-wrapped proxy and attaching a Programmable Compliance Layer rule. The flow has four real moving pieces: (1) get an EAS attestation issued to a wallet, (2) deploy a vanilla ERC20 implementation behind a PCL-registered proxy so the proxy's preCall / postCall hooks enforce ContractPolicyConfig, (3) bind an
EAS_POLICY PolicySet to the proxy address via changeContractPolicies, (4) verify rejections. The token contract never knows compliance exists; the proxy hook enforces it before any transfer state change commits. 1
Step 1 — Resolve the EAS / Indexer addresses
On Maroo, EAS and its Indexer are preinstalls (see
eas-precompile-overview). The EAS precompile exposes a getParams() view that returns the canonical addresses, so your script can run on testnet and mainnet unchanged. Resolve EAS addresses via the EAS precompile typescript
import { createPublicClient, http } from "viem";
const EAS_PRECOMPILE = "0x1000000000000000000000000000000000000009";
const easPrecompileAbi = [{
name: "getParams", type: "function", stateMutability: "view",
inputs: [],
outputs: [{
type: "tuple", components: [
{ name: "schemaRegistry", type: "address" },
{ name: "eas", type: "address" },
{ name: "indexer", type: "address" },
],
}],
}] as const;
const publicClient = createPublicClient({ transport: http("https://rpc-testnet.maroo.io") });
const { eas: EAS_ADDR, indexer: INDEX_ADDR, schemaRegistry: SCHEMA_REG } =
await publicClient.readContract({
address: EAS_PRECOMPILE,
abi: easPrecompileAbi,
functionName: "getParams",
}); 2
Step 2 — Issue a KYC attestation with EAS
Register a simple schema (or use one your network already has) and issue an attestation to the wallet that should be allowed to receive your token.
scripts/issueAttestation.js javascript
const { EAS, SchemaEncoder, SchemaRegistry } =
require("@ethereum-attestation-service/eas-sdk");
const { ethers } = require("hardhat");
async function main() {
const [issuer, kycUser] = await ethers.getSigners();
// 1) register schema
const registry = new SchemaRegistry(SCHEMA_REG);
await registry.connect(issuer);
const schemaUID = await (await registry.register({
schema: "bool kycVerified",
revocable: true,
})).wait();
// 2) issue attestation to kycUser
const eas = new EAS(EAS_ADDR);
await eas.connect(issuer);
const enc = new SchemaEncoder("bool kycVerified");
const attUID = await (await eas.attest({
schema: schemaUID,
data: {
recipient: kycUser.address,
expirationTime: 0,
revocable: true,
data: enc.encodeData([{ name: "kycVerified", value: true, type: "bool" }]),
},
})).wait();
console.log("attestationUID =", attUID);
}
main().catch(console.error); Note: Production networks usually delegate KYC to an authorized issuer — a regulated KYC partner, a market maker desk, etc. Use their schema UID instead of registering your own.
3
Step 3 — Deploy the ERC20 implementation behind a PCL-wrapped proxy
Deploy a stock OpenZeppelin ERC20 implementation, then wrap it with
IPcl.deployPclProxy(...). The returned proxy address is what users transact against, and it is the address you bind policies to. Compliance lives outside the implementation contract. Deploy the implementation, then the PCL-wrapped proxy typescript
import { createWalletClient, http, encodeAbiParameters, encodeFunctionData } from "viem";
import { privateKeyToAccount } from "viem/accounts";
const PCL = "0x1000000000000000000000000000000000000005" as const;
const wallet = createWalletClient({
account: privateKeyToAccount(process.env.OWNER_KEY as `0x${string}`),
transport: http("https://rpc-testnet.maroo.io"),
});
// 1) deploy the stock OZ ERC20 implementation via your normal Hardhat / viem
// deployment. Suppose it lands at `implAddress`.
// TODO: replace with the real implementation address after deployment.
const implAddress = "0x8F3ac2B1d9E74c05A6B18FE27Dc4913e5A0F7b62";
// TODO: replace with the real proxy admin (multisig) before production.
const initialOwner = "0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B";
// 2) build initializer calldata for the implementation (if it uses OZ Initializable),
// otherwise pass "0x".
const initializer = "0x";
// 3) ABI-encode Transparent proxy initData: (logic, initialOwner, initializer)
const initData = encodeAbiParameters(
[
{ type: "address" },
{ type: "address" },
{ type: "bytes" },
],
[implAddress, initialOwner, initializer],
);
// 4) deploy the PCL-registered proxy. Its address is what users will call.
const proxyAddress = await wallet.writeContract({
address: PCL,
abi: pclAbi,
functionName: "deployPclProxy",
args: [1 /* Transparent */, 0n, initData],
}); Warning: Only calls that go through this PCL-registered proxy trigger the contract-scoped enforcement path. If a user calls the implementation address directly, only the global config applies — so publish the proxy address as the canonical token address.
4
Step 4 — Confirm the EAS_POLICY template is registered
PCL only knows about templates that have been registered on this network by the policy admin (a governance action — not something an external dApp does). Before you build a
PolicySet, sanity-check that the template you want to instantiate is actually live by reading IPcl.policyTemplate(templateId). If the template is registered the call returns its descriptor; if not, it reverts with PolicyTemplateNotFound. Check EAS_POLICY is live typescript
// Returns the PolicyTemplate struct if registered; reverts PolicyTemplateNotFound otherwise.
const template = await publicClient.readContract({
address: PCL,
abi: pclAbi,
functionName: "policyTemplate",
args: ["EAS_POLICY"],
});
console.log("templateId:", template.templateId); 5
Step 5 — Build the EAS_POLICY PolicySet
Encode the
EasPolicy struct from IPcl.sol ((address easContract, address indexContract, bytes32 schemaUid)) and wrap it in a PolicySet with templateId "EAS_POLICY". Encode the policy bytes typescript
import { encodeAbiParameters, toHex } from "viem";
const easPolicyBytes = encodeAbiParameters(
[
{ type: "address", name: "easContract" },
{ type: "address", name: "indexContract" },
{ type: "bytes32", name: "schemaUid" },
],
[EAS_ADDR, INDEX_ADDR, schemaUID],
);
const policySet = {
templateId: "EAS_POLICY",
policy: easPolicyBytes,
selector: toHex("", { size: 0 }), // empty bytes → applies to all calls
}; 6
Step 6 — Bind the ContractPolicyConfig to the proxy
changeContractPolicies is the sole entry point — it upserts, so the first call for proxyAddress creates the config and stores the payload's admin as the future gatekeeper. There is no separate registerContractPolicies method. Bind the PolicySet to the proxy address (that is the address transactions target). Bind the contract policy typescript
// First call for `proxyAddress` — msg.sender may be anyone; the payload's
// `admin` becomes the future gatekeeper for `changeContractPolicies` and
// `removeContractPolicies` on this proxy.
// TODO: replace with the real admin address before production.
const ownerAddress = "0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B";
await wallet.writeContract({
address: PCL,
abi: pclAbi,
functionName: "changeContractPolicies",
args: [{
_contract: proxyAddress,
admin: ownerAddress,
policies: [policySet],
}],
}); Note: To rotate the admin later, call
changeContractPolicies again from the current admin with a different admin value in the payload — the policy replacement and the admin handover happen atomically in the same call. 7
Step 7 — Verify rejection paths
From three different wallets, call
The revert payload is ABI-encoded with the PCL ReasonCode selector; decode it client-side for proper UX.
transfer on the proxy address:| Sender | Expected outcome |
|---|---|
issuer (no KYC attestation) | Reject — EasNoAttestationReceived |
kycUser (has attestation, valid) | Admit — transfer succeeds |
kycUser after eas.revoke(attUID) | Reject — EasAttestationRevoked |
The revert payload is ABI-encoded with the PCL ReasonCode selector; decode it client-side for proper UX.
Decode PCL ReasonCode from revert data typescript
import { decodeErrorResult, parseEther } from "viem";
try {
await wallet.writeContract({
address: proxyAddress,
abi: erc20Abi,
functionName: "transfer",
// TODO: replace with the real recipient before production.
args: ["0x5aB7c1e40b8dA46f9c7e29D3fA614e97b8f0Ac21", parseEther("10000000")],
});
} catch (err: any) {
const decoded = decodeErrorResult({ abi: pclAbi, data: err.data });
console.log("PCL ReasonCode:", decoded.errorName, decoded.args);
// e.g. EasNoAttestationReceived { sender: 0x... }
} Conclusion
Compliance is the chain's job, not the token's. The same PolicySet pattern composes — add a
DENYLIST_POLICY to block sanctioned addresses, or layer OKRW_EAS_TRANSFER_LIMIT_POLICY for a Travel-Rule cap of 10,000,000 OKRW. See pcl-policy-templates for the catalog and advanced-managing-contract-policies for change / remove flows.