Concepts

The four resolver shapes

Every method in Ethernauta is a curried function:

// shape only — see the blocks below for runnable examples.
declare function method(args: unknown): (resolved_transport: unknown) => Promise<unknown>;

The first call binds the method’s parameters. The second call binds the transport. The two are never collapsed — that’s a hard rule (M3 in the project maxims).

The four shapes of “resolved transport” are:

  • Readable<T> — chain reads. No wallet.
  • Writable<T> — broadcast pre-signed bytes. No wallet.
  • Signable<T> — anything that needs a wallet to sign or expose state.
  • Callable<T>eth_call-shaped contract reads. No wallet.

Plus a fifth that the @ethernauta/transaction package layers on top:

  • Trackable<T> — receipt polling / lifecycle tracking. No wallet.

Readable<T>

import { create_reader, encode_chain_id, http } from "@ethernauta/transport";
import { eth_blockNumber } from "@ethernauta/eth";
import { eip155_1 } from "@ethernauta/chain/eip155-1";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
const reader = create_reader([
  { chainId: CHAIN_ID, transports: [http("https://ethereum-rpc.publicnode.com")] },
]);

const block = await eth_blockNumber()(
  reader({ chain_id: CHAIN_ID }),
);

Backed by an HTTP transport that picks an RPC URL from the chain definition. Read-only methods (eth_blockNumber, eth_getBalance, eth_getCode, eth_getBlockByNumber, eth_call, eth_getLogs, …) return Readable<T>. No wallet involved at any layer.

Writable<T>

import { create_writer, encode_chain_id, http } from "@ethernauta/transport";
import { eth_sendRawTransaction } from "@ethernauta/eth";
import { eip155_1 } from "@ethernauta/chain/eip155-1";
import { BytesSchema } from "@ethernauta/core";
import { parse } from "valibot";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
const writer = create_writer([
  { chainId: CHAIN_ID, transports: [http("https://ethereum-rpc.publicnode.com")] },
]);
const signed_bytes = parse(BytesSchema, "0x");

const hash = await eth_sendRawTransaction([signed_bytes])(
  writer({ chain_id: CHAIN_ID }),
);

Same HTTP transport, but reserved for methods that broadcast. Only eth_sendRawTransaction returns Writable<Hash32> today. Splitting Readable from Writable is a type-level guard against accidentally calling broadcast methods on a reader (and vice versa).

Signable<T>

import type { Provider } from "@ethernauta/eip/1193";
import { create_provider, encode_chain_id } from "@ethernauta/transport";
import { eth_sendTransaction } from "@ethernauta/eth";
import { personal_sign } from "@ethernauta/eip/191";
import { eip155_1 } from "@ethernauta/chain/eip155-1";
import { AddressSchema, BytesSchema, UintSchema } from "@ethernauta/core";
import { parse } from "valibot";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
declare const provider: Provider; // see /eips/6963 for discovery
const { signer } = create_provider(provider);
const to = parse(AddressSchema, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8");
const value = parse(UintSchema, "0x0");
const input = parse(BytesSchema, "0x");
const account = parse(AddressSchema, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8");

const hash = await eth_sendTransaction([{ to, value, input }])(
  signer({ chain_id: CHAIN_ID }),
);

const signature = await personal_sign(["hello", account])(
  signer({ chain_id: CHAIN_ID }),
);

The only shape that requires a wallet. The signer wraps a 1193 provider (the wallet extension, window.ethereum, or any EIP-6963 announcement). Methods that need user confirmation or wallet-held secrets — eth_sendTransaction, eth_signTransaction, personal_sign, eth_signTypedData_v4, eth_requestAccounts, wallet_switchEthereumChain, wallet_sendCalls, wallet_signAuthorization — return Signable<T>.

Callable<T>

import { contract, create_reader, encode_chain_id, http } from "@ethernauta/transport";
import { balanceOf } from "@ethernauta/erc/20";
import { eth_call } from "@ethernauta/eth";
import { eip155_1 } from "@ethernauta/chain/eip155-1";
import { AddressSchema } from "@ethernauta/core";
import { parse } from "valibot";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
const reader = create_reader([
  { chainId: CHAIN_ID, transports: [http("https://ethereum-rpc.publicnode.com")] },
]);
const token_address = parse(AddressSchema, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
const holder = parse(AddressSchema, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8");

const callable = balanceOf([holder])(
  contract({ chain_id: CHAIN_ID, to: token_address }),
);
const result_bytes = await eth_call([
  { to: callable.to, input: callable.data },
])(reader({ chain_id: CHAIN_ID }));
const balance = callable.decode(result_bytes);

A specialization of Readable for ABI-decoded contract reads. The Callable<T> returned by an ERC binding carries the calldata + a decode function; you pass it through eth_call and then decode the bytes. ERC method bindings (@ethernauta/erc/20/methods/balance-of, etc.) all return Callable<T>.

Trackable<T>

import { create_tracker, wait_for_receipt, watch_transaction, window_store } from "@ethernauta/transaction";
import { encode_chain_id, http } from "@ethernauta/transport";
import { eip155_1 } from "@ethernauta/chain/eip155-1";
import { Hash32Schema } from "@ethernauta/core";
import { parse } from "valibot";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
const tracker = create_tracker(
  [{ chainId: CHAIN_ID, transports: [http("https://ethereum-rpc.publicnode.com")] }],
  { store: window_store },
);
const hash = parse(Hash32Schema, "0x" + "0".repeat(64));

const receipt = await wait_for_receipt([hash])(
  tracker({ chain_id: CHAIN_ID }),
);

const unsubscribe = watch_transaction(hash, (_transaction) => {
  // react to mined transaction
})(tracker({ chain_id: CHAIN_ID }));

The tracker carries a Store (typically localStorage-backed) so pending transactions survive page reloads. Layered on top of Readable<T>.

Why split

Three reasons.

Type-safety. Calling eth_sendTransaction against a Reader is a compile error, not a runtime one. The shape is the type-system enforcement of “this method needs a wallet.”

Path-2 viability. If signing and broadcasting were one shape, dapps wanting to broadcast through a different RPC than the wallet would have to forge ahead anyway. The split makes path 2 ergonomic — see Two paths.

Transport flexibility. A Reader over an HTTP RPC and a Reader over an EIP-1193 provider’s eth_call are the same shape. The call site doesn’t change when you swap them. create_provider(provider).reader and create_reader(CHAINS) both produce a Reader.

The shape is the contract

When you write a new method (an EIP binding, an ERC binding, a wallet RPC handler), the very first decision is the shape. That decision is the public contract: it tells consumers whether the method needs a wallet, whether it can run in tests against a mock, whether it can be batched into a multicall. Picking the wrong shape is the bug; the implementation usually follows.