Building a Compliant ERC20 Token with PCL — End-to-End

intermediate integration 45 min

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 transfer on the proxy address:

SenderExpected 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.
ESC
Type to search