Guide

Custom providers

Extend Provider for an explorer this package doesn't know. Register it with its chains and capabilities. Let selection find it.

One class

tsmy-explorer.ts
import { Provider, formatWei, register, type Balance, type ChainKey, type Transaction } from "@agntn/explorers";

class MyExplorer extends Provider {
  static readonly key = "my-explorer";

  get capabilities() {
    return {
      balances: true,
      txHistory: true,
      txDetail: false,
      utxos: false,
      pubkeys: false,
      contractInfo: false,
      tokenBalances: false,
      tokenTransfers: false,
      gasData: false,
      blockInfo: false,
    };
  }

  async getBalance(address: string, chain?: ChainKey): Promise<Balance> {
    const answer = await this.getJSON<{ balance: string }>(`https://my.explorer/api/${address}`);
    return this.snapshotBalance({
      address,
      chain: chain ?? "ethereum",
      balance: answer.balance,
      balanceFormatted: formatWei(answer.balance, 18),
      symbol: "ETH",
    });
  }

  async getTxHistory(address: string, chain?: ChainKey): Promise<Transaction[]> {
    // map the explorer's rows onto Transaction
  }
}

register(MyExplorer, { chains: ["ethereum"], capabilities: ["balances", "txHistory"] });

Three things the base class gives you. this.getJSON() and this.postJSON() go through the shared client, so the configured timeout, the explorers/<version> user agent, big integers kept as strings and normalizeError all apply. this.snapshotBalance() stamps fetchedAt and fills blockNumber and blockHash with what you pass or null. And this.name is your static readonly key.

Optional operations are optional: define getTxDetail and the rest only when they're real, and keep capabilities in step. The one rule is the honest one. A flag that's true has a method behind it, a flag that's false has none. No stubs, no methods that throw "not implemented" three calls deep.

Registration

register(providerClass, meta) takes the class and what the registry needs to answer without an instance: chains, optional capabilities, optional chainGaps, optional defaultURL. After that create("my-explorer"), resolveProvider(undefined, "ethereum") and the CLI's --provider my-explorer all see it. Capabilities are optional in meta for backward compatibility, but leave them out and selection treats your provider as serving every read, so put them in. chainGaps names the reads a provider refuses on one of its chains, like { pepecoin: ["gasData", "blockInfo"] } on Mempool, and selection skips it there.

Inside the package

A provider that ships with the package follows four steps, and test/unit/registry.test.ts goes red when one gets skipped:

  1. A class extending Provider in src/providers/, with one unique static readonly key.
  2. Balances and history implemented, and only the optional methods that really work advertised.
  3. The class exported.
  4. An entry in builtins in src/providers/index.ts with its chains, capabilities, public endpoint and a load that imports the module.

There used to be a fifth, a line in the build config. Not anymore: build.config.ts reads src/providers/ and gives every file its own entry in dist/providers/, so create() imports one provider and nothing else.

The test compares the list against the files on disk, against the key of the class each entry loads, and against the build inputs. Metadata can't drift away from the implementation without a red test, which is the only kind of documentation that stays true.

Nothing runs on import

Library modules evaluate to declarations only. A derived value, a new Set(), a prebuilt map, a decoder, waits for its first use. sideEffects in package.json names the CLI bundle and nothing else, and that's what lets a consumer import one provider without paying for eighteen. A custom provider that keeps to the same rule stays tree-shakable in the bundle that carries it.