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, attach a PCL 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.
  • Encode an EAS_POLICY parameter struct and register a contract-scoped PolicySet on the PCL precompile.
  • Deploy a stock OpenZeppelin ERC20 — no compliance code in the contract.
  • Verify that PCL blocks transfers from non-attested senders at the AnteHandler level.

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 attaching a Programmable Compliance Layer rule to a stock token. The flow has three real moving pieces: (1) get an EAS attestation issued to a wallet, (2) deploy a vanilla ERC20, (3) register a contract-scoped PolicySet on the PCL precompile that requires that attestation. The token contract never knows compliance exists; the AnteHandler enforces it before any transfer reaches the EVM.
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(MAROO_RPC) });
const { eas: EAS_ADDR, indexer: INDEX_ADDR, schemaRegistry: SCHEMA_REG } =
  await publicClient.readContract({
    address: EAS_PRECOMPILE,
    abi: easPrecompileAbi,
    functionName: "getParams",
  });
Tip: On the public testnet you can also hard-code the canonical preinstall addresses (EAS = `0x1000…0007`, Indexer = `0x1000…0008`) — but resolving via `getParams()` is portable.
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();
  console.log("schemaUID =", schemaUID);

  // 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,    // no expiry
      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 a stock ERC20

Use OpenZeppelin verbatim. Compliance lives outside this contract.
scripts/deploy.js javascript
const { ethers } = require("hardhat");
async function main() {
  const Token = await ethers.getContractFactory("MyToken"); // standard OZ ERC20
  const token = await Token.deploy();
  await token.waitForDeployment();
  console.log("token at", token.target);
}
main();
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 InvalidPolicyTemplate.
Check EAS_POLICY is live typescript
const PCL = "0x1000000000000000000000000000000000000005";

// Returns the PolicyTemplate struct if registered; reverts InvalidPolicyTemplate otherwise.
const template = await publicClient.readContract({
  address: PCL,
  abi: pclAbi,
  functionName: "policyTemplate",
  args: ["EAS_POLICY"],
});
console.log("templateId:", template.templateId);
console.log("name:",       template.name);
Note: If the call reverts, the template hasn't been registered on this network yet. That's a consortium-governance step (not an external-dapp action) — surface the error and contact the network operators rather than trying to register the template yourself.
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 — Register the ContractPolicyConfig on PCL

Submit IPcl.registerContractPolicies from the wallet you want to be admin of this rule. The token's PolicySets live under that contract address; the admin field is the only key allowed to call changeContractPolicies / removeContractPolicies later.
Register the contract policy typescript
const PCL = "0x1000000000000000000000000000000000000005";

await walletClient.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "registerContractPolicies",
  args: [{
    _contract: tokenAddress,
    admin:     ownerAddress,    // change/remove gatekeeper
    policies:  [policySet],
  }],
});
Warning: Use `registerContractPolicies` for the *first* registration only — it reverts if a config already exists. Use `changeContractPolicies` thereafter.
7

Step 7 — Verify rejection paths

From three different wallets, try a transfer:

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 } from "viem";

try {
  await walletClient.writeContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: "transfer",
    args: [recipient, 100n],
  });
} catch (err: any) {
  const decoded = decodeErrorResult({ abi: pclAbi, data: err.data });
  console.log("PCL ReasonCode:", decoded.errorName, decoded.args);
  // e.g. EasAttestationRequired { 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. See pcl-policy-templates for the catalog and advanced-managing-contract-policies for change/remove flows.
Source: maroo
ESC
Type to search