Concepts

The four resolver shapes

Every method in Ethernauta is a curried function:

method(args)(resolved_transport) // → Promise<T>

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 } from "@ethernauta/transport";
import { eth_block_number } from "@ethernauta/eth";

const reader = create_reader([chain1, chain2]);

const block = await eth_block_number()(
  reader({ chain_id: chain1.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 } from "@ethernauta/transport";
import { eth_send_raw_transaction } from "@ethernauta/eth";

const writer = create_writer([chain1]);

const hash = await eth_send_raw_transaction(signed_bytes)(
  writer({ chain_id: chain1.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 { create_signer } from "@ethernauta/transport";
import { eth_send_transaction, personal_sign } from "@ethernauta/eth";

const signer = create_signer([chain1]);

const hash = await eth_send_transaction({ to, value, input })(
  signer({ chain_id: chain1.chain_id }),
);

const signature = await personal_sign({ message, account })(
  signer({ chain_id: chain1.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 { create_contract } from "@ethernauta/transport";
import { balance_of } from "@ethernauta/erc/20";

const contract = create_contract([chain1]);

const balance = await balance_of({ address: holder })(
  contract({ chain_id: chain1.chain_id, contract: token_address }),
);

A specialization of Readable for ABI-decoded contract reads. The resolver carries a contract address; the method binds function selector + args + return decoder automatically. ERC method bindings (@ethernauta/erc/20/methods/balance-of, etc.) all return Callable<T>.

Trackable<T>

import { create_tracker, wait_for_receipt, watch_transaction } from "@ethernauta/transaction";

const tracker = create_tracker([chain1], { store });

const receipt = await wait_for_receipt({ hash })(
  tracker({ chain_id: chain1.chain_id }),
);

const unsubscribe = watch_transaction({ hash, on_receipt })(
  tracker({ chain_id: chain1.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.