Skip to content

evmnow/sdk

Repository files navigation

@evmnow/sdk

Resolve complete contract metadata for any EVM contract from multiple sources — curated repository, on-chain contractURI (ERC-7572), Sourcify + NatSpec, and ERC-2535 diamond facets — merged into a single document.

Install

npm install @evmnow/sdk

Requires Node.js >= 18 (or any runtime with global fetch and AbortSignal.timeout).

Quick start

import { createContractClient } from '@evmnow/sdk'

const client = createContractClient({
  chainId: 1,
  rpc: 'https://eth.llamarpc.com',
})

const result = await client.get('0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984')

console.log(result.metadata.name)        // "Uniswap"
console.log(result.metadata.description) // ...
console.log(result.metadata.actions)     // { "delegate": { function: "delegate(address)", title: "...", ... }, ... }
console.log(result.abi)                  // full ABI from Sourcify (composite for diamonds)

ENS names work too:

const result = await client.get('uniswap.eth')

How it works

The SDK fetches metadata from four sources in parallel, then merges them with increasing priority:

Priority Source What it provides
Lowest Standard interfaces Bundled ERC-20/ERC-721 metadata layer, applied when the final ABI matches the standard
Low Diamonds and proxy targets ERC-2535 facet and proxy implementation ABI + NatSpec, merged into a composite ABI and result.proxy
Low Sourcify ABI and function/event/error descriptions from NatSpec comments
Medium contractURI On-chain ERC-7572 fields: name, symbol, description, image, links
Highest Repository Curated JSON from the evmnow/contract-metadata repo — full control over every field

Higher-priority sources override lower ones. Record sections (actions, events, errors, messages, groups) are shallow-merged per key, so a repository entry can add a title to an action while keeping the NatSpec description from Sourcify.

Contracts whose final ABI (after proxy composition) implements ERC-20 or ERC-721 get the corresponding interface layer automatically — labeled actions, groups, and semantic types like token-amount — with no curated document required. When no layer provides a name/symbol, they are read from the contract itself (result.interfaces reports what was detected).

Configuration

const client = createContractClient({
  // Required
  chainId: 1,

  // Optional
  rpc: 'https://eth.llamarpc.com',        // needed for contractURI, diamonds/proxies, ENS on mainnet
  ensRpc: 'https://eth.llamarpc.com',     // mainnet RPC for ENS when chainId !== 1
  ensResolver: '0x...',                   // custom ENS Universal Resolver address (default: canonical mainnet deployment)
  repositoryUrl: '...',                   // custom metadata repo base URL
  sourcifyUrl: 'https://sourcify.dev/server',
  schemaBaseUrl: 'https://evmnow.github.io/contract-metadata/v1', // base for `includes` interface resolution (https only)
  ipfsGateway: 'https://ipfs.io',
  fetch: customFetch,                     // custom fetch implementation
  cache: true,                            // memoize repository/Sourcify lookups per client (default: true)

  // Disable specific sources globally
  sources: {
    repository: true,   // default: true
    contractURI: true,  // default: true (requires rpc)
    sourcify: true,     // default: true
    proxy: true,        // default: true (requires rpc; detects ERC-2535 diamonds and proxies)
    interfaces: true,   // default: true (ERC-20/ERC-721 detection from the ABI; name()/symbol() fill requires rpc)
  },

  // Opt-in fields that aren't included by default
  include: {
    sources: false,           // verified source files from Sourcify, including proxy targets
    deployedBytecode: false,  // deployed bytecode from Sourcify
  },
})

ENS resolution only works on Ethereum mainnet. If chainId === 1, rpc is reused for ENS; otherwise set ensRpc explicitly to enable .eth name lookups.

ENS limitations

  • Only .eth names are supported — anything that isn't a 0x address or doesn't end in .eth (case-insensitively) is rejected with InvalidAddressError.
  • Names are ASCII-lowercased before hashing (so Vitalik.eth and VITALIK.ETH resolve like vitalik.eth), but full ENSIP-15 normalization of non-ASCII names is not performed — exotic Unicode names may hash differently than in a fully normalizing client.
  • CCIP-Read / offchain names (e.g. *.cb.id and other offchain-resolver subdomains) won't resolve — the SDK does not follow OffchainLookup reverts.

Addresses in EIP-55 mixed case are checksum-validated: a mixed-case address whose casing doesn't match its checksum is rejected with InvalidAddressError (it likely contains a typo). All-lowercase and all-uppercase addresses carry no checksum and are accepted as-is.

The SDK performs a one-time consistency check that config.chainId matches the RPC's eth_chainId, the first time an RPC-dependent method runs. A mismatch surfaces as ChainIdMismatchError.

API

createContractClient(config)ContractClient

Creates a client bound to a specific chain.

client.get(addressOrEns, options?)ContractResult

Fetches and merges metadata from all enabled sources. Accepts a 0x address or .eth ENS name (requires mainnet RPC via rpc or ensRpc).

Returns a ContractResult:

interface ContractResult {
  chainId: number
  address: string
  metadata: ContractMetadataDocument  // merged metadata from all sources
  abi?: AbiItem[]                     // ABI from Sourcify (composite for diamonds)
  natspec?: NatSpec                   // raw userdoc/devdoc (merged across facets)
  sources?: Record<string, string>    // verified source files (requires include.sources)
  deployedBytecode?: string           // onchain runtime bytecode (requires include.deployedBytecode)
  proxy?: ProxyResolution             // resolved ERC-2535 facets / proxy implementations
  interfaces?: StandardInterface[]    // token standards detected from the final ABI ('erc20' | 'erc721')
}

interface TargetInfo {
  address: string
  selectors?: string[] // defined for diamond facets
  abi?: AbiItem[]      // filtered to selectors for diamond facets
  natspec?: NatSpec
  sources?: Record<string, string> // requires include.sources
}

interface ProxyResolution {
  pattern: ProxyPattern
  targets: TargetInfo[]
  beacon?: string             // EIP-1967 beacon address (only for 'eip-1967-beacon')
  admin?: string              // proxy admin address (eip-1967 / zeppelinos when the admin slot is set)
  compositeAbi?: AbiItem[]    // deduped ABI across every target
  natspec?: NatSpec           // merged userdoc/devdoc across targets (first target wins)
  metadataLayer?: Partial<ContractMetadataDocument> // actions/events/errors distilled from target NatSpec (first target wins)
}

Note on include.deployedBytecode: the deployed bytecode is requested from Sourcify v2 as the runtimeBytecode field (deployedBytecode is not a valid v2 selector) and surfaced as result.deployedBytecode from runtimeBytecode.onchainBytecode.

Per-call overrides mirror the config shape:

// Disable a source and opt into extra fields for a single call
const result = await client.get('0x...', {
  sources: { sourcify: false },
  include: { sources: true, deployedBytecode: true },
})

Throws ContractMetadataNotFoundError if no source returns any data. Not-found errors include structured source and reason fields when a single source gives a meaningful signal, such as a Sourcify 404.

client.fetchRepository(address)

Fetch only the repository source. Looks up the chain-scoped layout (contracts/{chainId}/{address}.json) first and falls back to the legacy flat layout (contracts/{address}.json). A document whose own chainId field disagrees with the client's chain is discarded. Returns null if not found.

client.fetchContractURI(address)

Fetch only the on-chain contractURI. Returns null if not found or no RPC configured.

client.fetchSourcify(address)

Fetch only from Sourcify. Always requests sources and runtimeBytecode alongside the base fields (abi,userdoc,devdoc), and returns the raw SourcifyResult (ABI, parsed NatSpec, sources, bytecode). The Sourcify v2 runtimeBytecode.onchainBytecode value is exposed as SourcifyResult.deployedBytecode. Field selectors are validated against the known Sourcify v2 field list before the request is built (the standalone buildSourcifyFields helper and SOURCIFY_V2_FIELDS set are exported).

client.fetchProxy(address, options?)

Resolve ERC-2535 diamond facets or proxy implementation targets without running the full merge pipeline. Returns null if the contract is not a supported diamond/proxy or no RPC is configured; otherwise returns a ProxyResolution:

interface ProxyResolution {
  pattern: ProxyPattern
  targets: TargetInfo[]       // ERC-2535 facets or proxy implementations, plus ABI / NatSpec when Sourcify is enabled
  beacon?: string             // EIP-1967 beacon address (only for 'eip-1967-beacon')
  admin?: string              // proxy admin address (eip-1967 / zeppelinos when the admin slot is set)
  compositeAbi?: AbiItem[]    // deduped ABI across every target
  natspec?: NatSpec           // merged userdoc/devdoc across targets (first target wins)
  metadataLayer?: Partial<ContractMetadataDocument>  // actions/events/errors ready to merge (first target wins)
}

For diamond facets, everything derived from a facet — ABI, NatSpec methods, and NatSpec-derived actions — is filtered to the selectors actually mounted on the diamond, so unmounted facet functions can't inject actions.

Per-target Sourcify lookups can be skipped when you only need the topology:

// Addresses + selectors only — no Sourcify traffic
const proxy = await client.fetchProxy('0x...', { sourcify: false })

merge(...layers)

Standalone pure function for merging metadata layers. Exported for use outside the client.

import { merge } from '@evmnow/sdk'

const merged = merge(sourcifyLayer, contractUriLayer, repoLayer)

Metadata document

The resolved document follows the contract-metadata schema:

interface ContractMetadataDocument {
  $schema?: string
  chainId: number
  address: string
  includes?: string[]
  meta?: DocumentMeta
  name?: string
  symbol?: string
  description?: string
  image?: string
  banner_image?: string
  featured_image?: string
  external_link?: string
  collaborators?: string[]
  about?: string
  category?: string
  tags?: string[]
  links?: Link[]
  risks?: string[]
  audits?: AuditReference[]
  theme?: Theme
  groups?: Record<string, Group>
  actions?: Record<string, ActionMeta>
  events?: Record<string, EventMeta>
  errors?: Record<string, ErrorMeta>
  messages?: Record<string, MessageMeta>
  [key: `_${string}`]: unknown   // extension fields allowed on any underscore-prefixed key
}

Actions are keyed by a free-form identifier and reference the ABI function they invoke via an optional function field (bare name, full signature, or 4-byte selector; when omitted, the action's id is used as the reference). Each action may carry title, description, intent, warning, params (with types, labels, validation, autofill, hidden, disabled), a value object describing the native currency sent with a payable call, examples, and more. Multiple actions may target the same ABI function as variants (approve, approve-max, revoke). See src/types.ts for full type definitions.

Two pure helpers turn the document into UI-ready data:

  • resolveActions(abi, doc) — returns a ready-to-render list of ResolvedAction entries: every authored action resolved to its ABI function, plus a synthesized default for each ABI function no authored action references. Non-fatal authoring problems (unresolved refs, locked params without autofill, value on non-payable functions) are reported as issues.
  • matchAction(actions, call) — resolves a decoded transaction to the most specific action for confirmation previews and history views. Locked parameters (hidden/disabled with a resolvable autofill) act as equality constraints against the decoded arguments; the surviving candidate with the most locked parameters wins, with deterministic tie-breaks (lowest order, then smallest id). Decode calldata with your own ABI tooling — the SDK matches on decoded values.

Includes (interfaces)

Repository metadata can reference shared interfaces via the includes field:

{
  "includes": ["erc20", "ownable"],
  "name": "My Token"
}

Interfaces are fetched from the schema base URL (schemaBaseUrl config, default https://evmnow.github.io/contract-metadata/v1, resolved as {schemaBaseUrl}/interfaces/{name}.json) and merged as a base layer, with the document's own fields taking priority. Full https:// URLs are also accepted in includes; any other scheme (including http://) is skipped.

Diamonds (ERC-2535) and Proxies

ERC-2535 diamond support is first-class. When the proxy source is enabled and an rpc is configured, the SDK detects diamonds via supportsInterface(0x48e2b093) with a facets() fallback, then returns result.proxy.pattern === 'eip-2535-diamond'. Each live facet is represented in result.proxy.targets with its address and selectors.

The same pipeline also handles common single-implementation proxy patterns. For each diamond facet or proxy implementation, the SDK fetches Sourcify separately, filters diamond facet ABIs to mounted selectors, and expands the result:

  • result.proxy.targets — one entry per diamond facet or proxy implementation with its address, optional selectors, ABI, NatSpec, and source files when requested
  • result.abi — composite ABI across the main contract + every target (first-occurrence wins, deduped by selector for functions and by signature for events/errors)
  • result.natspecuserdoc / devdoc merged across the main contract and targets, main doc taking priority
  • result.sources — main contract source files plus target source files when include.sources is enabled; for an unverified proxy with one verified implementation, this is the implementation source map
  • result.metadata.actions / events / errors — NatSpec-derived sections from every target layered in at lowest priority, so curated repo/contractURI/main-Sourcify fields still win

Targets are fetched directly from Sourcify (not recursively through client.get), which guards against facets or implementations that themselves look like proxies. Setting sources.sourcify: false skips per-target Sourcify traffic as well — the target list still contains addresses and selectors, but ABI, NatSpec, and sources are omitted.

Modular imports

Every module is published as a subpath export, so consumers can import a single utility without the full barrel:

import { merge, resolveIncludes } from '@evmnow/sdk/merge'
import { resolveActions, matchAction, paramMetaAt, actionRequiresSender } from '@evmnow/sdk/actions'
import { mergeNatspecDocs } from '@evmnow/sdk/natspec'
import { resolveUri } from '@evmnow/sdk/uri'
import { namehash, dnsEncode, normalizeEnsName } from '@evmnow/sdk/ens'
import { ethCall, resolveEns, getChainId, decodeAbiString, UNIVERSAL_RESOLVER } from '@evmnow/sdk/rpc'
import { createTokenInfoResolver, decodeSymbol, sanitizeSymbol } from '@evmnow/sdk/token'
import { detectInterfaces, interfaceDocuments } from '@evmnow/sdk/interfaces/detect'
import { erc20Interface } from '@evmnow/sdk/interfaces/erc20'
import { erc721Interface } from '@evmnow/sdk/interfaces/erc721'
import { fetchRepository } from '@evmnow/sdk/sources/repository'
import { fetchContractURI } from '@evmnow/sdk/sources/contract-uri'
import {
  fetchSourcify,
  buildSourcifyLayer,
  buildSourcifyFields,
  SOURCIFY_V2_FIELDS,
} from '@evmnow/sdk/sources/sourcify'
import {
  fetchProxy,
  detectProxy,
  detectDiamond,
  enrichTargets,
  composeProxyResolution,
  filterSourcifyBySelectors,
  decodeFacets,
  computeSelector,
  canonicalSignature,
  filterAbiBySelectors,
  buildCompositeAbi,
  mergeNatspecDocs,
  DIAMOND_LOUPE_INTERFACE_ID,
  SUPPORTS_INTERFACE_SELECTOR,
  FACETS_SELECTOR,
} from '@evmnow/sdk/sources/proxy'
import {
  ContractMetadataError,
  ContractMetadataNotFoundError,
  ContractNotVerifiedOnSourcifyError,
  ContractMetadataFetchError,
  ENSResolutionError,
  InvalidAddressError,
  ChainIdMismatchError,
  ContractClientConfigError,
} from '@evmnow/sdk/errors'

Every symbol above is also re-exported from the barrel (@evmnow/sdk).

Standalone diamond/proxy resolution

When you only need diamond facet or proxy implementation topology, skip the client entirely:

import { fetchProxy } from '@evmnow/sdk/sources/proxy'

const proxy = await fetchProxy(
  'https://eth.llamarpc.com',   // rpc
  1,                            // chainId
  '0x...',                      // diamond or proxy address
  fetch,                        // fetch implementation
  { sourcifyUrl: 'https://sourcify.dev/server', sourcify: true, sources: true },
)

Pass { sourcify: false } to return only the on-chain address + selector topology.

Error handling

import {
  ContractMetadataNotFoundError,
  ContractNotVerifiedOnSourcifyError,
  ContractMetadataFetchError,
  ENSResolutionError,
  InvalidAddressError,
  ChainIdMismatchError,
  ContractClientConfigError,
} from '@evmnow/sdk'

try {
  const result = await client.get('0x...')
} catch (e) {
  if (e instanceof ContractNotVerifiedOnSourcifyError) {
    // Sourcify confirmed the contract is not verified
  } else if (e instanceof ContractMetadataNotFoundError) {
    // No metadata found from any source
    console.log(e.source, e.reason)
  }
  if (e instanceof ENSResolutionError) {
    // ENS name could not be resolved
  }
  if (e instanceof InvalidAddressError) {
    // Not an address/.eth name, or the EIP-55 checksum is wrong (e.input)
  }
  if (e instanceof ChainIdMismatchError) {
    // config.chainId disagrees with the RPC (e.expected vs e.actual)
  }
  if (e instanceof ContractClientConfigError) {
    // e.g. ENS lookup attempted without a mainnet ensRpc
  }
}

Individual source failures are swallowed — if Sourcify is down but the repository has data, you still get a result.

License

MIT

About

Resolve contract metadata for any EVM contract from multiple sources

Topics

Resources

License

Stars

3 stars

Watchers

1 watching

Forks

Contributors