Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions config/default.config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ rpcProxy:
# Optional ENS resolver: resolves execution addresses to their primary ENS name.
ensResolver:
enabled: false # enable ENS name resolution & display
# optional dedicated EL RPC endpoints (usually Ethereum mainnet). If empty, an
# available client from the main execution pool is used.
# EL RPC endpoints used for ENS lookups. ENS lives on Ethereum mainnet, so these
# should always point at a mainnet RPC regardless of the chain being explored.
# If empty, a public mainnet RPC (ethereum-rpc.publicnode.com) is used.
endpoints: []
# - url: "https://ethereum-rpc.publicnode.com"
# name: "mainnet"
Expand Down
1 change: 1 addition & 0 deletions handlers/pageData.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func InitPageData(w http.ResponseWriter, r *http.Request, active, path, title st
MainMenuItems: createMenuItems(active),
ApiEnabled: utils.Config.Api.Enabled && !utils.Config.Api.RequireAuth,
ExecutionIndexerEnabled: utils.Config.ExecutionIndexer.Enabled,
EnsSearchEnabled: ensSearchEnabled(),
}

chainState := services.GlobalBeaconService.GetChainState()
Expand Down
40 changes: 38 additions & 2 deletions handlers/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ import (

var searchLikeRE = regexp.MustCompile(`^[0-9a-fA-F]{0,96}$`)

// ensNameRE loosely matches a complete ENS name: dot-separated non-empty labels
// without whitespace, ending in a TLD-like label of at least 3 chars (e.g. "eth").
var ensNameRE = regexp.MustCompile(`^[^\s.]+(\.[^\s.]+)*\.[^\s.]{3,}$`)

// ensSearchEnabled reports whether ENS names can be searched: the resolver must be
// active and the execution indexer enabled (resolved names redirect to /address).
func ensSearchEnabled() bool {
return utils.Config.EnsResolver.Enabled && utils.Config.ExecutionIndexer.Enabled
}

// searchResolverResult is the cached outcome of resolving a search query (redirect URL or empty = not found).
type searchResolverResult struct {
RedirectURL string `json:"redirect_url"`
Expand Down Expand Up @@ -150,6 +160,12 @@ func buildSearchResolverResult(ctx context.Context, searchQuery string) (searchR
}
}

if ensSearchEnabled() && ensNameRE.MatchString(searchQuery) {
if addr, ok := services.GlobalBeaconService.GetEnsResolver().ResolveEnsName(ctx, searchQuery); ok {
return searchResolverResult{RedirectURL: fmt.Sprintf("/address/0x%x", addr)}, cacheTimeout
}
}

if nameMatch, err := db.HasValidatorNameMatch(ctx, "%"+searchQuery+"%"); err == nil && nameMatch {
return searchResolverResult{RedirectURL: "/slots/filtered?f&f.missing=1&f.orphaned=1&f.pname=" + searchQuery}, cacheTimeout
}
Expand Down Expand Up @@ -188,8 +204,12 @@ func SearchAhead(w http.ResponseWriter, r *http.Request) {
searchType := vars["type"]
urlArgs := r.URL.Query()
search := strings.Trim(urlArgs.Get("q"), " \t")
search = strings.Replace(search, "0x", "", -1)
search = strings.Replace(search, "0X", "", -1)
if searchType != "ens" {
// hex-based types accept queries with or without 0x prefix; ENS labels may
// legitimately contain "0x", so the raw query is kept for them
search = strings.Replace(search, "0x", "", -1)
search = strings.Replace(search, "0X", "", -1)
}

// 404 before cache so we don't cache disabled/unknown types
allowedTypes := map[string]bool{
Expand All @@ -201,6 +221,7 @@ func SearchAhead(w http.ResponseWriter, r *http.Request) {
"validator": true,
"addresses": utils.Config.ExecutionIndexer.Enabled,
"transactions": utils.Config.ExecutionIndexer.Enabled,
"ens": ensSearchEnabled(),
}
if !allowedTypes[searchType] {
http.Error(w, "Not found", 404)
Expand Down Expand Up @@ -571,6 +592,21 @@ func buildSearchAheadResult(ctx context.Context, searchType, search string) (*se
result = model
}
}
case "ens":
if !ensNameRE.MatchString(search) {
break
}
if addr, ok := services.GlobalBeaconService.GetEnsResolver().ResolveEnsName(ctx, search); ok {
account, _ := db.GetElAccountByAddress(ctx, addr.Bytes())
result = &[]models.SearchAheadEnsResult{
{
EnsName: utils.FormatGraffitiString(strings.ToLower(search)),
Address: strings.ToLower(addr.Hex()),
IsContract: account != nil && account.IsContract,
HasData: account != nil && account.ID > 0,
},
}
}
case "transactions":
if len(search) == 0 {
break
Expand Down
2 changes: 1 addition & 1 deletion services/chainservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func InitChainService(ctx context.Context, logger logrus.FieldLogger) {
buildoorInventory := NewBuildoorInventory(ctx)
mevRelayIndexer := mevrelay.NewMevIndexer(ctx, logger.WithField("service", "mev-relay"), beaconIndexer, chainState)
snooperManager := snooper.NewSnooperManager(ctx, logger.WithField("service", "snooper-manager"), beaconIndexer)
ensResolver := NewEnsResolver(ctx, logger.WithField("service", "ens-resolver"), executionPool)
ensResolver := NewEnsResolver(ctx, logger.WithField("service", "ens-resolver"))

// Set execution time provider
beaconIndexer.SetExecutionTimeProvider(snooper.NewExecutionTimeProvider(snooperManager.GetCache()))
Expand Down
129 changes: 97 additions & 32 deletions services/ensresolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,38 @@ import (
"github.com/jmoiron/sqlx"
"github.com/sirupsen/logrus"

"github.com/ethpandaops/dora/clients/execution"
exerpc "github.com/ethpandaops/dora/clients/execution/rpc"
"github.com/ethpandaops/dora/clients/sshtunnel"
"github.com/ethpandaops/dora/db"
"github.com/ethpandaops/dora/dbtypes"
"github.com/ethpandaops/dora/types"
"github.com/ethpandaops/dora/utils"
)

// EnsResolver resolves execution addresses to their primary ENS name. Resolution is
// batched, asynchronous and persisted to the ens_names table. Handlers call
// ResolveNames once per page to warm the in-memory cache and feed the resolve queue.
//
// ENS lookups always run against Ethereum mainnet (via the configured endpoints or a
// public mainnet RPC default), independent of the chain this explorer indexes.
type EnsResolver struct {
ctx context.Context
logger logrus.FieldLogger
execPool *execution.Pool
ctx context.Context
logger logrus.FieldLogger

started atomic.Bool
cache *lru.Cache[common.Address, *ensCacheEntry]

// forward resolution cache (name -> address), used by the search bar
forwardCache *lru.Cache[string, *ensForwardCacheEntry]

queue chan common.Address
pending sync.Map // common.Address -> struct{}

// dedicated RPC clients built from EnsResolver.Endpoints (lazy init)
dedicatedInit sync.Once
dedicatedClients []*ensEndpointClient

// probe results (worker-goroutine only, guarded by probeMutex until probed)
// probe results (immutable once probed; guarded by probeMutex until then)
probeMutex sync.Mutex
probed bool
registries []common.Address
Expand All @@ -55,17 +60,23 @@ type ensCacheEntry struct {
resolvedTime int64
}

// ensForwardCacheEntry is a cached forward-resolution result (name -> address).
// A zero address is a negative result.
type ensForwardCacheEntry struct {
address common.Address
resolvedTime int64
}

// ensEndpointClient is a dedicated ENS RPC client with its configured name (for logs).
type ensEndpointClient struct {
name string
client *exerpc.ExecutionClient
}

func NewEnsResolver(ctx context.Context, logger logrus.FieldLogger, execPool *execution.Pool) *EnsResolver {
func NewEnsResolver(ctx context.Context, logger logrus.FieldLogger) *EnsResolver {
return &EnsResolver{
ctx: ctx,
logger: logger.WithField("service", "ens-resolver"),
execPool: execPool,
ctx: ctx,
logger: logger.WithField("service", "ens-resolver"),
}
}

Expand Down Expand Up @@ -147,6 +158,12 @@ func (e *EnsResolver) StartUpdater() {
if len(cfg.RegistryAddresses) == 0 {
cfg.RegistryAddresses = []string{"0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"}
}
if len(cfg.Endpoints) == 0 {
// ENS lives on Ethereum mainnet, so resolution always runs against a mainnet
// RPC - never against the local chain (devnets/testnets have no ENS registry).
// Default to a public mainnet endpoint when no dedicated endpoint is configured.
cfg.Endpoints = []types.EndpointConfig{{Name: "mainnet-public", Url: "https://ethereum-rpc.publicnode.com"}}
}
if cfg.MulticallAddress == "" {
cfg.MulticallAddress = "0xcA11bde05977b3631167028862bE2a173976CA11"
}
Expand All @@ -157,7 +174,14 @@ func (e *EnsResolver) StartUpdater() {
return
}

forwardCache, err := lru.New[string, *ensForwardCacheEntry](cfg.CacheSize)
if err != nil {
e.logger.Errorf("failed to create ens forward cache: %v", err)
return
}

e.cache = cache
e.forwardCache = forwardCache
e.queue = make(chan common.Address, cfg.QueueSize)
e.started.Store(true)

Expand Down Expand Up @@ -247,6 +271,57 @@ func (e *EnsResolver) isStale(entry *ensCacheEntry, now int64) bool {
return now-entry.resolvedTime > int64(refresh/time.Second)
}

// ResolveEnsName forward-resolves an ENS name to its address (EIP-137), using a
// synchronous eth_call on cache miss. The bool result reports whether the name
// resolved to a non-zero address.
//
// It is called from the search handlers — results are cached here (LRU) and again
// at the page-cache layer, so on-chain lookups stay bounded.
func (e *EnsResolver) ResolveEnsName(ctx context.Context, name string) (common.Address, bool) {
if e == nil || !e.started.Load() {
return common.Address{}, false
}

name = strings.ToLower(strings.TrimSpace(name))
if name == "" || !strings.Contains(name, ".") {
return common.Address{}, false
}

if entry, ok := e.forwardCache.Get(name); ok && !e.isForwardStale(entry, time.Now().Unix()) {
return entry.address, entry.address != (common.Address{})
}

ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

ethClient, err := e.getEthClient(ctx)
if err != nil {
e.logger.Warnf("ens forward resolve %q: %v", name, err)
return common.Address{}, false
}
if err := e.ensureProbed(ctx, ethClient); err != nil {
e.logger.Warnf("ens forward resolve %q: %v", name, err)
return common.Address{}, false
}

addr := e.resolveForward(ctx, ethClient, name)
e.forwardCache.Add(name, &ensForwardCacheEntry{address: addr, resolvedTime: time.Now().Unix()})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 transient RPC errors are cached as 6h-negative ENS results

resolveForward returns a zero address both when a name genuinely has no resolver/addr and when an RPC call errors (it logs and continues past callBatch errors, and the non-multicall path swallows CallContract errors into success=false). ResolveEnsName then unconditionally caches that zero address with forwardCache.Add, so a single rate-limit or timeout blip on the endpoint during the first lookup of vitalik.eth makes the search bar report it as not found for up to RefreshNegative (default 6h), even after the RPC recovers. Consider not caching the negative when a stage actually errored, mirroring how processBatch avoids persisting negatives on client errors.

return addr, addr != (common.Address{})
}

// isForwardStale reports whether a forward cache entry is past its refresh interval
// (positive and negative results use separate intervals).
func (e *EnsResolver) isForwardStale(entry *ensForwardCacheEntry, now int64) bool {
refresh := utils.Config.EnsResolver.RefreshPositive
if entry.address == (common.Address{}) {
refresh = utils.Config.EnsResolver.RefreshNegative
}
if refresh <= 0 {
return false
}
return now-entry.resolvedTime > int64(refresh/time.Second)
}

// enqueue adds an address to the capped resolve queue, de-duplicating pending entries
// and dropping (best-effort) when the queue is full.
func (e *EnsResolver) enqueue(addr common.Address) {
Expand Down Expand Up @@ -396,32 +471,22 @@ func (e *EnsResolver) ensureProbed(ctx context.Context, ethClient *ethclient.Cli
return nil
}

// getEthClient returns an eth client for ENS lookups, preferring dedicated endpoints
// and falling back to a ready client from the main execution pool.
// getEthClient returns an eth client for ENS lookups. ENS is always resolved against
// mainnet: only the dedicated endpoints are used (defaulted to a public mainnet RPC in
// StartUpdater), never the local execution pool, which on devnets/testnets serves a
// chain without an ENS deployment.
func (e *EnsResolver) getEthClient(ctx context.Context) (*ethclient.Client, error) {
if len(utils.Config.EnsResolver.Endpoints) > 0 {
e.dedicatedInit.Do(e.initDedicatedClients)
for _, ec := range e.dedicatedClients {
if err := ec.client.Initialize(ctx); err != nil {
e.logger.Warnf("ens endpoint %s init failed: %v", ec.name, err)
continue
}
if ethClient := ec.client.GetEthClient(); ethClient != nil {
return ethClient, nil
}
e.dedicatedInit.Do(e.initDedicatedClients)
for _, ec := range e.dedicatedClients {
if err := ec.client.Initialize(ctx); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 dedicated ENS rpc client Initialize() is now called concurrently (data race)

On master, getEthClient() was only reached from the single worker loop (processBatch); this PR adds ResolveEnsName(), which runs on HTTP handler goroutines (SearchAhead/Search) and also calls getEthClient(), so ec.client.Initialize() (clients/execution/rpc/executionapi.go:89) can run concurrently from handler threads and the worker. Initialize() does an unlocked check-then-act on ec.ethClient and writes ec.rpcClient/ec.ethClient (lines 103-104) while GetEthClient() reads them — a fresh data race, and each racing caller passes the nil check and dials/leaks an extra RPC connection. Couldn't run go test -race in this sandbox (no Go toolchain), so impact assessment is static: both callers still get a usable client, so it's connection churn plus race-detector noise rather than a functional break — guarding Initialize() with its own mutex/sync.Once would fix it.

e.logger.Warnf("ens endpoint %s init failed: %v", ec.name, err)
continue
}
if ethClient := ec.client.GetEthClient(); ethClient != nil {
return ethClient, nil
}
return nil, fmt.Errorf("no usable dedicated ens endpoint")
}

client := e.execPool.GetReadyEndpoint(execution.AnyClient)
if client == nil {
return nil, fmt.Errorf("no ready execution client available for ens resolution")
}
ethClient := client.GetRPCClient().GetEthClient()
if ethClient == nil {
return nil, fmt.Errorf("execution client has no eth client")
}
return ethClient, nil
return nil, fmt.Errorf("no usable ens endpoint")
}

// initDedicatedClients builds RPC clients from the configured ENS endpoints.
Expand Down
36 changes: 36 additions & 0 deletions services/ensresolver_ens.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,42 @@ func reverseNode(addr common.Address) [32]byte {
return namehash(strings.ToLower(addr.Hex()[2:]) + ".addr.reverse")
}

// resolveForward resolves a name to its address (forward resolution, EIP-137):
// resolver = registry.resolver(namehash(name)); addr = resolver.addr(node).
// Registries are tried in configured order; the first non-zero address wins.
func (e *EnsResolver) resolveForward(ctx context.Context, ethClient *ethclient.Client, name string) common.Address {
node := namehash(name)

for _, registry := range e.registries {
res, err := e.callBatch(ctx, ethClient, []ensCall{{target: registry, data: appendNode(selectorResolver, node)}})
if err != nil {
e.logger.Warnf("ens forward stage1 (resolver) failed: %v", err)
continue
}
if !res[0].success {
continue
}
resolver := decodeAddress(res[0].data)
if resolver == (common.Address{}) {
continue
}

res, err = e.callBatch(ctx, ethClient, []ensCall{{target: resolver, data: appendNode(selectorEnsAddr, node)}})
if err != nil {
e.logger.Warnf("ens forward stage2 (addr) failed: %v", err)
continue
}
if !res[0].success {
continue
}
if addr := decodeAddress(res[0].data); addr != (common.Address{}) {
return addr
}
}

return common.Address{}
}

// resolveBatch resolves primary ENS names for the given addresses, trying the usable
// registries in configured order and keeping the first verified name per address.
func (e *EnsResolver) resolveBatch(ctx context.Context, ethClient *ethclient.Client, addrs []common.Address) map[common.Address]string {
Expand Down
Loading