From 8b1a42120220145b927afa925fd757dead2c9cd4 Mon Sep 17 00:00:00 2001 From: TxCorpi0x <6095314+TxCorpi0x@users.noreply.github.com> Date: Fri, 28 Feb 2025 19:13:58 +0300 Subject: [PATCH 1/2] feat: web3 block, network, and token search --- app/core/engine.py | 25 ++ app/core/system.py | 15 ++ models/agent.py | 5 + models/w3.py | 78 ++++++ skills/enso/base.py | 4 +- skills/general/__init__.py | 23 ++ skills/general/base.py | 25 ++ skills/general/timestamp.py | 34 +++ skills/w3/__init__.py | 78 ++++++ skills/w3/base.py | 29 +++ skills/w3/block.py | 261 ++++++++++++++++++++ skills/w3/network.py | 73 ++++++ skills/w3/token.py | 197 +++++++++++++++ skills/w3/transfer.py | 216 +++++++++++++++++ utils/chain.py | 467 ++++++++++++++++++++++++------------ utils/time.py | 28 +++ 16 files changed, 1398 insertions(+), 160 deletions(-) create mode 100644 app/core/system.py create mode 100644 models/w3.py create mode 100644 skills/general/__init__.py create mode 100644 skills/general/base.py create mode 100644 skills/general/timestamp.py create mode 100644 skills/w3/__init__.py create mode 100644 skills/w3/base.py create mode 100644 skills/w3/block.py create mode 100644 skills/w3/network.py create mode 100644 skills/w3/token.py create mode 100644 skills/w3/transfer.py create mode 100644 utils/time.py diff --git a/app/core/engine.py b/app/core/engine.py index a2a9140c..817e9a11 100644 --- a/app/core/engine.py +++ b/app/core/engine.py @@ -56,6 +56,7 @@ from app.core.graph import create_agent from app.core.prompt import agent_prompt from app.core.skill import skill_store +from app.core.system import SystemStore from clients import TwitterClient from models.agent import Agent, AgentData from models.chat import AuthorType, ChatMessage, ChatMessageSkillCall @@ -73,6 +74,7 @@ init_smart_wallets, ) from skills.twitter import get_twitter_skill +from skills.w3 import get_web3_skill logger = logging.getLogger(__name__) @@ -106,6 +108,10 @@ async def initialize_agent(aid, is_private=False): HTTPException: If agent not found (404) or database error (500) """ """Initialize the agent with CDP Agentkit.""" + + # init global store + system_store = SystemStore() + # init agent store agent_store = AgentStore(aid) @@ -289,6 +295,25 @@ async def initialize_agent(aid, is_private=False): except Exception as e: logger.warning(e) + if ( + hasattr(config, "chain_provider") + and agent.tx_skills + and len(agent.tx_skills) > 0 + ): + for skill in agent.tx_skills: + try: + s = get_web3_skill( + skill, + config.chain_provider, + system_store, + skill_store, + agent_store, + aid, + ) + tools.append(s) + except Exception as e: + logger.warning(e) + # Enso skills if agent.enso_skills and len(agent.enso_skills) > 0 and agent.enso_config: for skill in agent.enso_skills: diff --git a/app/core/system.py b/app/core/system.py new file mode 100644 index 00000000..36d8f64d --- /dev/null +++ b/app/core/system.py @@ -0,0 +1,15 @@ +from typing import Optional + +from models.w3 import W3Token +from utils.chain import Network + + +class SystemStore: + def __init__(self) -> None: + pass + + async def get_token(self, symbol: str, network: Network) -> Optional[W3Token]: + return await W3Token.get(symbol, network) + + async def get_all_wellknown_tokens(self) -> list[W3Token]: + return await W3Token.get_well_known() diff --git a/models/agent.py b/models/agent.py index 8b543960..e23fca53 100644 --- a/models/agent.py +++ b/models/agent.py @@ -113,6 +113,11 @@ class Agent(SQLModel, table=True): cdp_network_id: Optional[str] = Field( default="base-mainnet", description="Network identifier for CDP integration" ) + tx_skills: Optional[List[str]] = Field( + default=None, + sa_column=Column(ARRAY(String)), + description="List of Transaction skills available to this agent", + ) # if goat_enabled, will load goat skills crossmint_config: Optional[dict] = Field( default=None, diff --git a/models/w3.py b/models/w3.py new file mode 100644 index 00000000..a4edccce --- /dev/null +++ b/models/w3.py @@ -0,0 +1,78 @@ +from typing import Optional + +from sqlmodel import Field, SQLModel, select + +from models.db import get_session +from utils.chain import Network + + +class W3Token(SQLModel, table=True): + """Model for storing token-specific data for web3 tools. + + This model uses a composite primary key of (symbol, chain_id) to store + token data in a flexible way. + + Attributes: + token_id: ID of the token + symbol: Token symbol + """ + + __tablename__ = "w3_token" + + symbol: str = Field(primary_key=True) + chain_id: str = Field(primary_key=True) + is_well_known: bool = Field(nullable=False) + name: str = Field(nullable=False) + decimals: int = Field(nullable=False) + address: str = Field(nullable=False) + primary_address: str = Field(nullable=True) + token_type: str = Field(nullable=True) + protocol_slug: str = Field(nullable=True) + + @classmethod + async def get(cls, symbol: str, network: Network) -> Optional["W3Token"]: + async with get_session() as db: + result = ( + await db.exec( + select(cls).where( + cls.symbol == symbol, + cls.chain_id == network.value.id, + ) + ) + ).first() + return result + + @classmethod + async def get_well_known(cls) -> list["W3Token"]: + async with get_session() as db: + result = ( + await db.exec( + select(cls).where( + cls.is_well_known, + ) + ) + ).all() + return result + + async def save(self) -> None: + async with get_session() as db: + existing = ( + await db.exec( + select(self.__class__).where( + self.__class__.symbol == self.symbol, + self.__class__.chain_id == self.chain_id, + ) + ) + ).first() + if existing: + existing.is_well_known = self.is_well_known + existing.name = self.name + existing.decimals = self.decimals + existing.address = self.address + existing.primary_address = self.primary_address + existing.token_type = self.token_type + existing.protocol_slug = self.protocol_slug + db.add(existing) + else: + db.add(self) + await db.commit() diff --git a/skills/enso/base.py b/skills/enso/base.py index 41b94a95..96810ed6 100644 --- a/skills/enso/base.py +++ b/skills/enso/base.py @@ -5,10 +5,10 @@ from abstracts.agent import AgentStoreABC from abstracts.skill import IntentKitSkill, SkillStoreABC -from utils.chain import ChainProvider, NetworkId +from utils.chain import ChainProvider, Network base_url = "https://api.enso.finance" -default_chain_id = int(NetworkId.BaseMainnet) +default_chain_id = int(Network.BaseMainnet.value.id) class EnsoBaseTool(IntentKitSkill): diff --git a/skills/general/__init__.py b/skills/general/__init__.py new file mode 100644 index 00000000..b16bb75d --- /dev/null +++ b/skills/general/__init__.py @@ -0,0 +1,23 @@ +"""general skills.""" + +from abstracts.skill import SkillStoreABC +from app.core.system import SystemStore + +from .base import GeneralBaseTool +from .timestamp import CurrentEpochTimestampTool + + +def get_crestal_skills( + system_store: SystemStore, + skill_store: SkillStoreABC, + agent_store: SkillStoreABC, + agent_id: str, +) -> list[GeneralBaseTool]: + return [ + CurrentEpochTimestampTool( + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + ] diff --git a/skills/general/base.py b/skills/general/base.py new file mode 100644 index 00000000..dec8a700 --- /dev/null +++ b/skills/general/base.py @@ -0,0 +1,25 @@ +from typing import Type + +from pydantic import BaseModel, Field + +from abstracts.agent import AgentStoreABC +from abstracts.skill import IntentKitSkill, SkillStoreABC +from app.core.system import SystemStore + + +class GeneralBaseTool(IntentKitSkill): + """Base class for General tools.""" + + name: str = Field(description="The name of the tool") + description: str = Field(description="A description of what the tool does") + args_schema: Type[BaseModel] + agent_id: str = Field(description="The ID of the agent") + system_store: SystemStore = Field( + description="The global store for persisted data retrieval" + ) + agent_store: AgentStoreABC = Field( + description="The agent store for persisting data" + ) + skill_store: SkillStoreABC = Field( + description="The skill store for persisting data" + ) diff --git a/skills/general/timestamp.py b/skills/general/timestamp.py new file mode 100644 index 00000000..7e8e0223 --- /dev/null +++ b/skills/general/timestamp.py @@ -0,0 +1,34 @@ +import time +from typing import Type + +from pydantic import BaseModel, Field + +from .base import GeneralBaseTool + + +class CurrentEpochTimestampInput(BaseModel): + pass + + +class CurrentEpochTimestampOutput(BaseModel): + timestamp: int = Field(..., description="The current epoch timestamp") + + +class CurrentEpochTimestampTool(GeneralBaseTool): + name: str = "general_current_epoch_timestamp" + description: str = ( + """ + Useful for getting the current Unix epoch timestamp in seconds. you should use this tool before any tool that + needs the current epoch timestamp. This returns UTC epoch timestamp. + """ + ) + + args_schema: Type[BaseModel] = CurrentEpochTimestampInput + + def _run(self) -> CurrentEpochTimestampOutput: + """Returns the current epoch timestamp.""" + return CurrentEpochTimestampOutput(timestamp=int(time.time())) + + async def _arun(self) -> CurrentEpochTimestampOutput: + """Returns the current epoch timestamp asynchronously.""" + return CurrentEpochTimestampOutput(timestamp=int(time.time())) diff --git a/skills/w3/__init__.py b/skills/w3/__init__.py new file mode 100644 index 00000000..45ea296b --- /dev/null +++ b/skills/w3/__init__.py @@ -0,0 +1,78 @@ +"""Web3 skills.""" + +from abstracts.skill import SkillStoreABC +from app.core.system import SystemStore +from utils.chain import ChainProvider + +from .base import Web3BaseTool +from .block import GetBlocksBetweenDates, GetCurrentBlock +from .network import GetNetworks +from .token import GetToken, GetWellknownTokens +from .transfer import GetTransfers + + +def get_web3_skill( + name: str, + chain_provider: ChainProvider, + system_store: SystemStore, + skill_store: SkillStoreABC, + agent_store: SkillStoreABC, + agent_id: str, +) -> Web3BaseTool: + + if name == "get_networks": + return GetNetworks( + chain_provider=chain_provider, + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + + if name == "get_wellknown_tokens": + return GetWellknownTokens( + chain_provider=chain_provider, + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + + if name == "get_token": + return GetToken( + chain_provider=chain_provider, + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + + if name == "get_received_transfers": + return GetTransfers( + chain_provider=chain_provider, + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + + if name == "get_block_range_by_time": + return GetBlocksBetweenDates( + chain_provider=chain_provider, + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + + if name == "get_current_block": + return GetCurrentBlock( + chain_provider=chain_provider, + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ) + + else: + raise ValueError(f"Unknown Web3 skill: {name}") diff --git a/skills/w3/base.py b/skills/w3/base.py new file mode 100644 index 00000000..31f29c13 --- /dev/null +++ b/skills/w3/base.py @@ -0,0 +1,29 @@ +from typing import Type + +from pydantic import BaseModel, Field + +from abstracts.agent import AgentStoreABC +from abstracts.skill import IntentKitSkill, SkillStoreABC +from app.core.system import SystemStore +from utils.chain import ChainProvider + + +class Web3BaseTool(IntentKitSkill): + """Base class for Transaction tools.""" + + chain_provider: ChainProvider | None = Field( + None, description="Chain Provider object" + ) + name: str = Field(description="The name of the tool") + description: str = Field(description="A description of what the tool does") + args_schema: Type[BaseModel] + agent_id: str = Field(description="The ID of the agent") + system_store: SystemStore = Field( + description="The global store for persisted data retrieval" + ) + agent_store: AgentStoreABC = Field( + description="The agent store for persisting data" + ) + skill_store: SkillStoreABC = Field( + description="The skill store for persisting data" + ) diff --git a/skills/w3/block.py b/skills/w3/block.py new file mode 100644 index 00000000..d5a93c76 --- /dev/null +++ b/skills/w3/block.py @@ -0,0 +1,261 @@ +from typing import Type + +import httpx +from langchain.tools.base import ToolException +from pydantic import BaseModel, Field + +from utils.chain import ChainType, NetworkTitle, get_network_by_title +from utils.time import TimestampRange + +from .base import Web3BaseTool + + +class GetCurrentBlockInput(BaseModel): + """ + Input model for fetching the current block info of a network. + """ + + network_title: NetworkTitle = Field( + ..., description="The network to be used for querying." + ) + + +class GetCurrentBlockOutput(BaseModel): + """ + Output model for current block information. + """ + + timestamp: int = Field(..., description="The timestamp of the current block.") + number: int = Field(..., description="The current block number.") + + +class GetCurrentBlock(Web3BaseTool): + """ + This tool returns the block current block information. + + Attributes: + name (str): Name of the tool, specifically "w3_get_current_block". + description (str): Comprehensive description of the tool's purpose and functionality. + args_schema (Type[BaseModel]): Schema for input arguments, specifying expected parameters. + """ + + name: str = "w3_get_current_block" + description: str = ( + """ + This tool returns the block current block information. + """ + ) + args_schema: Type[BaseModel] = GetCurrentBlockInput + + def _run(self, network_title: NetworkTitle) -> GetCurrentBlockOutput: + """ + Run the tool to fetch the the block current block information. + + Args: + network_title (NetworkTitle): The network to check the block number for. + + Returns: + GetCurrentBlockOutput: A structured output containing blocks start and end. + + Raises: + NotImplementedError: This method should not be directly called; use _arun instead. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun(self, network_title: NetworkTitle) -> GetCurrentBlockOutput: + """ + Run the tool to fetch the block current block information. + + Args: + network_title (NetworkTitle): The network to check the block number for. + + Returns: + GetCurrentBlockOutput: A structured output containing blocks start and end. + + Raises: + ToolException: If there's an error accessing the RPC or any other issue during the process. + """ + + network = get_network_by_title(network_title) + chain_type = network.value.chain.value.chain_type + if chain_type != ChainType.EVM: + raise ToolException(f"chain type is not supported {chain_type}") + + chain_config = self.chain_provider.get_chain_config(network_title) + headers = { + "accept": "application/json", + } + try: + async with httpx.AsyncClient() as client: + # Get current block number and timestamp + json_block_number = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_blockNumber", + "params": [], + } + response_block_number = await client.post( + chain_config.rpc_url, headers=headers, json=json_block_number + ) + response_block_number.raise_for_status() + current_block_hex = response_block_number.json()["result"] + current_block = int(current_block_hex, 16) + + json_block_timestamp = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getBlockByNumber", + "params": [ + current_block_hex, + True, + ], # true returns full transaction objects. + } + + response_block_timestamp = await client.post( + chain_config.rpc_url, headers=headers, json=json_block_timestamp + ) + response_block_timestamp.raise_for_status() + current_block_timestamp = int( + response_block_timestamp.json()["result"]["timestamp"], 16 + ) + + return GetCurrentBlockOutput( + timestamp=current_block_timestamp, number=current_block + ) + except httpx.RequestError as req_err: + raise ToolException(f"request error from RPC API: {req_err}") from req_err + except httpx.HTTPStatusError as http_err: + raise ToolException(f"http error from RPC API: {http_err}") from http_err + except Exception as e: + raise ToolException(f"error from RPC API: {e}") from e + + +class GetBlocksBetweenDatesInput(BaseModel): + """ + Input model for fetching blocks between time range. + """ + + network_title: NetworkTitle = Field( + ..., description="The network to be used for querying." + ) + time_range: TimestampRange = Field( + ..., + description=""" + The time range to query blocks for, specified as a start and end Unix epoch timestamp. + The current time MUST be obtained using the 'general_current_epoch_timestamp' tool. + Calculate the start and end times relative to the current timestamp from 'general_current_epoch_timestamp', for example, to get blocks for the last 7 days, subtract 7 days worth of seconds from the current timestamp for the start time. + """, + ) + + +class GetBlocksBetweenDatesOutput(BaseModel): + """ + Output model for blocks between time range. + """ + + start_block: int = Field(..., description="the start block") + end_block: int = Field(..., description="the start block") + + +class GetBlocksBetweenDates(Web3BaseTool): + """ + This tool returns the block range according to the input timestamps. + The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + + Attributes: + name (str): Name of the tool, specifically "w3_get_block_range_by_time". + description (str): Comprehensive description of the tool's purpose and functionality. + args_schema (Type[BaseModel]): Schema for input arguments, specifying expected parameters. + """ + + name: str = "w3_get_block_range_by_time" + description: str = ( + """ + This tool returns the block range according to the input timestamps. + The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + For example, to get blocks for the last 5 minutes, use the output of 'general_current_epoch_timestamp' to get the current time, subtract 300 seconds (5 minutes) from it to get the start time, and use the current time as the end time. + """ + ) + args_schema: Type[BaseModel] = GetBlocksBetweenDatesInput + + def _run( + self, network_title: NetworkTitle, time_range: TimestampRange + ) -> GetBlocksBetweenDatesOutput: + """ + Run the tool to fetch the block range according to the input timestamps. + The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + + Args: + network_title (NetworkTitle): The network to check the block number for. + time_range (TimestampRange): The time range to query blocks for, specified as a start and end Unix epoch timestamp, calculated relative to the current timestamp from 'general_current_epoch_timestamp'. + + Returns: + GetBlocksBetweenDatesOutput: A structured output containing blocks start and end. + + Raises: + NotImplementedError: This method should not be directly called; use _arun instead. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun( + self, network_title: NetworkTitle, time_range: TimestampRange + ) -> GetBlocksBetweenDatesOutput: + """ + Run the tool to fetch the block range according to the input timestamps. + The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + + Args: + network_title (NetworkTitle): The network to check the block number for. + time_range (TimestampRange): The time range to query blocks for, specified as a start and end Unix epoch timestamp, calculated relative to the current timestamp from 'general_current_epoch_timestamp'. + + Returns: + GetBlocksBetweenDatesOutput: A structured output containing blocks start and end. + + Raises: + ToolException: If there's an error accessing the RPC or any other issue during the process. + """ + + network = get_network_by_title(network_title) + + max_block_difference = 10000 + try: + current_block = await GetCurrentBlock( + chain_provider=self.chain_provider, + system_store=self.system_store, + skill_store=self.skill_store, + agent_store=self.agent_store, + agent_id=self.agent_id, + ).arun( + tool_input=GetCurrentBlockInput(network_title=network_title).model_dump( + exclude_none=True + ) + ) + + # Calculate approximate block numbers + time_diff_start = current_block.timestamp - time_range.start + start_block = max( + 0, + current_block.number - int(time_diff_start / network.value.block_time), + ) + + time_diff_end = current_block.timestamp - time_range.end + end_block = max( + 0, + current_block.number - int(time_diff_end / network.value.block_time), + ) + + # Enforce maximum block difference, adjust start block. + if end_block - start_block > max_block_difference: + start_block = max( + 0, end_block - max_block_difference + ) # prevent negative start block. + + return GetBlocksBetweenDatesOutput( + start_block=start_block, end_block=end_block + ) + except httpx.RequestError as req_err: + raise ToolException(f"request error from RPC API: {req_err}") from req_err + except httpx.HTTPStatusError as http_err: + raise ToolException(f"http error from RPC API: {http_err}") from http_err + except Exception as e: + raise ToolException(f"error from RPC API: {e}") from e diff --git a/skills/w3/network.py b/skills/w3/network.py new file mode 100644 index 00000000..28b215a9 --- /dev/null +++ b/skills/w3/network.py @@ -0,0 +1,73 @@ +from typing import Type + +from pydantic import BaseModel, Field + +from utils.chain import Chain, Network + +from .base import Web3BaseTool + + +class GetNetworksInput(BaseModel): + """ + Input model for fetching information about an specific network by name. + """ + + pass + + +class GetNetworksOutput(BaseModel): + """ + Output model for Networks. + """ + + chains: list[Chain] = Field( + ..., description="List of all supported blockchain chains." + ) + networks: list[Network] = Field( + ..., description="List of all supported networks information." + ) + + +class GetNetworks(Web3BaseTool): + """ + This tool returns all supported blockchains, their networks and corresponding id list. + + Attributes: + name (str): Name of the tool, specifically "w3_get_networks". + description (str): Comprehensive description of the tool's purpose and functionality. + args_schema (Type[BaseModel]): Schema for input arguments, specifying expected parameters. + """ + + name: str = "w3_get_networks" + description: str = ( + "This tool returns all supported blockchains, their networks and corresponding id list." + ) + args_schema: Type[BaseModel] = GetNetworksInput + + def _run(self) -> GetNetworksOutput: + """ + Run the tool to fetch all supported blockchains, their networks and corresponding id list. + + Returns: + GetTransfersOutput: A structured output containing blockchains, their networks and id list. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun(self) -> GetNetworksOutput: + """ + Run the tool to fetch all supported blockchains, their networks and corresponding id list. + + Returns: + GetTransfersOutput: A structured output containing blockchains, their networks and id list. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + + return GetNetworksOutput( + chains=[chain for chain in Chain], + networks=[network for network in Network], + ) diff --git a/skills/w3/token.py b/skills/w3/token.py new file mode 100644 index 00000000..a69de2e8 --- /dev/null +++ b/skills/w3/token.py @@ -0,0 +1,197 @@ +from typing import Type + +from langchain.tools.base import ToolException +from pydantic import BaseModel, Field + +from utils.chain import NetworkTitle, get_network_by_title + +from .base import Web3BaseTool + + +class W3TokenInfo(BaseModel): + """ + Represents information about a Web3 token. + + This class encapsulates various attributes of a token, including its symbol, + chain ID, name, decimals, and addresses. It's designed to provide a structured + way to store and access token metadata. + """ + + symbol: str | None = Field( + None, description="The token's symbol (e.g., ETH, USDC)." + ) + chain_id: str | None = Field( + None, + description="The ID of the blockchain network the token belongs to (e.g., '1' for Ethereum Mainnet). this is the Network enum id field.", + ) + is_well_known: bool | None = Field( + False, + description="Indicates whether the token is widely recognized or a standard token.", + ) + name: str | None = Field( + None, description="The full name of the token (e.g., Ethereum, USD Coin)." + ) + decimals: int | None = Field( + None, + description="The number of decimal places the token uses (e.g., 18 for ETH, 6 for USDC).", + ) + address: str | None = Field( + None, description="The token's contract address on the blockchain." + ) + primary_address: str | None = Field( + None, + description=""" + The primary address of the token. In some cases, tokens have multiple addresses, + but this field indicates the main or canonical address. + """, + ) + token_type: str | None = Field( + None, description="The type of token (e.g., defi, base)." + ) + protocol_slug: str | None = Field( + None, + description="A slug representing the protocol or platform associated with the token.", + ) + + +class GetTokenInput(BaseModel): + """ + Input model for fetching information about an specific token of a particular network. + """ + + symbol: str = Field(..., description="The token symbol.") + network_title: NetworkTitle = Field( + ..., + description="The network of the token. it should be filled according to get_networks tool and user request", + ) + + +class GetTokenOutput(BaseModel): + """ + Output model for token. + """ + + token: W3TokenInfo = Field( + ..., description="The information of the requested token." + ) + + +class GetToken(Web3BaseTool): + """ + This tool returns the Web3 token information. + + Attributes: + name (str): Name of the tool, specifically "w3_get_token". + description (str): Comprehensive description of the tool's purpose and functionality. + args_schema (Type[BaseModel]): Schema for input arguments, specifying expected parameters. + """ + + name: str = "w3_get_token" + description: str = "This tool returns the token information." + args_schema: Type[BaseModel] = GetTokenInput + + def _run(self, symbol: str, network_title: NetworkTitle) -> GetTokenOutput: + """ + Run the tool to fetch the Web3 token information. + + Returns: + GetTokenOutput: A structured output containing the token information. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun(self, symbol: str, network_title: NetworkTitle) -> GetTokenOutput: + """ + Run the tool to fetch the Web3 token information. + + Arg: + symbol(str): the symbol of the token. + network_title(NetworkTitle): the network of the token. + + Returns: + GetTokenOutput: A structured output containing the token information. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + + w3token = await self.system_store.get_token( + symbol=symbol, network=get_network_by_title(network_title) + ) + + if not w3token: + raise ToolException("could not find the token") + + return GetTokenOutput( + token=W3TokenInfo(**w3token.model_dump(exclude_none=True)) + ) + + +class GetWellknownTokensInput(BaseModel): + """ + Input model for fetching well-known tokens and their corresponding information like network, network id (chain id). + """ + + pass + + +class GetWellknownTokensOutput(BaseModel): + """ + Output model for Wellknown tokens. + """ + + tokens: list[W3TokenInfo] = Field(..., description="The list of well-known tokens.") + + +class GetWellknownTokens(Web3BaseTool): + """ + This tool returns well-known tokens and their corresponding information. + + Attributes: + name (str): Name of the tool, specifically "w3_get_wellknown_tokens". + description (str): Comprehensive description of the tool's purpose and functionality. + args_schema (Type[BaseModel]): Schema for input arguments, specifying expected parameters. + """ + + name: str = "w3_get_wellknown_tokens" + description: str = ( + "This tool returns well-known tokens and their corresponding information." + ) + args_schema: Type[BaseModel] = GetWellknownTokensInput + + def _run(self) -> GetWellknownTokensOutput: + """ + Run the tool to fetch well-known tokens and their corresponding information. + + Returns: + GetWellknownTokensOutput: A structured output containing the list of well-known tokens. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun(self) -> GetWellknownTokensOutput: + """ + Run the tool to fetch well-known tokens and their corresponding information. + + Returns: + GetWellknownTokensOutput: A structured output containing the token information. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + + tokens = await self.system_store.get_all_wellknown_tokens() + + if not tokens: + raise ToolException("could not find the token") + + return GetWellknownTokensOutput( + tokens=[ + W3TokenInfo(**w3token.model_dump(exclude_none=True)) + for w3token in tokens + ] + ) diff --git a/skills/w3/transfer.py b/skills/w3/transfer.py new file mode 100644 index 00000000..3706e92a --- /dev/null +++ b/skills/w3/transfer.py @@ -0,0 +1,216 @@ +from typing import Type + +import httpx +from langchain.tools.base import ToolException +from pydantic import BaseModel, Field + +from utils.chain import ( + ChainType, + EventSignature, + NetworkTitle, + get_network_by_title, + get_padded_address, +) + +from .base import Web3BaseTool + + +class GetTransfersInput(BaseModel): + """ + Input model for fetching Token transfers to a specific address. + """ + + network_title: NetworkTitle = Field( + ..., + description="Network to fetch Token transfers from. this should be filled from the output of get_networks tool", + ) + receiver_address: str = Field( + ..., + description="Ethereum address to fetch Token transfers for. it can be agent's wallet address or user's wallet(if requested)", + ) + token_address: str = Field( + ..., + description="Ethereum address to fetch Token transfers for. this should be filled from the output of get_networks tool according to chain_id", + ) + token_decimals: int = Field( + ..., + description="The token decimals. this should be filled from the output of get_networks tool according to chain_id and token", + ) + first_block: int = Field( + ..., + description="The first block filled with the output of w3_get_block_range_by_time tool according to user's requested time range.", + ) + last_block: int = Field( + ..., + description="The last block filled with the output of w3_get_block_range_by_time tool according to user's requested time range.", + ) + + +class EvmLogEntry(BaseModel): + address: str | None = Field( + None, description="The contract address that generated the log." + ) + topics: list[str] | None = Field( + None, description="An array of topics associated with the log." + ) + data: str | None = Field(None, description="The data field of the log.") + blockNumber: str | None = Field( + None, description="The block number where the log was generated." + ) + transactionHash: str | None = Field( + None, description="The transaction hash that generated the log." + ) + transactionIndex: str | None = Field( + None, description="The transaction index within the block." + ) + blockHash: str | None = Field( + None, description="The block hash where the log was generated." + ) + logIndex: str | None = Field(None, description="The log index within the block.") + removed: bool | None = Field( + None, description="Indicates if the log was removed due to a reorg." + ) + + +class Transfer(BaseModel): + amount: float | None = Field(None, description="The transferred amount.") + transactionHash: str | None = Field( + None, description="The transaction hash that generated the log." + ) + + +class GetTransfersOutput(BaseModel): + """ + Output model for Token transfers. + """ + + transfers: list[Transfer] + + +class GetTransfers(Web3BaseTool): + """ + This tool fetches all token transfers to a specific address using the Quicknode API. + + Attributes: + name (str): Name of the tool, specifically "tx_get_token_transfers". + description (str): Comprehensive description of the tool's purpose and functionality. + args_schema (Type[BaseModel]): Schema for input arguments, specifying expected parameters. + """ + + name: str = "tx_get_token_transfers" + description: str = ( + "This tool fetches all token transfers to a specific address using the Quicknode API." + ) + args_schema: Type[BaseModel] = GetTransfersInput + + def _run( + self, + receiver_address: str, + network_title: NetworkTitle, + token_address: str, + token_decimals: int, + first_block: int, + last_block: int, + ) -> GetTransfersOutput: + """Run the tool to fetch all token transfers to a specific address using the Quicknode API. + + Returns: + GetTransfersOutput: A structured output containing the result of tokens and APYs. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun( + self, + receiver_address: str, + network_title: NetworkTitle, + token_address: str, + token_decimals: int, + first_block: int, + last_block: int, + ) -> GetTransfersOutput: + """Run the tool to fetch all token transfers to a specific address using the Quicknode API. + Args: + receiver_address (str): The receiver account address. + network_title (NetworkTitle): The requested network title. + token_address (str): The address of the token smart contract. + token_decimals (int): The token decimals + first_block (int): the first block for querying transactions, this should be filled with the output of w3_get_block_range_by_time according to the requested start timestamp by user. + last_block (int): the last block for querying transactions, this should be filled with the output of w3_get_block_range_by_time according to the requested end timestamp by user. + Returns: + GetTransfersOutput: A structured output containing the tokens APY data. + + Raises: + Exception: If there's an error accessing the Quicknode API. + """ + + network = get_network_by_title(network_title) + chain_type = network.value.chain.value.chain_type + if chain_type != ChainType.EVM: + raise ToolException(f"chain type is not supported {chain_type}") + + chain_config = self.chain_provider.get_chain_config(network_title) + headers = { + "accept": "application/json", + } + + async with httpx.AsyncClient() as client: + try: + json = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_blockNumber", + "params": [], + } + response = await client.post( + chain_config.rpc_url, headers=headers, json=json + ) + response.raise_for_status() + json_dict = response.json() + + json = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getLogs", + "params": [ + { + "address": token_address, + "topics": [ + EventSignature.Transfer.value, + None, + get_padded_address(receiver_address), + ], + "fromBlock": hex(first_block), + "toBlock": hex(last_block), + } + ], + } + response = await client.post( + chain_config.rpc_url, headers=headers, json=json + ) + response.raise_for_status() + json_dict = response.json() + + res = GetTransfersOutput(transfers=[]) + for item in json_dict["result"]: + log_item = EvmLogEntry(**item) + amount_wei = int(log_item.data, 16) + amount = amount_wei / (10**token_decimals) + res.transfers.append( + Transfer( + amount=amount, transactionHash=log_item.transactionHash + ) + ) + return res + except httpx.RequestError as req_err: + raise ToolException( + f"request error from Quicknode API: {req_err}" + ) from req_err + except httpx.HTTPStatusError as http_err: + raise ToolException( + f"http error from Quicknode API: {http_err}" + ) from http_err + except Exception as e: + raise ToolException(f"error from Quicknode API: {e}") from e diff --git a/utils/chain.py b/utils/chain.py index 46380a84..692d9baa 100644 --- a/utils/chain.py +++ b/utils/chain.py @@ -1,10 +1,31 @@ from abc import ABC, abstractmethod -from enum import IntEnum, StrEnum +from enum import Enum, StrEnum import httpx +from pydantic import BaseModel, Field -class Chain(StrEnum): +class EventSignature(StrEnum): + Transfer = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + + +class ChainType(Enum): + """ + ChainType is an enumeration that represents different types of blockchain networks. + """ + + EVM = 1 + Solana = 2 + Cosmos = 3 + Other = 4 + + +class ChainData(BaseModel): + title: str = Field(description="The name of the blockchain.") + chain_type: ChainType = Field(description="The type of the blockchain.") + + +class Chain(Enum): """ Enum of supported blockchain chains, using QuickNode's naming conventions. @@ -15,55 +36,58 @@ class Chain(StrEnum): """ # EVM Chains - Ethereum = "eth" # Or "ethereum" - Avalanche = "avax" # Or "avalanche" - Binance = "bsc" # BNB Smart Chain - Polygon = "matic" # Or "polygon" - Gnosis = "gnosis" # Or "xdai" - Celo = "celo" - Fantom = "fantom" - Moonbeam = "moonbeam" - Aurora = "aurora" - Arbitrum = "arbitrum" - Optimism = "optimism" - Linea = "linea" - ZkSync = "zksync" + Ethereum = ChainData(title="eth", chain_type=ChainType.EVM) # Or "ethereum" + Avalanche = ChainData(title="avax", chain_type=ChainType.EVM) # Or "avalanche" + Binance = ChainData(title="bsc", chain_type=ChainType.EVM) # BNB Smart Chain + Polygon = ChainData(title="matic", chain_type=ChainType.EVM) # Or "polygon" + Gnosis = ChainData(title="gnosis", chain_type=ChainType.EVM) # Or "xdai" + Celo = ChainData(title="celo", chain_type=ChainType.EVM) + Fantom = ChainData(title="fantom", chain_type=ChainType.EVM) + Moonbeam = ChainData(title="moonbeam", chain_type=ChainType.EVM) + Aurora = ChainData(title="aurora", chain_type=ChainType.EVM) + Arbitrum = ChainData(title="arbitrum", chain_type=ChainType.EVM) + Optimism = ChainData(title="optimism", chain_type=ChainType.EVM) + Linea = ChainData(title="linea", chain_type=ChainType.EVM) + ZkSync = ChainData(title="zksync", chain_type=ChainType.EVM) # Base - Base = "base" + Base = ChainData(title="base", chain_type=ChainType.EVM) # Cosmos Ecosystem - CosmosHub = "cosmos" # Or "cosmos-hub" - Osmosis = "osmosis" - Juno = "juno" - Evmos = "evmos" - Kava = "kava" - Persistence = "persistence" - Secret = "secret" - Stargaze = "stargaze" - Terra = "terra" # Or "terra-classic" - Axelar = "axelar" + CosmosHub = ChainData( + title="cosmos", chain_type=ChainType.Cosmos + ) # Or "cosmos-hub" + Osmosis = ChainData(title="osmosis", chain_type=ChainType.Cosmos) + Juno = ChainData(title="juno", chain_type=ChainType.Cosmos) + Evmos = ChainData(title="evmos", chain_type=ChainType.Cosmos) + Kava = ChainData(title="kava", chain_type=ChainType.Cosmos) + Persistence = ChainData(title="persistence", chain_type=ChainType.Cosmos) + Secret = ChainData(title="secret", chain_type=ChainType.Cosmos) + Stargaze = ChainData(title="stargaze", chain_type=ChainType.Cosmos) + Terra = ChainData(title="terra", chain_type=ChainType.Cosmos) # Or "terra-classic" + Axelar = ChainData(title="axelar", chain_type=ChainType.Cosmos) # Solana - Solana = "sol" # Or "solana" + Solana = ChainData(title="sol", chain_type=ChainType.Solana) # Or "solana" # Other Chains - Sonic = "sonic" - Bera = "bera" - Near = "near" - Frontera = "frontera" + Sonic = ChainData(title="sonic", chain_type=ChainType.Other) + Bera = ChainData(title="bera", chain_type=ChainType.Other) + Near = ChainData(title="near", chain_type=ChainType.Other) + Frontera = ChainData(title="frontera", chain_type=ChainType.Other) + # def __str__(self): + # return self.value.title -class Network(StrEnum): - """ - Enum of well-known blockchain network names, based on QuickNode API. - This list is not exhaustive and might not be completely up-to-date. - Always consult the official QuickNode documentation for the most accurate - and current list of supported networks. Network names can sometimes - be slightly different from what you might expect. - """ +def get_chain_by_title(title: str) -> Chain: + for chain in Chain: + if chain.value.title == title: + return chain + return None + +class NetworkTitle(StrEnum): # Ethereum Mainnet and Testnets EthereumMainnet = "ethereum-mainnet" EthereumGoerli = "ethereum-goerli" # Goerli Testnet (deprecated, Sepolia preferred) @@ -90,109 +114,259 @@ class Network(StrEnum): BaseSepolia = "base-sepolia" # Cosmos Ecosystem (These can be tricky and may need updates) - CosmosHubMainnet = "cosmos-hub-mainnet" # Or just "cosmos" - OsmosisMainnet = "osmosis-mainnet" # Or just "osmosis" - JunoMainnet = "juno-mainnet" # Or just "juno" + CosmosHubMainnet = "cosmos-hub-mainnet" # Or just "cosmos", Cosmos doesn't have a consistent chain ID + OsmosisMainnet = "osmosis-mainnet" # Or just "osmosis", Cosmos doesn't have a consistent chain ID + JunoMainnet = ( + "juno-mainnet" # Or just "juno", Cosmos doesn't have a consistent chain ID + ) # Solana (Note: Solana uses cluster names, not typical network names) - SolanaMainnet = "solana-mainnet" # Or "solana" + SolanaMainnet = "solana-mainnet" # Or "solana", Solana doesn't have a chain ID # Other Chains SonicMainnet = "sonic-mainnet" BeraMainnet = "bera-mainnet" - NearMainnet = "near-mainnet" # Or just "near" - KavaMainnet = "kava-mainnet" # Or just "kava" - EvmosMainnet = "evmos-mainnet" # Or just "evmos" - PersistenceMainnet = "persistence-mainnet" # Or just "persistence" - SecretMainnet = "secret-mainnet" # Or just "secret" - StargazeMainnet = "stargaze-mainnet" # Or just "stargaze" - TerraMainnet = "terra-mainnet" # Or "terra-classic" - AxelarMainnet = "axelar-mainnet" # Or just "axelar" + NearMainnet = "near-mainnet" # Or just "near", Near doesn't have a chain ID + KavaMainnet = ( + "kava-mainnet" # Or just "kava", Kava chain ID can vary depending on zone + ) + EvmosMainnet = ( + "evmos-mainnet" # Or just "evmos", Evmos chain ID can vary depending on zone + ) + PersistenceMainnet = "persistence-mainnet" # Or just "persistence", Persistence chain ID can vary depending on zone + SecretMainnet = ( + "secret-mainnet" # Or just "secret", Secret chain ID can vary depending on zone + ) + StargazeMainnet = "stargaze-mainnet" # Or just "stargaze", Stargaze chain ID can vary depending on zone + TerraMainnet = ( + "terra-mainnet" # Or "terra-classic", Terra chain ID can vary depending on zone + ) + AxelarMainnet = ( + "axelar-mainnet" # Or just "axelar", Axelar chain ID can vary depending on zone + ) FronteraMainnet = "frontera-mainnet" -class NetworkId(IntEnum): +class NetworkData(BaseModel): + id: str = Field(description="The id of the network (chain id).") + title: NetworkTitle = Field(description="The name of the blockchain.") + chain: Chain = Field(description="The Chain technology of the network") + block_time: float = Field(description="Average block time in seconds.") + + +class Network(Enum): """ - Enum of well-known blockchain network IDs. + Enum of well-known blockchain network names, based on QuickNode API. This list is not exhaustive and might not be completely up-to-date. - Always consult the official documentation for the specific blockchain - you are working with for the most accurate and current chain ID. + Always consult the official QuickNode documentation for the most accurate + and current list of supported networks. Network names can sometimes + be slightly different from what you might expect. """ # Ethereum Mainnet and Testnets - EthereumMainnet = 1 - EthereumGoerli = 5 # Goerli Testnet (deprecated, Sepolia is preferred) - EthereumSepolia = 11155111 + EthereumMainnet = NetworkData( + title=NetworkTitle.EthereumMainnet, + id="1", + chain=Chain.Ethereum, + block_time=13.5, # Approximate block time in seconds + ) + EthereumGoerli = NetworkData( + title=NetworkTitle.EthereumGoerli, + id="5", + chain=Chain.Ethereum, + block_time=15, # Approximate block time in seconds + ) + EthereumSepolia = NetworkData( + title=NetworkTitle.EthereumSepolia, + id="11155111", + chain=Chain.Ethereum, + block_time=12, # Approximate block time in seconds + ) # Layer 2s on Ethereum - ArbitrumMainnet = 42161 - OptimismMainnet = 10 - LineaMainnet = 59144 - ZkSyncMainnet = 324 # zkSync Era + ArbitrumMainnet = NetworkData( + title=NetworkTitle.ArbitrumMainnet, + id="42161", + chain=Chain.Arbitrum, + block_time=0.25, # Approximate block time in seconds + ) + OptimismMainnet = NetworkData( + title=NetworkTitle.OptimismMainnet, + id="10", + chain=Chain.Optimism, + block_time=2, # Approximate block time in seconds + ) + LineaMainnet = NetworkData( + title=NetworkTitle.LineaMainnet, + id="59144", + chain=Chain.Linea, + block_time=1, # Approximate block time in seconds + ) + ZkSyncMainnet = NetworkData( + title=NetworkTitle.ZkSyncMainnet, + id="324", + chain=Chain.ZkSync, + block_time=3, # Approximate block time in seconds + ) # Other EVM Chains - AvalancheMainnet = 43114 - BinanceMainnet = 56 # BNB Smart Chain (BSC) - PolygonMainnet = 137 - GnosisMainnet = 100 # xDai Chain - CeloMainnet = 42220 - FantomMainnet = 250 - MoonbeamMainnet = 1284 - AuroraMainnet = 1313161554 + AvalancheMainnet = NetworkData( + title=NetworkTitle.AvalancheMainnet, + id="43114", + chain=Chain.Avalanche, + block_time=2, # Approximate block time in seconds + ) + BinanceMainnet = NetworkData( + title=NetworkTitle.BinanceMainnet, + id="56", + chain=Chain.Binance, + block_time=3, # Approximate block time in seconds + ) + PolygonMainnet = NetworkData( + title=NetworkTitle.PolygonMainnet, + id="137", + chain=Chain.Polygon, + block_time=2.5, # Approximate block time in seconds + ) + GnosisMainnet = NetworkData( + title=NetworkTitle.GnosisMainnet, + id="100", + chain=Chain.Gnosis, + block_time=5, # Approximate block time in seconds + ) + CeloMainnet = NetworkData( + title=NetworkTitle.CeloMainnet, + id="42220", + chain=Chain.Celo, + block_time=5, # Approximate block time in seconds + ) + FantomMainnet = NetworkData( + title=NetworkTitle.FantomMainnet, + id="250", + chain=Chain.Fantom, + block_time=1, # Approximate block time in seconds + ) + MoonbeamMainnet = NetworkData( + title=NetworkTitle.MoonbeamMainnet, + id="1284", + chain=Chain.Moonbeam, + block_time=12, # Approximate block time in seconds + ) + AuroraMainnet = NetworkData( + title=NetworkTitle.AuroraMainnet, + id="1313161554", + chain=Chain.Aurora, + block_time=1, # Approximate block time in seconds + ) # Base - BaseMainnet = 8453 - BaseSepolia = 84532 + BaseMainnet = NetworkData( + title=NetworkTitle.BaseMainnet, + id="8453", + chain=Chain.Base, + block_time=2, # Approximate block time in seconds + ) + BaseSepolia = NetworkData( + title=NetworkTitle.BaseSepolia, + id="84532", + chain=Chain.Base, + block_time=2, # Approximate block time in seconds + ) + + # Cosmos Ecosystem + CosmosHubMainnet = NetworkData( + title=NetworkTitle.CosmosHubMainnet, + id="cosmoshub-4", + chain=Chain.CosmosHub, + block_time=6, # Approximate block time in seconds + ) + OsmosisMainnet = NetworkData( + title=NetworkTitle.OsmosisMainnet, + id="osmosis-1", + chain=Chain.Osmosis, + block_time=6, # Approximate block time in seconds + ) + JunoMainnet = NetworkData( + title=NetworkTitle.JunoMainnet, + id="juno-1", + chain=Chain.Juno, + block_time=6, # Approximate block time in seconds + ) + + # Solana + SolanaMainnet = NetworkData( + title=NetworkTitle.SolanaMainnet, + id="-1", + chain=Chain.Solana, + block_time=0.4, # Approximate block time in seconds + ) # Other Chains - SonicMainnet = 146 - BeraMainnet = 80094 - - -# Mapping of Network enum members to their corresponding NetworkId enum members. -# This dictionary facilitates efficient lookup of network IDs given a network name. -# Note: SolanaMainnet is intentionally excluded as it does not have a numeric chain ID. -# Always refer to the official documentation for the most up-to-date mappings. -network_to_id: dict[Network, NetworkId] = { - Network.ArbitrumMainnet: NetworkId.ArbitrumMainnet, - Network.AvalancheMainnet: NetworkId.AvalancheMainnet, - Network.BaseMainnet: NetworkId.BaseMainnet, - Network.BaseSepolia: NetworkId.BaseSepolia, - Network.BeraMainnet: NetworkId.BeraMainnet, - Network.BinanceMainnet: NetworkId.BinanceMainnet, - Network.EthereumMainnet: NetworkId.EthereumMainnet, - Network.EthereumSepolia: NetworkId.EthereumSepolia, - Network.GnosisMainnet: NetworkId.GnosisMainnet, - Network.LineaMainnet: NetworkId.LineaMainnet, - Network.OptimismMainnet: NetworkId.OptimismMainnet, - Network.PolygonMainnet: NetworkId.PolygonMainnet, - Network.SonicMainnet: NetworkId.SonicMainnet, - Network.ZkSyncMainnet: NetworkId.ZkSyncMainnet, -} - -# Mapping of NetworkId enum members (chain IDs) to their corresponding -# Network enum members (network names). This dictionary allows for reverse -# lookup, enabling retrieval of the network name given a chain ID. -# Note: Solana is not included here as it does not use a standard numeric -# chain ID. Always consult official documentation for the most -# up-to-date mappings. -id_to_network: dict[NetworkId, Network] = { - NetworkId.ArbitrumMainnet: Network.ArbitrumMainnet, - NetworkId.AvalancheMainnet: Network.AvalancheMainnet, - NetworkId.BaseMainnet: Network.BaseMainnet, - NetworkId.BaseSepolia: Network.BaseSepolia, - NetworkId.BeraMainnet: Network.BeraMainnet, - NetworkId.BinanceMainnet: Network.BinanceMainnet, - NetworkId.EthereumMainnet: Network.EthereumMainnet, - NetworkId.EthereumSepolia: Network.EthereumSepolia, - NetworkId.GnosisMainnet: Network.GnosisMainnet, - NetworkId.LineaMainnet: Network.LineaMainnet, - NetworkId.OptimismMainnet: Network.OptimismMainnet, - NetworkId.PolygonMainnet: Network.PolygonMainnet, - NetworkId.SonicMainnet: Network.SonicMainnet, - NetworkId.ZkSyncMainnet: Network.ZkSyncMainnet, -} + SonicMainnet = NetworkData( + title=NetworkTitle.SonicMainnet, id="146", chain=Chain.Sonic, block_time=2 + ) + BeraMainnet = NetworkData( + title=NetworkTitle.BeraMainnet, id="80094", chain=Chain.Bera, block_time=6 + ) + NearMainnet = NetworkData( + title=NetworkTitle.NearMainnet, + id="near", + chain=Chain.Near, + block_time=1, # Near ID is not a number, but a string. + ) + KavaMainnet = NetworkData( + title=NetworkTitle.KavaMainnet, + id="kava_2222-10", + chain=Chain.Kava, + block_time=6, # current kava mainnet chain ID. + ) + EvmosMainnet = NetworkData( + title=NetworkTitle.EvmosMainnet, + id="evmos_9001-2", + chain=Chain.Evmos, + block_time=6, # example of evmos ID. Version will change. + ) + PersistenceMainnet = NetworkData( + title=NetworkTitle.PersistenceMainnet, + id="core-1", + chain=Chain.Persistence, + block_time=6, + ) + SecretMainnet = NetworkData( + title=NetworkTitle.SecretMainnet, + id="secret-4", + chain=Chain.Secret, + block_time=6, + ) + StargazeMainnet = NetworkData( + title=NetworkTitle.StargazeMainnet, + id="stargaze-1", + chain=Chain.Stargaze, + block_time=6, + ) + TerraMainnet = NetworkData( + title=NetworkTitle.TerraMainnet, id="phoenix-1", chain=Chain.Terra, block_time=6 + ) + AxelarMainnet = NetworkData( + title=NetworkTitle.AxelarMainnet, + id="axelar-dojo-1", + chain=Chain.Axelar, + block_time=6, + ) + FronteraMainnet = NetworkData( + title=NetworkTitle.FronteraMainnet, + id="frontera-1", + chain=Chain.Frontera, + block_time=6, + ) + + +def get_network_by_title(title: NetworkTitle) -> Network: + for network in Network: + if network.value.title == title: + return network + return None class ChainConfig: @@ -242,14 +416,6 @@ def network(self) -> Network: """ return self._network - @property - def network_id(self) -> int | None: - """ - Returns the network ID (chain ID) for the configured network, or None if not applicable. - Uses the global network_to_id mapping to retrieve the ID. - """ - return network_to_id.get(self._network) - @property def rpc_url(self) -> str: """ @@ -287,9 +453,9 @@ def __init__(self): Sets up an empty dictionary `chain_configs` to store the configurations. """ - self.chain_configs: dict[Network, ChainConfig] = {} + self.chain_configs: dict[NetworkTitle, ChainConfig] = {} - def get_chain_config(self, network: Network) -> ChainConfig: + def get_chain_config(self, network_title: NetworkTitle) -> ChainConfig: """ Retrieves the chain configuration for a specific network. @@ -302,36 +468,13 @@ def get_chain_config(self, network: Network) -> ChainConfig: Raises: Exception: If no chain configuration is found for the specified network. """ - chain_config = self.chain_configs.get(network) + chain_config = self.chain_configs.get(network_title) if not chain_config: - raise Exception(f"chain config for network {network} not found") + raise Exception(f"chain config for network {network_title} not found") return chain_config - def get_chain_config_by_id(self, network_id: NetworkId) -> ChainConfig: - """ - Retrieves the chain configuration by network ID. - - This method first looks up the `Network` enum member associated with the - provided `NetworkId` and then uses `get_chain_config` to retrieve the - configuration. - - Args: - network_id: The `NetworkId` enum member representing the desired network ID. - - Returns: - The `ChainConfig` object associated with the network ID. - - Raises: - Exception: If no network is found for the given ID or if the - chain configuration is not found for the resolved network. - """ - network = id_to_network.get(network_id) - if not network: - raise Exception(f"network with id {network_id} not found") - return self.get_chain_config(network) - @abstractmethod - def init_chain_configs(self, api_key: str) -> dict[Network, ChainConfig]: + def init_chain_configs(self, api_key: str) -> dict[NetworkTitle, ChainConfig]: """ Initializes the chain configurations. @@ -344,7 +487,7 @@ def init_chain_configs(self, api_key: str) -> dict[Network, ChainConfig]: api_key: The API key used for initializing chain configurations. Returns: - A dictionary mapping `Network` enum members to `ChainConfig` objects. + A dictionary mapping `NetworkTitle` enum members to `ChainConfig` objects. """ raise NotImplementedError @@ -408,8 +551,8 @@ def init_chain_configs( for item in json_dict["data"]: # Assuming 'item' contains 'chain', 'network', 'http_url', 'wss_url' # and that these values can be used to construct the ChainConfig object - chain = Chain(item["chain"]) - network = Network(item["network"]) + chain = get_chain_by_title(item["chain"]) + network = get_network_by_title(item["network"]) self.chain_configs[item["network"]] = ChainConfig( chain, @@ -434,3 +577,11 @@ def init_chain_configs( ) except Exception as e: raise (f"Quicknode API An unexpected error occurred: {e}") + + +def get_padded_address(address): + """Pads an Ethereum address with leading zeros to 64 hex characters.""" + if not address.startswith("0x"): + raise ValueError("Address must start with '0x'") + + return "0x" + address[2:].lower().zfill(64) diff --git a/utils/time.py b/utils/time.py new file mode 100644 index 00000000..3e427570 --- /dev/null +++ b/utils/time.py @@ -0,0 +1,28 @@ +import time + +from pydantic import BaseModel, Field, field_validator + + +class TimestampRange(BaseModel): + """ + Represents a range of timestamps. + + Attributes: + start: The starting timestamp (Unix epoch in seconds). + end: The ending timestamp (Unix epoch in seconds). + """ + + start: int = Field( + int(time.time()), description="Starting timestamp (Unix epoch in seconds)" + ) + end: int = Field( + int(time.time()), description="Ending timestamp (Unix epoch in seconds)" + ) + + @field_validator("end") + def end_must_be_after_start(cls, value, info): + """Validates that the end timestamp is after the start timestamp.""" + start = info.data.get("start") + if start is not None and value <= start: + raise ValueError("End timestamp must be greater than start timestamp.") + return value From 4fb08403a82c8ab081862ca555ef8733e98cf0bd Mon Sep 17 00:00:00 2001 From: TxCorpi0x <6095314+TxCorpi0x@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:39:45 +0300 Subject: [PATCH 2/2] feat: downstream tool call from transfer --- app/core/engine.py | 3 ++ docs/init_tokens.py | 104 ++++++++++++++++++++++++++++++++++++ poetry.lock | 38 ++++++++++++- pyproject.toml | 1 + skills/general/__init__.py | 12 +++-- skills/general/timestamp.py | 78 +++++++++++++++++++++++++++ skills/w3/__init__.py | 11 +--- skills/w3/block.py | 53 ++++++++++++------ skills/w3/transfer.py | 65 ++++++++++++++++------ utils/time.py | 5 +- 10 files changed, 324 insertions(+), 46 deletions(-) create mode 100644 docs/init_tokens.py diff --git a/app/core/engine.py b/app/core/engine.py index 817e9a11..8c1ab2a1 100644 --- a/app/core/engine.py +++ b/app/core/engine.py @@ -68,6 +68,7 @@ from skills.common import get_common_skill from skills.elfa import get_elfa_skill from skills.enso import get_enso_skill +from skills.general import get_general_skills from skills.goat import ( create_smart_wallets_if_not_exist, get_goat_skill, @@ -397,6 +398,8 @@ async def initialize_agent(aid, is_private=False): for skill in agent.common_skills: tools.append(get_common_skill(skill)) + tools.extend(get_general_skills(system_store, skill_store, agent_store, aid)) + # filter the duplicate tools tools = list({tool.name: tool for tool in tools}.values()) diff --git a/docs/init_tokens.py b/docs/init_tokens.py new file mode 100644 index 00000000..7fc8befd --- /dev/null +++ b/docs/init_tokens.py @@ -0,0 +1,104 @@ +import asyncio +import json +import time + +import requests +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel import select + +from models.w3 import W3Token + +# Database URL (replace with your actual database URL) +DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/intentkit" + +# Create asynchronous engine +engine = create_async_engine(DATABASE_URL) + +# Create session factory +async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_session() -> AsyncSession: + async with async_session_maker() as session: + yield session + + +base_url = "https://api.enso.finance/api/v1/tokens" +headers = { + "accept": "application/json", + "Authorization": "Bearer 1e02632d-6feb-4a75-a157-documentation", +} +chain_ids = [ + # 1, # Ethereum Mainnet + 8453, + # 42161, # Arbitrum +] + + +async def add_to_db(item): + sym = item.get("symbol") + if sym and sym.strip() != "": + chid = str(item.get("chainId")) + async for db in get_session(): + existing = ( + await db.execute( + select(W3Token).where( + W3Token.symbol == sym, + W3Token.chain_id == chid, + ) + ) + ).first() + if not existing: + new_token = W3Token( + symbol=sym, + chain_id=chid, + name=item.get("name"), + decimals=item.get("decimals"), + address=item.get("address"), + primary_address=item.get("primaryAddress"), + is_well_known=False, + protocol_slug=item.get("protocolSlug"), + token_type=item.get("type"), + ) + db.add(new_token) + + await db.commit() + + +async def main(): + for ch_id in chain_ids: + page = 1 + while True: + url = f"{base_url}?&chainId={ch_id}&page={page}&includeMetadata=true" + try: + response = requests.get(url, headers=headers) + response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + data = response.json() + meta = data.get("meta", {}) + tokens = data.get("data", []) # access the items list + if ( + not tokens + ): # if the items list is empty, then break out of the loop. + break + print(f"processing chain {ch_id} page {page} of {meta["lastPage"]}") + for item in tokens: + if item.get("underlyingTokens"): + for t in item["underlyingTokens"]: + await add_to_db(t) + await add_to_db(item) + page += 1 + if page > int(meta["lastPage"]): + break + time.sleep(1) + + except requests.exceptions.RequestException as e: + print(f"Error fetching page {page}: {e}") + except json.JSONDecodeError as e: + print(f"Error decoding JSON on page {page}: {e}") + except KeyError as e: + print(f"Error accessing 'items' in the JSON on page {page}: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/poetry.lock b/poetry.lock index fb748308..d724de38 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1333,6 +1333,29 @@ files = [ marshmallow = ">=3.18.0,<4.0.0" typing-inspect = ">=0.4.0,<1" +[[package]] +name = "dateparser" +version = "1.2.1" +description = "Date parsing library designed to parse dates from HTML pages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c"}, + {file = "dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3"}, +] + +[package.dependencies] +python-dateutil = ">=2.7.0" +pytz = ">=2024.2" +regex = ">=2015.06.24,<2019.02.19 || >2019.02.19,<2021.8.27 || >2021.8.27" +tzlocal = ">=0.2" + +[package.extras] +calendars = ["convertdate (>=2.2.1)", "hijridate"] +fasttext = ["fasttext (>=0.9.1)", "numpy (>=1.19.3,<2)"] +langdetect = ["langdetect (>=1.0.0)"] + [[package]] name = "distro" version = "1.9.0" @@ -3501,6 +3524,7 @@ files = [ {file = "psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4"}, {file = "psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067"}, {file = "psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e"}, + {file = "psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2"}, {file = "psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b"}, {file = "psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442"}, {file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"}, @@ -3917,6 +3941,18 @@ files = [ {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, ] +[[package]] +name = "pytz" +version = "2025.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, + {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, +] + [[package]] name = "pyunormalize" version = "16.0.0" @@ -5243,4 +5279,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "f77151452236b3866c319a27d52d425000e6d112f2f6069f8dc145aa80dea01f" +content-hash = "1fad8d76ac6d17c43867d595266d4d8d52fc4557bfc4a0b9ac1902683869f70f" diff --git a/pyproject.toml b/pyproject.toml index 3eadf582..6a092680 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ langchain-xai = "^0.2.1" coinbase-agentkit = "0.1.4.dev202502250" coinbase-agentkit-langchain = "^0.1.0" jsonref = "^1.1.0" +dateparser = "^1.2.1" [tool.poetry.group.dev] optional = true diff --git a/skills/general/__init__.py b/skills/general/__init__.py index b16bb75d..cacbd8cb 100644 --- a/skills/general/__init__.py +++ b/skills/general/__init__.py @@ -4,10 +4,10 @@ from app.core.system import SystemStore from .base import GeneralBaseTool -from .timestamp import CurrentEpochTimestampTool +from .timestamp import CurrentEpochTimestampTool, GetRelativeTimeParser -def get_crestal_skills( +def get_general_skills( system_store: SystemStore, skill_store: SkillStoreABC, agent_store: SkillStoreABC, @@ -19,5 +19,11 @@ def get_crestal_skills( system_store=system_store, skill_store=skill_store, agent_store=agent_store, - ) + ), + GetRelativeTimeParser( + agent_id=agent_id, + system_store=system_store, + skill_store=skill_store, + agent_store=agent_store, + ), ] diff --git a/skills/general/timestamp.py b/skills/general/timestamp.py index 7e8e0223..1ebe8dc8 100644 --- a/skills/general/timestamp.py +++ b/skills/general/timestamp.py @@ -1,6 +1,8 @@ import time from typing import Type +import dateparser +from langchain.tools.base import ToolException from pydantic import BaseModel, Field from .base import GeneralBaseTool @@ -32,3 +34,79 @@ def _run(self) -> CurrentEpochTimestampOutput: async def _arun(self) -> CurrentEpochTimestampOutput: """Returns the current epoch timestamp asynchronously.""" return CurrentEpochTimestampOutput(timestamp=int(time.time())) + + +class GetRelativeTimeParserInput(BaseModel): + relative_time_str: str = Field( + ..., description="Human-readable relative time string" + ) + + +class GetRelativeTimeParser(GeneralBaseTool): + """ + This is a tool for parsing human-readable relative time strings and converting them into epoch timestamps in seconds. if the output of this tool + is going to be used in another tool, you should execute this tool every time. + + Attributes: + name (str): The name of the tool. + description (str): A detailed description of the tool's functionality and supported relative time expressions. + args_schema (Type[BaseModel]): The schema for the input arguments. + + Methods: + _run(relative_time_str: str) -> int: + Synchronous method to parse a relative time string. Always raises NotImplementedError to indicate that this method should not be used. + + _arun(relative_time_str: str) -> int: + Asynchronous method to parse a human-readable relative time string and return the corresponding epoch timestamp in seconds. + int: Epoch timestamp of the parsed relative time string. + """ + + name: str = "general_relative_time_parser" + description: str = ( + """ + Parses a human-readable relative time string and returns the corresponding epoch timestamp in seconds. if the output of this tool + is going to be used in another tool, you should execute this tool every time + This tool supports a wide range of relative time expressions, including: + - Past durations: "last 5 days", "last 2 hours", "last 10 minutes", "yesterday", "a week ago", "3 months ago", "2 years ago" + - Future durations: "in 6 days", "in 4 hours", "in 20 minutes", "tomorrow", "next week", "in 3 months", "in 2 years" + - Specific relative points: "today", "now", "this week", "next Monday", "last Friday" + - Fuzzy relative times: "a couple of days ago", "few hours from now" + - Combined relative dates and times: "last week at 3pm", "tomorrow morning" + """ + ) + + args_schema: Type[BaseModel] = GetRelativeTimeParserInput + + def _run(self, relative_time_str: str) -> int: + """ + Synchronous method to parse a relative time string. + + Args: + relative_time_str (str): The relative time string to be parsed. + + Raises: + NotImplementedError: Always raised to indicate that this method should not be used. + """ + raise NotImplementedError("Use _arun instead") + + async def _arun(self, relative_time_str: str) -> int: + """ + Parses a human-readable relative time string and returns the corresponding epoch timestamp in seconds. + + Args: + relative_time_str (str): The human-readable relative time string to be parsed. + + Returns: + int: epoch timestamp of the parsed relative time string. + + Raises: + ToolException: If the date string could not be parsed. + """ + parsed_date = dateparser.parse(relative_time_str) + + if parsed_date is None: + raise ToolException(f"Could not parse date string: {relative_time_str}") + + timestamp = int(parsed_date.timestamp()) + + return timestamp diff --git a/skills/w3/__init__.py b/skills/w3/__init__.py index 45ea296b..38881d40 100644 --- a/skills/w3/__init__.py +++ b/skills/w3/__init__.py @@ -5,7 +5,7 @@ from utils.chain import ChainProvider from .base import Web3BaseTool -from .block import GetBlocksBetweenDates, GetCurrentBlock +from .block import GetCurrentBlock from .network import GetNetworks from .token import GetToken, GetWellknownTokens from .transfer import GetTransfers @@ -56,15 +56,6 @@ def get_web3_skill( agent_store=agent_store, ) - if name == "get_block_range_by_time": - return GetBlocksBetweenDates( - chain_provider=chain_provider, - agent_id=agent_id, - system_store=system_store, - skill_store=skill_store, - agent_store=agent_store, - ) - if name == "get_current_block": return GetCurrentBlock( chain_provider=chain_provider, diff --git a/skills/w3/block.py b/skills/w3/block.py index d5a93c76..f821e1c3 100644 --- a/skills/w3/block.py +++ b/skills/w3/block.py @@ -1,3 +1,4 @@ +import time from typing import Type import httpx @@ -138,14 +139,19 @@ class GetBlocksBetweenDatesInput(BaseModel): network_title: NetworkTitle = Field( ..., description="The network to be used for querying." ) - time_range: TimestampRange = Field( + start_timestamp: int = Field( ..., description=""" - The time range to query blocks for, specified as a start and end Unix epoch timestamp. - The current time MUST be obtained using the 'general_current_epoch_timestamp' tool. - Calculate the start and end times relative to the current timestamp from 'general_current_epoch_timestamp', for example, to get blocks for the last 7 days, subtract 7 days worth of seconds from the current timestamp for the start time. + The lower bound timestamp for the block range, if it is asked directly with time or timestamp, if it is requested with relative time + (e.g., 1 hour ago, 2 days ago, 10 minutes) general_relative_time_parser tool should be used for conversion. """, ) + end_timestamp: int | None = Field( + int(time.time()), + description="""The end timestamp for the block range. the default value is now. otherwise if the user specifies the timestamp directly, it should be used. + otherwise general_relative_time_parser tool should be used for conversion, in relative end time should be extracted from the user input if available + (e.g. between 8 and 10 days ago, the input after `and` is the end timestamp).""", + ) class GetBlocksBetweenDatesOutput(BaseModel): @@ -154,13 +160,14 @@ class GetBlocksBetweenDatesOutput(BaseModel): """ start_block: int = Field(..., description="the start block") - end_block: int = Field(..., description="the start block") + end_block: int = Field(..., description="the end block") class GetBlocksBetweenDates(Web3BaseTool): """ - This tool returns the block range according to the input timestamps. - The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + This tool returns the block range according to the input timestamps. if the output of this tool is going to be used in another tool, you should execute this tool every time. + The start timestamp MUST be calculated using 'general_relative_time_parser' tool. + The end timestamp is optional, if not provided, it defaults to the current time, otherwise 'general_relative_time_parser' tool should be used for parsing the input. Attributes: name (str): Name of the tool, specifically "w3_get_block_range_by_time". @@ -171,23 +178,29 @@ class GetBlocksBetweenDates(Web3BaseTool): name: str = "w3_get_block_range_by_time" description: str = ( """ - This tool returns the block range according to the input timestamps. - The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. - For example, to get blocks for the last 5 minutes, use the output of 'general_current_epoch_timestamp' to get the current time, subtract 300 seconds (5 minutes) from it to get the start time, and use the current time as the end time. + This tool returns the block range according to the input timestamps. if the output of this tool is going to be used in another tool, you should execute this tool every time. + The start timestamp MUST be calculated using 'general_relative_time_parser' tool. + The end timestamp is optional, if not provided, it defaults to the current time, otherwise 'general_relative_time_parser' tool should be used for parsing the input. + IMPORTANT: The results change according to the block time of the network, so this tool should be called every time if the block time is passed for the requested network. """ ) args_schema: Type[BaseModel] = GetBlocksBetweenDatesInput def _run( - self, network_title: NetworkTitle, time_range: TimestampRange + self, + network_title: NetworkTitle, + start_timestamp: int, + end_timestamp: int = int(time.time()), ) -> GetBlocksBetweenDatesOutput: """ Run the tool to fetch the block range according to the input timestamps. - The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + The start timestamp MUST be calculated using 'general_relative_time_parser' tool. + The end timestamp is optional, if not provided, it defaults to the current time, otherwise 'general_relative_time_parser' tool should be used for parsing the input. Args: network_title (NetworkTitle): The network to check the block number for. - time_range (TimestampRange): The time range to query blocks for, specified as a start and end Unix epoch timestamp, calculated relative to the current timestamp from 'general_current_epoch_timestamp'. + start_timestamp (int): The start date and time range in this format: (e.g., 7 days ago, 2 minutes ago, between in 8 hours, etc.). + end_timestamp (int): It is optional, The end date and time range in this format: (e.g., 7 days ago, 2 minutes ago, between in 8 hours, etc.). Returns: GetBlocksBetweenDatesOutput: A structured output containing blocks start and end. @@ -198,15 +211,20 @@ def _run( raise NotImplementedError("Use _arun instead") async def _arun( - self, network_title: NetworkTitle, time_range: TimestampRange + self, + network_title: NetworkTitle, + start_timestamp: int, + end_timestamp: int = int(time.time()), ) -> GetBlocksBetweenDatesOutput: """ Run the tool to fetch the block range according to the input timestamps. - The time_range start and end times MUST be calculated using the current timestamp from the 'general_current_epoch_timestamp' tool. + The start timestamp MUST be calculated using 'general_relative_time_parser' tool. + The end timestamp is optional, if not provided, it defaults to the current time, otherwise 'general_relative_time_parser' tool should be used for parsing the input. Args: network_title (NetworkTitle): The network to check the block number for. - time_range (TimestampRange): The time range to query blocks for, specified as a start and end Unix epoch timestamp, calculated relative to the current timestamp from 'general_current_epoch_timestamp'. + start_timestamp (int): The start date and time range in this format: (e.g., 7 days ago, 2 minutes ago, between in 8 hours, etc.). + end_timestamp (int): It is optional, The end date and time range in this format: (e.g., 7 days ago, 2 minutes ago, between in 8 hours, etc.). Returns: GetBlocksBetweenDatesOutput: A structured output containing blocks start and end. @@ -215,8 +233,11 @@ async def _arun( ToolException: If there's an error accessing the RPC or any other issue during the process. """ + time_range = TimestampRange(start=start_timestamp, end=end_timestamp) + network = get_network_by_title(network_title) + # the maximum block difference between start and end block supported by Quicknode API. max_block_difference = 10000 try: current_block = await GetCurrentBlock( diff --git a/skills/w3/transfer.py b/skills/w3/transfer.py index 3706e92a..94326c9c 100644 --- a/skills/w3/transfer.py +++ b/skills/w3/transfer.py @@ -1,9 +1,11 @@ +import time from typing import Type import httpx from langchain.tools.base import ToolException from pydantic import BaseModel, Field +from skills.w3.block import GetBlocksBetweenDates, GetBlocksBetweenDatesInput from utils.chain import ( ChainType, EventSignature, @@ -36,13 +38,18 @@ class GetTransfersInput(BaseModel): ..., description="The token decimals. this should be filled from the output of get_networks tool according to chain_id and token", ) - first_block: int = Field( + start_timestamp: int = Field( ..., - description="The first block filled with the output of w3_get_block_range_by_time tool according to user's requested time range.", + description=""" + The lower bound timestamp for the block range, if it is asked directly with time or timestamp, if it is requested with relative time + (e.g., 1 hour ago, 2 days ago, 10 minutes) general_relative_time_parser tool should be used for conversion. + """, ) - last_block: int = Field( - ..., - description="The last block filled with the output of w3_get_block_range_by_time tool according to user's requested time range.", + end_timestamp: int | None = Field( + int(time.time()), + description="""The end timestamp for the block range. the default value is now. otherwise if the user specifies the timestamp directly, it should be used. + otherwise general_relative_time_parser tool should be used for conversion, in relative end time should be extracted from the user input if available + (e.g. between 8 and 10 days ago, the input after `and` is the end timestamp).""", ) @@ -89,7 +96,9 @@ class GetTransfersOutput(BaseModel): class GetTransfers(Web3BaseTool): """ - This tool fetches all token transfers to a specific address using the Quicknode API. + This tool fetches all token transfers to a specific address. + The start timestamp MUST be calculated using 'general_relative_time_parser' tool. + The end timestamp is optional, if not provided, it defaults to the current time, otherwise 'general_relative_time_parser' tool should be used for parsing the input. Attributes: name (str): Name of the tool, specifically "tx_get_token_transfers". @@ -99,7 +108,11 @@ class GetTransfers(Web3BaseTool): name: str = "tx_get_token_transfers" description: str = ( - "This tool fetches all token transfers to a specific address using the Quicknode API." + """ + This tool fetches all token transfers to a specific address. + The start timestamp MUST be calculated using 'general_relative_time_parser' tool. + The end timestamp is optional, if not provided, it defaults to the current time, otherwise 'general_relative_time_parser' tool should be used for parsing the input. + """ ) args_schema: Type[BaseModel] = GetTransfersInput @@ -109,11 +122,19 @@ def _run( network_title: NetworkTitle, token_address: str, token_decimals: int, - first_block: int, - last_block: int, + start_timestamp: int, + end_timestamp: int | None, ) -> GetTransfersOutput: """Run the tool to fetch all token transfers to a specific address using the Quicknode API. + Args: + receiver_address (str): The receiver account address. + network_title (NetworkTitle): The requested network title. + token_address (str): The address of the token smart contract. + token_decimals (int): The token decimals + start_timestamp (int): The start timestamp for filtering transfers. + end_timestamp (int | None): The end timestamp for filtering transfers. + Returns: GetTransfersOutput: A structured output containing the result of tokens and APYs. @@ -128,8 +149,8 @@ async def _arun( network_title: NetworkTitle, token_address: str, token_decimals: int, - first_block: int, - last_block: int, + start_timestamp: int, + end_timestamp: int | None, ) -> GetTransfersOutput: """Run the tool to fetch all token transfers to a specific address using the Quicknode API. Args: @@ -137,8 +158,8 @@ async def _arun( network_title (NetworkTitle): The requested network title. token_address (str): The address of the token smart contract. token_decimals (int): The token decimals - first_block (int): the first block for querying transactions, this should be filled with the output of w3_get_block_range_by_time according to the requested start timestamp by user. - last_block (int): the last block for querying transactions, this should be filled with the output of w3_get_block_range_by_time according to the requested end timestamp by user. + start_timestamp (int): The start timestamp for filtering transfers. + end_timestamp (int | None): The end timestamp for filtering transfers. Returns: GetTransfersOutput: A structured output containing the tokens APY data. @@ -146,6 +167,20 @@ async def _arun( Exception: If there's an error accessing the Quicknode API. """ + blocks_range = await GetBlocksBetweenDates( + chain_provider=self.chain_provider, + system_store=self.system_store, + skill_store=self.skill_store, + agent_store=self.agent_store, + agent_id=self.agent_id, + ).arun( + tool_input=GetBlocksBetweenDatesInput( + network_title=network_title, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + ).model_dump(exclude_none=True) + ) + network = get_network_by_title(network_title) chain_type = network.value.chain.value.chain_type if chain_type != ChainType.EVM: @@ -182,8 +217,8 @@ async def _arun( None, get_padded_address(receiver_address), ], - "fromBlock": hex(first_block), - "toBlock": hex(last_block), + "fromBlock": hex(blocks_range.start_block), + "toBlock": hex(blocks_range.end_block), } ], } diff --git a/utils/time.py b/utils/time.py index 3e427570..d93f5c72 100644 --- a/utils/time.py +++ b/utils/time.py @@ -5,7 +5,10 @@ class TimestampRange(BaseModel): """ - Represents a range of timestamps. + Represents a range of epoch timestamps. If the end timestamp is not provided, it defaults to the current time. + If the user specifies the last n days, the start will be the current time minus n days in seconds, and the end will be the current time. + If the user specifies the last n hours, the start will be the current time minus n hours in seconds, and the end will be the current time. + If the user specifies the last n minutes, the start will be the current time minus n minutes in seconds, and the end will be the current time. Attributes: start: The starting timestamp (Unix epoch in seconds).