공유 프리컴파일 오류 — IPrecompile
모든 마루 프리컴파일은 IPrecompile을 상속하며, 입력 검증, 체인 계층 권한, 인코딩, 트랜잭션 수준 거절에 사용하는 공유 커스텀 오류를 여기서 선언합니다.
IPrecompile은 모든 마루 프리컴파일(IOkrw, IPcl, IEas, IAgent, IPrivacy)이 상속하는 기본 Solidity 인터페이스입니다. 세 가지 계열의 커스텀 오류를 선언하며, 각 프리컴파일이 이 오류로 revert할 수 있습니다. 입력·인코딩 오류(InvalidAddress, InvalidAmount, InvalidNumberOfArgs, UnknownMethod, QueryFailed, MsgServerFailed, EventEmitFailed, ABISetupFailed, RequesterIsNotMsgSender, InvalidHeight, InvalidPubkey, InvalidPubkeySize, InvalidPageRequest), 표준 SDK 오류 매핑(SDKUnauthorized, SDKInsufficientFunds, SDKInvalidAddress, SDKInvalidCoins, SDKInvalidRequest, SDKInvalidType, SDKNotFound, UnmappedCosmosError), 트랜잭션 수준 거절 오류(InsufficientFee, NonceTooLow, NonceGap, IntrinsicGasTooLow, FloorDataGasTooLow, TipAboveFeeCap, FeeCapTooHigh, TipTooHigh, GasPriceTooLow, GasLimitExceeded, InvalidSender, ChainIdMismatch)입니다. 한 번만 선언되고 상속되므로, 이 오류들의 ABI 조각 하나가 모든 마루 프리컴파일에 대해 정상적으로 디코드됩니다.
아키텍처
flowchart LR
DApp["dApp / Contract caller"]:::evm
Module["Module precompile<br/>(IOkrw / IPcl / IEas / IAgent / IPrivacy)"]:::precompile
Shared["IPrecompile (shared errors)"]:::precompile
Revert["Revert data<br/>typed error OR Error(string)"]:::evm
DApp -->|"call"| Module
Module -.->|"inherits"| Shared
Module -->|"module-specific error<br/>e.g. UnauthorizedMinter"| Revert
Shared -->|"shared error<br/>e.g. SDKInsufficientFunds"| Revert
Revert -->|"decodeErrorResult(combinedAbi, data)"| DApp
classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff; 모든 모듈 프리컴파일이 IPrecompile을 상속하므로, 한 호출이 모듈별 타입 지정 오류 또는 공통 IPrecompile 오류 중 하나로 revert될 수 있습니다. 클라이언트 디코더는 두 ABI를 결합하여 실패 원인을 이름으로 확인해야 합니다.
세 가지 오류 계열
- 입력·인코딩 — ABI 디코드 단계나 프리컴파일 소유 사전조건이 실패한 경우입니다.
InvalidAddress(string bad)와InvalidAmount(string amount)는 원래address또는uint256이 아니라 문자열로 문제 값을 담습니다. 예전 숫자 형태로 디코딩하는 것이 흔한 ABI 불일치 원인입니다. - 표준 SDK 매핑 — 체인 계층이 호출을 거절한 경우입니다.
SDKUnauthorized,SDKInsufficientFunds,SDKInvalidAddress,SDKInvalidCoins,SDKInvalidRequest,SDKInvalidType,SDKNotFound가 흔한 사례를 다루고, 그 외에는 표준 SDK 오류 코드 원본을 담은UnmappedCosmosError(string codespace, uint32 code)로 떨어집니다. - 트랜잭션 수준 거절 — 주변 EVM 트랜잭션이 사전 실행 검증(수수료, 논스, 가스, 체인 id, 발신자)에서 실패한 경우입니다. 최근에 추가된 12개 오류가 여기에 속하며, PCL 검증 RPC mempool이 브로드캐스트 전에 트랜잭션을 거절할 때
eth_sendRawTransaction이 이제 이 오류들을 반환합니다.
// precompiles/common/interfaces/IPrecompile.sol에서
interface IPrecompile {
// --- 입력·인코딩 ---
error RequesterIsNotMsgSender(address msgSender, address requester);
error InvalidAddress(string bad);
error InvalidAmount(string amount);
error InvalidHeight(string height);
error InvalidPubkey(string pubkey);
error InvalidPubkeySize(uint256 got, uint256 expected);
error ABISetupFailed(string reason);
error InvalidNumberOfArgs(uint256 expected, uint256 got);
error InvalidPageRequest(string method, uint256 index, string value);
error UnknownMethod(string methodName);
error QueryFailed(string queryMethod, string reason);
error MsgServerFailed(string msgMethod, string reason);
error EventEmitFailed(string eventKind, string reason);
// --- 표준 SDK 매핑 ---
error SDKUnauthorized();
error SDKInsufficientFunds();
error SDKInvalidAddress();
error SDKInvalidCoins();
error SDKInvalidRequest();
error SDKInvalidType();
error SDKNotFound();
error UnmappedCosmosError(string codespace, uint32 code);
// --- 트랜잭션 수준 거절 ---
error InsufficientFee();
error NonceTooLow();
error NonceGap();
error IntrinsicGasTooLow();
error FloorDataGasTooLow();
error TipAboveFeeCap();
error FeeCapTooHigh();
error TipTooHigh();
error GasPriceTooLow();
error GasLimitExceeded();
error InvalidSender();
error ChainIdMismatch(uint256 expected, uint256 actual);
} 트랜잭션 수준 거절 오류
| 오류 | 의미 |
|---|---|
InsufficientFee | 총 수수료(gasPrice * gas 또는 maxFeePerGas 기반)가 base fee 하한 미만입니다. |
NonceTooLow | 트랜잭션의 논스가 계정의 다음 기대 논스보다 낮습니다. |
NonceGap | 논스가 다음 기대 값을 건너뛰어 mempool이 대기열에 넣지 않습니다. |
IntrinsicGasTooLow | 가스 한도가 calldata 페이로드의 고유 비용 미만입니다. |
FloorDataGasTooLow | EIP-7623 이후 calldata 하한 가스를 충족하지 못했습니다. |
TipAboveFeeCap | maxPriorityFeePerGas > maxFeePerGas 상태입니다. |
FeeCapTooHigh | maxFeePerGas가 설정된 상한을 초과했습니다. |
TipTooHigh | maxPriorityFeePerGas가 설정된 상한을 초과했습니다. |
GasPriceTooLow | 레거시 gasPrice가 현재 base fee 미만입니다. |
GasLimitExceeded | 가스 한도가 블록 가스 한도를 초과했습니다. |
InvalidSender | 복원된 발신자가 유효한 외부 소유 계정이 아닙니다. |
ChainIdMismatch(expected, actual) | 트랜잭션의 체인 id가 접속 네트워크와 일치하지 않습니다. 지갑 전환을 유도할 때 사용합니다. 메인넷은 815, 테스트넷은 450815입니다. |
IPrecompile에서 상속하므로, 동일한 ABI 조각이 어떤 프리컴파일의 revert에서도, 또는 아래 강제 배선 절에서 설명하는 것처럼 eth_sendRawTransaction으로 노출되는 PCL 거절에서도 이 오류들을 디코드합니다.RPC 제출 단계의 PCL 거절도 타입 지정 오류로 노출됩니다
eth_sendRawTransaction 응답에 해당 PCL 오류를 EVM 스타일의 타입 지정 revert로 감싸서 반환합니다. 예전처럼 불투명한 문자열로 노출되지 않습니다. revert 페이로드는 ABI 인코딩된 PCL ReasonCode(IPcl 소유)이거나, 실패가 권한·인코딩 조건인 경우 위의 공유 IPrecompile 오류 중 하나입니다. 따라서 클라이언트 디코드 로직을 하나로 통일할 수 있습니다. 오류 데이터를 디코드할 ABI에 PCL 오류 조각과 IPrecompile 오류 조각을 함께 붙이면 됩니다.import { decodeErrorResult } from "viem";
// IPcl ReasonCode와 IPrecompile 공유 오류를 하나의 ABI에 포함시킵니다.
const sharedErrorAbi = [
{ type: "error", name: "InvalidAddress", inputs: [{ name: "bad", type: "string" }] },
{ type: "error", name: "InvalidAmount", inputs: [{ name: "amount", type: "string" }] },
{ type: "error", name: "ChainIdMismatch", inputs: [
{ name: "expected", type: "uint256" },
{ name: "actual", type: "uint256" },
] },
{ type: "error", name: "NonceTooLow", inputs: [] },
{ type: "error", name: "NonceGap", inputs: [] },
{ type: "error", name: "InsufficientFee", inputs: [] },
{ type: "error", name: "IntrinsicGasTooLow", inputs: [] },
{ type: "error", name: "FloorDataGasTooLow", inputs: [] },
{ type: "error", name: "TipAboveFeeCap", inputs: [] },
{ type: "error", name: "FeeCapTooHigh", inputs: [] },
{ type: "error", name: "TipTooHigh", inputs: [] },
{ type: "error", name: "GasPriceTooLow", inputs: [] },
{ type: "error", name: "GasLimitExceeded", inputs: [] },
{ type: "error", name: "InvalidSender", inputs: [] },
{ type: "error", name: "InDenylist", inputs: [{ name: "sender", type: "address" }] },
{ type: "error", name: "EasNoAttestationReceived", inputs: [{ name: "sender", type: "address" }] },
// ...나머지 IPcl ReasonCode 오류를 이어서 추가합니다.
] as const;
try {
await wallet.sendRawTransaction({ serializedTransaction: signedTx });
} catch (err: any) {
if (err?.data) {
const decoded = decodeErrorResult({ abi: sharedErrorAbi, data: err.data });
console.error(`트랜잭션 거절: ${decoded.errorName}`, decoded.args);
} else {
throw err;
}
} 주의할 페이로드 형태
InvalidAddress와 InvalidAmount가 프리컴파일 소유의 (address) / (uint256) 형태에서 공유 (string) 형태로 이동했습니다. 이 이동 이전에 작성된 ABI 조각은 4바이트 selector 매칭에 실패하고, revert가 인식되지 않은 오류로 노출됩니다. 클라이언트를 업그레이드할 때는 다음을 확인합니다.InvalidAddress(address)를InvalidAddress(string bad)로 교체합니다.InvalidAmount(uint256)을InvalidAmount(string amount)로 교체합니다.- 12개 트랜잭션 수준 거절 오류(
InsufficientFee,NonceTooLow,NonceGap,IntrinsicGasTooLow,FloorDataGasTooLow,TipAboveFeeCap,FeeCapTooHigh,TipTooHigh,GasPriceTooLow,GasLimitExceeded,InvalidSender,ChainIdMismatch(uint256, uint256))를 추가하여 브로드캐스트 전 거절을 깔끔하게 디코드합니다.
온체인 selector 기반 분기(
bytes4(errorSelector)를 읽는 Solidity try/catch)는 두 변경 모두에 대해 그대로 동작합니다. ABI 페이로드 디코더만 갱신하면 됩니다.일반 문자열로 노출되는 경우
Error(string) 형태로 일반 문자열 이유를 담아 revert할 수도 있습니다. Go 구현이 타입 매핑 없는 오류 텍스트를 반환할 때 이렇게 됩니다. 저수준 ABI 호출자가 nil big.Int를 전달할 때 IOkrw.mint에서 나오는 "amount must not be nil"처럼, 일반 Solidity에서는 도달할 수 없는 조건이 대표적입니다. 이 경우 decodeErrorResult가 매칭되지 않으므로, 일반 문자열 revert인지 확인하고 직접 읽습니다. 그런 조건에 CamelCase 이름을 지어내지 않습니다. 타입 지정 오류가 아니며, 존재하지 않는 이름을 클라이언트가 디코드할 수 없습니다.