npm.io
1.1.1 • Published 2d ago

@a16k/hana

Licence
Apache-2.0
Version
1.1.1
Deps
5
Size
894 kB
Vulns
0
Weekly
0

HANA

Hardened · Adapter-agnostic · Network · Abstraction

HANA is a hardened, adapter-agnostic network abstraction for effortless Ethereum development. Write your contract logic once against a single, fully type-safe API and run it on top of whichever Web3 library you prefer, with built-in caching, type-safe contract APIs, and easy-to-use testing mocks.

  • Hardened - extra-audited and optimized.
  • Adapter-agnostic - one API across viem, ethers (v6 and v5), and web3.js via swappable adapters (an EIP-1193-compatible default adapter is built in).
  • Network - reads, writes, events, and batching for any EVM chain.
  • Abstraction - a single, unified, type-safe layer instead of raw provider calls.

Installation

npm install @a16k/hana

The default adapter talks to any EIP-1193 provider (or an RPC URL) with no extra dependencies. To back HANA with a specific library, install that library too and import the matching adapter (see Adapters).

Quick start

import { createHana, erc20 } from "@a16k/hana";

const hana = createHana({ rpcUrl: process.env.RPC_URL });

// Read, with built-in caching.
const balance = await hana.read({
  abi: erc20.abi,
  address: "0x...",
  fn: "balanceOf",
  args: { account: "0x..." },
});

// Or use a contract instance.
const token = hana.contract({ abi: erc20.abi, address: "0x..." });
const symbol = await token.read("symbol");

Adapters

The same HANA API runs on top of whichever Web3 library you already use. Pass an adapter to createHana and every read, write, contract, event, and batch call works identically:

const hana = createHana({ adapter: new SomeAdapter({ /* your client */ }) });
Library Subpath Adapter
viem @a16k/hana/viem ViemAdapter
ethers v6 @a16k/hana/ethers EthersV6Adapter
ethers v5 @a16k/hana/ethers5 EthersV5Adapter
web3.js v4 @a16k/hana/web3 Web3Adapter

Each adapter wraps an existing client or provider (or builds one from an rpcUrl, or falls back to globalThis.ethereum), so HANA reuses your one connection. Values follow a single convention regardless of the backing library: amounts are bigint and hashes, addresses, and bytes are 0x${string}. The shape-heavy methods (block, transaction, receipt, logs, and EIP-5792 wallet calls) are inherited from the default adapter and routed through your client as an EIP-1193 transport, so they behave the same everywhere. Reads work with any provider or RPC URL; writes need a signer or wallet-capable client.

The ethers adapters are the one place that transport is conditional. Only a JSON-RPC provider (JsonRpcProvider, BrowserProvider, or another JsonRpcApiProvider; in v5, a JsonRpcProvider subclass such as Web3Provider) implements the raw send those shape-heavy methods route through. A non-JSON-RPC provider (FallbackProvider, getDefaultProvider()) still serves the typed reads (call, estimateGas, getBalance, getBytecode, getChainId, getBlockNumber) and, with a signer, writes; the shape-heavy methods throw a HanaError naming the provider class instead.

viem
npm install viem
import { createHana } from "@a16k/hana";
import { ViemAdapter } from "@a16k/hana/viem";
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";

const client = createPublicClient({ chain: mainnet, transport: http() });
const hana = createHana({ adapter: new ViemAdapter({ client }) });

ViemAdapter also accepts { provider } (any EIP-1193 provider) or { rpcUrl }. For writes, pass a wallet client.

ethers v6
npm install ethers
import { createHana } from "@a16k/hana";
import { EthersV6Adapter } from "@a16k/hana/ethers";
import { JsonRpcProvider } from "ethers";

const provider = new JsonRpcProvider(process.env.RPC_URL);
const hana = createHana({ adapter: new EthersV6Adapter({ provider }) });

EthersV6Adapter also accepts { rpcUrl }. For writes, pass a { signer }; its provider is used for reads when no provider is given.

ethers v5

ethers v5 and v6 both publish as ethers, so install v5 under an alias and import the adapter from @a16k/hana/ethers5:

npm install ethers-v5@npm:ethers@^5.8.0
import { createHana } from "@a16k/hana";
import { EthersV5Adapter } from "@a16k/hana/ethers5";
import { providers } from "ethers-v5";

const provider = new providers.JsonRpcProvider(process.env.RPC_URL);
const hana = createHana({ adapter: new EthersV5Adapter({ provider }) });

EthersV5Adapter also accepts { signer } or { rpcUrl }. v5's BigNumber values are converted to bigint at the boundary, so the HANA API is unchanged.

web3.js v4
npm install web3
import { createHana } from "@a16k/hana";
import { Web3Adapter } from "@a16k/hana/web3";
import { Web3 } from "web3";

const web3 = new Web3(process.env.RPC_URL);
const hana = createHana({ adapter: new Web3Adapter({ web3 }) });

Web3Adapter also accepts { provider } or { rpcUrl }. Note that web3.js is in maintenance (sunset by ChainSafe); this adapter targets legacy and migration interop.

Disposing clients

A client registers a chainChanged listener only when its adapter's provider exposes an EIP-1193 on, which in practice means the default adapter over an event-emitting source such as window.ethereum. When that provider is long-lived and shared, a dApp that recreates its client on wallet or route changes accumulates listeners, and the provider retains every old client and its cache. Call dispose() on a client you are discarding:

const hana = createHana();

// ...later, when the client is no longer needed:
hana.dispose();

dispose() removes only that client's own chainChanged listener, and it is a no-op when no listener was registered, so it is always safe to call.

The library adapters (ViemAdapter, EthersV6Adapter, EthersV5Adapter, Web3Adapter) hand the client their library's own client or a request-only shim, none of which expose on. Two consequences: those clients never need dispose(), and they do not observe a network switch, so a client kept across one keeps serving the old chain id and the cache it namespaced under it, even if the underlying window.ethereum emits chainChanged. Until this is addressed, recreate the client when you switch chains behind one of those adapters: hana.cache.clear() is not enough, because the memoized chain id survives it.

Cache lifetime and reorgs

The cache has no reorg awareness and no time-based expiry. A read, call, balance, transaction, block, or event query is cached under a key derived from its arguments and served from then on; the default LruStore evicts only by capacity (least-recently-used), never by age. A client also never invalidates its own writes. So a value read at the chain tip keeps being served indefinitely even if that block is later orphaned by a reorg.

On reorg-prone chains, invalidate the affected entries yourself once you know a block was reorged. Use hana.cache.clear() for a full reset, or the targeted methods for a single kind of entry: invalidateRead / invalidateReadsMatching / clearReads, invalidateCall / invalidateCallsMatching / clearCalls, invalidateBalance / clearBalances, invalidateTransaction / clearTransactions, and invalidateBlock / clearBlocks. Cached event queries have no dedicated invalidator, so drop them with hana.cache.clear().

Hooks

Hooks let you intercept and modify any adapter method call. They live on hana.hooks (and client.hooks), keyed by a before:<method> / after:<method> naming convention, one pair per method (read, write, getBlock, getChainId, multicall, getEvents, and so on). For a typed adapter, hana.hooks.on autocompletes the valid names.

import { createHana } from "@a16k/hana";

const hana = createHana({ rpcUrl: process.env.RPC_URL });

// `before:` hooks can inspect or replace the arguments.
hana.hooks.on("before:read", ({ args }) => {
  console.log("reading", args[0].fn);
});

// A `before:` hook can also short-circuit the call: `resolve` sets the return
// value and the underlying method is not called.
hana.hooks.on("before:read", ({ resolve }) => {
  resolve(0n);
});

// `after:` hooks see the resolved result and can override it.
hana.hooks.on("after:getChainId", ({ result, setResult }) => {
  console.log("chain id", result);
  setResult(result);
});

Contracts do not have their own hook registry: use contract.client.hooks. Client-level method hooks (such as before:read) still fire for contract reads, because the interceptor wraps the whole client.

Semantics
  • Order. Multiple handlers per hook run sequentially in registration order. Registering the same function twice runs it twice.
  • Modifying args. In a before: hook, setArgs(...args) replaces the entire argument tuple (it is not a merge); omitted arguments become undefined.
  • Short-circuiting. resolve(value) in a before: hook skips the method and returns value. First call wins: later resolve calls are ignored.
  • Modifying results. In an after: hook, result is the already-resolved value (async methods are awaited before after: hooks run), and setResult(value) overrides what the caller receives.
  • Async. Handlers may be async. Once any handler returns a promise, the rest are chained after it, so async handlers run in sequence, never in parallel. A single async hook promotes an otherwise synchronous call to a Promise, so await the call to observe completion.
  • Errors. If the method throws or rejects, the error propagates to the caller and after: hooks are skipped (there is no error channel). A handler that throws aborts the remaining handlers for that hook.
Removing handlers
import type { BeforeMethodHook } from "@a16k/hana";

// A standalone handler is not contextually typed, so annotate it.
const onRead: BeforeMethodHook<typeof hana.read> = ({ args }) =>
  console.log(args[0].fn);
hana.hooks.on("before:read", onRead);
hana.hooks.off("before:read", onRead); // returns true if a handler was removed

// One-shot handlers remove themselves after firing once.
hana.hooks.once("after:getChainId", ({ result }) => console.log("chain", result));

off matches by function identity, so keep a reference (an inline handler cannot be removed). A once handler cannot be cancelled with off(hook, originalFn) because once registers an internal wrapper. Passing a non-function handler to on or once throws an InvalidHookHandlerError. Note that an unknown or misspelled hook name (e.g. before:reed) is a silent no-op: it registers under a dead key and never fires. To type your own handlers, use the exported BeforeMethodHook, AfterMethodHook, and MethodHooks helpers.

React & Next.js

@a16k/hana/react ships first-class hooks. They are a thin state layer: the client's own cache, request deduplication, and multicall batching do the heavy lifting, so there is no second query-cache to configure. React (>= 16.8) is an optional peer dependency, needed only for this entry.

import { createHana } from "@a16k/hana";
import { HanaProvider, useRead, useWrite } from "@a16k/hana/react";

// Create the client ONCE (module scope), never per render.
const hana = createHana({ rpcUrl: "https://..." });
// In the browser with a wallet: createHana({ provider: window.ethereum })

export function App() {
  return (
    <HanaProvider hana={hana}>
      <Balance owner="0x..." />
    </HanaProvider>
  );
}

function Balance({ owner }: { owner: `0x${string}` }) {
  const { data, isLoading, error, refetch } = useRead({
    abi: erc20Abi,
    address: "0xA0b8...",
    fn: "balanceOf",          // fully typed from the abi
    args: { owner },          // typed args; bigint-safe
    refetchInterval: 15_000,  // optional polling
  });

  const { write, status, receipt } = useWrite();
  const send = () =>
    write({ abi: erc20Abi, address: "0xA0b8...", fn: "transfer",
            args: { to: owner, amount: 1_000_000n } });

  if (isLoading) return <span>Loading...</span>;
  if (error) return <button onClick={refetch}>Retry</button>;
  return <span>{data?.toString()} {status === "mined" && "sent!"}</span>;
}

The hooks are useHana() (the client from context), useContract({ abi, address }) (a memoized contract instance), useRead(params) (data / isLoading / isFetching / error / refetch, with enabled and refetchInterval options), and useWrite() (write / status / hash / receipt / reset, tracking idle -> pending -> submitted -> mined).

Notes for Next.js: the shipped entry carries the "use client" directive, so importing the hooks from a client component Just Works with the App Router. On the server (route handlers, server components), use the plain client from @a16k/hana as a module singleton; its cache is shared across requests. Reads mounted in the same render tick are batched into one multicall, and a MetaMask network switch (chainChanged) clears the cache automatically.

Errors

Everything HANA throws is a HanaError (or a subclass, like BlockNotFoundError). Detect them with the exported isHanaError guard rather than instanceof:

import { isHanaError } from "@a16k/hana";

try {
  await hana.getBlock();
} catch (error) {
  if (isHanaError(error)) console.error(error.kind, error.message);
}

Prefer isHanaError over instanceof HanaError for cross-module checks. In the shipped package both builds share one HanaError class across the subpath entries (., ./testing, ./viem, ...), so instanceof works there today, but that is an artifact of the current build layout, not a guarantee: a consumer that mixes require and import of the package (the dual-package hazard), or a bundler configuration that duplicates the chunk, ends up with two distinct classes, and an error thrown by one is not an instanceof the other.

const { HanaError, isHanaError } = require("@a16k/hana");
const { MissingStubError } = require("@a16k/hana/testing");

// Reliable everywhere: matches a `Symbol.for` brand shared by every copy of
// the class, across entries, formats, and duplicated bundles.
isHanaError(new MissingStubError({ method: "read" })); // true

To branch on a specific error, switch on the kind discriminant: unlike name, it is a fixed string literal that callers cannot override and minifiers cannot mangle. Keep a default branch, since subclasses can add their own kind.

Testing

The @a16k/hana/testing entry provides mock clients and adapters for unit tests without a network:

import { erc20 } from "@a16k/hana";
import { createMockHana } from "@a16k/hana/testing";

const hana = createMockHana();
hana.onRead({ abi: erc20.abi, fn: "symbol" }).resolves("TEST");

const symbol = await hana.read({ abi: erc20.abi, address: "0x...", fn: "symbol" });
// "TEST"

The mocks are built on Sinon, which is a peer dependency of the /testing entry only (the main entry needs no test tooling). Install it alongside HANA in projects that use the mocks:

npm install --save-dev sinon @types/sinon

License

HANA is licensed under the Apache License 2.0.

HANA is a fork of Drift by Ryan Goree, also licensed under Apache 2.0. See NOTICE for attribution.

Keywords