#!/usr/bin/env python3 """ ENS Resolver Utility for resolving Ethereum addresses to ENS names and extracting profile information. """ import os import sys import json from typing import Dict, Optional, Any import requests from web3 import Web3 from web3.exceptions import BadFunctionCallOutput from dotenv import load_dotenv # Load environment variables load_dotenv() class ENSResolver: """Resolver for ENS names and profiles.""" def __init__(self, web3_instance: Web3): """ Initialize the ENS resolver. Args: web3_instance: Web3 instance to use for ENS resolution """ self.web3 = web3_instance self.alchemy_api_key = os.getenv("ALCHEMY_API_KEY") if not self.alchemy_api_key: raise ValueError("ALCHEMY_API_KEY not found in environment variables") self.alchemy_url = f"https://eth-mainnet.g.alchemy.com/v2/{self.alchemy_api_key}" # ENS Registry contract address self.ens_registry_address = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" self.ens_registry_abi = [ { "constant": True, "inputs": [{"name": "node", "type": "bytes32"}], "name": "resolver", "outputs": [{"name": "", "type": "address"}], "type": "function" } ] self.ens_registry = self.web3.eth.contract( address=self.ens_registry_address, abi=self.ens_registry_abi ) # ENS Resolver ABI (partial) self.ens_resolver_abi = [ { "constant": True, "inputs": [{"name": "node", "type": "bytes32"}], "name": "addr", "outputs": [{"name": "", "type": "address"}], "type": "function" }, { "constant": True, "inputs": [{"name": "node", "type": "bytes32"}, {"name": "key", "type": "string"}], "name": "text", "outputs": [{"name": "", "type": "string"}], "type": "function" } ] def namehash(self, name: str) -> bytes: """ Compute the namehash of an ENS name. Args: name: ENS name Returns: Namehash as bytes """ if name == '': return bytes([0] * 32) # Handle names that start with '0x' differently if name.startswith('0x') and len(name) > 2 and all(c in '0123456789abcdefABCDEF' for c in name[2:]): # This is a hex string, not an ENS name return bytes.fromhex(name[2:]) # For actual ENS names (even if they start with numbers like 0xcorini.eth) labels = name.split('.') labels.reverse() node = bytes([0] * 32) for label in labels: label_hash = self.web3.keccak(text=label) node = self.web3.keccak(node + label_hash) return node def get_ens_name(self, address: str) -> Optional[str]: """ Resolve an Ethereum address to an ENS name using reverse lookup. Args: address: Ethereum address to resolve Returns: ENS name if found, None otherwise """ try: # Ensure the address is checksummed address = Web3.to_checksum_address(address) # Use web3.py's built-in ENS functionality ens_name = self.web3.ens.name(address) # Verify the name resolves back to the address if ens_name: resolved_address = self.get_ens_address(ens_name) if resolved_address and resolved_address.lower() == address.lower(): return ens_name return None except Exception as e: # Log errors but don't fail print(f"Error resolving ENS name for {address}: {str(e)}") return None def get_ens_address(self, ens_name: str) -> Optional[str]: """ Resolve an ENS name to an Ethereum address. Args: ens_name: ENS name to resolve Returns: Ethereum address if found, None otherwise """ try: # Use web3.py's built-in ENS functionality address = self.web3.ens.address(ens_name) return address except Exception as e: # Log errors but don't fail print(f"Error resolving ENS address for {ens_name}: {str(e)}") return None def get_resolver_for_name(self, ens_name: str) -> Optional[str]: """ Get the resolver contract address for an ENS name. Args: ens_name: ENS name Returns: Resolver contract address if found, None otherwise """ try: node = self.namehash(ens_name) resolver_address = self.ens_registry.functions.resolver(node).call() if resolver_address == "0x0000000000000000000000000000000000000000": return None return resolver_address except Exception as e: print(f"Error getting resolver for {ens_name}: {str(e)}") return None def get_text_record(self, ens_name: str, key: str) -> Optional[str]: """ Get a text record for an ENS name. Args: ens_name: ENS name key: Text record key Returns: Text record value if found, None otherwise """ try: resolver_address = self.get_resolver_for_name(ens_name) if not resolver_address: return None resolver = self.web3.eth.contract( address=resolver_address, abi=self.ens_resolver_abi ) node = self.namehash(ens_name) value = resolver.functions.text(node, key).call() return value if value else None except Exception as e: print(f"Error getting text record {key} for {ens_name}: {str(e)}") return None def get_ens_profile(self, ens_name: str) -> Dict[str, Any]: """ Get profile information for an ENS name. Args: ens_name: ENS name to get profile for Returns: Dictionary of profile information """ profile = {} try: # Common text record keys text_keys = [ "name", "email", "url", "avatar", "description", "com.twitter", "twitter", "com.github", "github", "org.telegram", "telegram", "com.discord", "discord", "com.farcaster", "social.farcaster", "farcaster" ] # Get text records for key in text_keys: value = self.get_text_record(ens_name, key) if value: # Handle Farcaster variants if key in ["com.farcaster", "social.farcaster", "farcaster"]: profile["farcaster"] = value # Handle Twitter variants elif key in ["com.twitter", "twitter"]: profile["twitter"] = value # Handle Discord variants elif key in ["com.discord", "discord"]: profile["discord"] = value # Handle Telegram variants elif key in ["org.telegram", "telegram"]: profile["telegram"] = value # Handle GitHub variants elif key in ["com.github", "github"]: profile["github"] = value # Handle other common fields elif key in ["email", "url", "avatar", "description", "name"]: profile[key] = value # Try to get additional social media records other_social = {} for prefix in ["com.", "social."]: for platform in ["reddit", "linkedin", "instagram", "facebook", "youtube", "tiktok", "lens"]: key = f"{prefix}{platform}" value = self.get_text_record(ens_name, key) if value: other_social[key] = value if other_social: profile["otherSocial"] = json.dumps(other_social) except Exception as e: # Log errors but don't fail print(f"Error getting ENS profile for {ens_name}: {str(e)}") return profile def update_contact_from_ens(self, contact_id: str, ens_name: str) -> None: """ Update a contact with information from their ENS profile. Args: contact_id: ID of the contact to update ens_name: ENS name of the contact """ # Import here to avoid circular imports from utils.db_connector import DatabaseConnector # Get the profile profile = self.get_ens_profile(ens_name) if not profile: return # Map ENS profile fields to database fields # Only include fields that exist in the Contact model db_fields = { "name": profile.get("name"), "email": profile.get("email"), "farcaster": profile.get("farcaster"), "twitter": profile.get("twitter"), "discord": profile.get("discord"), "telegram": profile.get("telegram"), "otherSocial": profile.get("otherSocial") } # Filter out None values db_fields = {k: v for k, v in db_fields.items() if v is not None} if db_fields: # Update the contact db = DatabaseConnector() db.update_contact(contact_id, db_fields) # Add a note with additional profile information note_content = f"ENS Profile Information for {ens_name}:\n" for key, value in profile.items(): if key not in ["name", "email", "farcaster", "twitter", "discord", "telegram"]: note_content += f"{key}: {value}\n" if note_content != f"ENS Profile Information for {ens_name}:\n": db.add_note_to_contact(contact_id, note_content)