From 677b4716119da0a370e798ef161f938a7f02e1b0 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Fri, 14 Aug 2026 10:35:15 +0200 Subject: [PATCH 1/7] feat: ENS name search in the main search bar Adds forward ENS resolution (name -> address) on top of the existing reverse-resolution subsystem, wired into the header search: - typing a complete ENS name (e.g. vitalik.eth) shows an "ENS Names" typeahead section with the resolved address; selecting it (or submitting the search) redirects to the /address page - new "ens" search-ahead type; the global 0x-strip is skipped for it since ENS labels may legitimately contain "0x" - forward results are cached in a dedicated LRU (honoring the existing refreshPositive/refreshNegative intervals) plus the page cache - gated on both ensResolver.enabled and executionIndexer.enabled (resolved names redirect to /address, which needs the indexer) ENS lookups now always run against Ethereum mainnet: the configured ensResolver.endpoints, defaulting to a public mainnet RPC (ethereum-rpc.publicnode.com) when unset. The local execution pool is no longer used as fallback - on devnets/testnets it serves a chain without an ENS deployment. --- config/default.config.yml | 5 +- handlers/pageData.go | 1 + handlers/search.go | 40 ++++++++++- services/chainservice.go | 2 +- services/ensresolver.go | 129 +++++++++++++++++++++++++--------- services/ensresolver_ens.go | 36 ++++++++++ static/js/explorer.js | 39 ++++++++++ templates/_layout/header.html | 2 +- types/config.go | 5 +- types/models.go | 1 + types/models/search.go | 8 +++ 11 files changed, 228 insertions(+), 40 deletions(-) diff --git a/config/default.config.yml b/config/default.config.yml index 811245c0c..00ab0d758 100644 --- a/config/default.config.yml +++ b/config/default.config.yml @@ -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" diff --git a/handlers/pageData.go b/handlers/pageData.go index 9917c557a..d2213cf3d 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -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() diff --git a/handlers/search.go b/handlers/search.go index d40b33677..3922e1613 100644 --- a/handlers/search.go +++ b/handlers/search.go @@ -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"` @@ -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 } @@ -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{ @@ -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) @@ -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 diff --git a/services/chainservice.go b/services/chainservice.go index badc4f3b0..07af4c05c 100644 --- a/services/chainservice.go +++ b/services/chainservice.go @@ -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())) diff --git a/services/ensresolver.go b/services/ensresolver.go index 26cf23c95..12acc16c4 100644 --- a/services/ensresolver.go +++ b/services/ensresolver.go @@ -15,25 +15,30 @@ 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{} @@ -41,7 +46,7 @@ type EnsResolver struct { 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 @@ -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"), } } @@ -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" } @@ -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) @@ -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()}) + 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) { @@ -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 { + 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. diff --git a/services/ensresolver_ens.go b/services/ensresolver_ens.go index c267ffba5..9eaefbd62 100644 --- a/services/ensresolver_ens.go +++ b/services/ensresolver_ens.go @@ -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 { diff --git a/static/js/explorer.js b/static/js/explorer.js index da0e2815a..906b3dd0f 100644 --- a/static/js/explorer.js +++ b/static/js/explorer.js @@ -368,6 +368,7 @@ var searchEl = jQuery("#explorer-search"); let requestNum = 9 var executionIndexerEnabled = searchEl.data("execution-indexer-enabled") === true || searchEl.attr("data-execution-indexer-enabled") === "true" || searchEl.data("executionIndexerEnabled") === true; + var ensSearchEnabled = searchEl.data("ens-search-enabled") === true || searchEl.attr("data-ens-search-enabled") === "true" || searchEl.data("ensSearchEnabled") === true; var prepareQueryFn = function(query, settings) { settings.url += encodeURIComponent(query); @@ -475,6 +476,22 @@ }); } + var bhEnsNames = null; + if (ensSearchEnabled) { + bhEnsNames = new Bloodhound({ + datumTokenizer: Bloodhound.tokenizers.whitespace, + queryTokenizer: Bloodhound.tokenizers.whitespace, + identify: function (obj) { + return obj.ens_name + }, + remote: { + url: "/search/ens?q=", + prepare: prepareQueryFn, + maxPendingRequests: requestNum, + }, + }); + } + // Build datasets array conditionally var datasets = [ { @@ -600,6 +617,26 @@ }); } + // Add ENS dataset conditionally + if (ensSearchEnabled && bhEnsNames) { + datasets.push({ + limit: 5, + name: "ens", + source: bhEnsNames, + display: "ens_name", + templates: { + header: '

ENS Names:

', + suggestion: function (data) { + var badges = ""; + if (data.is_contract) { + badges += `Contract`; + } + return `
${data.ens_name}${data.address}${badges}
`; + }, + }, + }); + } + // Initialize typeahead with all datasets searchEl.typeahead.apply(searchEl, [ { @@ -638,6 +675,8 @@ window.location = "/slots/filtered?f&f.orphaned=1&f.graffiti=" + encodeURIComponent(el.value) } else if (sug.pubkey !== undefined) { window.location = "/validator/" + sug.index + } else if (sug.ens_name !== undefined) { + window.location = "/address/" + sug.address } else if (sug.name !== undefined) { // sug.name is html-escaped to prevent xss, we need to unescape it var el = document.createElement("textarea") diff --git a/templates/_layout/header.html b/templates/_layout/header.html index a460c7a8a..0992f835b 100644 --- a/templates/_layout/header.html +++ b/templates/_layout/header.html @@ -54,7 +54,7 @@ {{ if .IsReady }}
- +
{{ end }} diff --git a/types/config.go b/types/config.go index e0ae82a19..165089a47 100644 --- a/types/config.go +++ b/types/config.go @@ -215,8 +215,9 @@ type Config struct { } `yaml:"rpcProxy"` // EnsResolver optionally resolves execution addresses to their primary ENS name. - // ENS lives on Ethereum mainnet, so Endpoints usually point at a mainnet RPC; when - // empty the resolver falls back to an available client from the main execution pool. + // ENS lives on Ethereum mainnet, so lookups always run against a mainnet RPC: + // the configured Endpoints, or a public mainnet RPC when empty. The local + // execution pool is never used (devnets/testnets have no ENS deployment). EnsResolver struct { Enabled bool `yaml:"enabled" envconfig:"ENSRESOLVER_ENABLED"` Endpoints []EndpointConfig `yaml:"endpoints"` diff --git a/types/models.go b/types/models.go index 404a4861f..66247143d 100644 --- a/types/models.go +++ b/types/models.go @@ -33,6 +33,7 @@ type PageData struct { MainMenuItems []MainMenuItem ApiEnabled bool ExecutionIndexerEnabled bool + EnsSearchEnabled bool } type MainMenuItem struct { diff --git a/types/models/search.go b/types/models/search.go index f93fa0529..fb853717f 100644 --- a/types/models/search.go +++ b/types/models/search.go @@ -61,6 +61,14 @@ type SearchAheadAddressResult struct { HasData bool `json:"has_data,omitempty"` } +// SearchAheadEnsResult is a struct to hold the search ahead ENS name results +type SearchAheadEnsResult struct { + EnsName string `json:"ens_name,omitempty"` + Address string `json:"address,omitempty"` + IsContract bool `json:"is_contract,omitempty"` + HasData bool `json:"has_data,omitempty"` +} + // SearchAheadTransactionResult is a struct to hold the search ahead transaction results type SearchAheadTransactionResult struct { TxHash string `json:"tx_hash,omitempty"` From 75c1b3538ff6dee65f66ce671ea8973f1d87a581 Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 14 Aug 2026 17:37:07 +0200 Subject: [PATCH 2/7] rework ens resolver for multi-network resolution - resolve ENS names on the local network (via the main execution pool) and on configured remote networks (ensResolver.remoteNetworks, each with own RPC endpoints + registries); drops the single-network ensResolver.endpoints config - persist results per (address, network) in ens_names (PK address+network) and show all resolved names in the UI: clickable ENS icon (tag=local, globe=remote) in front of swapped names opens a callout with the raw address and every name+network, all copyable - forward resolution + typeahead ENS search across all networks, including prefix suggestions from already-resolved names - fix probe-once bug: registry/multicall bytecode probing is retried every 5min while incomplete, so contracts deployed after startup are picked up --- config/default.config.yml | 22 +- db/ens_names.go | 54 +- .../20260814000000_ens-names-network.sql | 16 + .../20260814000000_ens-names-network.sql | 46 ++ dbtypes/dbtypes.go | 6 +- handlers/address.go | 7 +- handlers/pageData.go | 22 +- handlers/search.go | 61 +- services/chainservice.go | 2 +- services/ensresolver.go | 697 +++++++++++++----- services/ensresolver_ens.go | 43 +- static/css/layout.css | 38 + static/js/explorer.js | 129 +++- templates/_shared/txDetailsModal.html | 2 + templates/address/address.html | 15 +- templates/debug_cache/debug_cache.html | 57 +- types/config.go | 34 +- types/models/address.go | 9 +- types/models/common.go | 47 +- types/models/search.go | 6 +- utils/templateFucs.go | 19 +- 21 files changed, 1011 insertions(+), 321 deletions(-) create mode 100644 db/schema/pgsql/20260814000000_ens-names-network.sql create mode 100644 db/schema/sqlite/20260814000000_ens-names-network.sql diff --git a/config/default.config.yml b/config/default.config.yml index 00ab0d758..dcf6c842e 100644 --- a/config/default.config.yml +++ b/config/default.config.yml @@ -189,20 +189,22 @@ rpcProxy: - "eth_chainId" # Network chain ID - "net_version" # Network version -# Optional ENS resolver: resolves execution addresses to their primary ENS name. +# Optional ENS resolver: resolves execution addresses to their primary ENS name on the +# local network (via the main execution pool) and on optional remote networks. ensResolver: enabled: false # enable ENS name resolution & display - # 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" - # ENS registries to query, in priority order (first verified name wins). Each is - # probed for bytecode on the target chain and skipped if not deployed. + # ENS registries on the local network (the chain this explorer indexes), in priority + # order (first verified name wins). Each is probed for bytecode and skipped while not + # deployed (probing is retried periodically, so late deployments are picked up). registryAddresses: - - "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" # ENS registry (mainnet) + - "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" # ENS registry (canonical address) multicallAddress: "0xcA11bde05977b3631167028862bE2a173976CA11" # Multicall3 (batches lookups when deployed) + # Additional networks to resolve ENS names on, each with dedicated EL RPC endpoints. + # registryAddresses/multicallAddress default to the canonical mainnet deployments. + remoteNetworks: [] + # - name: "mainnet" + # endpoints: + # - url: "https://ethereum-rpc.publicnode.com" refreshPositive: 24h # re-resolve interval for addresses that have a name refreshNegative: 6h # re-resolve interval for addresses without a name batchSize: 100 # addresses resolved per worker batch diff --git a/db/ens_names.go b/db/ens_names.go index 13385e3ca..3335610f8 100644 --- a/db/ens_names.go +++ b/db/ens_names.go @@ -9,18 +9,18 @@ import ( "github.com/jmoiron/sqlx" ) -// GetEnsNamesByAddresses retrieves persisted ENS lookups (positive and negative) -// for multiple addresses in a single query. Returns a map from address (hex string) -// to the stored entry for efficient lookup. -func GetEnsNamesByAddresses(ctx context.Context, addresses [][]byte) (map[string]*dbtypes.EnsName, error) { +// GetEnsNamesByAddresses retrieves persisted ENS lookups (positive and negative, all +// networks) for multiple addresses in a single query. Returns a map from address +// (hex string) to the stored per-network entries for efficient lookup. +func GetEnsNamesByAddresses(ctx context.Context, addresses [][]byte) (map[string][]*dbtypes.EnsName, error) { if len(addresses) == 0 { - return make(map[string]*dbtypes.EnsName), nil + return make(map[string][]*dbtypes.EnsName), nil } var sql strings.Builder args := make([]any, len(addresses)) - fmt.Fprint(&sql, "SELECT address, name, resolved_time FROM ens_names WHERE address IN (") + fmt.Fprint(&sql, "SELECT address, network, name, resolved_time FROM ens_names WHERE address IN (") for i, addr := range addresses { args[i] = addr } @@ -34,26 +34,50 @@ func GetEnsNamesByAddresses(ctx context.Context, addresses [][]byte) (map[string return nil, err } - result := make(map[string]*dbtypes.EnsName, len(names)) + result := make(map[string][]*dbtypes.EnsName, len(names)) for _, name := range names { - result[byteSliceMapKey(name.Address)] = name + key := byteSliceMapKey(name.Address) + result[key] = append(result[key], name) } return result, nil } +// GetEnsNamesByPrefix retrieves persisted positive ENS lookups whose name starts with +// the given prefix (case-sensitive; stored names are lowercase), across all networks. +func GetEnsNamesByPrefix(ctx context.Context, prefix string, limit int) ([]*dbtypes.EnsName, error) { + if prefix == "" || limit <= 0 { + return nil, nil + } + + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix) + + names := []*dbtypes.EnsName{} + err := ReaderDb.SelectContext(ctx, &names, ` + SELECT address, network, name, resolved_time + FROM ens_names + WHERE name LIKE $1 ESCAPE '\' AND name != '' + ORDER BY name ASC + LIMIT $2`, escaped+"%", limit) + if err != nil { + logger.Errorf("Error while fetching ens names by prefix: %v", err) + return nil, err + } + return names, nil +} + // InsertEnsNames upserts a batch of ENS lookups (positive and negative), refreshing -// the name and resolved_time for addresses that already exist. +// the name and resolved_time for (address, network) pairs that already exist. func InsertEnsNames(ctx context.Context, dbTx *sqlx.Tx, names []*dbtypes.EnsName) error { if len(names) == 0 { return nil } var sql strings.Builder - args := make([]any, 0, len(names)*3) + args := make([]any, 0, len(names)*4) fmt.Fprint(&sql, EngineQuery(map[dbtypes.DBEngineType]string{ - dbtypes.DBEnginePgsql: "INSERT INTO ens_names (address, name, resolved_time) VALUES ", - dbtypes.DBEngineSqlite: "INSERT OR REPLACE INTO ens_names (address, name, resolved_time) VALUES ", + dbtypes.DBEnginePgsql: "INSERT INTO ens_names (address, network, name, resolved_time) VALUES ", + dbtypes.DBEngineSqlite: "INSERT OR REPLACE INTO ens_names (address, network, name, resolved_time) VALUES ", })) for i, name := range names { @@ -61,12 +85,12 @@ func InsertEnsNames(ctx context.Context, dbTx *sqlx.Tx, names []*dbtypes.EnsName fmt.Fprint(&sql, ", ") } argIdx := len(args) + 1 - fmt.Fprintf(&sql, "($%d, $%d, $%d)", argIdx, argIdx+1, argIdx+2) - args = append(args, name.Address, name.Name, name.ResolvedTime) + fmt.Fprintf(&sql, "($%d, $%d, $%d, $%d)", argIdx, argIdx+1, argIdx+2, argIdx+3) + args = append(args, name.Address, name.Network, name.Name, name.ResolvedTime) } fmt.Fprint(&sql, EngineQuery(map[dbtypes.DBEngineType]string{ - dbtypes.DBEnginePgsql: " ON CONFLICT (address) DO UPDATE SET name = excluded.name, resolved_time = excluded.resolved_time", + dbtypes.DBEnginePgsql: " ON CONFLICT (address, network) DO UPDATE SET name = excluded.name, resolved_time = excluded.resolved_time", })) _, err := dbTx.ExecContext(ctx, sql.String(), args...) diff --git a/db/schema/pgsql/20260814000000_ens-names-network.sql b/db/schema/pgsql/20260814000000_ens-names-network.sql new file mode 100644 index 000000000..b48d5875d --- /dev/null +++ b/db/schema/pgsql/20260814000000_ens-names-network.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE public."ens_names" ADD COLUMN "network" TEXT NOT NULL DEFAULT ''; +ALTER TABLE public."ens_names" DROP CONSTRAINT "ens_names_pkey"; +ALTER TABLE public."ens_names" ADD CONSTRAINT "ens_names_pkey" PRIMARY KEY ("address", "network"); +CREATE INDEX IF NOT EXISTS "ens_names_name_idx" ON public."ens_names" ("name") WHERE "name" != ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS "ens_names_name_idx"; +DELETE FROM public."ens_names" WHERE "network" != ''; +ALTER TABLE public."ens_names" DROP CONSTRAINT "ens_names_pkey"; +ALTER TABLE public."ens_names" ADD CONSTRAINT "ens_names_pkey" PRIMARY KEY ("address"); +ALTER TABLE public."ens_names" DROP COLUMN "network"; +-- +goose StatementEnd diff --git a/db/schema/sqlite/20260814000000_ens-names-network.sql b/db/schema/sqlite/20260814000000_ens-names-network.sql new file mode 100644 index 000000000..0e3e9aefd --- /dev/null +++ b/db/schema/sqlite/20260814000000_ens-names-network.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE "ens_names_new" ( + address BLOB NOT NULL, + network TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + resolved_time INTEGER NOT NULL DEFAULT 0, + CONSTRAINT ens_names_pkey PRIMARY KEY (address, network) +); +-- +goose StatementEnd +-- +goose StatementBegin +INSERT INTO "ens_names_new" (address, network, name, resolved_time) +SELECT address, '', name, resolved_time FROM "ens_names"; +-- +goose StatementEnd +-- +goose StatementBegin +DROP TABLE "ens_names"; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE "ens_names_new" RENAME TO "ens_names"; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE INDEX IF NOT EXISTS "ens_names_name_idx" ON "ens_names" ("name") WHERE "name" != ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS "ens_names_name_idx"; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TABLE "ens_names_old" ( + address BLOB NOT NULL, + name TEXT NOT NULL DEFAULT '', + resolved_time INTEGER NOT NULL DEFAULT 0, + CONSTRAINT ens_names_pkey PRIMARY KEY (address) +); +-- +goose StatementEnd +-- +goose StatementBegin +INSERT INTO "ens_names_old" (address, name, resolved_time) +SELECT address, name, resolved_time FROM "ens_names" WHERE network = ''; +-- +goose StatementEnd +-- +goose StatementBegin +DROP TABLE "ens_names"; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE "ens_names_old" RENAME TO "ens_names"; +-- +goose StatementEnd diff --git a/dbtypes/dbtypes.go b/dbtypes/dbtypes.go index 91117fe4b..e1d4c24f7 100644 --- a/dbtypes/dbtypes.go +++ b/dbtypes/dbtypes.go @@ -677,10 +677,12 @@ type ElAccount struct { LastBlockUid uint64 `db:"last_block_uid"` } -// EnsName holds a resolved primary ENS name for an execution address. -// An empty Name is a persisted negative result (address has no primary name). +// EnsName holds a resolved primary ENS name for an execution address on one network. +// An empty Network is the local network (the chain this explorer indexes); an empty +// Name is a persisted negative result (address has no primary name on that network). type EnsName struct { Address []byte `db:"address"` + Network string `db:"network"` Name string `db:"name"` ResolvedTime int64 `db:"resolved_time"` } diff --git a/handlers/address.go b/handlers/address.go index ddf2e66b1..4471a1a4c 100644 --- a/handlers/address.go +++ b/handlers/address.go @@ -330,8 +330,11 @@ func buildAddressPageData(ctx context.Context, addressBytes []byte, tabView stri ensNames := resolveEnsNames(ctx, ensAddrs) pageData.SetEnsNames(ensNames) // The page's own address is rendered full (not as a swappable link), so surface its - // name explicitly for the header to show alongside the address. - pageData.AddressEnsName = ensNames[strings.ToLower(common.BytesToAddress(pageData.Address).Hex())] + // names explicitly for the header/info rows to show alongside the address. + pageData.AddressEnsNames = ensNames[strings.ToLower(common.BytesToAddress(pageData.Address).Hex())] + if len(pageData.AddressEnsNames) > 0 { + pageData.AddressEnsName = pageData.AddressEnsNames[0].Name + } return pageData, 2 * time.Minute } diff --git a/handlers/pageData.go b/handlers/pageData.go index d2213cf3d..33b27d341 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -15,6 +15,7 @@ import ( "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/types" + "github.com/ethpandaops/dora/types/models" "github.com/ethpandaops/dora/utils" ) @@ -396,18 +397,29 @@ func handleTemplateError(w http.ResponseWriter, r *http.Request, fileIdentifier return err } -// resolveEnsNames resolves the primary ENS names for the given execution addresses via -// the ENS resolver service. It returns an address->name map (empty when the resolver is -// disabled) and enqueues unresolved/stale addresses for asynchronous resolution. +// resolveEnsNames resolves the ENS names for the given execution addresses on all +// configured networks via the ENS resolver service. It returns a map from lowercase +// 0x-hex address to the per-network names (display name first; empty map when the +// resolver is disabled) and enqueues unresolved/stale addresses for asynchronous +// resolution. // // Handlers call this once per page build with all addresses shown on the page; the // result is stored on the page model (models.EnsNameData) and rendered client-side. -func resolveEnsNames(ctx context.Context, addrs [][]byte) map[string]string { +func resolveEnsNames(ctx context.Context, addrs [][]byte) map[string][]models.EnsNameEntry { ensResolver := services.GlobalBeaconService.GetEnsResolver() if ensResolver == nil { return nil } - return ensResolver.ResolveNames(ctx, addrs) + resolved := ensResolver.ResolveNames(ctx, addrs) + names := make(map[string][]models.EnsNameEntry, len(resolved)) + for addr, entries := range resolved { + list := make([]models.EnsNameEntry, 0, len(entries)) + for _, entry := range entries { + list = append(list, models.EnsNameEntry{Name: entry.Name, Network: entry.Network, Local: entry.Local}) + } + names[addr] = list + } + return names } // appendEnsHexAddrs appends the 20-byte form of 0x-hex address strings to dst, skipping diff --git a/handlers/search.go b/handlers/search.go index 3922e1613..361542bb9 100644 --- a/handlers/search.go +++ b/handlers/search.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/gorilla/mux" "github.com/sirupsen/logrus" @@ -161,8 +162,9 @@ 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 + // matches are in network priority order (local first), so the first one wins + if matches := services.GlobalBeaconService.GetEnsResolver().ResolveEnsName(ctx, searchQuery); len(matches) > 0 { + return searchResolverResult{RedirectURL: fmt.Sprintf("/address/%s", strings.ToLower(matches[0].Address.Hex()))}, cacheTimeout } } @@ -207,8 +209,8 @@ func SearchAhead(w http.ResponseWriter, r *http.Request) { 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) + search = strings.ReplaceAll(search, "0x", "") + search = strings.ReplaceAll(search, "0X", "") } // 404 before cache so we don't cache disabled/unknown types @@ -593,20 +595,51 @@ func buildSearchAheadResult(ctx context.Context, searchType, search string) (*se } } case "ens": - if !ensNameRE.MatchString(search) { + if len(search) < 2 { 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, - }, + ensResolver := services.GlobalBeaconService.GetEnsResolver() + results := make([]models.SearchAheadEnsResult, 0, 10) + seen := make(map[string]struct{}, 10) + + // complete names are forward-resolved (EIP-137) on every configured network + if ensNameRE.MatchString(search) { + for _, match := range ensResolver.ResolveEnsName(ctx, search) { + results = append(results, models.SearchAheadEnsResult{ + EnsName: utils.FormatGraffitiString(strings.ToLower(search)), + Address: strings.ToLower(match.Address.Hex()), + Network: match.Network, + Local: match.Local, + }) + seen[strings.ToLower(search)+"\x00"+match.Network] = struct{}{} } } + + // suggest already-known (reverse-resolved) names matching the prefix + for _, match := range ensResolver.GetCachedNamesByPrefix(ctx, search, 10) { + if _, ok := seen[strings.ToLower(match.Name)+"\x00"+match.Network]; ok { + continue + } + if len(results) >= 10 { + break + } + results = append(results, models.SearchAheadEnsResult{ + EnsName: utils.FormatGraffitiString(match.Name), + Address: strings.ToLower(match.Address.Hex()), + Network: match.Network, + Local: match.Local, + }) + } + + for i := range results { + addrBytes := common.HexToAddress(results[i].Address).Bytes() + account, _ := db.GetElAccountByAddress(ctx, addrBytes) + results[i].IsContract = account != nil && account.IsContract + results[i].HasData = account != nil && account.ID > 0 + } + if len(results) > 0 { + result = &results + } case "transactions": if len(search) == 0 { break diff --git a/services/chainservice.go b/services/chainservice.go index 07af4c05c..badc4f3b0 100644 --- a/services/chainservice.go +++ b/services/chainservice.go @@ -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")) + ensResolver := NewEnsResolver(ctx, logger.WithField("service", "ens-resolver"), executionPool) // Set execution time provider beaconIndexer.SetExecutionTimeProvider(snooper.NewExecutionTimeProvider(snooperManager.GetCache())) diff --git a/services/ensresolver.go b/services/ensresolver.go index 12acc16c4..5fa31d42c 100644 --- a/services/ensresolver.go +++ b/services/ensresolver.go @@ -15,6 +15,7 @@ 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" @@ -23,41 +24,116 @@ import ( "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. +// Canonical mainnet deployments, used as defaults for registry/multicall addresses. +const ( + ensDefaultRegistry = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" + ensDefaultMulticall = "0xcA11bde05977b3631167028862bE2a173976CA11" +) + +// ensProbeRetryInterval is how often incomplete probe results (missing registries or +// multicall) are re-checked, so contracts deployed after startup are picked up. +const ensProbeRetryInterval = 5 * time.Minute + +// ensNetworkBackoff is how long a network is skipped by the resolve worker after a +// client/probe error, so one broken endpoint doesn't stall the whole batch pipeline. +const ensNetworkBackoff = 30 * time.Second + +// EnsResolver resolves execution addresses to their primary ENS name on every +// configured network: the local network (the chain this explorer indexes, via the +// main execution pool) and any remote networks with dedicated RPC endpoints. +// Resolution is batched, asynchronous and persisted per (address, network) to the +// ens_names table. Handlers call ResolveNames once per page to warm the in-memory +// cache and feed the resolve queue. type EnsResolver struct { - ctx context.Context - logger logrus.FieldLogger + ctx context.Context + logger logrus.FieldLogger + execPool *execution.Pool - started atomic.Bool - cache *lru.Cache[common.Address, *ensCacheEntry] + started atomic.Bool + networks []*ensNetwork - // forward resolution cache (name -> address), used by the search bar + cache *lru.Cache[common.Address, *ensCacheEntry] + + // forward resolution cache ("\x00" -> address), used by search forwardCache *lru.Cache[string, *ensForwardCacheEntry] queue chan common.Address pending sync.Map // common.Address -> struct{} +} + +// ensNetwork is one ENS resolution source: the local network (the chain this explorer +// indexes, queried via the main execution pool) or a remote network with dedicated RPC +// endpoints (e.g. Ethereum mainnet on a devnet explorer). +type ensNetwork struct { + key string // DB/storage key ("" = local network) + name string // display name + local bool + + registryAddrs []common.Address // configured (valid) registry addresses + multicallAddr common.Address // configured multicall address (zero = disabled) + + endpoints []types.EndpointConfig // remote networks only + clientInit sync.Once + clients []*ensEndpointClient + + failedUntil atomic.Int64 // resolve-worker backoff after client/probe errors (unix ts) + + // probe results: which contracts actually have bytecode on the network. Incomplete + // results are re-probed every ensProbeRetryInterval so late deployments are found. + probeMutex sync.Mutex + probed bool + probeTime int64 + registries []common.Address + multicallReady bool +} - // dedicated RPC clients built from EnsResolver.Endpoints (lazy init) - dedicatedInit sync.Once - dedicatedClients []*ensEndpointClient +// ensProbeState is a consistent snapshot of a network's probe results for one resolve +// operation (the underlying state may change concurrently on re-probe). +type ensProbeState struct { + registries []common.Address + multicallReady bool + multicallAddr common.Address +} - // probe results (immutable once probed; guarded by probeMutex until then) - probeMutex sync.Mutex - probed bool - registries []common.Address - multicallAddress common.Address - multicallReady bool +// getProbeState returns a snapshot of the network's probe results. +func (n *ensNetwork) getProbeState() ensProbeState { + n.probeMutex.Lock() + defer n.probeMutex.Unlock() + return ensProbeState{ + registries: n.registries, + multicallReady: n.multicallReady, + multicallAddr: n.multicallAddr, + } } -// ensCacheEntry is a cached lookup result. An empty name is a negative result. +// ResolvedEnsName is one resolved primary ENS name on a specific network. +type ResolvedEnsName struct { + Name string + Network string // display name of the network + Local bool // resolved on the chain this explorer indexes +} + +// EnsForwardMatch is a forward-resolution result (name -> address) on one network. +type EnsForwardMatch struct { + Address common.Address + Network string + Local bool +} + +// EnsPrefixMatch is a known (already reverse-resolved) name matching a search prefix. +type EnsPrefixMatch struct { + Address common.Address + Name string + Network string + Local bool +} + +// ensCacheEntry is a cached lookup result for one address covering all configured +// networks. No names with full coverage is a negative result. type ensCacheEntry struct { - name string - resolvedTime int64 + names []*ResolvedEnsName // positive results in display order (local first) + covered int // number of configured networks with a persisted result + resolvedTime int64 // oldest resolve time across covered networks } // ensForwardCacheEntry is a cached forward-resolution result (name -> address). @@ -73,31 +149,43 @@ type ensEndpointClient struct { client *exerpc.ExecutionClient } -func NewEnsResolver(ctx context.Context, logger logrus.FieldLogger) *EnsResolver { +func NewEnsResolver(ctx context.Context, logger logrus.FieldLogger, execPool *execution.Pool) *EnsResolver { return &EnsResolver{ - ctx: ctx, - logger: logger.WithField("service", "ens-resolver"), + ctx: ctx, + logger: logger.WithField("service", "ens-resolver"), + execPool: execPool, } } -// EnsResolverStats is a snapshot of the ENS resolver's runtime state for the debug page. -type EnsResolverStats struct { - Enabled bool +// EnsResolverNetworkStats is a per-network snapshot of probe/endpoint state for the +// debug page. +type EnsResolverNetworkStats struct { + Name string + Local bool + Endpoints int // dedicated endpoints (0 for local = main execution pool) Probed bool - QueueLen int - QueueCap int - CacheLen int - CacheCap int ConfiguredRegistries int Registries []string // usable (bytecode-probed) registries, in priority order MulticallReady bool MulticallAddress string - RefreshPositive time.Duration - RefreshNegative time.Duration } -// GetDebugStats returns a snapshot of the resolver's queue, cache and probed registries -// for the /debug/cache page. Safe to call when the resolver is disabled (nil-safe). +// EnsResolverStats is a snapshot of the ENS resolver's runtime state for the debug page. +type EnsResolverStats struct { + Enabled bool + QueueLen int + QueueCap int + CacheLen int + CacheCap int + ForwardCacheLen int + RefreshPositive time.Duration + RefreshNegative time.Duration + Networks []EnsResolverNetworkStats +} + +// GetDebugStats returns a snapshot of the resolver's queue, cache and per-network +// probe state for the /debug/cache page. Safe to call when the resolver is disabled +// (nil-safe). func (e *EnsResolver) GetDebugStats() *EnsResolverStats { stats := &EnsResolverStats{} if e == nil { @@ -106,7 +194,6 @@ func (e *EnsResolver) GetDebugStats() *EnsResolverStats { cfg := &utils.Config.EnsResolver stats.Enabled = e.started.Load() - stats.ConfiguredRegistries = len(cfg.RegistryAddresses) stats.RefreshPositive = cfg.RefreshPositive stats.RefreshNegative = cfg.RefreshNegative @@ -118,22 +205,37 @@ func (e *EnsResolver) GetDebugStats() *EnsResolverStats { stats.CacheLen = e.cache.Len() stats.CacheCap = cfg.CacheSize } - - e.probeMutex.Lock() - stats.Probed = e.probed - stats.MulticallReady = e.multicallReady - if e.multicallReady { - stats.MulticallAddress = e.multicallAddress.Hex() - } - for _, registry := range e.registries { - stats.Registries = append(stats.Registries, registry.Hex()) + if e.forwardCache != nil { + stats.ForwardCacheLen = e.forwardCache.Len() + } + + stats.Networks = make([]EnsResolverNetworkStats, 0, len(e.networks)) + for _, network := range e.networks { + network.probeMutex.Lock() + netStats := EnsResolverNetworkStats{ + Name: network.name, + Local: network.local, + Endpoints: len(network.endpoints), + Probed: network.probed, + ConfiguredRegistries: len(network.registryAddrs), + Registries: make([]string, 0, len(network.registries)), + MulticallReady: network.multicallReady, + } + if network.multicallReady { + netStats.MulticallAddress = network.multicallAddr.Hex() + } + for _, registry := range network.registries { + netStats.Registries = append(netStats.Registries, registry.Hex()) + } + network.probeMutex.Unlock() + stats.Networks = append(stats.Networks, netStats) } - e.probeMutex.Unlock() return stats } -// StartUpdater applies defaults, initializes the cache/queue and starts the worker. +// StartUpdater applies defaults, builds the network list, initializes the cache/queue +// and starts the worker. func (e *EnsResolver) StartUpdater() { if e.started.Load() { return @@ -156,18 +258,14 @@ func (e *EnsResolver) StartUpdater() { cfg.CacheSize = 50000 } 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"}} + cfg.RegistryAddresses = []string{ensDefaultRegistry} } if cfg.MulticallAddress == "" { - cfg.MulticallAddress = "0xcA11bde05977b3631167028862bE2a173976CA11" + cfg.MulticallAddress = ensDefaultMulticall } + e.networks = e.buildNetworks() + cache, err := lru.New[common.Address, *ensCacheEntry](cfg.CacheSize) if err != nil { e.logger.Errorf("failed to create ens name cache: %v", err) @@ -188,20 +286,113 @@ func (e *EnsResolver) StartUpdater() { go e.runUpdaterLoop() } -// ResolveNames returns the known primary names for the given addresses (keyed by -// lowercase 0x-hex), warming the cache with one batched DB query for cache misses and -// enqueuing unresolved or stale addresses for asynchronous resolution. +// buildNetworks assembles the resolution sources from config: the local network first +// (resolved via the main execution pool), then the remote networks in config order. +func (e *EnsResolver) buildNetworks() []*ensNetwork { + cfg := &utils.Config.EnsResolver + + localName := utils.Config.Chain.DisplayName + if localName == "" { + localName = "local" + } + + networks := make([]*ensNetwork, 0, len(cfg.RemoteNetworks)+1) + networks = append(networks, &ensNetwork{ + key: "", + name: localName, + local: true, + registryAddrs: e.parseEnsAddresses(cfg.RegistryAddresses, "local"), + multicallAddr: e.parseEnsAddress(cfg.MulticallAddress, "local"), + }) + + seen := make(map[string]struct{}, len(cfg.RemoteNetworks)) + for i := range cfg.RemoteNetworks { + remote := &cfg.RemoteNetworks[i] + if remote.Name == "" { + e.logger.Warnf("skipping ens remote network without a name") + continue + } + if len(remote.Endpoints) == 0 { + e.logger.Warnf("skipping ens remote network %q without endpoints", remote.Name) + continue + } + if _, dup := seen[remote.Name]; dup { + e.logger.Warnf("skipping duplicate ens remote network %q", remote.Name) + continue + } + seen[remote.Name] = struct{}{} + + registryAddrs := remote.RegistryAddresses + if len(registryAddrs) == 0 { + registryAddrs = []string{ensDefaultRegistry} + } + multicallAddr := remote.MulticallAddress + if multicallAddr == "" { + multicallAddr = ensDefaultMulticall + } + + networks = append(networks, &ensNetwork{ + key: remote.Name, + name: remote.Name, + registryAddrs: e.parseEnsAddresses(registryAddrs, remote.Name), + multicallAddr: e.parseEnsAddress(multicallAddr, remote.Name), + endpoints: remote.Endpoints, + }) + } + + return networks +} + +// parseEnsAddresses parses configured hex addresses, dropping invalid entries with a +// warning. +func (e *EnsResolver) parseEnsAddresses(raw []string, networkName string) []common.Address { + addrs := make([]common.Address, 0, len(raw)) + for _, entry := range raw { + if !common.IsHexAddress(entry) { + e.logger.Warnf("invalid ens registry address %q for network %q, skipping", entry, networkName) + continue + } + addrs = append(addrs, common.HexToAddress(entry)) + } + return addrs +} + +// parseEnsAddress parses a configured hex address, returning the zero address (which +// disables the contract) for invalid entries. +func (e *EnsResolver) parseEnsAddress(raw, networkName string) common.Address { + if !common.IsHexAddress(raw) { + e.logger.Warnf("invalid ens multicall address %q for network %q, disabling multicall", raw, networkName) + return common.Address{} + } + return common.HexToAddress(raw) +} + +// networkByKey returns the configured network with the given storage key, or nil. +func (e *EnsResolver) networkByKey(key string) *ensNetwork { + for _, network := range e.networks { + if network.key == key { + return network + } + } + return nil +} + +// ResolveNames returns the known names for the given addresses on all configured +// networks (keyed by lowercase 0x-hex, per-address list in display order: local network +// first). The cache is warmed with one batched DB query for cache misses; unresolved or +// stale addresses (including addresses missing results for a newly added network) are +// enqueued for asynchronous resolution. // // It is called by page handlers in the (uncached) build path — never from templates. -func (e *EnsResolver) ResolveNames(ctx context.Context, addrs [][]byte) map[string]string { - result := make(map[string]string) +func (e *EnsResolver) ResolveNames(ctx context.Context, addrs [][]byte) map[string][]*ResolvedEnsName { + result := make(map[string][]*ResolvedEnsName, len(addrs)) if e == nil || !e.started.Load() || len(addrs) == 0 { return result } now := time.Now().Unix() seen := make(map[common.Address]struct{}, len(addrs)) - misses := make([][]byte, 0) + misses := make([][]byte, 0, len(addrs)) for _, raw := range addrs { if len(raw) != 20 { @@ -214,8 +405,8 @@ func (e *EnsResolver) ResolveNames(ctx context.Context, addrs [][]byte) map[stri seen[addr] = struct{}{} if entry, ok := e.cache.Get(addr); ok { - if entry.name != "" { - result[strings.ToLower(addr.Hex())] = entry.name + if len(entry.names) > 0 { + result[strings.ToLower(addr.Hex())] = entry.names } if e.isStale(entry, now) { e.enqueue(addr) @@ -238,17 +429,17 @@ func (e *EnsResolver) ResolveNames(ctx context.Context, addrs [][]byte) map[stri for _, raw := range misses { addr := common.BytesToAddress(raw) - dbEntry, ok := dbEntries[hex.EncodeToString(raw)] + rows, ok := dbEntries[hex.EncodeToString(raw)] if !ok { // never resolved yet e.enqueue(addr) continue } - entry := &ensCacheEntry{name: dbEntry.Name, resolvedTime: dbEntry.ResolvedTime} + entry := e.cacheEntryFromRows(rows) e.cache.Add(addr, entry) - if entry.name != "" { - result[strings.ToLower(addr.Hex())] = entry.name + if len(entry.names) > 0 { + result[strings.ToLower(addr.Hex())] = entry.names } if e.isStale(entry, now) { e.enqueue(addr) @@ -258,11 +449,45 @@ func (e *EnsResolver) ResolveNames(ctx context.Context, addrs [][]byte) map[stri return result } -// isStale reports whether an entry is older than its refresh interval and should be -// re-resolved (positive and negative results use separate intervals). +// cacheEntryFromRows builds a cache entry from the persisted per-network rows of one +// address, ordering positive names by network priority (local first). Rows of networks +// that are no longer configured are ignored. +func (e *EnsResolver) cacheEntryFromRows(rows []*dbtypes.EnsName) *ensCacheEntry { + byKey := make(map[string]*dbtypes.EnsName, len(rows)) + for _, row := range rows { + byKey[row.Network] = row + } + + entry := &ensCacheEntry{} + for _, network := range e.networks { + row, ok := byKey[network.key] + if !ok { + continue + } + entry.covered++ + if entry.resolvedTime == 0 || row.ResolvedTime < entry.resolvedTime { + entry.resolvedTime = row.ResolvedTime + } + if row.Name != "" { + entry.names = append(entry.names, &ResolvedEnsName{ + Name: row.Name, + Network: network.name, + Local: network.local, + }) + } + } + return entry +} + +// isStale reports whether an entry should be re-resolved: it lacks a result for a +// configured network, or it is older than its refresh interval (positive and negative +// results use separate intervals). func (e *EnsResolver) isStale(entry *ensCacheEntry, now int64) bool { + if entry.covered < len(e.networks) { + return true + } refresh := utils.Config.EnsResolver.RefreshPositive - if entry.name == "" { + if len(entry.names) == 0 { refresh = utils.Config.EnsResolver.RefreshNegative } if refresh <= 0 { @@ -271,42 +496,83 @@ 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. +// ResolveEnsName forward-resolves an ENS name (EIP-137) on every configured network, +// using synchronous eth_calls on cache miss (uncached networks are queried +// concurrently). Results keep network display order (local first) and only contain +// networks where 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) { +// 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) []*EnsForwardMatch { if e == nil || !e.started.Load() { - return common.Address{}, false + return nil } name = strings.ToLower(strings.TrimSpace(name)) if name == "" || !strings.Contains(name, ".") { - return common.Address{}, false + return nil } - if entry, ok := e.forwardCache.Get(name); ok && !e.isForwardStale(entry, time.Now().Unix()) { - return entry.address, entry.address != (common.Address{}) + now := time.Now().Unix() + matches := make([]*EnsForwardMatch, len(e.networks)) + pending := make([]*ensNetwork, 0, len(e.networks)) + pendingIdx := make([]int, 0, len(e.networks)) + + for i, network := range e.networks { + if entry, ok := e.forwardCache.Get(network.key + "\x00" + name); ok && !e.isForwardStale(entry, now) { + if entry.address != (common.Address{}) { + matches[i] = &EnsForwardMatch{Address: entry.address, Network: network.name, Local: network.local} + } + continue + } + pending = append(pending, network) + pendingIdx = append(pendingIdx, i) + } + + if len(pending) > 0 { + resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + var wg sync.WaitGroup + for j := range pending { + wg.Add(1) + go func(idx int, network *ensNetwork) { + defer wg.Done() + addr, err := e.resolveForwardOnNetwork(resolveCtx, network, name) + if err != nil { + e.logger.Warnf("ens forward resolve %q on network %q: %v", name, network.name, err) + return + } + e.forwardCache.Add(network.key+"\x00"+name, &ensForwardCacheEntry{address: addr, resolvedTime: time.Now().Unix()}) + if addr != (common.Address{}) { + // each goroutine writes a distinct slice index, so no lock is needed + matches[idx] = &EnsForwardMatch{Address: addr, Network: network.name, Local: network.local} + } + }(pendingIdx[j], pending[j]) + } + wg.Wait() } - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() + out := make([]*EnsForwardMatch, 0, len(matches)) + for _, match := range matches { + if match != nil { + out = append(out, match) + } + } + return out +} - ethClient, err := e.getEthClient(ctx) +// resolveForwardOnNetwork forward-resolves a name on one network (client + probe + +// registry lookups). +func (e *EnsResolver) resolveForwardOnNetwork(ctx context.Context, network *ensNetwork, name string) (common.Address, error) { + ethClient, err := e.getEthClient(ctx, network) if err != nil { - e.logger.Warnf("ens forward resolve %q: %v", name, err) - return common.Address{}, false + return common.Address{}, err } - if err := e.ensureProbed(ctx, ethClient); err != nil { - e.logger.Warnf("ens forward resolve %q: %v", name, err) - return common.Address{}, false + if err := e.ensureProbed(ctx, network, ethClient); err != nil { + return common.Address{}, err } - - addr := e.resolveForward(ctx, ethClient, name) - e.forwardCache.Add(name, &ensForwardCacheEntry{address: addr, resolvedTime: time.Now().Unix()}) - return addr, addr != (common.Address{}) + return e.resolveForward(ctx, ethClient, network.getProbeState(), name), nil } // isForwardStale reports whether a forward cache entry is past its refresh interval @@ -322,6 +588,39 @@ func (e *EnsResolver) isForwardStale(entry *ensForwardCacheEntry, now int64) boo return now-entry.resolvedTime > int64(refresh/time.Second) } +// GetCachedNamesByPrefix returns already-resolved ENS names starting with the given +// prefix from the DB, translating stored network keys to display names. Rows of +// networks that are no longer configured are dropped. +func (e *EnsResolver) GetCachedNamesByPrefix(ctx context.Context, prefix string, limit int) []*EnsPrefixMatch { + if e == nil || !e.started.Load() { + return nil + } + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if prefix == "" { + return nil + } + + rows, err := db.GetEnsNamesByPrefix(ctx, prefix, limit) + if err != nil { + return nil + } + + matches := make([]*EnsPrefixMatch, 0, len(rows)) + for _, row := range rows { + network := e.networkByKey(row.Network) + if network == nil { + continue + } + matches = append(matches, &EnsPrefixMatch{ + Address: common.BytesToAddress(row.Address), + Name: row.Name, + Network: network.name, + Local: network.local, + }) + } + return matches +} + // 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) { @@ -375,41 +674,61 @@ func (e *EnsResolver) gatherBatch(first common.Address) []common.Address { return batch } -// processBatch resolves a batch of addresses and persists the results (positive and -// negative) to the cache and DB. +// processBatch resolves a batch of addresses on every configured network and persists +// the per-network results (positive and negative) to the cache and DB. Networks that +// fail with a client error are skipped for ensNetworkBackoff and get no persisted +// result, so their coverage stays missing and affected addresses are re-enqueued on +// the next page view. An error is returned only when every network failed. func (e *EnsResolver) processBatch(batch []common.Address) error { if len(batch) == 0 { return nil } - ctx, cancel := context.WithTimeout(e.ctx, 60*time.Second) - defer cancel() - - ethClient, err := e.getEthClient(ctx) - if err != nil { - return err - } + now := time.Now().Unix() + succeeded := make([]*ensNetwork, 0, len(e.networks)) + names := make(map[string]map[common.Address]string, len(e.networks)) - if err := e.ensureProbed(ctx, ethClient); err != nil { - return err + for _, network := range e.networks { + if now < network.failedUntil.Load() { + continue + } + resolved, err := e.resolveBatchOnNetwork(network, batch) + if err != nil { + network.failedUntil.Store(time.Now().Unix() + int64(ensNetworkBackoff/time.Second)) + e.logger.Warnf("ens resolve batch on network %q failed: %v", network.name, err) + continue + } + succeeded = append(succeeded, network) + names[network.key] = resolved } - - names := map[common.Address]string{} - if len(e.registries) > 0 { - names = e.resolveBatch(ctx, ethClient, batch) + if len(succeeded) == 0 { + return fmt.Errorf("ens resolution failed on all %d networks", len(e.networks)) } - // with no usable registry, all addresses are persisted as negative so they aren't - // re-queued until RefreshNegative elapses (avoids spinning on chains without ENS). - now := time.Now().Unix() - dbNames := make([]*dbtypes.EnsName, 0, len(batch)) + now = time.Now().Unix() + dbNames := make([]*dbtypes.EnsName, 0, len(batch)*len(succeeded)) for _, addr := range batch { - name := names[addr] - e.cache.Add(addr, &ensCacheEntry{name: name, resolvedTime: now}) - dbNames = append(dbNames, &dbtypes.EnsName{Address: addr.Bytes(), Name: name, ResolvedTime: now}) + rows := make([]*dbtypes.EnsName, 0, len(succeeded)) + for _, network := range succeeded { + rows = append(rows, &dbtypes.EnsName{ + Address: addr.Bytes(), + Network: network.key, + Name: names[network.key][addr], + ResolvedTime: now, + }) + } + dbNames = append(dbNames, rows...) + + if len(succeeded) == len(e.networks) { + e.cache.Add(addr, e.cacheEntryFromRows(rows)) + } else { + // partial result: drop the cache entry so the next lookup rebuilds it from + // the DB (merging older rows of the failed networks) and re-enqueues. + e.cache.Remove(addr) + } } - err = db.RunDBTransaction(func(tx *sqlx.Tx) error { + err := db.RunDBTransaction(func(tx *sqlx.Tx) error { return db.InsertEnsNames(e.ctx, tx, dbNames) }) if err != nil { @@ -419,83 +738,124 @@ func (e *EnsResolver) processBatch(batch []common.Address) error { return nil } -// ensureProbed checks (once) which configured registries and the Multicall3 contract -// are actually deployed on the target chain. A client error is treated as transient -// (returns error, leaves unprobed); missing bytecode marks the contract unusable. -func (e *EnsResolver) ensureProbed(ctx context.Context, ethClient *ethclient.Client) error { - e.probeMutex.Lock() - defer e.probeMutex.Unlock() +// resolveBatchOnNetwork resolves the batch on a single network (client + probe + +// registry lookups). With no usable registry all addresses resolve to no name, which +// gets persisted as a negative result so they aren't re-queued until RefreshNegative +// elapses (avoids spinning on chains without ENS; the periodic re-probe picks up +// registries deployed later). +func (e *EnsResolver) resolveBatchOnNetwork(network *ensNetwork, batch []common.Address) (map[common.Address]string, error) { + ctx, cancel := context.WithTimeout(e.ctx, 60*time.Second) + defer cancel() - if e.probed { - return nil + ethClient, err := e.getEthClient(ctx, network) + if err != nil { + return nil, err + } + if err := e.ensureProbed(ctx, network, ethClient); err != nil { + return nil, err } - cfg := &utils.Config.EnsResolver - registries := make([]common.Address, 0, len(cfg.RegistryAddresses)) - for _, raw := range cfg.RegistryAddresses { - if !common.IsHexAddress(raw) { - e.logger.Warnf("invalid ens registry address %q, skipping", raw) - continue + probeState := network.getProbeState() + if len(probeState.registries) == 0 { + return map[common.Address]string{}, nil + } + return e.resolveBatch(ctx, ethClient, probeState, batch), nil +} + +// ensureProbed checks which configured registries and the multicall contract are +// actually deployed on the network. Complete results are final; incomplete results +// (missing registries or multicall) are re-probed every ensProbeRetryInterval, so +// contracts deployed after startup are picked up. A client error is treated as +// transient (returns error, keeps the previous state); missing bytecode marks the +// contract unusable until the next probe. +func (e *EnsResolver) ensureProbed(ctx context.Context, network *ensNetwork, ethClient *ethclient.Client) error { + network.probeMutex.Lock() + defer network.probeMutex.Unlock() + + now := time.Now().Unix() + if network.probed { + complete := len(network.registries) == len(network.registryAddrs) && + (network.multicallReady || network.multicallAddr == (common.Address{})) + if complete || now-network.probeTime < int64(ensProbeRetryInterval/time.Second) { + return nil } - addr := common.HexToAddress(raw) + } + firstProbe := !network.probed + + registries := make([]common.Address, 0, len(network.registryAddrs)) + for _, addr := range network.registryAddrs { code, err := ethClient.CodeAt(ctx, addr, nil) if err != nil { - return fmt.Errorf("probing ens registry %s: %w", raw, err) + return fmt.Errorf("probing ens registry %s on network %q: %w", addr.Hex(), network.name, err) } if len(code) == 0 { - e.logger.Warnf("ens registry %s has no bytecode on this chain, skipping", raw) + if firstProbe { + e.logger.Warnf("ens registry %s has no bytecode on network %q, skipping until deployed", addr.Hex(), network.name) + } continue } registries = append(registries, addr) } multicallReady := false - if common.IsHexAddress(cfg.MulticallAddress) { - mc := common.HexToAddress(cfg.MulticallAddress) - code, err := ethClient.CodeAt(ctx, mc, nil) + if network.multicallAddr != (common.Address{}) { + code, err := ethClient.CodeAt(ctx, network.multicallAddr, nil) if err != nil { - return fmt.Errorf("probing multicall %s: %w", cfg.MulticallAddress, err) + return fmt.Errorf("probing multicall %s on network %q: %w", network.multicallAddr.Hex(), network.name, err) } - if len(code) > 0 { - e.multicallAddress = mc - multicallReady = true - } else { - e.logger.Warnf("multicall %s not deployed, falling back to individual calls", cfg.MulticallAddress) + multicallReady = len(code) > 0 + if !multicallReady && firstProbe { + e.logger.Warnf("multicall %s not deployed on network %q, falling back to individual calls", network.multicallAddr.Hex(), network.name) } } - e.registries = registries - e.multicallReady = multicallReady - e.probed = true - e.logger.Infof("ens resolver ready: %d usable registries, multicall=%v", len(registries), multicallReady) + if firstProbe || len(registries) != len(network.registries) || multicallReady != network.multicallReady { + e.logger.Infof("ens network %q probed: %d/%d usable registries, multicall=%v", + network.name, len(registries), len(network.registryAddrs), multicallReady) + } + + network.registries = registries + network.multicallReady = multicallReady + network.probed = true + network.probeTime = now return nil } -// 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) { - e.dedicatedInit.Do(e.initDedicatedClients) - for _, ec := range e.dedicatedClients { +// getEthClient returns an eth client for ENS lookups on the given network: a ready +// client from the main execution pool for the local network, or one of the network's +// dedicated endpoints otherwise. +func (e *EnsResolver) getEthClient(ctx context.Context, network *ensNetwork) (*ethclient.Client, error) { + if network.local { + 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 + } + + network.clientInit.Do(func() { e.initNetworkClients(network) }) + for _, ec := range network.clients { if err := ec.client.Initialize(ctx); err != nil { - e.logger.Warnf("ens endpoint %s init failed: %v", ec.name, err) + e.logger.Warnf("ens endpoint %s (network %q) init failed: %v", ec.name, network.name, err) continue } if ethClient := ec.client.GetEthClient(); ethClient != nil { return ethClient, nil } } - return nil, fmt.Errorf("no usable ens endpoint") + return nil, fmt.Errorf("no usable ens endpoint for network %q", network.name) } -// initDedicatedClients builds RPC clients from the configured ENS endpoints. -func (e *EnsResolver) initDedicatedClients() { - endpoints := utils.Config.EnsResolver.Endpoints - clients := make([]*ensEndpointClient, 0, len(endpoints)) +// initNetworkClients builds RPC clients from a remote network's configured endpoints. +func (e *EnsResolver) initNetworkClients(network *ensNetwork) { + clients := make([]*ensEndpointClient, 0, len(network.endpoints)) - for i := range endpoints { - endpoint := endpoints[i] + for i := range network.endpoints { + endpoint := network.endpoints[i] processed, err := applyAuthGroupToEndpoint(&endpoint) if err != nil { e.logger.Warnf("could not apply authGroup to ens endpoint %q: %v", endpoint.Name, err) @@ -513,13 +873,18 @@ func (e *EnsResolver) initDedicatedClients() { } } - client, err := exerpc.NewExecutionClient(processed.Name, processed.Url, processed.Headers, sshConfig, e.logger.WithField("ens-endpoint", processed.Name)) + clientName := processed.Name + if clientName == "" { + clientName = fmt.Sprintf("%s-%d", network.name, i) + } + + client, err := exerpc.NewExecutionClient(clientName, processed.Url, processed.Headers, sshConfig, e.logger.WithField("ens-endpoint", clientName)) if err != nil { - e.logger.Warnf("could not create ens endpoint %q: %v", processed.Name, err) + e.logger.Warnf("could not create ens endpoint %q: %v", clientName, err) continue } - clients = append(clients, &ensEndpointClient{name: processed.Name, client: client}) + clients = append(clients, &ensEndpointClient{name: clientName, client: client}) } - e.dedicatedClients = clients + network.clients = clients } diff --git a/services/ensresolver_ens.go b/services/ensresolver_ens.go index 9eaefbd62..652140ea2 100644 --- a/services/ensresolver_ens.go +++ b/services/ensresolver_ens.go @@ -84,11 +84,11 @@ func reverseNode(addr common.Address) [32]byte { // 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 { +func (e *EnsResolver) resolveForward(ctx context.Context, ethClient *ethclient.Client, probeState ensProbeState, 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)}}) + for _, registry := range probeState.registries { + res, err := e.callBatch(ctx, ethClient, probeState, []ensCall{{target: registry, data: appendNode(selectorResolver, node)}}) if err != nil { e.logger.Warnf("ens forward stage1 (resolver) failed: %v", err) continue @@ -101,7 +101,7 @@ func (e *EnsResolver) resolveForward(ctx context.Context, ethClient *ethclient.C continue } - res, err = e.callBatch(ctx, ethClient, []ensCall{{target: resolver, data: appendNode(selectorEnsAddr, node)}}) + res, err = e.callBatch(ctx, ethClient, probeState, []ensCall{{target: resolver, data: appendNode(selectorEnsAddr, node)}}) if err != nil { e.logger.Warnf("ens forward stage2 (addr) failed: %v", err) continue @@ -117,18 +117,19 @@ func (e *EnsResolver) resolveForward(ctx context.Context, ethClient *ethclient.C 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 { +// resolveBatch resolves primary ENS names for the given addresses on one network, +// 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, probeState ensProbeState, addrs []common.Address) map[common.Address]string { result := make(map[common.Address]string, len(addrs)) pending := addrs - for _, registry := range e.registries { + for _, registry := range probeState.registries { if len(pending) == 0 { break } - resolved := e.resolveWithRegistry(ctx, ethClient, registry, pending) + resolved := e.resolveWithRegistry(ctx, ethClient, probeState, registry, pending) remaining := make([]common.Address, 0, len(pending)) for _, addr := range pending { @@ -145,7 +146,7 @@ func (e *EnsResolver) resolveBatch(ctx context.Context, ethClient *ethclient.Cli } // resolveWithRegistry runs the full reverse+verify flow against a single registry. -func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethclient.Client, registry common.Address, addrs []common.Address) map[common.Address]string { +func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethclient.Client, probeState ensProbeState, registry common.Address, addrs []common.Address) map[common.Address]string { out := make(map[common.Address]string) // stage 1: registry.resolver(reverseNode) -> reverse resolver address @@ -156,7 +157,7 @@ func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethcli calls[i] = ensCall{target: registry, data: appendNode(selectorResolver, revNodes[i])} } - res, err := e.callBatch(ctx, ethClient, calls) + res, err := e.callBatch(ctx, ethClient, probeState, calls) if err != nil { e.logger.Warnf("ens stage1 (resolver) failed: %v", err) return out @@ -192,7 +193,7 @@ func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethcli calls[i] = ensCall{target: w.revResolver, data: appendNode(selectorEnsName, w.revNode)} } - res, err = e.callBatch(ctx, ethClient, calls) + res, err = e.callBatch(ctx, ethClient, probeState, calls) if err != nil { e.logger.Warnf("ens stage2 (name) failed: %v", err) return out @@ -221,7 +222,7 @@ func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethcli calls[i] = ensCall{target: registry, data: appendNode(selectorResolver, w.fwdNode)} } - res, err = e.callBatch(ctx, ethClient, calls) + res, err = e.callBatch(ctx, ethClient, probeState, calls) if err != nil { e.logger.Warnf("ens stage3 (fwd resolver) failed: %v", err) return out @@ -249,7 +250,7 @@ func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethcli calls[i] = ensCall{target: w.fwdResolver, data: appendNode(selectorEnsAddr, w.fwdNode)} } - res, err = e.callBatch(ctx, ethClient, calls) + res, err = e.callBatch(ctx, ethClient, probeState, calls) if err != nil { e.logger.Warnf("ens stage4 (addr) failed: %v", err) return out @@ -267,15 +268,15 @@ func (e *EnsResolver) resolveWithRegistry(ctx context.Context, ethClient *ethcli return out } -// callBatch executes a set of eth_calls, using Multicall3 when available and falling -// back to individual calls otherwise. -func (e *EnsResolver) callBatch(ctx context.Context, ethClient *ethclient.Client, calls []ensCall) ([]ensCallResult, error) { +// callBatch executes a set of eth_calls, using Multicall3 when available on the +// network and falling back to individual calls otherwise. +func (e *EnsResolver) callBatch(ctx context.Context, ethClient *ethclient.Client, probeState ensProbeState, calls []ensCall) ([]ensCallResult, error) { if len(calls) == 0 { return nil, nil } - if e.multicallReady { - return e.callBatchMulticall(ctx, ethClient, calls) + if probeState.multicallReady { + return e.callBatchMulticall(ctx, ethClient, probeState, calls) } results := make([]ensCallResult, len(calls)) @@ -291,7 +292,7 @@ func (e *EnsResolver) callBatch(ctx context.Context, ethClient *ethclient.Client } // callBatchMulticall batches all calls into a single Multicall3.aggregate3 eth_call. -func (e *EnsResolver) callBatchMulticall(ctx context.Context, ethClient *ethclient.Client, calls []ensCall) ([]ensCallResult, error) { +func (e *EnsResolver) callBatchMulticall(ctx context.Context, ethClient *ethclient.Client, probeState ensProbeState, calls []ensCall) ([]ensCallResult, error) { mcCalls := make([]multicall3Call, len(calls)) for i, c := range calls { mcCalls[i] = multicall3Call{Target: c.target, AllowFailure: true, CallData: c.data} @@ -302,7 +303,7 @@ func (e *EnsResolver) callBatchMulticall(ctx context.Context, ethClient *ethclie return nil, fmt.Errorf("pack aggregate3: %w", err) } - target := e.multicallAddress + target := probeState.multicallAddr output, err := ethClient.CallContract(ctx, ethereum.CallMsg{To: &target, Data: input}, nil) if err != nil { return nil, fmt.Errorf("multicall eth_call: %w", err) diff --git a/static/css/layout.css b/static/css/layout.css index 091498231..928fb2b87 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -380,6 +380,44 @@ span.validator-label { vertical-align: bottom; } +/* Clickable ENS badge in front of a swapped name (local = tag icon, remote = globe + icon); opens the ENS callout popover with the raw address and all resolved names. */ +.ens-icon { + cursor: pointer; + margin-right: 0.25em; + font-size: 0.85em; +} +.ens-icon-local i { + color: var(--bs-primary); +} +.ens-icon-remote i { + color: var(--bs-secondary-color); +} +.ens-callout-popover { + max-width: 470px; +} +.ens-callout-popover .popover-body { + font-family: var(--bs-font-monospace); + font-size: 0.85em; +} +.ens-callout-row { + display: flex; + align-items: center; + gap: 0.5em; +} +.ens-callout-row .ens-callout-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ens-callout-row .badge { + flex-shrink: 0; +} +.ens-callout-row .fa-copy { + flex-shrink: 0; + margin-left: auto; +} + /* Highlight equal address links across rows on hover (set by explorer.js). */ .el-data-table .addr-cell a, .itx-wrap .itx-addr a { diff --git a/static/js/explorer.js b/static/js/explorer.js index 906b3dd0f..da868e77d 100644 --- a/static/js/explorer.js +++ b/static/js/explorer.js @@ -38,6 +38,7 @@ serverNow: serverNow, updateServerTime: updateServerTime, ensNameFor: ensNameFor, + ensEntriesFor: ensEntriesFor, applyEnsToNode: applyEnsToNode, }; @@ -61,22 +62,37 @@ }); } - // ensNamesMap holds the merged address->name lookup from every `.ens-names` JSON block - // in the DOM (the layout carries one; lazy-loaded fragments carry their own). + // ensNamesMap holds the merged address->entries lookup from every `.ens-names` JSON + // block in the DOM (the layout carries one; lazy-loaded fragments carry their own). + // Each entry is a list of {name, network, local} objects in display order (local + // network first). Plain-string values (from stale cached page fragments using the + // old single-name format) are normalized into the list form. var ensNamesMap = {}; function refreshEnsNamesMap() { document.querySelectorAll('script.ens-names').forEach(function(blob) { try { var parsed = JSON.parse(blob.textContent || '{}'); if (parsed && typeof parsed === 'object') { - for (var key in parsed) ensNamesMap[key.toLowerCase()] = parsed[key]; + for (var key in parsed) { + var value = parsed[key]; + if (typeof value === 'string') { + value = value ? [{ name: value, network: '', local: true }] : []; + } + if (Array.isArray(value) && value.length > 0) { + ensNamesMap[key.toLowerCase()] = value; + } + } } } catch (e) { /* ignore malformed block */ } }); } - function ensNameFor(address) { + function ensEntriesFor(address) { return address ? (ensNamesMap[String(address).toLowerCase()] || null) : null; } + function ensNameFor(address) { + var entries = ensEntriesFor(address); + return entries && entries.length > 0 ? entries[0].name : null; + } // setEnsTooltip sets a node's tooltip to "
" using Bootstrap's // data-bs-title (NOT the native `title`, which would show a second browser tooltip that @@ -96,16 +112,40 @@ node.setAttribute('data-bs-title', escapeHtml(name) + '
' + String(addr).toLowerCase()); } - // applyEnsToNode swaps a single element's text for the ENS name of `address` (if any), - // adding the ellipsis class and a full-name+address tooltip. Copy/href stay untouched. - // Returns true when a name was applied. Used for client-rendered callouts. + // attachEnsIcon inserts the clickable ENS icon (tag = resolved on the local network, + // globe = resolved on a remote network) before a name-swapped node. Clicking it opens + // the ENS callout with the raw address and all resolved names. A stale icon from a + // previous swap (reused nodes in client-rendered callouts) is replaced. + function attachEnsIcon(node, address, entries) { + if (!node.parentNode) return; + var prev = node.previousElementSibling; + if (prev && prev.classList.contains('ens-icon')) { + if (prev.getAttribute('data-ens-address') === address) return; + var stalePopover = bootstrap.Popover.getInstance(prev); + if (stalePopover) stalePopover.dispose(); + prev.remove(); + } + var icon = document.createElement('span'); + icon.className = 'ens-icon ' + (entries[0].local ? 'ens-icon-local' : 'ens-icon-remote'); + icon.setAttribute('role', 'button'); + icon.setAttribute('tabindex', '0'); + icon.setAttribute('data-ens-address', address); + icon.innerHTML = ''; + node.parentNode.insertBefore(icon, node); + } + + // applyEnsToNode swaps a single element's text for the primary ENS name of `address` + // (if any), adding the ellipsis class, a full-name+address tooltip and the callout + // icon. Copy/href stay untouched. Returns true when a name was applied. Used for + // client-rendered callouts. function applyEnsToNode(node, address) { if (!node) return false; - var name = ensNameFor(address); - if (!name) return false; - node.textContent = name; + var entries = ensEntriesFor(address); + if (!entries) return false; + node.textContent = entries[0].name; node.classList.add('ens-name'); - setEnsTooltip(node, name, address); + setEnsTooltip(node, entries[0].name, address); + attachEnsIcon(node, String(address).toLowerCase(), entries); return true; } @@ -120,15 +160,68 @@ document.querySelectorAll('.ens-addr[data-address]').forEach(function(node) { if (node.getAttribute('data-ens-applied')) return; var addr = (node.getAttribute('data-address') || '').toLowerCase(); - var name = ensNamesMap[addr]; - if (!name) return; - node.textContent = name; + var entries = ensNamesMap[addr]; + if (!entries) return; + node.textContent = entries[0].name; node.classList.add('ens-name'); - setEnsTooltip(node, name, addr); + setEnsTooltip(node, entries[0].name, addr); + attachEnsIcon(node, addr, entries); node.setAttribute('data-ens-applied', '1'); }); } + // The ENS callout: a popover on the `.ens-icon` badge showing the raw address and + // every resolved name with its network, each copyable. One delegated listener covers + // JS-injected icons and server-rendered ones (address page); only one callout is open + // at a time and any outside click closes it. + var openEnsPopover = null; + function closeEnsPopover() { + if (!openEnsPopover) return; + try { openEnsPopover.hide(); } catch (e) { /* element may be gone */ } + openEnsPopover = null; + } + function buildEnsCalloutContent(address) { + var copyIcon = function(text) { + return ''; + }; + var rows = ['
' + escapeHtml(address) + '' + copyIcon(address) + '
']; + (ensEntriesFor(address) || []).forEach(function(entry) { + rows.push('
' + escapeHtml(entry.name) + '' + + '' + escapeHtml(entry.network || 'local') + '' + + copyIcon(entry.name) + '
'); + }); + return rows.join(''); + } + document.addEventListener('click', function(ev) { + var icon = ev.target.closest ? ev.target.closest('.ens-icon[data-ens-address]') : null; + if (!icon) { + if (openEnsPopover && !(ev.target.closest && ev.target.closest('.ens-callout-popover'))) closeEnsPopover(); + return; + } + ev.preventDefault(); + ev.stopPropagation(); + var popover = bootstrap.Popover.getOrCreateInstance(icon, { + html: true, + title: 'ENS Names', + content: ' ', + trigger: 'manual', + container: 'body', + customClass: 'ens-callout-popover', + }); + if (openEnsPopover === popover) { + closeEnsPopover(); + return; + } + closeEnsPopover(); + popover.show(); + var body = popover.tip && popover.tip.querySelector('.popover-body'); + if (body) { + body.innerHTML = buildEnsCalloutContent((icon.getAttribute('data-ens-address') || '').toLowerCase()); + initControls(); + } + openEnsPopover = popover; + }); + function initControls() { // swap addresses for ENS names before tooltips are initialized applyEnsNames(); @@ -482,7 +575,8 @@ datumTokenizer: Bloodhound.tokenizers.whitespace, queryTokenizer: Bloodhound.tokenizers.whitespace, identify: function (obj) { - return obj.ens_name + // the same name can resolve on multiple networks + return obj.ens_name + "@" + obj.network }, remote: { url: "/search/ens?q=", @@ -627,7 +721,8 @@ templates: { header: '

ENS Names:

', suggestion: function (data) { - var badges = ""; + // ens_name is server-side html-escaped; network is a trusted config value + var badges = `${data.network}`; if (data.is_contract) { badges += `Contract`; } diff --git a/templates/_shared/txDetailsModal.html b/templates/_shared/txDetailsModal.html index 5e140914d..c796b4afb 100644 --- a/templates/_shared/txDetailsModal.html +++ b/templates/_shared/txDetailsModal.html @@ -84,6 +84,8 @@ // reset any ENS state left from a previous open (the container is reused) var existing = bootstrap.Tooltip.getInstance(el); if (existing) existing.dispose(); + var staleIcon = el.previousElementSibling; + if (staleIcon && staleIcon.classList.contains('ens-icon')) staleIcon.remove(); el.classList.remove('ens-name'); el.removeAttribute('data-bs-html'); el.removeAttribute('data-bs-toggle'); diff --git a/templates/address/address.html b/templates/address/address.html index 562d6acdb..2dba8f3d9 100644 --- a/templates/address/address.html +++ b/templates/address/address.html @@ -1,7 +1,7 @@ {{ define "page" }}
-

Address {{ formatEthAddressFull .Address }}{{ if .AddressEnsName }} {{ .AddressEnsName }}{{ end }}

+

Address {{ formatEthAddressFull .Address }}{{ if .AddressEnsNames }} {{ .AddressEnsName }}{{ end }}

- {{ if .AddressEnsName }} + {{ if .AddressEnsNames }}
-
ENS Name:
+
ENS Names:
- {{ .AddressEnsName }} - + {{ range $i, $entry := .AddressEnsNames }} + + {{ $entry.Name }} + {{ $entry.Network }} + +
+ {{ end }}
{{ end }} diff --git a/templates/debug_cache/debug_cache.html b/templates/debug_cache/debug_cache.html index 4e0dc9aff..533171b83 100644 --- a/templates/debug_cache/debug_cache.html +++ b/templates/debug_cache/debug_cache.html @@ -188,33 +188,50 @@
Queue & Cache Resolve Queue (pending / cap){{ formatNumber .QueueLen }} / {{ formatNumber .QueueCap }} Name Cache (LRU / cap){{ formatNumber .CacheLen }} / {{ formatNumber .CacheCap }} + Forward Cache (LRU){{ formatNumber .ForwardCacheLen }} Refresh — found{{ .RefreshPositive }} Refresh — not found{{ .RefreshNegative }} -
-
Registries & Multicall
- - - - - - -
Probed{{ if .Probed }}yes{{ else }}pending{{ end }}
Registries (usable / configured){{ len .Registries }} / {{ .ConfiguredRegistries }}
Multicall3{{ if .MulticallReady }}enabled {{ .MulticallAddress }}{{ else }}individual calls{{ end }}
-
-
Working Registries
- {{ if .Registries }} -
    - {{ range $i, $r := .Registries }} -
  • {{ add $i 1 }}{{ $r }}
  • - {{ end }} -
- {{ else }} -
{{ if .Probed }}No usable registries found on the target chain.{{ else }}Not probed yet — the first resolve batch probes the configured registries.{{ end }}
- {{ end }} +
Networks
+
+ + + + + + + + + + + + {{ range .Networks }} + + + + + + + + {{ end }} + +
NetworkEndpointsProbedRegistries (usable / configured)Multicall3
{{ .Name }}{{ if .Local }} local{{ end }}{{ if .Local }}execution pool{{ else }}{{ formatNumber .Endpoints }}{{ end }}{{ if .Probed }}yes{{ else }}pending{{ end }} + {{ len .Registries }} / {{ .ConfiguredRegistries }} + {{ if .Registries }} +
    + {{ range .Registries }} +
  • {{ . }}
  • + {{ end }} +
+ {{ else if .Probed }} +
none deployed (re-probed periodically)
+ {{ end }} +
{{ if .MulticallReady }}enabled {{ .MulticallAddress }}{{ else }}individual calls{{ end }}
+
{{ end }} diff --git a/types/config.go b/types/config.go index 165089a47..91aadb319 100644 --- a/types/config.go +++ b/types/config.go @@ -215,22 +215,32 @@ type Config struct { } `yaml:"rpcProxy"` // EnsResolver optionally resolves execution addresses to their primary ENS name. - // ENS lives on Ethereum mainnet, so lookups always run against a mainnet RPC: - // the configured Endpoints, or a public mainnet RPC when empty. The local - // execution pool is never used (devnets/testnets have no ENS deployment). + // Names are resolved on the local network (the chain this explorer indexes, via + // the main execution pool) and on every configured remote network (each with its + // own RPC endpoints), so e.g. a devnet explorer can show mainnet ENS names. EnsResolver struct { - Enabled bool `yaml:"enabled" envconfig:"ENSRESOLVER_ENABLED"` - Endpoints []EndpointConfig `yaml:"endpoints"` - RegistryAddresses []string `yaml:"registryAddresses" envconfig:"ENSRESOLVER_REGISTRY_ADDRESSES"` // tried in config order; first verified name wins - MulticallAddress string `yaml:"multicallAddress" envconfig:"ENSRESOLVER_MULTICALL_ADDRESS"` // used to batch lookups when deployed - RefreshPositive time.Duration `yaml:"refreshPositive" envconfig:"ENSRESOLVER_REFRESH_POSITIVE"` // re-resolve interval for addresses with a name - RefreshNegative time.Duration `yaml:"refreshNegative" envconfig:"ENSRESOLVER_REFRESH_NEGATIVE"` // re-resolve interval for addresses without a name - BatchSize int `yaml:"batchSize" envconfig:"ENSRESOLVER_BATCH_SIZE"` - QueueSize int `yaml:"queueSize" envconfig:"ENSRESOLVER_QUEUE_SIZE"` - CacheSize int `yaml:"cacheSize" envconfig:"ENSRESOLVER_CACHE_SIZE"` + Enabled bool `yaml:"enabled" envconfig:"ENSRESOLVER_ENABLED"` + RegistryAddresses []string `yaml:"registryAddresses" envconfig:"ENSRESOLVER_REGISTRY_ADDRESSES"` // local-network registries; tried in config order, first verified name wins + MulticallAddress string `yaml:"multicallAddress" envconfig:"ENSRESOLVER_MULTICALL_ADDRESS"` // local-network multicall; used to batch lookups when deployed + RemoteNetworks []EnsRemoteNetwork `yaml:"remoteNetworks"` // additional networks to resolve names on + RefreshPositive time.Duration `yaml:"refreshPositive" envconfig:"ENSRESOLVER_REFRESH_POSITIVE"` // re-resolve interval for addresses with a name + RefreshNegative time.Duration `yaml:"refreshNegative" envconfig:"ENSRESOLVER_REFRESH_NEGATIVE"` // re-resolve interval for addresses without a name + BatchSize int `yaml:"batchSize" envconfig:"ENSRESOLVER_BATCH_SIZE"` + QueueSize int `yaml:"queueSize" envconfig:"ENSRESOLVER_QUEUE_SIZE"` + CacheSize int `yaml:"cacheSize" envconfig:"ENSRESOLVER_CACHE_SIZE"` } `yaml:"ensResolver"` } +// EnsRemoteNetwork configures ENS resolution on an additional network reachable via +// dedicated RPC endpoints (e.g. Ethereum mainnet on a devnet explorer). Registry and +// multicall addresses default to the canonical mainnet deployments when empty. +type EnsRemoteNetwork struct { + Name string `yaml:"name"` + Endpoints []EndpointConfig `yaml:"endpoints"` + RegistryAddresses []string `yaml:"registryAddresses"` + MulticallAddress string `yaml:"multicallAddress"` +} + type EndpointConfig struct { Ssh *EndpointSshConfig `yaml:"ssh"` Url string `yaml:"url"` diff --git a/types/models/address.go b/types/models/address.go index 533236bd7..31da84dac 100644 --- a/types/models/address.go +++ b/types/models/address.go @@ -6,10 +6,11 @@ import ( // AddressPageData is a struct to hold info for the address page type AddressPageData struct { - Address []byte `json:"address"` - AddressEnsName string `json:"address_ens_name"` // resolved primary ENS name for this address (empty if none) - AccountID uint64 `json:"account_id"` - IsContract bool `json:"is_contract"` + Address []byte `json:"address"` + AddressEnsName string `json:"address_ens_name"` // primary (display) ENS name for this address (empty if none) + AddressEnsNames []EnsNameEntry `json:"address_ens_names"` // all resolved ENS names for this address across networks + AccountID uint64 `json:"account_id"` + IsContract bool `json:"is_contract"` DataRange *ElDataRangeInfo IsToken bool `json:"is_token"` TokenName string `json:"token_name"` diff --git a/types/models/common.go b/types/models/common.go index b6af06679..a751035ba 100644 --- a/types/models/common.go +++ b/types/models/common.go @@ -12,48 +12,61 @@ type KeyValue struct { Value string `json:"v"` } -// EnsNameMapping maps a lowercase 0x-hex execution address to its primary ENS name. +// EnsNameEntry is one resolved ENS name for an address on a specific network. +type EnsNameEntry struct { + Name string `json:"name"` + Network string `json:"network"` // display name of the network the name was resolved on + Local bool `json:"local,omitempty"` // resolved on the chain this explorer indexes +} + +// EnsNameMapping maps a lowercase 0x-hex execution address to all its resolved ENS +// names (primary/display name first: local network, then remotes in config order). // A slice of these is embedded in page models (SSZ-cacheable, unlike a map) and // rendered client-side to swap displayed addresses for their name. type EnsNameMapping struct { - Address string `json:"a"` - Name string `json:"n"` + Address string `json:"a"` + Names []EnsNameEntry `json:"n"` } // EnsNameData is embedded in page models that display execution addresses. It carries -// the resolved primary ENS names and exposes them to the layout without reflection. +// the resolved ENS names and exposes them to the layout without reflection. type EnsNameData struct { EnsNames []EnsNameMapping `json:"ens_names,omitempty"` } -// SetEnsNames stores resolved names (address -> name) in cacheable slice form. -func (d *EnsNameData) SetEnsNames(names map[string]string) { +// SetEnsNames stores resolved names (address -> per-network names) in cacheable slice form. +func (d *EnsNameData) SetEnsNames(names map[string][]EnsNameEntry) { d.EnsNames = EnsNamesFromMap(names) } -// EnsNamesForJS returns the carried names as an address->name map for embedding in a -// page's ens-names - - + + +
@@ -57,10 +56,10 @@
{{ template "footer" . }}
- - + + - + {{ template "js" .Data }} diff --git a/templates/_shared/el_filter_assets.html b/templates/_shared/el_filter_assets.html index 4c54d238b..822e8665d 100644 --- a/templates/_shared/el_filter_assets.html +++ b/templates/_shared/el_filter_assets.html @@ -1,5 +1,5 @@ {{ define "elFilterCss" }} - +