Skip to content

Commit 8a4877c

Browse files
authored
Merge pull request #902 from crestalnetwork/codex/add-function-to-convert-ens-domain-to-wallet-address
feat: add ENS resolution utility
2 parents 52a0d3f + 266dfb4 commit 8a4877c

File tree

3 files changed

+143
-3
lines changed

3 files changed

+143
-3
lines changed

intentkit/models/agent.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,18 @@
3838
from intentkit.models.db import get_session
3939
from intentkit.models.llm import LLMModelInfo, LLMProvider
4040
from intentkit.models.skill import Skill
41+
from intentkit.utils.ens import resolve_ens_to_address
4142
from intentkit.utils.error import IntentKitAPIError
4243

4344
logger = logging.getLogger(__name__)
4445

4546

47+
ENS_NAME_PATTERN = re.compile(
48+
r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:eth|base\.eth)$",
49+
re.IGNORECASE,
50+
)
51+
52+
4653
class AgentAutonomous(BaseModel):
4754
"""Autonomous agent configuration."""
4855

@@ -1337,16 +1344,20 @@ async def get_by_id_or_slug(cls, agent_id: str) -> "Agent" | None:
13371344
Returns:
13381345
Agent if found, None otherwise
13391346
"""
1347+
query_id = agent_id
1348+
if ENS_NAME_PATTERN.fullmatch(agent_id):
1349+
query_id = await resolve_ens_to_address(agent_id)
1350+
13401351
async with get_session() as db:
13411352
agent = None
13421353

13431354
# Try to get by ID if length <= 20
1344-
if len(agent_id) <= 20:
1345-
agent = await Agent.get(agent_id)
1355+
if len(query_id) <= 20 or query_id.startswith("0x"):
1356+
agent = await Agent.get(query_id)
13461357

13471358
# If not found, try to get by slug
13481359
if agent is None:
1349-
slug_stmt = select(AgentTable).where(AgentTable.slug == agent_id)
1360+
slug_stmt = select(AgentTable).where(AgentTable.slug == query_id)
13501361
agent_row = await db.scalar(slug_stmt)
13511362
if agent_row is not None:
13521363
agent = Agent.model_validate(agent_row)

intentkit/utils/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1+
"""Utility exports for IntentKit."""
12

3+
from intentkit.utils.ens import resolve_ens_to_address
4+
5+
__all__ = ["resolve_ens_to_address"]

intentkit/utils/ens.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Utilities for resolving ENS domains to wallet addresses."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
8+
from ens import ENS
9+
from web3 import Web3
10+
from web3.middleware import geth_poa_middleware
11+
12+
from intentkit.config.config import config
13+
from intentkit.models.redis import get_redis
14+
from intentkit.utils.error import IntentKitAPIError
15+
16+
logger = logging.getLogger(__name__)
17+
18+
_CACHE_PREFIX = "intentkit:ens:"
19+
_CACHE_TTL_SECONDS = 4 * 60 * 60
20+
21+
_NETWORKS_BY_SUFFIX: dict[str, tuple[str, ...]] = {
22+
".base.eth": ("base-mainnet", "ethereum-mainnet"),
23+
".eth": ("ethereum-mainnet",),
24+
}
25+
26+
27+
async def resolve_ens_to_address(name: str) -> str:
28+
"""Resolve an ENS domain to a checksum wallet address.
29+
30+
Args:
31+
name: ENS name to resolve.
32+
33+
Returns:
34+
The checksum wallet address associated with the ENS name.
35+
36+
Raises:
37+
IntentKitAPIError: If the ENS name cannot be resolved to a wallet address.
38+
"""
39+
40+
normalized = name.strip().lower()
41+
if not normalized:
42+
raise IntentKitAPIError(404, "ENSNameNotFound", "ENS name is empty.")
43+
44+
redis_client = None
45+
cache_key = f"{_CACHE_PREFIX}{normalized}"
46+
try:
47+
redis_client = get_redis()
48+
except Exception: # Redis is optional; ignore if unavailable
49+
redis_client = None
50+
51+
if redis_client is not None:
52+
cached_address = await redis_client.get(cache_key)
53+
if cached_address:
54+
return cached_address
55+
56+
networks = _networks_for_name(normalized)
57+
if not networks:
58+
raise IntentKitAPIError(
59+
404,
60+
"ENSNameNotFound",
61+
"Unsupported ENS name suffix.",
62+
)
63+
64+
for network in networks:
65+
address = await _resolve_on_network(normalized, network)
66+
if address:
67+
if redis_client is not None:
68+
try:
69+
await redis_client.set(cache_key, address, ex=_CACHE_TTL_SECONDS)
70+
except Exception as exc: # pragma: no cover - optional cache
71+
logger.debug("Failed to cache ENS resolution: %s", exc)
72+
return address
73+
74+
raise IntentKitAPIError(
75+
404,
76+
"ENSNameNotFound",
77+
f"ENS name {name} could not be resolved.",
78+
)
79+
80+
81+
def _networks_for_name(name: str) -> tuple[str, ...]:
82+
for suffix, networks in _NETWORKS_BY_SUFFIX.items():
83+
if name.endswith(suffix):
84+
return networks
85+
return tuple()
86+
87+
88+
async def _resolve_on_network(name: str, network: str) -> str | None:
89+
chain_provider = getattr(config, "chain_provider", None)
90+
if chain_provider is None:
91+
logger.debug("No chain provider configured; cannot resolve ENS name.")
92+
return None
93+
94+
try:
95+
chain_config = chain_provider.get_chain_config(network)
96+
except Exception as exc: # pragma: no cover - dependent on external config
97+
logger.debug("Chain config for %s unavailable: %s", network, exc)
98+
return None
99+
100+
rpc_url = chain_config.ens_url or chain_config.rpc_url
101+
if not rpc_url:
102+
logger.debug("No RPC/ENS URL configured for %s", network)
103+
return None
104+
105+
def _resolve() -> str | None:
106+
web3_client = Web3(Web3.HTTPProvider(rpc_url))
107+
if network.startswith("base"):
108+
web3_client.middleware_onion.inject(geth_poa_middleware, layer=0)
109+
110+
ens_client = ENS.from_web3(web3_client)
111+
try:
112+
resolved = ens_client.address(name)
113+
except Exception as exc: # pragma: no cover - dependent on external provider
114+
logger.debug("Error resolving %s on %s: %s", name, network, exc)
115+
return None
116+
117+
if not resolved:
118+
return None
119+
120+
try:
121+
return Web3.to_checksum_address(resolved)
122+
except ValueError:
123+
return None
124+
125+
return await asyncio.to_thread(_resolve)

0 commit comments

Comments
 (0)