PCL을 사용한 규제 준수 ERC20 토큰 만들기 — 처음부터 끝까지

intermediate integration 45 min

처음부터 끝까지 따라가는 워크스루입니다. EAS로 KYC attestation을 발급하고, 표준 ERC20을 배포한 뒤, attestation을 요구하는 PCL ContractPolicyConfig를 부착해 거절 동작을 확인합니다.

학습 목표

  • 마루 테스트넷 RPC에 대해 Hardhat 프로젝트를 설정합니다.
  • 정식 EAS 컨트랙트 preinstall로 KYC attestation을 발급합니다.
  • EAS_POLICY 파라미터 struct를 인코딩하고 PCL 프리컴파일에 컨트랙트 범위 PolicySet을 등록합니다.
  • OpenZeppelin 표준 ERC20를 배포합니다 — 컨트랙트 내 컴플라이언스 코드는 없습니다.
  • PCL이 미인증 발신자의 전송을 AnteHandler 레벨에서 차단하는지 확인합니다.

사전 요구사항

  • 마루 테스트넷 RPC에 접근 가능하고 자금이 충전된 계정을 보유하고 있습니다(가스용).
  • Solidity와 ERC20에 대한 기본 지식을 갖추고 있습니다.
  • 선택한 스키마로 attestation을 발급할 수 있는 주소(issuer key)를 보유하고 있습니다.

필요 도구

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
이제 규제 준수 ERC20을 만듭니다. Solidity에 화이트리스트 코드를 작성하는 대신, 표준 토큰에 프로그래머블 컴플라이언스 레이어 규칙을 붙이는 방식입니다. 실제 구성 요소는 세 가지입니다. (1) 어떤 지갑에 EAS attestation을 발급하고, (2) 평범한 ERC20을 배포한 다음, (3) 그 attestation을 요구하는 컨트랙트 범위 PolicySet을 PCL 프리컴파일에 등록합니다. 토큰 컨트랙트는 컴플라이언스의 존재를 알 필요가 없으며, 어떤 전송이든 EVM에 도달하기 전에 AnteHandler가 강제합니다.
1

1단계 — EAS / Indexer 주소 해결

마루에서 EAS와 Indexer는 preinstall입니다(eas-precompile-overview 참고). EAS 프리컴파일이 정식 주소를 반환하는 getParams() view를 제공하므로, 동일한 스크립트가 testnet과 mainnet 모두에서 그대로 동작합니다.
EAS 프리컴파일로 EAS 주소 해결 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",
  });
팁: 공개 테스트넷에서는 정식 preinstall 주소(EAS = `0x1000…0007`, Indexer = `0x1000…0008`)를 하드코딩해도 됩니다. 다만 `getParams()`로 가져오는 편이 이식성이 좋습니다.
2

2단계 — EAS로 KYC attestation 발급

간단한 스키마를 등록하고(네트워크에 이미 있는 스키마를 사용해도 됩니다), 토큰을 받을 수 있어야 하는 지갑에 attestation을 발급합니다.
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) 스키마 등록
  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) kycUser에게 attestation 발급
  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);
참고: 프로덕션 네트워크에서는 보통 KYC를 권한 있는 issuer(규제된 KYC 파트너, 마켓메이커 데스크 등)에게 위임합니다. 자체 스키마 등록 대신 그들의 schema UID를 사용합니다.
3

3단계 — 표준 ERC20 배포

OpenZeppelin을 그대로 사용합니다. 컴플라이언스는 이 컨트랙트 외부에 존재합니다.
scripts/deploy.js javascript
const { ethers } = require("hardhat");
async function main() {
  const Token = await ethers.getContractFactory("MyToken"); // 표준 OZ ERC20
  const token = await Token.deploy();
  await token.waitForDeployment();
  console.log("token at", token.target);
}
main();
4

4단계 — EAS_POLICY 템플릿 등록 여부 확인

PCL은 정책 admin이 이 네트워크에 등록한 템플릿만 인식합니다(거버넌스 작업이며 외부 dApp이 수행하는 일이 아닙니다). PolicySet을 빌드하기 전에 인스턴스화하려는 템플릿이 실제로 활성 상태인지 IPcl.policyTemplate(templateId)로 확인합니다. 등록되어 있으면 디스크립터를 반환하고, 아니면 InvalidPolicyTemplate로 revert됩니다.
EAS_POLICY 활성 확인 typescript
const PCL = "0x1000000000000000000000000000000000000005";

// 등록되어 있으면 PolicyTemplate struct 반환; 아니면 InvalidPolicyTemplate revert.
const template = await publicClient.readContract({
  address: PCL,
  abi: pclAbi,
  functionName: "policyTemplate",
  args: ["EAS_POLICY"],
});
console.log("templateId:", template.templateId);
console.log("name:",       template.name);
참고: 호출이 revert하면 이 네트워크에 템플릿이 아직 등록되지 않았다는 의미입니다. 거버넌스 단계(외부 dApp이 수행하지 않습니다)이므로 직접 등록하지 말고 오류를 표시한 뒤 네트워크 운영자에게 연락합니다.
5

5단계 — EAS_POLICY PolicySet 빌드

IPcl.solEasPolicy struct((address easContract, address indexContract, bytes32 schemaUid))를 인코딩하고 templateId "EAS_POLICY"PolicySet을 래핑합니다.
정책 바이트 인코딩 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 → 모든 호출에 적용
};
6

6단계 — PCL에 ContractPolicyConfig 등록

이 규칙의 admin이 되어야 할 지갑에서 IPcl.registerContractPolicies를 제출합니다. 토큰의 PolicySet들은 그 컨트랙트 주소 아래 저장되며, admin 필드는 향후 changeContractPolicies / removeContractPolicies를 호출할 수 있는 유일한 키입니다.
컨트랙트 정책 등록 typescript
const PCL = "0x1000000000000000000000000000000000000005";

await walletClient.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "registerContractPolicies",
  args: [{
    _contract: tokenAddress,
    admin:     ownerAddress,    // change/remove 게이트키퍼
    policies:  [policySet],
  }],
});
주의: `registerContractPolicies`는 *최초* 등록에만 사용합니다. 이미 설정이 있으면 revert되며, 그 이후에는 `changeContractPolicies`를 사용합니다.
7

7단계 — 거절 경로 검증

세 개의 다른 지갑에서 전송을 시도합니다.

발신자예상 결과
issuer (KYC attestation 없음)거절 — EasNoAttestationReceived
kycUser (유효 attestation 보유)입장 — 전송 성공
kycUser (eas.revoke(attUID) 후)거절 — EasAttestationRevoked

revert 페이로드는 PCL ReasonCode selector로 ABI 인코딩되어 있으므로, 사용자 경험을 위해 클라이언트에서 디코드합니다.
revert 데이터에서 PCL ReasonCode 디코드 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);
  // 예: EasAttestationRequired { sender: 0x... }
}

마무리

컴플라이언스는 체인이 담당할 영역이지 토큰이 담당할 영역이 아닙니다. 동일한 PolicySet 패턴을 조합할 수 있어 — 제재 주소 차단을 위해 DENYLIST_POLICY를 추가하거나 Travel-Rule 상한을 위해 OKRW_EAS_TRANSFER_LIMIT_POLICY를 계층화할 수 있습니다. 카탈로그는 pcl-policy-templates, change/remove 흐름은 advanced-managing-contract-policies 페이지에서 확인할 수 있습니다.
소스: maroo
ESC
검색어를 입력하세요