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

intermediate integration 45 min

처음부터 끝까지 따라가는 워크스루입니다. EAS로 KYC attestation을 발급해 인덱싱하고, 컴플라이언스 코드가 없는 일반 ERC20 구현체(initialize 함수로 초기화)를 PCL 래핑 프록시 뒤에 배포한 뒤, attestation을 요구하는 ContractPolicyConfig를 부착해 거절 동작을 확인합니다.

학습 목표

  • ✓마루 테스트넷 RPC에 대해 Hardhat 프로젝트를 설정합니다.
  • ✓정식 EAS 컨트랙트 preinstall로 KYC attestation을 발급하고, PCL이 찾을 수 있도록 인덱싱합니다.
  • ✓OpenZeppelin으로 만들고 initialize 함수로 초기화하는 일반 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을 발급합니다. EAS_POLICY는 프록시를 거쳐 호출을 보내는 계정을 검사합니다. 그다음 attestation을 인덱싱합니다. PCL은 EAS Indexer(1단계의 INDEX_ADDR)를 통해서만 attestation을 찾으며, 일반 attest만으로는 인덱싱되지 않습니다. 이 과정을 빠뜨리면 attestation을 받은 지갑도 EasNoAttestationReceived로 거절됩니다. indexAttestation은 누구나 호출할 수 있으므로 issuer가 제출하면 됩니다. 스키마의 resolver가 attestation을 만들 때 이미 인덱싱한다면 isAttestationIndexed(attUID)가 true를 반환하므로 이 과정을 건너뛰어도 됩니다.

스키마 등록, 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);

  // 3) 인덱싱합니다. PCL의 EAS_POLICY는 EAS Indexer에서만 attestation을 조회합니다
  const indexer = new ethers.Contract(INDEX_ADDR, [
    "function indexAttestation(bytes32 attestationUID)",
    "function isAttestationIndexed(bytes32 attestationUID) view returns (bool)",
  ], issuer);
  await (await indexer.indexAttestation(attUID)).wait();
  console.log("indexed:", await indexer.isAttestationIndexed(attUID));
}
main().catch(console.error);
참고: 프로덕션 네트워크에서는 보통 KYC를 지정된 issuer(규제된 KYC 파트너, 마켓메이커 데스크 등)에게 위임합니다. 자체 스키마 등록 대신 그들의 schema UID를 사용합니다.
3

3단계 — ERC20 구현체를 PCL 래핑 프록시 뒤에 배포

토큰 구현체를 배포한 뒤 IPcl.deployPclProxy(...)로 래핑합니다. 구현체는 컴플라이언스 코드가 없는 일반 ERC20이며, 생성자 대신 initialize 함수로 초기화합니다. 프록시 뒤에서는 생성자가 프록시의 스토리지에서 실행되지 않기 때문입니다. OpenZeppelin의 ERC20Upgradeable은 추상 컨트랙트이고 자체 initialize가 없으므로 직접 작성합니다. 아래 최소 예시는 @openzeppelin/contracts만 사용합니다(업그레이드 가능 패키지를 쓴다면 ERC20Upgradeable을 상속하고 initialize 안에서 __ERC20_init을 호출합니다). 프록시가 배포 중에 이 initializer를 실행하므로 비워 둘 수 없으며, "0x"를 넘기면 배포가 revert됩니다. 여기서는 초기 공급량을 2단계에서 attestation을 발급한 지갑에 발행해, 7단계에서 옮길 잔액이 있도록 합니다. writeContract는 트랜잭션 해시를 반환하므로 새 프록시 주소는 영수증의 PclProxyDeployed 이벤트에서 읽습니다. 이 프록시 주소가 사용자가 트랜잭션을 보내는 주소이자 정책을 바인딩할 주소이며, 컴플라이언스는 구현 컨트랙트 외부에 존재합니다.

최소 구현체: initialize()로 초기화하는 일반 ERC20

contracts/MyToken.sol solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

/// PCL 프록시 뒤에 놓이는 토큰 로직입니다. 컴플라이언스 코드가 없는 일반 ERC20입니다.
/// 프록시 뒤에서는 생성자가 프록시의 스토리지에서 실행되지 않으므로, 이름·심볼·초기 공급량은
/// 프록시가 배포되는 동안 한 번 호출하는 `initialize`에서 설정합니다.
contract MyToken is ERC20, Initializable {
    string private _tokenName;
    string private _tokenSymbol;

    constructor() ERC20("", "") {
        _disableInitializers(); // 구현 컨트랙트 자체는 초기화하지 않습니다
    }

    function initialize(address holder) external initializer {
        _tokenName = "My Token";
        _tokenSymbol = "MYT";
        _mint(holder, 1_000_000 ether);
    }

    function name() public view override returns (string memory) {
        return _tokenName;
    }

    function symbol() public view override returns (string memory) {
        return _tokenSymbol;
    }
}
구현체 배포 후 PCL 래핑 프록시 배포 typescript
import { createWalletClient, http, encodeAbiParameters, encodeFunctionData, parseEventLogs } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { iPclAbi as pclAbi } from "@maroo-chain/contracts/abi/precompiles/pcl/IPcl";

const PCL = "0x1000000000000000000000000000000000000005" as const;

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

// 1) 평소 Hardhat / viem 배포로 구현체(위의 MyToken)를 배포합니다.
//    `implAddress`에 배포되었다고 가정합니다.
// TODO: 배포 후 실제 구현체 주소로 교체하십시오.
const implAddress = "0x8F3ac2B1d9E74c05A6B18FE27Dc4913e5A0F7b62";
// TODO: 프로덕션 배포 전 실제 업그레이드 admin(멀티시그)으로 교체하십시오.
const initialOwner = "0x2c7f09B81a6D3FF1e5A0d4c6bC2a8f7E19dc3a4B";
// TODO: 2단계에서 attestation을 발급한 지갑(`kycUser`)으로 교체하십시오.
const kycUser = "0x3a9C71E4B8D2F6A05C7e19B4d3F82a6C5e0B9d47";

// 2) initializer calldata를 만듭니다. initialize(kycUser)는 초기 공급량을
//    attestation을 받은 지갑에 발행합니다. 프록시가 배포 중에 이 calldata를
//    실행하므로 초기화된 상태는 프록시에 저장됩니다. 비워 둘 수 없습니다.
//    "0x"를 넘기면 프록시 생성자가 revert되고 deployPclProxy도 revert됩니다.
const implAbi = [
  { type: "function", name: "initialize", stateMutability: "nonpayable",
    inputs: [{ name: "holder", type: "address" }], outputs: [] },
] as const;
const initializer = encodeFunctionData({
  abi: implAbi,
  functionName: "initialize",
  args: [kycUser],
});

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

// 4) PCL 등록 프록시를 배포합니다. writeContract는 트랜잭션 해시를 반환하므로
//    프록시 주소는 영수증의 PclProxyDeployed 이벤트에서 읽습니다.
const hash = await wallet.writeContract({
  address: PCL,
  abi: pclAbi,
  functionName: "deployPclProxy",
  args: [1 /* Transparent */, 0n, initData],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
const [deployed] = parseEventLogs({ abi: pclAbi, eventName: "PclProxyDeployed", logs: receipt.logs });
if (!deployed) throw new Error(`deployPclProxy did not deploy a proxy (status: ${receipt.status})`);
// 사용자가 호출할 주소이자 정책을 바인딩할 주소입니다.
const proxyAddress = deployed.args.proxy;
주의: 이 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.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단계 — 프록시에 ContractPolicyConfig 바인딩

changeContractPolicies가 유일한 진입점이며, 별도의 registerContractPolicies 메서드는 없습니다. deployPclProxy가 이미 그 호출을 보낸 계정(3단계의 wallet)을 이 프록시의 정책 admin으로 정했으므로, 이 호출도 그 지갑에서 보내야 합니다. 다른 발신자는 Unauthorized로 거절됩니다. 페이로드의 admin은 다음에 이 정책을 변경하거나 제거할 수 있는 주소입니다. 권한을 유지하려면 자신의 주소를, 넘기려면 다른 주소(멀티시그 등)를 지정하며, 인계는 같은 호출에서 이뤄집니다. PolicySet은 트랜잭션이 향하는 주소인 프록시 주소에 바인딩합니다.
컨트랙트 정책 바인딩 typescript
// 3단계에서 deployPclProxy를 호출한 지갑(`wallet`)에서 보냅니다.
// deployPclProxy가 그 지갑을 이 프록시의 정책 admin으로 정했으므로, 다른 발신자는
// `Unauthorized`로 거절됩니다. 페이로드의 `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 인코딩되어 있으므로, 사용자 경험을 위해 클라이언트에서 디코드합니다. 아래 코드의 wallet은 3단계의 소유자 지갑이며 attestation이 없으므로 첫 번째 행의 오류가 나타납니다. 나머지 두 행은 같은 호출을 3단계에서 발행한 공급량을 가진 kycUser 지갑에서 보내 확인합니다.
revert 데이터에서 PCL ReasonCode 디코드 typescript
import { BaseError, ContractFunctionRevertedError, decodeErrorResult, erc20Abi, parseEther } from "viem";

try {
  await wallet.writeContract({
    address: proxyAddress,
    abi: erc20Abi,
    functionName: "transfer",
    // TODO: 프로덕션 배포 전 실제 수신자 주소로 교체하십시오.
    args: ["0x5AB7c1e40b8DA46F9C7E29d3fA614e97b8F0ac21", parseEther("100")],
  });
} catch (err) {
  // viem은 revert를 감싸서 던집니다. 원본 revert 데이터는 던져진 오류(`data` 없음)가
  // 아니라 cause 체인의 ContractFunctionRevertedError에 있습니다.
  const reverted = err instanceof BaseError
    ? err.walk((e) => e instanceof ContractFunctionRevertedError)
    : null;
  if (!(reverted instanceof ContractFunctionRevertedError) || !reverted.raw) throw err;
  const decoded = decodeErrorResult({ abi: pclAbi, data: reverted.raw });
  console.log("PCL ReasonCode:", decoded.errorName, decoded.args);
  // 예: 거부된 발신자 주소를 담은 EasNoAttestationReceived
}

마무리

컴플라이언스는 체인이 담당할 영역이지 토큰이 담당할 영역이 아닙니다. 같은 호출에 검사를 더하려면(제재 주소를 차단하는 DENYLIST_POLICY, 건당 상한을 두는 VOLUME_POLICY 등) 템플릿이 LOGICAL_POLICY(And)인 PolicySet 하나로 EAS_POLICY와 함께 묶습니다. 같은 selector에 PolicySet 항목을 따로 두 개 두면 거절됩니다(pcl-composite-policies 참고). 카탈로그는 pcl-policy-templates, 변경·제거 흐름은 advanced-managing-contract-policies 페이지에서 확인할 수 있습니다.
ESC
검색어를 입력하세요