PCL을 사용한 규제 준수 ERC20 토큰 만들기 — 처음부터 끝까지
처음부터 끝까지 따라가는 워크스루입니다. 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.sol의 EasPolicy 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단계 — 거절 경로 검증
세 개의 다른 지갑에서 전송을 시도합니다.
revert 페이로드는 PCL ReasonCode selector로 ABI 인코딩되어 있으므로, 사용자 경험을 위해 클라이언트에서 디코드합니다.
| 발신자 | 예상 결과 |
|---|---|
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 페이지에서 확인할 수 있습니다.