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, 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:
The revert payload is ABI-encoded with the PCL ReasonCode selector; decode it client-side for proper UX.
| 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 } 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.