PCL로 규제 준수 ERC20 토큰 만들기 — 처음부터 끝까지

intermediate integration 45 min

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

학습 목표

  • 마루 테스트넷 RPC에 대해 Hardhat 프로젝트를 설정합니다.
  • 정식 EAS 컨트랙트 preinstall로 KYC attestation을 발급합니다.
  • OpenZeppelin 표준 ERC20를 PCL 래핑 프록시 뒤에 배포하여 컨트랙트 범위 정책이 강제되도록 합니다.
  • `EAS_POLICY` 파라미터 struct를 인코딩하고 `changeContractPolicies`(유일한 등록 진입점)로 ContractPolicyConfig를 바인딩합니다.
  • 프록시의 preCall 훅을 통해 PCL이 미인증 발신자의 전송을 차단하는지 확인합니다.

사전 요구사항

  • 마루 테스트넷 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에 화이트리스트 코드를 작성하는 대신, 표준 토큰을 PCL 래핑 프록시 뒤에 배포하고 프로그래머블 컴플라이언스 레이어 규칙을 붙이는 방식입니다. 실제 구성 요소는 네 가지입니다. (1) 어떤 지갑에 EAS attestation을 발급합니다. (2) 평범한 ERC20 구현체를 PCL 등록 프록시 뒤에 배포하여 프록시의 preCall/postCall 훅이 ContractPolicyConfig를 강제하게 합니다. (3) changeContractPolicies를 통해 EAS_POLICY PolicySet을 프록시 주소에 바인딩합니다. (4) 거절 동작을 검증합니다. 토큰 컨트랙트는 컴플라이언스의 존재를 알 필요가 없으며, 프록시 훅이 어떤 전송 상태 변경이 커밋되기 전에 강제합니다.
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("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

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

  // 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 구현체를 PCL 래핑 프록시 뒤에 배포

OpenZeppelin 표준 ERC20 구현체를 배포한 뒤 IPcl.deployPclProxy(...)로 래핑합니다. 반환된 프록시 주소가 사용자가 트랜잭션을 보내는 주소이며, 정책을 바인딩할 주소이기도 합니다. 컴플라이언스는 구현 컨트랙트 외부에 존재합니다.
구현체 배포 후 PCL 래핑 프록시 배포 typescript
import { createWalletClient, http, encodeAbiParameters } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const PCL = "0x1000000000000000000000000000000000000005" as const;

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

// 1) 평소 Hardhat / viem 배포로 표준 OZ ERC20 구현체를 배포합니다.
//    `implAddress`에 배포되었다고 가정합니다.
// TODO: 배포 후 실제 구현체 주소로 교체하십시오.
const implAddress = "0x8F3ac2B1d9E74c05A6B18FE27Dc4913e5A0F7b62";
// TODO: 프로덕션 배포 전 실제 프록시 admin(멀티시그)으로 교체하십시오.
const initialOwner = "0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B";

// 2) 구현체가 OZ Initializable을 쓰면 initializer calldata를 만들고,
//    아니면 "0x"를 전달합니다.
const initializer = "0x";

// 3) Transparent 프록시 initData를 ABI 인코딩합니다: (logic, initialOwner, initializer)
const initData = encodeAbiParameters(
  [
    { type: "address" },
    { type: "address" },
    { type: "bytes" },
  ],
  [implAddress, initialOwner, initializer],
);

// 4) PCL 등록 프록시를 배포합니다. 이 주소가 사용자가 호출할 주소입니다.
const proxyAddress = await wallet.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "deployPclProxy",
  args: [1 /* Transparent */, 0n, initData],
});
주의: 이 PCL 등록 프록시를 경유하는 호출만 컨트랙트 범위 강제 경로를 발동시킵니다. 사용자가 구현체 주소를 직접 호출하면 전역 설정만 적용되므로, 프록시 주소를 표준 토큰 주소로 공개하십시오.
4

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

PCL은 정책 admin이 이 네트워크에 등록한 템플릿만 인식합니다(거버넌스 작업이며 외부 dApp이 수행하는 일이 아닙니다). PolicySet을 빌드하기 전에 인스턴스화하려는 템플릿이 실제로 활성 상태인지 IPcl.policyTemplate(templateId)로 확인합니다. 등록되어 있으면 디스크립터를 반환하고, 아니면 PolicyTemplateNotFound로 revert됩니다.
EAS_POLICY 활성 확인 typescript
// 등록되어 있으면 PolicyTemplate struct 반환; 아니면 PolicyTemplateNotFound revert.
const template = await publicClient.readContract({
  address: PCL,
  abi: pclAbi,
  functionName: "policyTemplate",
  args: ["EAS_POLICY"],
});
console.log("templateId:", template.templateId);
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단계 — 프록시에 ContractPolicyConfig 바인딩

changeContractPolicies가 유일한 진입점이며 upsert 방식으로 동작합니다. proxyAddress에 대한 최초 호출은 설정을 생성하고, 페이로드의 admin을 향후 게이트키퍼로 저장합니다. 별도의 registerContractPolicies 메서드는 없습니다. PolicySet은 트랜잭션이 향하는 주소인 프록시 주소에 바인딩합니다.
컨트랙트 정책 바인딩 typescript
// `proxyAddress`에 대한 최초 호출입니다. msg.sender는 누구든 가능하며,
// 페이로드의 `admin`이 이 프록시에 대한 향후 `changeContractPolicies` /
// `removeContractPolicies` 호출의 게이트키퍼가 됩니다.
// TODO: 프로덕션 배포 전 실제 admin 주소로 교체하십시오.
const ownerAddress = "0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B";

await wallet.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "changeContractPolicies",
  args: [{
    _contract: proxyAddress,
    admin:     ownerAddress,
    policies:  [policySet],
  }],
});
참고: 이후에 admin을 회전하려면 현재 admin이 페이로드의 admin 값을 다른 주소로 바꾸어 changeContractPolicies를 다시 호출합니다. 정책 교체와 admin 인계가 한 호출에서 원자적으로 이뤄집니다.
7

7단계 — 거절 경로 검증

세 개의 다른 지갑에서 프록시 주소로 transfer를 호출합니다.

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

revert 페이로드는 PCL ReasonCode selector로 ABI 인코딩되어 있으므로, 사용자 경험을 위해 클라이언트에서 디코드합니다.
revert 데이터에서 PCL ReasonCode 디코드 typescript
import { decodeErrorResult, parseEther } from "viem";

try {
  await wallet.writeContract({
    address: proxyAddress,
    abi: erc20Abi,
    functionName: "transfer",
    // TODO: 프로덕션 배포 전 실제 수신자 주소로 교체하십시오.
    args: ["0x5aB7c1e40b8dA46f9c7e29D3fA614e97b8f0Ac21", parseEther("10000000")],
  });
} catch (err: any) {
  const decoded = decodeErrorResult({ abi: pclAbi, data: err.data });
  console.log("PCL ReasonCode:", decoded.errorName, decoded.args);
  // 예: EasNoAttestationReceived { sender: 0x... }
}

마무리

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