Overview

@ethernauta/core

Primitive Valibot schemas for Ethereum base types: addresses, bytes, hashes, unsigned integers, ratios. Every other package composes these. If you find yourself reaching for a regex to validate an address, stop and import from here.

pnpm add @ethernauta/core
import { AddressSchema, type Address } from "@ethernauta/core";
import * as v from "valibot";

const raw_input = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const address: Address = v.parse(AddressSchema, raw_input);
//    ^? Address

Why this package exists

Hard rule 3 in CLAUDE.md: primitive schemas live in @ethernauta/core and nowhere else. Composing them is fine; redeclaring them inside a feature package is not. Centralizing primitives is what keeps the wire-validation surface auditable.

When a new EIP needs a schema for a “32-byte hash,” it imports Hash32Schema. It does not write v.pipe(v.string(), v.regex(/^0x[0-9a-f]{64}$/)). If the right primitive is missing, the rule is add it here, not widen the consumer.

The catalog

Addresses

SchemaDescription
AddressSchema0x + 40 hex chars. EIP-55 checksum-validated.
AddressesSchemaArray of AddressSchema.

Types: Address, Addresses.

Bytes

SchemaLengthUse
ByteSchema1 byteSingle hex byte (0x + 2 hex chars).
BytesSchemavariableAny 0x-prefixed hex string.
Bytes4Schema4 bytesFunction selectors.
Bytes8Schema8 bytesEIP-4337 packed gas / fees fields.
Bytes32Schema32 bytesHashes, salts, EVM words.
Bytes48Schema48 bytesEIP-4844 commitments.
Bytes64Schema64 bytesBLS-style signatures.
Bytes65Schema65 bytesECDSA signatures (r ‖ s ‖ v).
Bytes256Schema256 bytesBloom filters in receipts.
BytesMax32Schema≤ 32 bytesPadded EVM args.

Types: Byte, Bytes, Bytes4, Bytes8, Bytes32, Bytes48, Bytes64, Bytes65, Bytes256, BytesMax32.

Hashes

SchemaDescription
Hash32SchemaAlias of bytes32 semantically reserved for keccak hashes.

Type: Hash32.

Unsigned integers

Every standard EVM uint size has a schema. The wire representation is hex-encoded (Ethereum JSON-RPC convention); the parsed JavaScript type is bigint (or number for the small ones — see the inferred type per row).

SchemaRangeTS type
Uint8Schema0 – 2⁸ − 1number
Uint16Schema0 – 2¹⁶ − 1number
Uint24Schema0 – 2²⁴ − 1number
Uint32Schema0 – 2³² − 1number
Uint40Schema0 – 2⁴⁰ − 1bigint
Uint48Schema0 – 2⁴⁸ − 1bigint
Uint56Schema0 – 2⁵⁶ − 1bigint
Uint64Schema0 – 2⁶⁴ − 1bigint
Uint96Schema0 – 2⁹⁶ − 1bigint
Uint128Schema0 – 2¹²⁸ − 1bigint
Uint160Schema0 – 2¹⁶⁰ − 1bigint
Uint192Schema0 – 2¹⁹² − 1bigint
Uint224Schema0 – 2²²⁴ − 1bigint
Uint256Schema0 – 2²⁵⁶ − 1bigint
UintSchemaany unsignedbigint

Types: Uint8, Uint16, … Uint256, Uint.

Misc

SchemaDescription
RatioSchemaA number between 0 and 1 inclusive.
NotFoundSchemaThe sentinel shape for “this RPC returned nothing.”

Types: Ratio, NotFound.

Example: composing

import * as v from "valibot";
import {
  AddressSchema,
  Bytes32Schema,
  Uint256Schema,
} from "@ethernauta/core";

const transferSchema = v.object({
  token: AddressSchema,
  from: AddressSchema,
  to: AddressSchema,
  amount: Uint256Schema,
  block_hash: Bytes32Schema,
});

type Transfer = v.InferOutput<typeof transferSchema>;

Every field is a primitive from core. There’s no regex anywhere in the consumer. When EIP-55 checksumming improves or bytes32’s wire format changes, this consumer doesn’t change.

Adding a primitive

If you genuinely need a new primitive — say a 17-byte field for a hypothetical new tx type — the right move is to add bytes17Schema to @ethernauta/core, ship it as a normal export, and import it into your consumer. Do not widen with BytesSchema and hand-validate later; parse would have caught the bug at the boundary, and BytesSchema defeats that.

See also