Shared Precompile Errors — IPrecompile

component developer

Every Maroo precompile inherits IPrecompile, which declares the shared typed errors used for input validation, chain-layer authorization, encoding, and transaction-level rejection.

IPrecompile is the base Solidity interface that every Maroo precompile (IOkrw, IPcl, IEas, IAgent, IPrivacy) extends. It declares three families of typed errors that any of those precompiles may revert with: input / encoding errors (InvalidAddress, InvalidAmount, InvalidNumberOfArgs, UnknownMethod, QueryFailed, MsgServerFailed, EventEmitFailed, ABISetupFailed, RequesterIsNotMsgSender, InvalidHeight, InvalidPubkey, InvalidPubkeySize, InvalidPageRequest), stock SDK error mappings (SDKUnauthorized, SDKInsufficientFunds, SDKInvalidAddress, SDKInvalidCoins, SDKInvalidRequest, SDKInvalidType, SDKNotFound, UnmappedCosmosError), and transaction-level rejection errors (InsufficientFee, NonceTooLow, NonceGap, IntrinsicGasTooLow, FloorDataGasTooLow, TipAboveFeeCap, FeeCapTooHigh, TipTooHigh, GasPriceTooLow, GasLimitExceeded, InvalidSender, ChainIdMismatch). Because they are declared once and inherited, an ABI fragment for these errors decodes correctly against any Maroo precompile.

Architecture

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;

Every module precompile inherits IPrecompile, so a single call can revert with either a module-specific typed error or a shared IPrecompile error. Client decoders should combine both ABIs to name the failure.

The three error families

The interface groups errors by where they originate:

  • Input / encoding — the ABI decode step or a precompile-owned precondition failed. InvalidAddress(string bad) and InvalidAmount(string amount) carry the offending value as a string, not the raw address or uint256 — a common ABI mismatch is decoding them with the old numeric shapes. Calldata that does not match a method — an unknown selector, or arguments too short to decode — reverts with ABISetupFailed(string reason) on every Maroo precompile, not with UnknownMethod or InvalidNumberOfArgs; bytes after the last argument are ignored.
  • Stock SDK mappings — the chain-layer rejected the call. SDKUnauthorized, SDKInsufficientFunds, SDKInvalidAddress, SDKInvalidCoins, SDKInvalidRequest, SDKInvalidType, SDKNotFound cover the common cases; anything else falls through to UnmappedCosmosError(string codespace, uint32 code) with the raw stock SDK error code.
  • Transaction-level rejection — the surrounding EVM transaction failed pre-execution validation (fees, nonce, gas, chain id, sender). These are the twelve errors added most recently; eth_sendRawTransaction now returns them when the node rejects a transaction before broadcast for one of those reasons. A transaction rejected by the PCL policy check at submission carries a PCL error instead, not one of these twelve (see PCL rejections at RPC submission below).
// From precompiles/common/interfaces/IPrecompile.sol
interface IPrecompile {
    // --- input / encoding ---
    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);

    // --- stock SDK mappings ---
    error SDKUnauthorized();
    error SDKInsufficientFunds();
    error SDKInvalidAddress();
    error SDKInvalidCoins();
    error SDKInvalidRequest();
    error SDKInvalidType();
    error SDKNotFound();
    error UnmappedCosmosError(string codespace, uint32 code);

    // --- transaction-level rejection ---
    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);
}

Transaction-level rejection errors

The transaction-level family is the newest addition. Each error corresponds to a specific pre-execution check the chain performs against an incoming EVM transaction:

ErrorMeaning
InsufficientFeeTotal fee (gasPrice * gas, or maxFeePerGas-derived) is below the base-fee floor.
NonceTooLowThe transaction's nonce is lower than the account's next expected nonce.
NonceGapThe nonce skips past the next expected value — mempool will not queue it.
IntrinsicGasTooLowGas limit is below the intrinsic cost for the calldata payload.
FloorDataGasTooLowCalldata-floor gas (post-EIP-7623) not met.
TipAboveFeeCapmaxPriorityFeePerGas > maxFeePerGas.
FeeCapTooHighmaxFeePerGas exceeds the configured ceiling.
TipTooHighmaxPriorityFeePerGas exceeds the configured ceiling.
GasPriceTooLowLegacy gasPrice is below the current base fee.
GasLimitExceededGas limit exceeds the block gas limit.
InvalidSenderThe recovered sender is not a valid externally-owned account.
ChainIdMismatch(expected, actual)The transaction's chain id does not match the connected network. Use it to prompt a wallet-switch: mainnet is 815, testnet is 450815.

Because these are inherited from IPrecompile, the same ABI fragment you use for precompile reverts also decodes them when eth_sendRawTransaction rejects a transaction for one of these reasons. A PCL rejection at submission is not one of them (see PCL rejections at RPC submission below).

PCL rejections at RPC submission are typed too

When a client sends a transaction that fails a PCL policy check, the PCL-validating RPC mempool now wraps the underlying PCL error as an EVM-style typed revert on the eth_sendRawTransaction response — instead of surfacing it as an opaque string. The revert payload is the ABI-encoded PCL ReasonCode (from IPcl) or, when the failure is an authorization / encoding condition, another error IPcl declares or inherits from the shared IPrecompile errors above. Decoding logic on the client can therefore be uniform: attach both the PCL error fragment and the IPrecompile error fragment to the ABI you decode error data against.
import { BaseError, decodeErrorResult } from "viem";

// Include IPcl reason codes AND IPrecompile shared errors in one 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" }] },
  // ...append the rest of IPcl's ReasonCode errors
] as const;

try {
  await wallet.sendRawTransaction({ serializedTransaction: signedTx });
} catch (err) {
  // The node's revert data is on the RPC error in the cause chain; viem may wrap that error.
  const rpcError = err instanceof BaseError ? err.walk((e) => typeof (e as { data?: unknown }).data === "string") : null;
  const data = (rpcError as { data?: `0x${string}` } | null)?.data;
  if (data) {
    const decoded = decodeErrorResult({ abi: sharedErrorAbi, data });
    console.error(`tx rejected: ${decoded.errorName}`, decoded.args);
  } else {
    throw err;
  }
}

Payload shapes to watch

A recent refactor moved InvalidAddress and InvalidAmount from precompile-owned (address) / (uint256) shapes to shared (string) shapes. Any ABI fragment written before that move will fail to match the 4-byte selector and the revert will surface as an unrecognised error. When upgrading a client:

  • Replace InvalidAddress(address) with InvalidAddress(string bad).
  • Replace InvalidAmount(uint256) with InvalidAmount(string amount).
  • Add the twelve transaction-level rejection errors (InsufficientFee, NonceTooLow, NonceGap, IntrinsicGasTooLow, FloorDataGasTooLow, TipAboveFeeCap, FeeCapTooHigh, TipTooHigh, GasPriceTooLow, GasLimitExceeded, InvalidSender, ChainIdMismatch(uint256, uint256)) so pre-broadcast rejections decode cleanly.


The on-chain selector-based dispatch (Solidity try/catch reading bytes4(errorSelector)) keeps working for both changes — only the ABI-tail decoders need updating.

When you'll see a plain string instead

A precompile can also revert with a plain string reason (Solidity's Error(string)) rather than a typed custom error. That happens when the Go implementation returns an error text with no typed mapping — for example "privacy batchId must be non-zero" from an IPrivacy batch call with a zero batchId, or "execution reverted" from a deployPclProxy whose proxy constructor fails. The reason is ABI-encoded like any Solidity Error(string), so viem's decodeErrorResult returns it as errorName: "Error" with the reason in args[0] — one catch branch handles typed and plain-string reverts alike. Do not invent a CamelCase name for such a condition — it is not a typed error and clients cannot decode a name that does not exist.
ESC
Type to search