OKRW 프리컴파일 오류 처리하기

integration intermediate

OKRW 프리컴파일은 문서화된 모든 mint 실패를 안정적인 4바이트 selector가 붙은 커스텀 오류로 revert합니다. 이 가이드는 UnauthorizedMinter, InvalidAddress, InvalidAmount selector를 Solidity 측과 클라이언트(viem / ethers v6) 측에서 각각 디코드하는 방법을 안내합니다.

IOkrw.mint(address, uint256)은 문서화된 세 가지 revert 경로와 흔치 않은 fallback 하나를 가집니다. 세 문서화 경로 모두 커스텀 오류로 반환되므로, dApp이 revert 문자열을 파싱하지 않고 4바이트 selector만으로 분기할 수 있습니다.

Selector발생 조건인자
UnauthorizedMintermsg.sender가 지정된 발행자가 아닐 때(address caller, address authorizedMinter)
InvalidAddressrecipient == 0x0 (또는 호출자를 정규화할 수 없을 때)(address addr)
InvalidAmountamount == 0(uint256 amount)
일반 revert (타입 없음)amount == nil 이거나 ABI로 주입된 음수 big.Int (Solidity uint256으로는 표현 불가)

아래 예시는 @maroo-chain/contracts/precompiles/okrw/IOkrw.sol 인터페이스 또는 그에 대응하는 TypeScript ABI를 사용할 수 있다고 가정합니다.

사전 요구사항

  • Solidity try/catch에 대한 기본 지식.
  • JavaScript try/catch와 Ethers.js에 익숙할 것.

Solidity에서 selector 디코드

Solidity의 try/catch는 외부 호출 revert 페이로드 전체를 catch (bytes memory raw) 분기로 전달합니다. 앞의 4바이트가 selector이므로, IOkrw.<Error>.selector와 비교하고 필요하면 남은 바이트를 ABI 디코드합니다.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.18;

import "@maroo-chain/contracts/precompiles/okrw/IOkrw.sol";

contract MintGateway {
    IOkrw constant okrw = IOkrw(0x1000000000000000000000000000000000000001);

    error MintRejected(bytes4 selector);

    function tryMint(address to, uint256 amount) external returns (bool) {
        try okrw.mint(to, amount) returns (bool ok) {
            return ok;
        } catch (bytes memory raw) {
            bytes4 sel;
            assembly { sel := mload(add(raw, 32)) }

            if (sel == IOkrw.UnauthorizedMinter.selector) {
                // (caller, authorizedMinter) 인자를 필요 시 디코드
                bytes memory tail = new bytes(raw.length - 4);
                for (uint256 i = 0; i < tail.length; ++i) tail[i] = raw[i + 4];
                (address caller, address authorized) = abi.decode(tail, (address, address));
                // 로깅, 알림 등 처리
                revert MintRejected(sel);
            }
            if (sel == IOkrw.InvalidAddress.selector) revert MintRejected(sel);
            if (sel == IOkrw.InvalidAmount.selector)  revert MintRejected(sel);

            // 알 수 없는 revert (드묾: 음수 big.Int 또는 ABI 레벨 실패)는 그대로 전파
            assembly { revert(add(raw, 32), mload(raw)) }
        }
    }
}
참고: bytes memory 슬라이싱은 최신 Solidity 초안에 추가되었지만 안정 릴리스에는 아직 없습니다. 위와 같이 수동 복사 루프로 4바이트 selector를 잘라내고 abi.decode를 호출하는 방식이 이식성이 좋습니다.

viem으로 클라이언트에서 디코드

viem의 decodeErrorResult는 구분 가능한 errorName과 타입이 붙은 args를 함께 반환하므로, 클라이언트 코드가 바이트를 직접 다루지 않고도 selector로 분기할 수 있습니다.
import { createWalletClient, http, parseEther, decodeErrorResult, BaseError, ContractFunctionRevertedError } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const OKRW = "0x1000000000000000000000000000000000000001" as const;
const okrwAbi = [
  { type: "function", name: "mint", stateMutability: "nonpayable",
    inputs: [{ name: "recipient", type: "address" }, { name: "amount", type: "uint256" }],
    outputs: [{ type: "bool" }] },
  { type: "error", name: "UnauthorizedMinter",
    inputs: [{ name: "caller", type: "address" }, { name: "authorizedMinter", type: "address" }] },
  { type: "error", name: "InvalidAddress", inputs: [{ name: "addr", type: "address" }] },
  { type: "error", name: "InvalidAmount", inputs: [{ name: "amount", type: "uint256" }] },
] as const;

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

try {
  await wallet.writeContract({
    address: OKRW,
    abi: okrwAbi,
    functionName: "mint",
    // TODO: 프로덕션 전에 실제 수신자 주소로 교체합니다.
    args: ["0x1111111111111111111111111111111111111111", parseEther("10000000")],
  });
} catch (err) {
  if (err instanceof BaseError) {
    const rev = err.walk((e) => e instanceof ContractFunctionRevertedError);
    if (rev instanceof ContractFunctionRevertedError && rev.data) {
      const decoded = decodeErrorResult({ abi: okrwAbi, data: rev.data.data ?? "0x" });
      switch (decoded.errorName) {
        case "UnauthorizedMinter": {
          const [caller, authorized] = decoded.args ?? [];
          console.error(`OKRW.mint: 호출자 ${caller}는 지정된 발행자 ${authorized}가 아닙니다`);
          break;
        }
        case "InvalidAddress":
          console.error("OKRW.mint: 수신자가 0 주소입니다.");
          break;
        case "InvalidAmount":
          console.error("OKRW.mint: amount가 0입니다.");
          break;
      }
      return;
    }
  }
  throw err;
}

ethers v6로 디코드

ethers v6는 이런 상황을 위해 interface.parseError를 제공합니다. v5의 contract.callStatic.x(...)는 v6에서 제거되었으므로, 트랜잭션을 실제 제출하지 않고 mint를 사전 검증하려면 contract.mint.staticCall(...)을 사용합니다.
const { ethers } = require("ethers");

const OKRW = "0x1000000000000000000000000000000000000001";
const okrwAbi = [
  "function mint(address recipient, uint256 amount) external returns (bool)",
  "error UnauthorizedMinter(address caller, address authorizedMinter)",
  "error InvalidAddress(address addr)",
  "error InvalidAmount(uint256 amount)",
];

const provider = new ethers.JsonRpcProvider("https://rpc-testnet.maroo.io");
const wallet = new ethers.Wallet(process.env.MINTER_KEY, provider);
const okrw = new ethers.Contract(OKRW, okrwAbi, wallet);

async function mint(recipient, whole) {
  try {
    // 가스를 쓰지 않고 사전 검증: ethers v6 패턴(callStatic은 제거됨).
    await okrw.mint.staticCall(recipient, ethers.parseEther(whole));
    const tx = await okrw.mint(recipient, ethers.parseEther(whole));
    await tx.wait();
  } catch (err) {
    if (err.data) {
      const parsed = okrw.interface.parseError(err.data);
      console.error(`OKRW.mint revert: ${parsed.name}`, parsed.args);
    } else {
      console.error("알 수 없는 OKRW.mint 실패:", err.message);
    }
  }
}

// TODO: 프로덕션 전에 교체합니다.
mint("0x1111111111111111111111111111111111111111", "10000000");
주의: InvalidAmountamount == 0일 때 발동합니다. 저수준 ABI 조작으로만 도달 가능한 음수 big.Int(Solidity uint256으로는 표현 불가)는 커스텀 오류 없이 revert되므로, 디코드할 수 없는 revert는 예기치 못한 실패로 간주하여 로그에 남깁니다.

발행자 확인으로 UnauthorizedMinter 사전 방지

발행자 주소는 체인 파라미터이며 컨소시엄만 교체할 수 있습니다. 발행자 키를 소유하지 않은 dApp이라면 IOkrw.getParams()로 현재 발행자를 조회한 뒤 자기 서명자와 비교하는 편이 안전합니다. 이렇게 하면 항상 revert되는 트랜잭션을 애초에 제출하지 않습니다.
import { createPublicClient, http } from "viem";

const client = createPublicClient({ transport: http("https://rpc-testnet.maroo.io") });

const { minter, mintDenom } = await client.readContract({
  address: "0x1000000000000000000000000000000000000001",
  abi: [{
    type: "function", name: "getParams", stateMutability: "view", inputs: [],
    outputs: [{ type: "tuple", components: [
      { name: "minter", type: "address" },
      { name: "mintDenom", type: "string" },
    ]}],
  }],
  functionName: "getParams",
});

console.log(`지정된 발행자: ${minter}, base denom: ${mintDenom}`);

마무리

dApp이 실제로 마주칠 mint 실패는 모두 안정적이고 디코드 가능한 selector를 가집니다. 세 selector를 각각 다른 UX 분기로 연결하고(UnauthorizedMinter → "발행 권한이 없습니다", InvalidAddress → "올바른 수신자를 지정합니다", InvalidAmount → "금액은 0보다 커야 합니다"), 디코드할 수 없는 revert는 후속 확인을 위해 로그에 남깁니다. 전체 API 명세는 contract-okrw-mint, 프리컴파일 배경은 okrw-precompile-overview에서 확인할 수 있습니다.
ESC
검색어를 입력하세요