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

intermediate integration 45 min

An end-to-end walk-through of a compliant token on Maroo. You issue a KYC attestation via EAS and index it, deploy a plain ERC20 implementation (no compliance code, set up by an initialize function) behind a PCL-wrapped proxy, attach a ContractPolicyConfig that requires the attestation, then verify the 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, and index it so PCL can find it.
  • ✓Deploy a plain ERC20 implementation, built on OpenZeppelin and set up by an initialize function, 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 plain 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 and indexed, (2) deploy a plain 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 will use your token — EAS_POLICY checks the account that sends each call through the proxy. Then index the attestation. PCL finds attestations only through the EAS Indexer (INDEX_ADDR from Step 1), and a plain attest does not index itself, so without this the attested wallet is still rejected with EasNoAttestationReceived. indexAttestation is open to any caller, so the issuer can submit it. If your schema's resolver already indexes attestations as they are made, isAttestationIndexed(attUID) returns true and you can skip it.

Register schema, issue attestation, index it

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);

  // 3) index it — PCL's EAS_POLICY looks attestations up in the EAS Indexer only
  const indexer = new ethers.Contract(INDEX_ADDR, [
    "function indexAttestation(bytes32 attestationUID)",
    "function isAttestationIndexed(bytes32 attestationUID) view returns (bool)",
  ], issuer);
  await (await indexer.indexAttestation(attUID)).wait();
  console.log("indexed:", await indexer.isAttestationIndexed(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 your token's implementation, then wrap it with IPcl.deployPclProxy(...). The implementation is a plain ERC20 with no compliance code, set up by an initialize function instead of a constructor, because behind a proxy the constructor never runs in the proxy's storage. OpenZeppelin's ERC20Upgradeable is abstract and has no initialize of its own, so you write one: the minimal contract below uses only @openzeppelin/contracts (on the upgradeable package, inherit ERC20Upgradeable and call __ERC20_init inside initialize). The proxy runs the initializer during deployment, so it must not be empty — with "0x" the deployment reverts. Here it mints the initial supply to the wallet you attested in Step 2, so Step 7 has a balance to move. writeContract returns the transaction hash, so read the new proxy's address from the PclProxyDeployed event in the receipt. That proxy address is what users transact against, and the address you bind policies to; compliance lives outside the implementation contract.

A minimal implementation: a plain ERC20 set up by initialize()

contracts/MyToken.sol solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

/// The token logic behind the PCL proxy: a plain ERC20 with no compliance code.
/// Behind a proxy the constructor never runs in the proxy's storage, so the name,
/// the symbol and the initial supply are set by `initialize`, which the proxy
/// calls once while it is being deployed.
contract MyToken is ERC20, Initializable {
    string private _tokenName;
    string private _tokenSymbol;

    constructor() ERC20("", "") {
        _disableInitializers(); // the implementation contract itself is never initialized
    }

    function initialize(address holder) external initializer {
        _tokenName = "My Token";
        _tokenSymbol = "MYT";
        _mint(holder, 1_000_000 ether);
    }

    function name() public view override returns (string memory) {
        return _tokenName;
    }

    function symbol() public view override returns (string memory) {
        return _tokenSymbol;
    }
}
Deploy the implementation, then the PCL-wrapped proxy typescript
import { createWalletClient, http, encodeAbiParameters, encodeFunctionData, parseEventLogs } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { iPclAbi as pclAbi } from "@maroo-chain/contracts/abi/precompiles/pcl/IPcl";

const PCL = "0x1000000000000000000000000000000000000005" as const;

const owner = privateKeyToAccount(process.env.OWNER_KEY as `0x${string}`);
const wallet = createWalletClient({
  account: owner,
  transport: http("https://rpc-testnet.maroo.io"),
});

// 1) deploy the implementation (MyToken above) 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 upgrade admin (multisig) before production.
const initialOwner = "0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B";
// TODO: replace with the wallet you attested in Step 2 (`kycUser`).
const kycUser = "0x3a9C71E4B8D2F6A05C7e19B4d3F82a6C5e0B9d47";

// 2) build the initializer calldata: initialize(kycUser) mints the initial supply
//    to the attested wallet. The proxy runs it during deployment, so the
//    initialized state lives in the proxy. It must not be empty: with "0x" the
//    proxy constructor reverts, and so does deployPclProxy.
const implAbi = [
  { type: "function", name: "initialize", stateMutability: "nonpayable",
    inputs: [{ name: "holder", type: "address" }], outputs: [] },
] as const;
const initializer = encodeFunctionData({
  abi: implAbi,
  functionName: "initialize",
  args: [kycUser],
});

// 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. writeContract returns the transaction hash;
//    the proxy address comes from the PclProxyDeployed event in the receipt.
const hash = await wallet.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "deployPclProxy",
  args: [1 /* Transparent */, 0n, initData],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
const [deployed] = parseEventLogs({ abi: pclAbi, eventName: "PclProxyDeployed", logs: receipt.logs });
if (!deployed) throw new Error(`deployPclProxy did not deploy a proxy (status: ${receipt.status})`);
// The proxy address is what users will call, and what you bind policies to.
const proxyAddress = deployed.args.proxy;
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 — there is no separate registerContractPolicies method. deployPclProxy already made the account that called it (your wallet from Step 3) this proxy's policy admin, so this call must come from that wallet; any other sender gets Unauthorized. The payload's admin is who may change or remove these policies next: pass your own address to keep control, or another one (a multisig) to hand it over in the same call. Bind the PolicySet to the proxy address (that is the address transactions target).
Bind the contract policy typescript
// Send this from the wallet that called deployPclProxy in Step 3 (`wallet`):
// deployPclProxy made it this proxy's policy admin, and any other sender gets
// `Unauthorized`. The payload's `admin` is who may call `changeContractPolicies`
// and `removeContractPolicies` on this proxy next.
// 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. wallet in the code below is the Step 3 owner wallet, which has no attestation, so the call shows the first row's error; send the same call from the kycUser wallet, which holds the supply minted in Step 3, for the other two rows.
Decode PCL ReasonCode from revert data typescript
import { BaseError, ContractFunctionRevertedError, decodeErrorResult, erc20Abi, parseEther } from "viem";

try {
  await wallet.writeContract({
    address: proxyAddress,
    abi: erc20Abi,
    functionName: "transfer",
    // TODO: replace with the real recipient before production.
    args: ["0x5AB7c1e40b8DA46F9C7E29d3fA614e97b8F0ac21", parseEther("100")],
  });
} catch (err) {
  // viem wraps the revert: its raw data is on the ContractFunctionRevertedError
  // in the cause chain, not on the thrown error (which has no `data`).
  const reverted = err instanceof BaseError
    ? err.walk((e) => e instanceof ContractFunctionRevertedError)
    : null;
  if (!(reverted instanceof ContractFunctionRevertedError) || !reverted.raw) throw err;
  const decoded = decodeErrorResult({ abi: pclAbi, data: reverted.raw });
  console.log("PCL ReasonCode:", decoded.errorName, decoded.args);
  // e.g. EasNoAttestationReceived, with the rejected sender's address
}

Conclusion

Compliance is the chain's job, not the token's. To add more checks to the same calls — a DENYLIST_POLICY that blocks sanctioned addresses, a VOLUME_POLICY cap per transaction — combine them with the EAS_POLICY in one PolicySet whose template is LOGICAL_POLICY (And): two separate PolicySet entries for the same selector are rejected (see pcl-composite-policies). See pcl-policy-templates for the catalog and advanced-managing-contract-policies for change / remove flows.
ESC
Type to search