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), andweb3.jsvia 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 becomeundefined. - Short-circuiting.
resolve(value)in abefore:hook skips the method and returnsvalue. First call wins: laterresolvecalls are ignored. - Modifying results. In an
after:hook,resultis the already-resolved value (async methods are awaited beforeafter:hooks run), andsetResult(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, soawaitthe 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.