diff --git a/condor/acp/client.py b/condor/acp/client.py index 7c56d7f0..e56f0345 100644 --- a/condor/acp/client.py +++ b/condor/acp/client.py @@ -20,8 +20,8 @@ ACP_COMMANDS: dict[str, str] = { "claude-code": "claude-agent-acp", - "gemini": "gemini --experimental-acp", - "copilot": "copilot --acp", + "gemini": "npx @google/gemini-cli --acp", + "copilot": "npx @github/copilot --acp --stdio", "codex": "npx @zed-industries/codex-acp" } diff --git a/handlers/__init__.py b/handlers/__init__.py index 914ee403..a4274cf5 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -118,6 +118,14 @@ def clear_all_input_states(context: ContextTypes.DEFAULT_TYPE) -> None: context.user_data.pop("awaiting_token_input", None) context.user_data.pop("token_network", None) + # Gateway - RPC provider states + context.user_data.pop("awaiting_rpc_input", None) + context.user_data.pop("rpc_message_id", None) + context.user_data.pop("rpc_chat_id", None) + context.user_data.pop("rpc_url_network_list", None) + context.user_data.pop("rpc_edit_network_id", None) + context.user_data.pop("rpc_edit_network_idx", None) + # Bots - controller config states context.user_data.pop("bots_state", None) context.user_data.pop("controller_config_params", None) diff --git a/handlers/config/__init__.py b/handlers/config/__init__.py index 9ffb438c..df8122c0 100644 --- a/handlers/config/__init__.py +++ b/handlers/config/__init__.py @@ -267,6 +267,7 @@ async def handle_all_text_input(update: Update, context: ContextTypes.DEFAULT_TY or context.user_data.get("awaiting_network_input") or context.user_data.get("awaiting_token_input") or context.user_data.get("awaiting_pool_input") + or context.user_data.get("awaiting_rpc_input") ): await handle_gateway_input(update, context) return diff --git a/handlers/config/gateway/__init__.py b/handlers/config/gateway/__init__.py index 6f113685..ad6584b2 100644 --- a/handlers/config/gateway/__init__.py +++ b/handlers/config/gateway/__init__.py @@ -56,6 +56,7 @@ async def gateway_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> show_networks_menu, ) from .pools import handle_pool_action, handle_pool_input, show_pools_menu +from .rpc_providers import handle_rpc_action, handle_rpc_input, show_rpc_providers_menu from .tokens import handle_token_action, handle_token_input, show_tokens_menu from .wallets import handle_wallet_action, handle_wallet_input, show_wallets_menu @@ -108,6 +109,10 @@ async def handle_gateway_callback( await show_tokens_menu(query, context) elif query.data.startswith("gateway_token_"): await handle_token_action(query, context) + elif query.data == "gateway_rpc_providers": + await show_rpc_providers_menu(query, context) + elif query.data.startswith("gateway_rpc_"): + await handle_rpc_action(query, context) async def handle_gateway_input( @@ -127,6 +132,8 @@ async def handle_gateway_input( await handle_network_config_input(update, context) elif context.user_data.get("awaiting_connector_config"): await handle_connector_config_input(update, context) + elif context.user_data.get("awaiting_rpc_input"): + await handle_rpc_input(update, context) __all__ = [ diff --git a/handlers/config/gateway/_shared.py b/handlers/config/gateway/_shared.py index c4193aa6..e00c193f 100644 --- a/handlers/config/gateway/_shared.py +++ b/handlers/config/gateway/_shared.py @@ -63,3 +63,43 @@ def get_connector_networks( """ connector_info = connectors_data.get(connector_name, {}) return connector_info.get("networks", []) + + +async def get_default_networks(client) -> List[str]: + """ + Get combined default networks from solana and ethereum configs. + + Fetches default_networks from solana-mainnet-beta and ethereum-mainnet + and returns the combined list of network IDs. + + Args: + client: HummingbotAPIClient instance + + Returns: + List of default network IDs (e.g., ['solana-mainnet-beta', 'ethereum-mainnet']) + """ + default_networks = [] + + # Check solana defaults + try: + solana_config = await client.gateway.get_network_config("solana-mainnet-beta") + solana_defaults = solana_config.get("default_networks", []) + for network in solana_defaults: + network_id = f"solana-{network}" + if network_id not in default_networks: + default_networks.append(network_id) + except Exception as e: + logger.debug(f"Could not fetch solana defaults: {e}") + + # Check ethereum defaults + try: + eth_config = await client.gateway.get_network_config("ethereum-mainnet") + eth_defaults = eth_config.get("default_networks", []) + for network in eth_defaults: + network_id = f"ethereum-{network}" + if network_id not in default_networks: + default_networks.append(network_id) + except Exception as e: + logger.debug(f"Could not fetch ethereum defaults: {e}") + + return default_networks diff --git a/handlers/config/gateway/menu.py b/handlers/config/gateway/menu.py index a3c428f6..1b5914a5 100644 --- a/handlers/config/gateway/menu.py +++ b/handlers/config/gateway/menu.py @@ -62,13 +62,18 @@ async def show_gateway_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: "๐Ÿช™ Tokens", callback_data="gateway_tokens" ), InlineKeyboardButton( - "๐Ÿ“‹ Logs", callback_data="gateway_logs" + "๐Ÿ“ก RPC Providers", callback_data="gateway_rpc_providers" ), ], [ + InlineKeyboardButton( + "๐Ÿ“‹ Logs", callback_data="gateway_logs" + ), InlineKeyboardButton( "๐Ÿ”„ Restart", callback_data="gateway_restart" ), + ], + [ InlineKeyboardButton( "โน Stop", callback_data="gateway_stop" ), diff --git a/handlers/config/gateway/networks.py b/handlers/config/gateway/networks.py index 0f29fea7..6c595ef2 100644 --- a/handlers/config/gateway/networks.py +++ b/handlers/config/gateway/networks.py @@ -6,7 +6,7 @@ from telegram.ext import ContextTypes from ..user_preferences import get_active_server -from ._shared import escape_markdown_v2, extract_network_id, logger +from ._shared import escape_markdown_v2, extract_network_id, get_default_networks, logger async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -34,6 +34,9 @@ async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: [InlineKeyboardButton("ยซ Back", callback_data="config_gateway")] ] else: + # Get default networks to highlight them + default_network_ids = await get_default_networks(client) + # Group networks by chain if possible network_buttons = [] network_count = len(networks) @@ -47,9 +50,14 @@ async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: networks[:20] ): # Limit to first 20 to avoid message size issues network_id = extract_network_id(network_item) + # Add checkmark if this is a default network + if network_id in default_network_ids: + label = f"โœ… {network_id}" + else: + label = network_id # Use index-based callback to avoid exceeding 64-byte limit button = InlineKeyboardButton( - network_id, callback_data=f"gateway_network_view_{idx}" + label, callback_data=f"gateway_network_view_{idx}" ) row.append(button) @@ -63,9 +71,11 @@ async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: network_buttons.append(row) count_escaped = escape_markdown_v2(str(network_count)) + default_count = len(default_network_ids) message_text = ( - f"๐ŸŒ *Networks* \\({count_escaped} available\\)\n\n" - "_Click on a network to view and configure settings\\._" + f"๐ŸŒ *Networks* \\({count_escaped} available, {default_count} default\\)\n\n" + "_Click on a network to view and configure settings\\._\n" + "_โœ… indicates default networks shown in Tokens/Pools\\._" ) keyboard = network_buttons + [ @@ -112,6 +122,10 @@ async def handle_network_action(query, context: ContextTypes.DEFAULT_TYPE) -> No # Fallback for old-style callback data network_id = network_idx_str await show_network_details(query, context, network_id) + elif action_data.startswith("toggle_default_"): + # Toggle default network setting + network_id = action_data.replace("toggle_default_", "") + await toggle_default_network(query, context, network_id) elif action_data == "config_cancel": await handle_network_config_cancel(query, context) else: @@ -146,6 +160,15 @@ async def show_network_details( network_escaped = escape_markdown_v2(network_id) + # Parse chain and network from network_id (e.g., "solana-mainnet-beta") + parts = network_id.split("-", 1) + chain = parts[0] if parts else network_id + network_name = parts[1] if len(parts) > 1 else "" + + # Check if this network is in the default_networks list + default_networks = config_fields.get("default_networks", []) + is_default = network_name in default_networks if network_name else False + if not config_fields: message_text = ( f"๐ŸŒ *Network: {network_escaped}*\n\n" "_No configuration available_" @@ -154,15 +177,23 @@ async def show_network_details( [InlineKeyboardButton("ยซ Back", callback_data="gateway_networks")] ] else: - # Build copyable config for editing + # Build copyable config for editing (exclude default_networks for display) config_lines = [] for key, value in config_fields.items(): - config_lines.append(f"{key}={value}") + if key != "default_networks": + config_lines.append(f"{key}={value}") config_text = "\n".join(config_lines) + # Show default status + if is_default: + default_status = "โœ… *Default Network* \\(shown in Tokens/Pools\\)" + else: + default_status = "โฌœ _Not a default network_" + message_text = ( f"๐ŸŒ *{network_escaped}*\n\n" + f"{default_status}\n\n" f"```\n{config_text}\n```\n\n" f"โœ๏ธ _Send `key=value` to update_" ) @@ -177,7 +208,19 @@ async def show_network_details( context.user_data["network_message_id"] = query.message.message_id context.user_data["network_chat_id"] = query.message.chat_id + # Toggle default button + if is_default: + toggle_text = "โฌœ Remove from Defaults" + else: + toggle_text = "โœ… Add to Defaults" + keyboard = [ + [ + InlineKeyboardButton( + toggle_text, + callback_data=f"gateway_network_toggle_default_{network_id}" + ) + ], [InlineKeyboardButton("ยซ Back", callback_data="gateway_networks")] ] @@ -382,3 +425,63 @@ async def handle_network_config_cancel( except Exception as e: logger.error(f"Error handling cancel: {e}", exc_info=True) await query.answer("Error cancelling configuration") + + +async def toggle_default_network( + query, context: ContextTypes.DEFAULT_TYPE, network_id: str +) -> None: + """Toggle whether a network is in the default_networks list""" + try: + from config_manager import get_config_manager + + await query.answer("Updating defaults...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Get current config + response = await client.gateway.get_network_config(network_id) + if isinstance(response, dict): + config = ( + response.get("config", response) if "config" in response else response + ) + else: + config = {} + + # Parse network name from network_id (e.g., "solana-mainnet-beta" -> "mainnet-beta") + parts = network_id.split("-", 1) + network_name = parts[1] if len(parts) > 1 else network_id + + # Get current default_networks list + default_networks = config.get("default_networks", []) + if not isinstance(default_networks, list): + default_networks = [] + + # Toggle + if network_name in default_networks: + # Remove from defaults + default_networks = [n for n in default_networks if n != network_name] + action = "removed from" + else: + # Add to defaults + default_networks.append(network_name) + action = "added to" + + # Update the config + await client.gateway.update_network_config( + network_id, + {"default_networks": default_networks} + ) + + # Show success and refresh + network_escaped = escape_markdown_v2(network_id) + await query.answer(f"โœ… {network_id} {action} defaults") + + # Refresh the network details view + await show_network_details(query, context, network_id) + + except Exception as e: + logger.error(f"Error toggling default network: {e}", exc_info=True) + await query.answer(f"โŒ Error: {str(e)[:100]}") diff --git a/handlers/config/gateway/pools.py b/handlers/config/gateway/pools.py index 5859be38..8f9e28e3 100644 --- a/handlers/config/gateway/pools.py +++ b/handlers/config/gateway/pools.py @@ -6,44 +6,37 @@ from telegram.error import BadRequest from telegram.ext import ContextTypes -from utils.telegram_formatters import resolve_token_address - from ..user_preferences import get_active_server from ._shared import ( escape_markdown_v2, extract_network_id, - filter_pool_connectors, + get_default_networks, logger, ) -async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: - """Show liquidity pools menu - select connector first""" +async def show_pools_menu( + query, context: ContextTypes.DEFAULT_TYPE, show_all: bool = False +) -> None: + """Show pools menu - select network to view pools (like tokens)""" try: from config_manager import get_config_manager - await query.answer("Loading connectors...") + await query.answer("Loading networks...") chat_id = query.message.chat_id client = await get_config_manager().get_client_for_chat( chat_id, preferred_server=get_active_server(context.user_data) ) - response = await client.gateway.list_connectors() - connectors = response.get("connectors", []) - - # Filter connectors that support liquidity pools (AMM or CLMM trading types) - pool_connectors = filter_pool_connectors(connectors) + response = await client.gateway.list_networks() - # Store full connector data in context for later use - context.user_data["pool_connectors_data"] = { - c.get("name"): c for c in pool_connectors - } + all_networks = response.get("networks", []) - if not pool_connectors: + if not all_networks: message_text = ( "๐Ÿ’ง *Liquidity Pools*\n\n" - "No pool\\-enabled connectors available\\.\n\n" - "_Ensure Gateway is running with DEX connectors\\._" + "No networks available\\.\n\n" + "_Ensure Gateway is running\\._" ) keyboard = [ [ @@ -53,34 +46,71 @@ async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: ] ] else: - message_text = ( - "๐Ÿ’ง *Liquidity Pools*\n\n" - "_Select a connector to view and manage pools:_" - ) - - # Create connector buttons - connector_buttons = [] - for connector in pool_connectors[ - :15 - ]: # Limit to 15 to avoid message size issues - connector_name = connector.get("name", "unknown") - trading_types = ", ".join(connector.get("trading_types", [])) - connector_buttons.append( + # Get default networks from config + default_network_ids = await get_default_networks(client) + + # Decide which networks to show + if show_all or not default_network_ids: + # Show all networks + networks_to_show = all_networks[:20] + showing_defaults = False + else: + # Filter to only default networks + networks_to_show = [ + n for n in all_networks + if extract_network_id(n) in default_network_ids + ][:20] + showing_defaults = True + + # Store networks in context + context.user_data["pool_network_list"] = networks_to_show + context.user_data["pool_all_networks"] = all_networks[:20] + + # Create network buttons + network_buttons = [] + for idx, network_item in enumerate(networks_to_show): + network_id = extract_network_id(network_item) + network_buttons.append( [ InlineKeyboardButton( - f"{connector_name} ({trading_types})", - callback_data=f"gateway_pool_connector_{connector_name}", + network_id, callback_data=f"gateway_pool_network_{idx}" ) ] ) - keyboard = connector_buttons + [ - [ - InlineKeyboardButton( - "ยซ Back to Gateway", callback_data="config_gateway" - ) + if showing_defaults: + count_escaped = escape_markdown_v2(str(len(networks_to_show))) + message_text = ( + f"๐Ÿ’ง *Liquidity Pools* \\({count_escaped} default\\)\n\n" + "_Select a network to view and manage pools:_" + ) + # Add "All Networks" button + keyboard = network_buttons + [ + [ + InlineKeyboardButton( + f"๐ŸŒ All Networks ({len(all_networks)})", + callback_data="gateway_pool_all_networks" + ) + ], + [ + InlineKeyboardButton( + "ยซ Back to Gateway", callback_data="config_gateway" + ) + ] + ] + else: + count_escaped = escape_markdown_v2(str(len(all_networks))) + message_text = ( + f"๐Ÿ’ง *Liquidity Pools* \\({count_escaped} networks\\)\n\n" + "_Select a network to view and manage pools:_" + ) + keyboard = network_buttons + [ + [ + InlineKeyboardButton( + "ยซ Back to Gateway", callback_data="config_gateway" + ) + ] ] - ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -89,8 +119,12 @@ async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: ) except Exception as e: + # Ignore "message not modified" errors - they're harmless + if "not modified" in str(e).lower(): + logger.debug(f"Message not modified (ignored): {e}") + return logger.error(f"Error showing pools menu: {e}", exc_info=True) - error_text = f"โŒ Error loading connectors: {escape_markdown_v2(str(e))}" + error_text = f"โŒ Error loading networks: {escape_markdown_v2(str(e))}" keyboard = [ [InlineKeyboardButton("ยซ Back to Gateway", callback_data="config_gateway")] ] @@ -104,192 +138,94 @@ async def handle_pool_action(query, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle pool-specific actions""" action_data = query.data.replace("gateway_pool_", "") - if action_data.startswith("connector_"): - # Show networks for selected connector - connector_name = action_data.replace("connector_", "") - await show_pool_networks(query, context, connector_name) - elif action_data.startswith("network_"): - # Show pools for selected network using index - # Format: network_{idx} + if action_data == "all_networks": + # Show all networks instead of just defaults + await show_pools_menu(query, context, show_all=True) + return + + if action_data.startswith("network_"): + # Show pools for selected network network_idx_str = action_data.replace("network_", "") try: network_idx = int(network_idx_str) network_list = context.user_data.get("pool_network_list", []) - connector_name = context.user_data.get("pool_connector_name") - - if connector_name and 0 <= network_idx < len(network_list): + if 0 <= network_idx < len(network_list): network_item = network_list[network_idx] network_id = extract_network_id(network_item) - - # Store current network for later operations - context.user_data["pool_current_network"] = network_id - await show_connector_pools(query, context, connector_name, network_id) + await show_network_pools(query, context, network_id) else: await query.answer("โŒ Network not found") except ValueError: await query.answer("โŒ Invalid network") - elif action_data == "add": - # Add pool to connector/network - # Using stored context data - connector_name = context.user_data.get("pool_connector_name") - network_id = context.user_data.get("pool_current_network") - if connector_name and network_id: - await prompt_add_pool(query, context, connector_name, network_id) - else: - await query.answer("โŒ Session expired, please start over") - elif action_data == "remove": - # Remove pool from connector/network - # Using stored context data - connector_name = context.user_data.get("pool_connector_name") - network_id = context.user_data.get("pool_current_network") - if connector_name and network_id: - await prompt_remove_pool(query, context, connector_name, network_id) - else: - await query.answer("โŒ Session expired, please start over") - elif action_data.startswith("select_remove_"): - # User selected a pool to remove from the list - pool_idx_str = action_data.replace("select_remove_", "") + elif action_data.startswith("add_"): + # Add pool to network + network_id = action_data.replace("add_", "") + await prompt_add_pool(query, context, network_id) + elif action_data.startswith("remove_"): + # Show pool list to manage (remove) + network_id = action_data.replace("remove_", "") + await prompt_remove_pool(query, context, network_id) + elif action_data.startswith("select_"): + # Show options for selected pool try: - pool_idx = int(pool_idx_str) - pool_list = context.user_data.get("pool_list", []) - connector_name = context.user_data.get("pool_connector_name") - network_id = context.user_data.get("pool_current_network") - - if connector_name and network_id and 0 <= pool_idx < len(pool_list): - pool = pool_list[pool_idx] + pool_idx = int(action_data.replace("select_", "")) + await show_pool_options(query, context, pool_idx) + except ValueError: + await query.answer("โŒ Invalid pool") + elif action_data.startswith("del_"): + # Delete selected pool (show confirmation) + try: + pool_idx = int(action_data.replace("del_", "")) + pools = context.user_data.get("pool_manage_list", []) + network_id = context.user_data.get("pool_manage_network") + if pools and pool_idx < len(pools) and network_id: + pool = pools[pool_idx] pool_address = pool.get("address", pool.get("pool_id", "")) pool_type = pool.get("type", "") - # Store for confirmation - context.user_data["pool_remove_address"] = pool_address - context.user_data["pool_remove_type"] = pool_type await show_delete_pool_confirmation( - query, context, connector_name, network_id, pool_address, pool_type + query, context, network_id, pool_address, pool_type, pool_idx ) else: await query.answer("โŒ Pool not found") except ValueError: - await query.answer("โŒ Invalid pool selection") + await query.answer("โŒ Invalid pool") elif action_data == "confirm_remove": - # Full pool address and type stored in context - pool_address = context.user_data.get("pool_remove_address") - pool_type = context.user_data.get("pool_remove_type") - connector_name = context.user_data.get("pool_connector_name") - network_id = context.user_data.get("pool_current_network") - if pool_address and pool_type and connector_name and network_id: - await remove_pool( - query, context, connector_name, network_id, pool_address, pool_type - ) - else: - await query.answer("โŒ Session expired, please start over") - elif action_data == "view": - # Back to viewing pools - connector_name = context.user_data.get("pool_connector_name") - network_id = context.user_data.get("pool_current_network") - if connector_name and network_id: - await show_connector_pools(query, context, connector_name, network_id) + # Get pool info from user_data (stored to avoid 64-byte callback limit) + pending_delete = context.user_data.get("pending_pool_delete") + if pending_delete: + network_id = pending_delete["network_id"] + pool_address = pending_delete["pool_address"] + pool_type = pending_delete.get("pool_type") + context.user_data.pop("pending_pool_delete", None) # Clean up + await remove_pool(query, context, network_id, pool_address, pool_type) else: - await query.answer("โŒ Session expired, please start over") + await query.answer("โŒ Pool deletion expired. Please try again.") + elif action_data.startswith("view_"): + # Back to viewing pools for network + network_id = action_data.replace("view_", "") + await show_network_pools(query, context, network_id) + elif action_data.startswith("page_"): + # Handle pagination + try: + page = int(action_data.replace("page_", "")) + network_id = context.user_data.get("pool_view_network") + if network_id: + await show_network_pools(query, context, network_id, page=page) + else: + await query.answer("โŒ Network not found") + except ValueError: + await query.answer("โŒ Invalid page") else: await query.answer("Unknown action") -async def show_pool_networks( - query, context: ContextTypes.DEFAULT_TYPE, connector_name: str +async def show_network_pools( + query, context: ContextTypes.DEFAULT_TYPE, network_id: str, page: int = 0 ) -> None: - """Show network selection for viewing pools - only connector-specific networks""" - try: - from config_manager import get_config_manager - - await query.answer("Loading networks...") - - # Get connector data from context - connectors_data = context.user_data.get("pool_connectors_data", {}) - connector_info = connectors_data.get(connector_name) - - if not connector_info: - # Fallback: fetch connector info again if not in context - chat_id = query.message.chat_id - client = await get_config_manager().get_client_for_chat( - chat_id, preferred_server=get_active_server(context.user_data) - ) - response = await client.gateway.list_connectors() - connectors = response.get("connectors", []) - connector_info = next( - (c for c in connectors if c.get("name") == connector_name), None - ) + """Show pools for a specific network with button grid and pagination""" + POOLS_PER_PAGE = 16 + COLUMNS = 4 - if not connector_info: - message_text = ( - "๐Ÿ’ง *Liquidity Pools*\n\n" - "Connector not found\\.\n\n" - "_Please go back and try again\\._" - ) - keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")]] - else: - # Get networks specific to this connector - connector_networks = connector_info.get("networks", []) - - if not connector_networks: - connector_escaped = escape_markdown_v2(connector_name) - message_text = ( - f"๐Ÿ’ง *{connector_escaped} Pools*\n\n" - "No networks available for this connector\\.\n\n" - "_Ensure Gateway is properly configured\\._" - ) - keyboard = [ - [InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")] - ] - else: - connector_escaped = escape_markdown_v2(connector_name) - chain = connector_info.get("chain", "unknown") - chain_escaped = escape_markdown_v2(chain) - - message_text = ( - f"๐Ÿ’ง *{connector_escaped} Pools*\n" - f"Chain: `{chain_escaped}`\n\n" - "_Select a network to view pools:_" - ) - - # Store network list in user_data to avoid long callback_data - context.user_data["pool_network_list"] = connector_networks[:15] - context.user_data["pool_connector_name"] = connector_name - - # Create network buttons using indices to avoid Button_data_invalid - network_buttons = [] - for idx, network_item in enumerate(connector_networks[:15]): - network_str = extract_network_id(network_item) - network_buttons.append( - [ - InlineKeyboardButton( - network_str, callback_data=f"gateway_pool_network_{idx}" - ) - ] - ) - - keyboard = network_buttons + [ - [InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")] - ] - - reply_markup = InlineKeyboardMarkup(keyboard) - - await query.message.edit_text( - message_text, parse_mode="MarkdownV2", reply_markup=reply_markup - ) - - except Exception as e: - logger.error(f"Error showing pool networks: {e}", exc_info=True) - error_text = f"โŒ Error loading networks: {escape_markdown_v2(str(e))}" - keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")]] - reply_markup = InlineKeyboardMarkup(keyboard) - await query.message.edit_text( - error_text, parse_mode="MarkdownV2", reply_markup=reply_markup - ) - - -async def show_connector_pools( - query, context: ContextTypes.DEFAULT_TYPE, connector_name: str, network: str -) -> None: - """Show pools for a specific connector and network""" try: from config_manager import get_config_manager @@ -299,71 +235,114 @@ async def show_connector_pools( client = await get_config_manager().get_client_for_chat( chat_id, preferred_server=get_active_server(context.user_data) ) - pools = await client.gateway.list_pools( - connector_name=connector_name, network=network - ) - connector_escaped = escape_markdown_v2(connector_name) - network_escaped = escape_markdown_v2(network) + # Get pools for the network + try: + result = await client.gateway.get_network_pools(network_id) + pools = result.get("pools", []) if isinstance(result, dict) else result + except Exception as e: + logger.warning(f"Failed to get pools for {network_id}: {e}") + pools = [] + + network_escaped = escape_markdown_v2(network_id) if not pools: message_text = ( - f"๐Ÿ’ง *{connector_escaped}*\n" - f"Network: `{network_escaped}`\n\n" + f"๐Ÿ’ง *{network_escaped}*\n\n" "_No pools found\\._\n\n" - "Add a custom pool to get started\\." + "Add custom pools to get started\\." ) keyboard = [ - [InlineKeyboardButton("โž• Add Pool", callback_data="gateway_pool_add")], [ InlineKeyboardButton( - "ยซ Back", - callback_data=f"gateway_pool_connector_{connector_name}", + "โž• Add Pool", callback_data=f"gateway_pool_add_{network_id}" ) ], + [InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")], ] else: - # Display first 10 pools - pool_lines = [] - for idx, pool in enumerate(pools[:10], 1): - trading_pair = pool.get("trading_pair", pool.get("tradingPair", "N/A")) - pool_type = pool.get("type", "N/A") - trading_pair_escaped = escape_markdown_v2(str(trading_pair)) - pool_type_escaped = escape_markdown_v2(str(pool_type)) - pool_lines.append( - f"{idx}\\. `{trading_pair_escaped}` \\({pool_type_escaped}\\)" - ) - - pools_text = "\n".join(pool_lines) - pool_count = escape_markdown_v2(str(len(pools))) + # Store all pools for selection + context.user_data["pool_manage_list"] = pools + context.user_data["pool_manage_network"] = network_id + context.user_data["pool_view_network"] = network_id + context.user_data["pool_view_page"] = page + + # Calculate pagination + total_pools = len(pools) + total_pages = (total_pools + POOLS_PER_PAGE - 1) // POOLS_PER_PAGE + page = max(0, min(page, total_pages - 1)) # Clamp page to valid range + + start_idx = page * POOLS_PER_PAGE + end_idx = min(start_idx + POOLS_PER_PAGE, total_pools) + page_pools = pools[start_idx:end_idx] + + # Build page indicator + if total_pages > 1: + page_indicator = f" \\[{page + 1}/{total_pages}\\]" + else: + page_indicator = "" + pool_count = escape_markdown_v2(str(total_pools)) message_text = ( - f"๐Ÿ’ง *{connector_escaped}*\n" - f"Network: `{network_escaped}`\n\n" - f"*Pools* \\({pool_count} total\\):\n" - f"{pools_text}\n\n" - "_Add or remove custom pools as needed\\._" + f"๐Ÿ’ง *{network_escaped}*{page_indicator}\n\n" + f"*Pools* \\({pool_count} total\\)\n" + "_Select a pool to view or remove:_" ) - keyboard = [ - [ - InlineKeyboardButton( - "โž• Add Pool", callback_data="gateway_pool_add" - ), + # Build pool buttons in grid (4 columns) + keyboard = [] + row = [] + for idx, pool in enumerate(page_pools): + global_idx = start_idx + idx + # Use trading pair as button text, truncate if needed + trading_pair = pool.get("trading_pair", pool.get("tradingPair", "?/?")) + label = trading_pair[:10] # Truncate long pairs + row.append( InlineKeyboardButton( - "โž– Remove Pool", callback_data="gateway_pool_remove" - ), - ], + label, callback_data=f"gateway_pool_select_{global_idx}" + ) + ) + + if len(row) == COLUMNS: + keyboard.append(row) + row = [] + + # Add remaining buttons if any + if row: + keyboard.append(row) + + # Add pagination buttons if needed + if total_pages > 1: + nav_buttons = [] + if page > 0: + nav_buttons.append( + InlineKeyboardButton( + "ยซ Prev", callback_data=f"gateway_pool_page_{page - 1}" + ) + ) + if page < total_pages - 1: + nav_buttons.append( + InlineKeyboardButton( + "Next ยป", callback_data=f"gateway_pool_page_{page + 1}" + ) + ) + if nav_buttons: + keyboard.append(nav_buttons) + + # Action buttons + keyboard.append( [ InlineKeyboardButton( - "๐Ÿ”„ Refresh", callback_data="gateway_pool_view" + "โž• Add Pool", callback_data=f"gateway_pool_add_{network_id}" ), InlineKeyboardButton( - "ยซ Back", - callback_data=f"gateway_pool_connector_{connector_name}", + "๐Ÿ”„ Refresh", callback_data=f"gateway_pool_view_{network_id}" ), - ], - ] + ] + ) + keyboard.append( + [InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")] + ) reply_markup = InlineKeyboardMarkup(keyboard) @@ -373,64 +352,119 @@ async def show_connector_pools( except BadRequest as e: if "Message is not modified" in str(e): - # Ignore - message content is the same (e.g., on refresh with no changes) - pass + pass # Ignore - message content is the same else: - logger.error(f"Error showing connector pools: {e}", exc_info=True) - error_text = f"โŒ Error loading pools: {escape_markdown_v2(str(e))}" - keyboard = [ - [ - InlineKeyboardButton( - "ยซ Back", - callback_data=f"gateway_pool_connector_{connector_name}", - ) - ] - ] - reply_markup = InlineKeyboardMarkup(keyboard) - await query.message.edit_text( - error_text, parse_mode="MarkdownV2", reply_markup=reply_markup - ) + raise except Exception as e: - logger.error(f"Error showing connector pools: {e}", exc_info=True) + # Ignore "message not modified" errors - they're harmless + if "not modified" in str(e).lower(): + logger.debug(f"Message not modified (ignored): {e}") + return + logger.error(f"Error showing network pools: {e}", exc_info=True) error_text = f"โŒ Error loading pools: {escape_markdown_v2(str(e))}" + keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_pools")]] + reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( + error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + +async def show_pool_options( + query, context: ContextTypes.DEFAULT_TYPE, pool_idx: int +) -> None: + """Show details and options for a selected pool""" + try: + pools = context.user_data.get("pool_manage_list", []) + network_id = context.user_data.get("pool_manage_network") + + if not pools or pool_idx >= len(pools) or not network_id: + await query.answer("โŒ Pool not found") + return + + pool = pools[pool_idx] + trading_pair = pool.get("trading_pair", pool.get("tradingPair", "N/A")) + pool_type = pool.get("type", "N/A") + connector = pool.get("connector_name", pool.get("connector", "N/A")) + address = pool.get("address", pool.get("pool_id", "N/A")) + fee_pct = pool.get("fee_pct", pool.get("feePct")) + + network_escaped = escape_markdown_v2(network_id) + pair_escaped = escape_markdown_v2(str(trading_pair)) + type_escaped = escape_markdown_v2(str(pool_type)) + connector_escaped = escape_markdown_v2(str(connector)) + + message_text = ( + f"๐Ÿ’ง *{pair_escaped}* on {network_escaped}\n\n" + f"*Type:* {type_escaped}\n" + f"*Connector:* {connector_escaped}\n" + ) + if fee_pct is not None: + fee_escaped = escape_markdown_v2(f"{fee_pct}%") + message_text += f"*Fee:* {fee_escaped}\n" + message_text += f"*Address:*\n`{escape_markdown_v2(address)}`\n\n" + message_text += "_Choose an action:_" + + # Store selected pool info + context.user_data["selected_pool_idx"] = pool_idx + + # Get current page to return to correct page + current_page = context.user_data.get("pool_view_page", 0) + keyboard = [ [ InlineKeyboardButton( - "ยซ Back", callback_data=f"gateway_pool_connector_{connector_name}" + "๐Ÿ—‘ Remove", callback_data=f"gateway_pool_del_{pool_idx}" + ), + ], + [ + InlineKeyboardButton( + "ยซ Back", callback_data=f"gateway_pool_page_{current_page}" ) - ] + ], ] reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( - error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup ) + await query.answer() + + except Exception as e: + logger.error(f"Error showing pool options: {e}", exc_info=True) + await query.answer(f"โŒ Error: {str(e)[:100]}") async def prompt_add_pool( - query, context: ContextTypes.DEFAULT_TYPE, connector_name: str, network: str + query, context: ContextTypes.DEFAULT_TYPE, network_id: str ) -> None: """Prompt user to enter pool details""" try: - connector_escaped = escape_markdown_v2(connector_name) - network_escaped = escape_markdown_v2(network) + network_escaped = escape_markdown_v2(network_id) + + # Clear any lingering states from previous operations + context.user_data.pop("dex_state", None) + context.user_data.pop("cex_state", None) context.user_data["awaiting_pool_input"] = "pool_details" - context.user_data["pool_connector"] = connector_name - context.user_data["pool_network"] = network + context.user_data["pool_network"] = network_id context.user_data["pool_message_id"] = query.message.message_id context.user_data["pool_chat_id"] = query.message.chat_id message_text = ( - f"โž• *Add Pool to {connector_escaped}*\n" - f"Network: `{network_escaped}`\n\n" + f"โž• *Add Pool to {network_escaped}*\n\n" "*Enter pool details in this format:*\n" - "`pool_type,base,quote,address`\n\n" + "`connector,pool_type,address`\n\n" "*Example:*\n" - "`CLMM,SOL,USDC,8sLbNZoA1cfnvMJLPfp98ZLAnFSYCFApfJKMbiXNLwxj`" + "`raydium,clmm,8sLbNZoA1cfnvMJLPfp98ZLAnFSYCFApfJKMbiXNLwxj`\n\n" + "_Pool info \\(tokens, fees\\) will be fetched automatically\\._" ) keyboard = [ - [InlineKeyboardButton("ยซ Cancel", callback_data="gateway_pool_view")] + [ + InlineKeyboardButton( + "ยซ Cancel", callback_data=f"gateway_pool_view_{network_id}" + ) + ] ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -445,96 +479,104 @@ async def prompt_add_pool( async def prompt_remove_pool( - query, context: ContextTypes.DEFAULT_TYPE, connector_name: str, network: str + query, context: ContextTypes.DEFAULT_TYPE, network_id: str ) -> None: - """Show list of pools to remove with numbered buttons""" + """Show list of pools to select for removal""" try: from config_manager import get_config_manager - connector_escaped = escape_markdown_v2(connector_name) - network_escaped = escape_markdown_v2(network) + await query.answer("Loading pools...") chat_id = query.message.chat_id - - # Fetch pools to display as options client = await get_config_manager().get_client_for_chat( chat_id, preferred_server=get_active_server(context.user_data) ) - pools = await client.gateway.list_pools( - connector_name=connector_name, network=network - ) + + # Get pools for the network + try: + result = await client.gateway.get_network_pools(network_id) + pools = result.get("pools", []) if isinstance(result, dict) else result + except Exception as e: + logger.warning(f"Failed to get pools for {network_id}: {e}") + pools = [] + + network_escaped = escape_markdown_v2(network_id) if not pools: message_text = ( - f"โž– *Remove Pool from {connector_escaped}*\n" - f"Network: `{network_escaped}`\n\n" - "_No pools found to remove\\._" + f"๐Ÿ’ง *Manage Pools \\- {network_escaped}*\n\n" + "_No pools found to manage\\._" ) keyboard = [ - [InlineKeyboardButton("ยซ Back", callback_data="gateway_pool_view")] + [ + InlineKeyboardButton( + "ยซ Back", callback_data=f"gateway_pool_view_{network_id}" + ) + ] ] else: - # Store pools in context for later retrieval - context.user_data["pool_list"] = pools - - pool_lines = [] - keyboard = [] - for idx, pool in enumerate(pools[:10], 1): - trading_pair = pool.get("trading_pair", pool.get("tradingPair", "N/A")) - pool_type = pool.get("type", "N/A") - trading_pair_escaped = escape_markdown_v2(str(trading_pair)) - pool_type_escaped = escape_markdown_v2(str(pool_type)) - pool_lines.append( - f"{idx}\\. `{trading_pair_escaped}` \\({pool_type_escaped}\\)" - ) - # Add button for each pool - keyboard.append( + # Store pools in user_data for later retrieval + context.user_data["pool_manage_list"] = pools[:20] + context.user_data["pool_manage_network"] = network_id + + # Create buttons for each pool (limit to 20) + pool_buttons = [] + for idx, pool in enumerate(pools[:20]): + trading_pair = pool.get("trading_pair", pool.get("tradingPair", "?/?")) + pool_type = pool.get("type", "") + label = f"{trading_pair} ({pool_type})" + pool_buttons.append( [ InlineKeyboardButton( - f"{idx}. {trading_pair} ({pool_type})", - callback_data=f"gateway_pool_select_remove_{idx-1}", + label, callback_data=f"gateway_pool_select_{idx}" ) ] ) - pools_text = "\n".join(pool_lines) - + count_escaped = escape_markdown_v2(str(len(pools))) message_text = ( - f"โž– *Remove Pool from {connector_escaped}*\n" - f"Network: `{network_escaped}`\n\n" - "*Select a pool to remove:*\n\n" - f"{pools_text}\n\n" - "โš ๏ธ _Restart Gateway after removing for changes to take effect\\._" - ) - keyboard.append( - [InlineKeyboardButton("ยซ Cancel", callback_data="gateway_pool_view")] + f"๐Ÿ’ง *Manage Pools \\- {network_escaped}*\n\n" + f"_Select a pool to view or remove \\({count_escaped} total\\):_" ) + keyboard = pool_buttons + [ + [ + InlineKeyboardButton( + "ยซ Back", callback_data=f"gateway_pool_view_{network_id}" + ) + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) await query.message.edit_text( message_text, parse_mode="MarkdownV2", reply_markup=reply_markup ) - await query.answer() except Exception as e: - logger.error(f"Error prompting remove pool: {e}", exc_info=True) + logger.error(f"Error showing pool list: {e}", exc_info=True) await query.answer(f"โŒ Error: {str(e)[:100]}") async def show_delete_pool_confirmation( query, context: ContextTypes.DEFAULT_TYPE, - connector_name: str, - network: str, + network_id: str, pool_address: str, pool_type: str, + pool_idx: int, ) -> None: """Show confirmation dialog before deleting a pool""" try: - connector_escaped = escape_markdown_v2(connector_name) - network_escaped = escape_markdown_v2(network) - pool_type_escaped = escape_markdown_v2(pool_type) + from config_manager import get_config_manager + + chat_id = query.message.chat_id + + # Get pool details from stored list + pools = context.user_data.get("pool_manage_list", []) + pool_info = pools[pool_idx] if pool_idx < len(pools) else None + + network_escaped = escape_markdown_v2(network_id) addr_display = ( pool_address[:10] + "..." + pool_address[-8:] if len(pool_address) > 20 @@ -542,20 +584,30 @@ async def show_delete_pool_confirmation( ) addr_escaped = escape_markdown_v2(addr_display) - message_text = ( - f"๐Ÿ—‘ *Delete Pool*\n\n" - f"Connector: *{connector_escaped}*\n" - f"Network: *{network_escaped}*\n" - f"Type: *{pool_type_escaped}*\n" + message_text = f"๐Ÿ—‘ *Delete Pool*\n\nNetwork: *{network_escaped}*\n" + + if pool_info: + trading_pair = pool_info.get("trading_pair", pool_info.get("tradingPair")) + if trading_pair: + pair_escaped = escape_markdown_v2(trading_pair) + message_text += f"Pool: *{pair_escaped}*\n" + + if pool_type: + type_escaped = escape_markdown_v2(pool_type) + message_text += f"Type: *{type_escaped}*\n" + + message_text += ( f"Address: `{addr_escaped}`\n\n" - f"โš ๏ธ This will remove the pool from *{connector_escaped}* on *{network_escaped}*\\.\n" - "You will need to restart the Gateway for changes to take effect\\.\n\n" + f"โš ๏ธ This will remove the pool from *{network_escaped}*\\.\n\n" "Are you sure you want to delete this pool?" ) - # Store pool address and type in context - context.user_data["pool_remove_address"] = pool_address - context.user_data["pool_remove_type"] = pool_type + # Store pool info in user_data to avoid exceeding Telegram's 64-byte callback limit + context.user_data["pending_pool_delete"] = { + "network_id": network_id, + "pool_address": pool_address, + "pool_type": pool_type, + } keyboard = [ [ @@ -563,31 +615,28 @@ async def show_delete_pool_confirmation( "โœ… Yes, Delete", callback_data="gateway_pool_confirm_remove" ) ], - [InlineKeyboardButton("โŒ Cancel", callback_data="gateway_pool_view")], + [ + InlineKeyboardButton( + "โŒ Cancel", callback_data=f"gateway_pool_view_{network_id}" + ) + ], ] reply_markup = InlineKeyboardMarkup(keyboard) await query.message.edit_text( message_text, parse_mode="MarkdownV2", reply_markup=reply_markup ) - try: - await query.answer() - except TypeError: - pass # Mock query doesn't support answer + await query.answer() except Exception as e: - logger.error(f"Error showing delete pool confirmation: {e}", exc_info=True) - try: - await query.answer(f"โŒ Error: {str(e)[:100]}") - except TypeError: - pass # Mock query doesn't support answer + logger.error(f"Error showing delete confirmation: {e}", exc_info=True) + await query.answer(f"โŒ Error: {str(e)[:100]}") async def remove_pool( query, context: ContextTypes.DEFAULT_TYPE, - connector_name: str, - network: str, + network_id: str, pool_address: str, pool_type: str, ) -> None: @@ -595,24 +644,19 @@ async def remove_pool( try: from config_manager import get_config_manager - try: - await query.answer("Removing pool...") - except TypeError: - pass # Mock query doesn't support answer + await query.answer("Removing pool...") chat_id = query.message.chat_id client = await get_config_manager().get_client_for_chat( chat_id, preferred_server=get_active_server(context.user_data) ) - await client.gateway.delete_pool( - connector=connector_name, - network=network, - pool_type=pool_type, + await client.gateway.delete_network_pool( + network_id=network_id, address=pool_address, + pool_type=pool_type ) - connector_escaped = escape_markdown_v2(connector_name) - network_escaped = escape_markdown_v2(network) + network_escaped = escape_markdown_v2(network_id) addr_display = ( pool_address[:10] + "..." + pool_address[-8:] if len(pool_address) > 20 @@ -623,12 +667,15 @@ async def remove_pool( success_text = ( f"โœ… *Pool Removed*\n\n" f"`{addr_escaped}`\n\n" - f"Removed from {connector_escaped} on {network_escaped}\n\n" - "โš ๏ธ _Restart Gateway for changes to take effect\\._" + f"Removed from {network_escaped}" ) keyboard = [ - [InlineKeyboardButton("ยซ Back to Pools", callback_data="gateway_pool_view")] + [ + InlineKeyboardButton( + "ยซ Back to Pools", callback_data=f"gateway_pool_view_{network_id}" + ) + ] ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -639,12 +686,18 @@ async def remove_pool( import asyncio await asyncio.sleep(2) - await show_connector_pools(query, context, connector_name, network) + await show_network_pools(query, context, network_id) except Exception as e: logger.error(f"Error removing pool: {e}", exc_info=True) error_text = f"โŒ Error removing pool: {escape_markdown_v2(str(e))}" - keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_pool_view")]] + keyboard = [ + [ + InlineKeyboardButton( + "ยซ Back", callback_data=f"gateway_pool_view_{network_id}" + ) + ] + ] reply_markup = InlineKeyboardMarkup(keyboard) await query.message.edit_text( error_text, parse_mode="MarkdownV2", reply_markup=reply_markup @@ -652,9 +705,14 @@ async def remove_pool( async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle text input during pool addition/removal flow""" + """Handle text input during pool addition flow""" awaiting_field = context.user_data.get("awaiting_pool_input") + logger.info( + f"handle_pool_input called. awaiting_field={awaiting_field}, user_data keys={list(context.user_data.keys())}" + ) + if not awaiting_field: + logger.info("No awaiting_pool_input, returning") return # Delete user's input message @@ -668,55 +726,44 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) from config_manager import get_config_manager - connector_name = context.user_data.get("pool_connector") - network = context.user_data.get("pool_network") + network_id = context.user_data.get("pool_network") message_id = context.user_data.get("pool_message_id") chat_id = context.user_data.get("pool_chat_id") + logger.info( + f"Pool input: network_id={network_id}, message_id={message_id}, chat_id={chat_id}" + ) if awaiting_field == "pool_details": - # Parse pool details: pool_type,base,quote,address + # Parse pool details: connector,pool_type,address pool_input = update.message.text.strip() parts = [p.strip() for p in pool_input.split(",")] - if len(parts) != 4: - await update.message.reply_text( - "โŒ Invalid format. Use: pool_type,base,quote,address" + if len(parts) != 3: + await update.get_bot().send_message( + chat_id=chat_id, + text="โŒ Invalid format. Use: connector,pool_type,address\n\nExample: raydium,clmm,8sLbNZoA1cfnvMJLPfp98ZLAnFSYCFApfJKMbiXNLwxj", ) return - pool_type, base, quote, address = parts - - # Resolve token addresses from symbols - base_address = resolve_token_address(base) - quote_address = resolve_token_address(quote) - - if not base_address: - await update.message.reply_text( - f"โŒ Unknown token symbol: {base}\nPlease use known tokens (SOL, USDC, USDT, etc.)" - ) - return - if not quote_address: - await update.message.reply_text( - f"โŒ Unknown token symbol: {quote}\nPlease use known tokens (SOL, USDC, USDT, etc.)" - ) - return + connector_name, pool_type, address = parts - # Clear context + # Clear context (including any lingering states from previous operations) context.user_data.pop("awaiting_pool_input", None) - context.user_data.pop("pool_connector", None) context.user_data.pop("pool_network", None) context.user_data.pop("pool_message_id", None) context.user_data.pop("pool_chat_id", None) + context.user_data.pop("dex_state", None) # Show adding message + network_escaped = escape_markdown_v2(network_id) connector_escaped = escape_markdown_v2(connector_name) - pair_escaped = escape_markdown_v2(f"{base}/{quote}") + type_escaped = escape_markdown_v2(pool_type) if message_id and chat_id: await update.get_bot().edit_message_text( chat_id=chat_id, message_id=message_id, - text=f"โณ *Adding Pool {pair_escaped}*\n\nTo {connector_escaped}\n\n_Please wait\\.\\.\\._", + text=f"โณ *Adding Pool*\n\nConnector: {connector_escaped}\nType: {type_escaped}\nNetwork: {network_escaped}\n\n_Fetching pool info\\.\\.\\._", parse_mode="MarkdownV2", ) @@ -726,25 +773,21 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) ) logger.info( - f"Adding pool: connector={connector_name}, network={network}, " - f"pool_type={pool_type}, base={base}, quote={quote}, address={address}, " - f"base_address={base_address}, quote_address={quote_address}" + f"Adding pool: network_id={network_id}, connector={connector_name}, " + f"pool_type={pool_type}, address={address}" ) - await client.gateway.add_pool( + # Use new network-based endpoint + await client.gateway.add_network_pool( + network_id=network_id, connector_name=connector_name, pool_type=pool_type, - network=network, - base=base, - quote=quote, address=address, - base_address=base_address, - quote_address=quote_address, ) success_text = ( f"โœ… *Pool Added Successfully*\n\n" - f"{pair_escaped} added to {connector_escaped}" + f"Added to {connector_escaped} on {network_escaped}" ) if message_id and chat_id: @@ -760,6 +803,10 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) await asyncio.sleep(2) + async def mock_answer(text=""): + """Mock async answer method""" + pass + mock_message = SimpleNamespace( edit_text=lambda text, parse_mode=None, reply_markup=None: update.get_bot().edit_message_text( chat_id=chat_id, @@ -771,12 +818,8 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) chat_id=chat_id, message_id=message_id, ) - - async def noop_answer(text=""): - pass - - mock_query = SimpleNamespace(message=mock_message, answer=noop_answer) - await show_connector_pools(mock_query, context, connector_name, network) + mock_query = SimpleNamespace(message=mock_message, answer=mock_answer) + await show_network_pools(mock_query, context, network_id) except Exception as e: logger.error(f"Error adding pool: {e}", exc_info=True) diff --git a/handlers/config/gateway/rpc_providers.py b/handlers/config/gateway/rpc_providers.py new file mode 100644 index 00000000..f7760a6b --- /dev/null +++ b/handlers/config/gateway/rpc_providers.py @@ -0,0 +1,881 @@ +""" +Gateway RPC Providers management - API keys and RPC provider configuration +""" + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +from ..user_preferences import get_active_server +from ._shared import escape_markdown_v2, logger + + +# RPC Provider configuration +# Maps provider name to chain and default network +RPC_PROVIDERS = { + "helius": { + "name": "Helius", + "chain": "solana", + "default_network": "solana-mainnet-beta", + "description": "Premium Solana RPC provider", + }, + "infura": { + "name": "Infura", + "chain": "ethereum", + "default_network": "ethereum-mainnet", + "description": "Popular Ethereum RPC provider", + }, +} + + +async def show_rpc_providers_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show RPC Providers configuration menu""" + try: + from config_manager import get_config_manager + + await query.answer("Loading RPC providers...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Get current API keys + api_keys = await client.gateway.get_api_keys() + + # Get current rpcProvider settings for each chain + rpc_settings = {} + for provider_key, provider_info in RPC_PROVIDERS.items(): + network_id = provider_info["default_network"] + try: + config = await client.gateway.get_network_config(network_id) + rpc_settings[provider_info["chain"]] = config.get("rpc_provider", "url") + except Exception as e: + logger.debug(f"Could not fetch {network_id} config: {e}") + rpc_settings[provider_info["chain"]] = "url" + + # Build provider buttons + provider_buttons = [] + for provider_key, provider_info in RPC_PROVIDERS.items(): + chain = provider_info["chain"] + current_rpc = rpc_settings.get(chain, "url") + has_key = bool(api_keys.get(provider_key, "")) + + # Status indicator + if current_rpc == provider_key: + status = "โœ…" # Active as RPC provider + elif has_key: + status = "๐Ÿ”‘" # Has API key but not active + else: + status = "โฌœ" # No API key + + # Button text shows provider name and current status + chain_label = chain.capitalize() + button_text = f"{status} {provider_info['name']} ({chain_label})" + + provider_buttons.append([ + InlineKeyboardButton( + button_text, + callback_data=f"gateway_rpc_{provider_key}" + ) + ]) + + message_text = ( + "๐Ÿ“ก *RPC Providers*\n\n" + "_Configure API keys and RPC providers for blockchain access\\._\n\n" + "โœ… \\= Active RPC provider\n" + "๐Ÿ”‘ \\= API key configured\n" + "โฌœ \\= Not configured" + ) + + keyboard = provider_buttons + [ + [InlineKeyboardButton("๐Ÿ”— Custom URL", callback_data="gateway_rpc_url_menu")], + [InlineKeyboardButton("ยซ Back", callback_data="config_gateway")] + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error showing RPC providers: {e}", exc_info=True) + error_text = f"โŒ Error loading RPC providers: {escape_markdown_v2(str(e))}" + keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="config_gateway")]] + reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( + error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + +async def handle_rpc_action(query, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle RPC provider actions""" + action_data = query.data.replace("gateway_rpc_", "") + + if action_data in RPC_PROVIDERS: + await show_provider_details(query, context, action_data) + elif action_data.startswith("setkey_"): + provider_key = action_data.replace("setkey_", "") + await prompt_api_key_input(query, context, provider_key) + elif action_data.startswith("activate_"): + provider_key = action_data.replace("activate_", "") + await activate_provider(query, context, provider_key) + elif action_data.startswith("deactivate_"): + provider_key = action_data.replace("deactivate_", "") + await deactivate_provider(query, context, provider_key) + elif action_data == "url_menu": + await show_url_networks_menu(query, context) + elif action_data == "url_all": + await show_url_networks_menu(query, context, show_all=True) + elif action_data.startswith("url_net_"): + network_idx = int(action_data.replace("url_net_", "")) + await show_network_rpc_config(query, context, network_idx) + elif action_data.startswith("url_edit_"): + network_id = action_data.replace("url_edit_", "") + await prompt_node_url_input(query, context, network_id) + elif action_data.startswith("url_activate_"): + network_id = action_data.replace("url_activate_", "") + await activate_custom_url(query, context, network_id) + elif action_data == "providers": + await show_rpc_providers_menu(query, context) + else: + await query.answer("Unknown action") + + +async def show_provider_details( + query, context: ContextTypes.DEFAULT_TYPE, provider_key: str +) -> None: + """Show details for a specific RPC provider""" + try: + from config_manager import get_config_manager + + provider_info = RPC_PROVIDERS.get(provider_key) + if not provider_info: + await query.answer("โŒ Unknown provider") + return + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Get current API key status + api_keys = await client.gateway.get_api_keys() + current_key = api_keys.get(provider_key, "") + has_key = bool(current_key) + + # Get current rpcProvider setting + network_id = provider_info["default_network"] + try: + config = await client.gateway.get_network_config(network_id) + current_rpc = config.get("rpc_provider", "url") + except Exception: + current_rpc = "url" + + is_active = current_rpc == provider_key + + # Build message + provider_name = escape_markdown_v2(provider_info["name"]) + chain = escape_markdown_v2(provider_info["chain"].capitalize()) + network_escaped = escape_markdown_v2(network_id) + + # API key status + if has_key: + # Mask the API key for display (show first 4 chars) + masked_key = current_key[:4] + "..." if len(current_key) > 4 else current_key + key_status = f"๐Ÿ”‘ API Key: `{escape_markdown_v2(masked_key)}`" + else: + key_status = "โฌœ No API key configured" + + # RPC status + if is_active: + rpc_status = f"โœ… *Active* as RPC provider for {chain}" + else: + current_rpc_escaped = escape_markdown_v2(current_rpc) + rpc_status = f"โฌœ Not active \\(current: `{current_rpc_escaped}`\\)" + + message_text = ( + f"๐Ÿ”Œ *{provider_name}*\n\n" + f"Chain: {chain}\n" + f"Network: `{network_escaped}`\n\n" + f"{key_status}\n" + f"{rpc_status}" + ) + + # Build action buttons + keyboard = [] + + # API key button + if has_key: + keyboard.append([ + InlineKeyboardButton( + "๐Ÿ”‘ Update API Key", + callback_data=f"gateway_rpc_setkey_{provider_key}" + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton( + "โž• Add API Key", + callback_data=f"gateway_rpc_setkey_{provider_key}" + ) + ]) + + # Activate/Deactivate button (only if has key) + if has_key: + if is_active: + keyboard.append([ + InlineKeyboardButton( + "โฌœ Deactivate (use custom URL)", + callback_data=f"gateway_rpc_deactivate_{provider_key}" + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton( + "โœ… Activate as RPC Provider", + callback_data=f"gateway_rpc_activate_{provider_key}" + ) + ]) + + keyboard.append([ + InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_providers") + ]) + + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error showing provider details: {e}", exc_info=True) + error_text = f"โŒ Error: {escape_markdown_v2(str(e))}" + keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_providers")]] + reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( + error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + +async def prompt_api_key_input( + query, context: ContextTypes.DEFAULT_TYPE, provider_key: str +) -> None: + """Prompt user to enter API key""" + provider_info = RPC_PROVIDERS.get(provider_key) + if not provider_info: + await query.answer("โŒ Unknown provider") + return + + provider_name = escape_markdown_v2(provider_info["name"]) + + message_text = ( + f"๐Ÿ”‘ *Enter {provider_name} API Key*\n\n" + f"_Send your API key to configure {provider_name}\\._\n\n" + f"_The key will be saved and Gateway will be configured to use it\\._" + ) + + # Store state for input handling + context.user_data["awaiting_rpc_input"] = provider_key + context.user_data["rpc_message_id"] = query.message.message_id + context.user_data["rpc_chat_id"] = query.message.chat_id + + keyboard = [ + [InlineKeyboardButton("โœ– Cancel", callback_data=f"gateway_rpc_{provider_key}")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + +async def handle_rpc_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle text input for API key or node URL""" + input_type = context.user_data.get("awaiting_rpc_input") + if not input_type: + logger.debug("handle_rpc_input called but no awaiting_rpc_input state") + return + + logger.info(f"handle_rpc_input called with input_type: {input_type}") + + # Delete user's input message for security (contains API key or URL) + try: + await update.message.delete() + except Exception as del_err: + logger.debug(f"Could not delete user message: {del_err}") + + # Check if this is a URL input or API key input + if input_type.startswith("url_"): + await _handle_url_input(update, context, input_type) + else: + await _handle_api_key_input(update, context, input_type) + + +async def _handle_api_key_input( + update: Update, context: ContextTypes.DEFAULT_TYPE, provider_key: str +) -> None: + """Handle API key input""" + # Always clear state at the start to prevent stuck states + message_id = context.user_data.pop("rpc_message_id", None) + chat_id = context.user_data.pop("rpc_chat_id", None) + context.user_data.pop("awaiting_rpc_input", None) + + try: + logger.info(f"Processing API key input for provider: {provider_key}") + + api_key = update.message.text.strip() if update.message and update.message.text else "" + provider_info = RPC_PROVIDERS.get(provider_key) + + if not provider_info: + logger.error(f"Unknown provider: {provider_key}") + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=f"โŒ Unknown provider: {provider_key}" + ) + return + + if not api_key: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text="โŒ API key cannot be empty" + ) + return + + # Show saving message + if message_id and chat_id: + try: + await update.get_bot().edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=f"๐Ÿ’พ Saving {provider_info['name']} API key..." + ) + except Exception as edit_err: + logger.debug(f"Could not edit message: {edit_err}") + + # Save API key + from config_manager import get_config_manager + + logger.info(f"Getting client for chat {update.effective_chat.id}") + client = await get_config_manager().get_client_for_chat( + update.effective_chat.id, + preferred_server=get_active_server(context.user_data) + ) + + # Step 1: Update API key + logger.info(f"Updating API key for {provider_key}") + result = await client.gateway.update_api_keys({provider_key: api_key}) + logger.info(f"API key update result: {result}") + + # Step 2: Set rpcProvider on the default network + network_id = provider_info["default_network"] + logger.info(f"Setting rpc_provider to {provider_key} for {network_id}") + config_result = await client.gateway.update_network_config( + network_id, + {"rpc_provider": provider_key} + ) + logger.info(f"Network config update result: {config_result}") + + # Show success and return to provider details + network_escaped = escape_markdown_v2(network_id) + success_text = ( + f"โœ… {provider_info['name']} configured\\!\n\n" + f"API key saved and set as RPC provider for `{network_escaped}`\\.\n\n" + f"_Restart Gateway for changes to take effect\\._" + ) + + keyboard = [ + [InlineKeyboardButton("๐Ÿ”„ Restart Gateway", callback_data="gateway_restart")], + [InlineKeyboardButton("ยซ Back", callback_data=f"gateway_rpc_{provider_key}")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + if message_id and chat_id: + try: + await update.get_bot().edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + except Exception as edit_err: + logger.warning(f"Could not edit message, sending new: {edit_err}") + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + else: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + + logger.info(f"Successfully configured {provider_key}") + + except Exception as e: + logger.error(f"Error handling API key input: {e}", exc_info=True) + try: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=f"โŒ Error saving API key: {str(e)}" + ) + except Exception as send_err: + logger.error(f"Could not send error message: {send_err}") + + +async def _handle_url_input( + update: Update, context: ContextTypes.DEFAULT_TYPE, input_type: str +) -> None: + """Handle node URL input""" + # Always clear state at the start to prevent stuck states + message_id = context.user_data.pop("rpc_message_id", None) + chat_id = context.user_data.pop("rpc_chat_id", None) + context.user_data.pop("awaiting_rpc_input", None) + + try: + # Extract network_id from input_type (format: "url_{network_id}") + network_id = input_type.replace("url_", "") + node_url = update.message.text.strip() if update.message and update.message.text else "" + + logger.info(f"Processing URL input for network: {network_id}") + + if not node_url: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text="โŒ URL cannot be empty" + ) + return + + # Basic URL validation + if not node_url.startswith(("http://", "https://")): + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text="โŒ URL must start with http:// or https://" + ) + return + + # Show saving message + if message_id and chat_id: + try: + await update.get_bot().edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=f"๐Ÿ’พ Saving node URL for {network_id}..." + ) + except Exception as edit_err: + logger.debug(f"Could not edit message: {edit_err}") + + # Save URL + from config_manager import get_config_manager + + client = await get_config_manager().get_client_for_chat( + update.effective_chat.id, + preferred_server=get_active_server(context.user_data) + ) + + # Get current rpc_provider before updating + try: + config = await client.gateway.get_network_config(network_id) + current_rpc = config.get("rpc_provider", "url") + except Exception: + current_rpc = "url" + + # Update only node_url (not rpc_provider yet) + logger.info(f"Updating node_url for {network_id}") + result = await client.gateway.update_network_config( + network_id, + {"node_url": node_url} + ) + logger.info(f"Network config update result: {result}") + + network_escaped = escape_markdown_v2(network_id) + + # If rpc_provider is not "url", ask user if they want to activate the custom URL + if current_rpc != "url": + current_rpc_escaped = escape_markdown_v2(current_rpc) + success_text = ( + f"โœ… Node URL saved for `{network_escaped}`\\!\n\n" + f"Current RPC provider: `{current_rpc_escaped}`\n\n" + f"_Do you want to use the new nodeURL for this network?_" + ) + keyboard = [ + [InlineKeyboardButton( + "โœ… Yes, use new URL", + callback_data=f"gateway_rpc_url_activate_{network_id}" + )], + [InlineKeyboardButton( + f"Keep using {current_rpc}", + callback_data="gateway_rpc_url_menu" + )], + ] + else: + success_text = ( + f"โœ… Node URL updated for `{network_escaped}`\\!\n\n" + f"_Restart Gateway for changes to take effect\\._" + ) + keyboard = [ + [InlineKeyboardButton("๐Ÿ”„ Restart Gateway", callback_data="gateway_restart")], + [InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_url_menu")] + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + + if message_id and chat_id: + try: + await update.get_bot().edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + except Exception as edit_err: + logger.warning(f"Could not edit message, sending new: {edit_err}") + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + else: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + + logger.info(f"Successfully updated URL for {network_id}") + + except Exception as e: + logger.error(f"Error handling URL input: {e}", exc_info=True) + try: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=f"โŒ Error saving URL: {str(e)}" + ) + except Exception as send_err: + logger.error(f"Could not send error message: {send_err}") + + +async def activate_provider( + query, context: ContextTypes.DEFAULT_TYPE, provider_key: str +) -> None: + """Activate a provider as the RPC provider for its chain""" + try: + from config_manager import get_config_manager + + provider_info = RPC_PROVIDERS.get(provider_key) + if not provider_info: + await query.answer("โŒ Unknown provider") + return + + await query.answer("Activating provider...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Update rpcProvider on the default network + network_id = provider_info["default_network"] + await client.gateway.update_network_config( + network_id, + {"rpc_provider": provider_key} + ) + + await query.answer(f"โœ… {provider_info['name']} activated! Restart Gateway.") + + # Refresh provider details + await show_provider_details(query, context, provider_key) + + except Exception as e: + logger.error(f"Error activating provider: {e}", exc_info=True) + await query.answer(f"โŒ Error: {str(e)[:100]}") + + +async def deactivate_provider( + query, context: ContextTypes.DEFAULT_TYPE, provider_key: str +) -> None: + """Deactivate a provider, reverting to custom URL""" + try: + from config_manager import get_config_manager + + provider_info = RPC_PROVIDERS.get(provider_key) + if not provider_info: + await query.answer("โŒ Unknown provider") + return + + await query.answer("Deactivating provider...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Set rpcProvider back to "url" (custom) + network_id = provider_info["default_network"] + await client.gateway.update_network_config( + network_id, + {"rpc_provider": "url"} + ) + + await query.answer(f"โœ… {provider_info['name']} deactivated. Restart Gateway.") + + # Refresh provider details + await show_provider_details(query, context, provider_key) + + except Exception as e: + logger.error(f"Error deactivating provider: {e}", exc_info=True) + await query.answer(f"โŒ Error: {str(e)[:100]}") + + +async def activate_custom_url( + query, context: ContextTypes.DEFAULT_TYPE, network_id: str +) -> None: + """Activate custom URL as the RPC provider for a network""" + try: + from config_manager import get_config_manager + + await query.answer("Activating custom URL...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Set rpcProvider to "url" + await client.gateway.update_network_config( + network_id, + {"rpc_provider": "url"} + ) + + network_escaped = escape_markdown_v2(network_id) + success_text = ( + f"โœ… Custom URL activated for `{network_escaped}`\\!\n\n" + f"_Restart Gateway for changes to take effect\\._" + ) + + keyboard = [ + [InlineKeyboardButton("๐Ÿ”„ Restart Gateway", callback_data="gateway_restart")], + [InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_url_menu")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + success_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error activating custom URL: {e}", exc_info=True) + await query.answer(f"โŒ Error: {str(e)[:100]}") + + +# ============================================ +# Custom URL Configuration +# ============================================ + +async def show_url_networks_menu( + query, context: ContextTypes.DEFAULT_TYPE, show_all: bool = False +) -> None: + """Show networks menu for custom URL configuration""" + try: + from config_manager import get_config_manager + + from ._shared import extract_network_id, get_default_networks + + await query.answer("Loading networks...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + response = await client.gateway.list_networks() + all_networks = response.get("networks", []) + + if not all_networks: + message_text = ( + "๐Ÿ”— *Custom RPC URLs*\n\n" + "No networks available\\.\n\n" + "_Ensure Gateway is running\\._" + ) + keyboard = [ + [InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_providers")] + ] + else: + # Get default networks from config + default_network_ids = await get_default_networks(client) + + # Decide which networks to show + if show_all or not default_network_ids: + networks_to_show = all_networks[:20] + showing_defaults = False + else: + networks_to_show = [ + n for n in all_networks + if extract_network_id(n) in default_network_ids + ][:20] + showing_defaults = True + + # Store networks in context + context.user_data["rpc_url_network_list"] = networks_to_show + + # Create network buttons + network_buttons = [] + for idx, network_item in enumerate(networks_to_show): + network_id = extract_network_id(network_item) + network_buttons.append([ + InlineKeyboardButton( + network_id, + callback_data=f"gateway_rpc_url_net_{idx}" + ) + ]) + + if showing_defaults: + count_escaped = escape_markdown_v2(str(len(networks_to_show))) + message_text = ( + f"๐Ÿ”— *Custom RPC URLs* \\({count_escaped} default\\)\n\n" + "_Select a network to view and edit RPC settings:_" + ) + keyboard = network_buttons + [ + [ + InlineKeyboardButton( + f"๐ŸŒ All Networks ({len(all_networks)})", + callback_data="gateway_rpc_url_all" + ) + ], + [InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_providers")] + ] + else: + count_escaped = escape_markdown_v2(str(len(all_networks))) + message_text = ( + f"๐Ÿ”— *Custom RPC URLs* \\({count_escaped} networks\\)\n\n" + "_Select a network to view and edit RPC settings:_" + ) + keyboard = network_buttons + [ + [InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_providers")] + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error showing URL networks menu: {e}", exc_info=True) + error_text = f"โŒ Error loading networks: {escape_markdown_v2(str(e))}" + keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_providers")]] + reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( + error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + +async def show_network_rpc_config( + query, context: ContextTypes.DEFAULT_TYPE, network_idx: int +) -> None: + """Show RPC configuration for a specific network""" + try: + from config_manager import get_config_manager + + from ._shared import extract_network_id + + network_list = context.user_data.get("rpc_url_network_list", []) + if network_idx >= len(network_list): + await query.answer("โŒ Network not found") + return + + network_item = network_list[network_idx] + network_id = extract_network_id(network_item) + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Get network config + config = await client.gateway.get_network_config(network_id) + + rpc_provider = config.get("rpc_provider", "url") + node_url = config.get("node_url", "") + + network_escaped = escape_markdown_v2(network_id) + rpc_provider_escaped = escape_markdown_v2(rpc_provider) + + # Truncate long URLs for display + if len(node_url) > 60: + url_display = node_url[:30] + "..." + node_url[-20:] + else: + url_display = node_url + url_escaped = escape_markdown_v2(url_display) + + # Store for editing + context.user_data["rpc_edit_network_id"] = network_id + context.user_data["rpc_edit_network_idx"] = network_idx + + message_text = ( + f"๐Ÿ”— *{network_escaped}*\n\n" + f"*RPC Provider:* `{rpc_provider_escaped}`\n" + f"*Node URL:*\n`{url_escaped}`\n\n" + "_Click Edit URL to change the RPC endpoint\\._" + ) + + keyboard = [ + [ + InlineKeyboardButton( + "โœ๏ธ Edit URL", + callback_data=f"gateway_rpc_url_edit_{network_id}" + ) + ], + [InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_url_menu")] + ] + + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error showing network RPC config: {e}", exc_info=True) + error_text = f"โŒ Error: {escape_markdown_v2(str(e))}" + keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_rpc_url_menu")]] + reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( + error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + +async def prompt_node_url_input( + query, context: ContextTypes.DEFAULT_TYPE, network_id: str +) -> None: + """Prompt user to enter custom node URL""" + network_escaped = escape_markdown_v2(network_id) + + message_text = ( + f"โœ๏ธ *Edit Node URL for {network_escaped}*\n\n" + "_Send the new RPC endpoint URL\\._\n\n" + "_Example:_\n" + "`https://api\\.mainnet\\-beta\\.solana\\.com`" + ) + + # Store state for input handling + context.user_data["awaiting_rpc_input"] = f"url_{network_id}" + context.user_data["rpc_message_id"] = query.message.message_id + context.user_data["rpc_chat_id"] = query.message.chat_id + + keyboard = [ + [InlineKeyboardButton("โœ– Cancel", callback_data="gateway_rpc_url_menu")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + message_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) diff --git a/handlers/config/gateway/tokens.py b/handlers/config/gateway/tokens.py index 1ec544fc..d37fdf3e 100644 --- a/handlers/config/gateway/tokens.py +++ b/handlers/config/gateway/tokens.py @@ -7,7 +7,7 @@ from telegram.ext import ContextTypes from ..user_preferences import get_active_server -from ._shared import escape_markdown_v2, extract_network_id, logger +from ._shared import escape_markdown_v2, extract_network_id, get_default_networks, logger # Gateway network ID -> GeckoTerminal network ID mapping NETWORK_TO_GECKO = { @@ -28,7 +28,9 @@ } -async def show_tokens_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: +async def show_tokens_menu( + query, context: ContextTypes.DEFAULT_TYPE, show_all: bool = False +) -> None: """Show tokens menu - select network to view tokens""" try: from config_manager import get_config_manager @@ -41,9 +43,9 @@ async def show_tokens_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: ) response = await client.gateway.list_networks() - networks = response.get("networks", []) + all_networks = response.get("networks", []) - if not networks: + if not all_networks: message_text = ( "๐Ÿช™ *Token Management*\n\n" "No networks available\\.\n\n" @@ -57,11 +59,29 @@ async def show_tokens_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: ] ] else: - # Limit to first 20 networks + # Get default networks from config + default_network_ids = await get_default_networks(client) + + # Decide which networks to show + if show_all or not default_network_ids: + # Show all networks + networks_to_show = all_networks[:20] + showing_defaults = False + else: + # Filter to only default networks + networks_to_show = [ + n for n in all_networks + if extract_network_id(n) in default_network_ids + ][:20] + showing_defaults = True + + # Store networks in context + context.user_data["token_network_list"] = networks_to_show + context.user_data["token_all_networks"] = all_networks[:20] + + # Create network buttons network_buttons = [] - context.user_data["token_network_list"] = networks[:20] - - for idx, network_item in enumerate(networks[:20]): + for idx, network_item in enumerate(networks_to_show): network_id = extract_network_id(network_item) network_buttons.append( [ @@ -71,19 +91,40 @@ async def show_tokens_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: ] ) - count_escaped = escape_markdown_v2(str(len(networks))) - message_text = ( - f"๐Ÿช™ *Token Management* \\({count_escaped} networks\\)\n\n" - "_Select a network to view and manage tokens:_" - ) - - keyboard = network_buttons + [ - [ - InlineKeyboardButton( - "ยซ Back to Gateway", callback_data="config_gateway" - ) + if showing_defaults: + count_escaped = escape_markdown_v2(str(len(networks_to_show))) + total_escaped = escape_markdown_v2(str(len(all_networks))) + message_text = ( + f"๐Ÿช™ *Token Management* \\({count_escaped} default\\)\n\n" + "_Select a network to view and manage tokens:_" + ) + # Add "All Networks" button + keyboard = network_buttons + [ + [ + InlineKeyboardButton( + f"๐ŸŒ All Networks ({len(all_networks)})", + callback_data="gateway_token_all_networks" + ) + ], + [ + InlineKeyboardButton( + "ยซ Back to Gateway", callback_data="config_gateway" + ) + ] + ] + else: + count_escaped = escape_markdown_v2(str(len(all_networks))) + message_text = ( + f"๐Ÿช™ *Token Management* \\({count_escaped} networks\\)\n\n" + "_Select a network to view and manage tokens:_" + ) + keyboard = network_buttons + [ + [ + InlineKeyboardButton( + "ยซ Back to Gateway", callback_data="config_gateway" + ) + ] ] - ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -111,6 +152,11 @@ async def handle_token_action(query, context: ContextTypes.DEFAULT_TYPE) -> None """Handle token-specific actions""" action_data = query.data.replace("gateway_token_", "") + if action_data == "all_networks": + # Show all networks instead of just defaults + await show_tokens_menu(query, context, show_all=True) + return + if action_data.startswith("network_"): # Show tokens for selected network network_idx_str = action_data.replace("network_", "") @@ -181,7 +227,7 @@ async def handle_token_action(query, context: ContextTypes.DEFAULT_TYPE) -> None # Handle pagination try: page = int(action_data.replace("page_", "")) - network_id = context.user_data.get("token_view_network") + network_id = context.user_data.get("token_manage_network") if network_id: await show_network_tokens(query, context, network_id, page=page) else: @@ -364,8 +410,7 @@ async def prompt_add_token( "*Option 2:* Full format\n" "`address,symbol,decimals,name`\n\n" "*Example:*\n" - "`9QFfgxdSqH5zT7j6rZb1y6SZhw2aFtcQu2r6BuYpump`\n\n" - "โš ๏ธ _Restart Gateway after adding for changes to take effect\\._" + "`9QFfgxdSqH5zT7j6rZb1y6SZhw2aFtcQu2r6BuYpump`" ) keyboard = [ @@ -576,8 +621,7 @@ async def prompt_edit_token( "`symbol,decimals,name`\n\n" "*Example:*\n" "`GOLD,9,Goldcoin`\n\n" - "_Leave name empty if not needed \\(e\\.g\\. `GOLD,9`\\)_\n\n" - "โš ๏ธ _Restart Gateway after editing for changes to take effect\\._" + "_Leave name empty if not needed \\(e\\.g\\. `GOLD,9`\\)_" ) keyboard = [ @@ -650,8 +694,7 @@ async def show_delete_token_confirmation( message_text += ( f"Address: `{addr_escaped}`\n\n" - f"โš ๏ธ This will remove the token from *{network_escaped}*\\.\n" - "You will need to restart the Gateway for changes to take effect\\.\n\n" + f"โš ๏ธ This will remove the token from *{network_escaped}*\\.\n\n" "Are you sure you want to delete this token?" ) @@ -713,8 +756,7 @@ async def remove_token( success_text = ( f"โœ… *Token Removed*\n\n" f"`{addr_escaped}`\n\n" - f"Removed from {network_escaped}\n\n" - "โš ๏ธ _Restart Gateway for changes to take effect\\._" + f"Removed from {network_escaped}" ) keyboard = [ @@ -876,8 +918,7 @@ async def handle_token_input( success_text = ( f"โœ… *Token Added Successfully*\n\n" - f"*{symbol_escaped}* added to {network_escaped}\n\n" - "โš ๏ธ _Restart Gateway for changes to take effect\\._" + f"*{symbol_escaped}* added to {network_escaped}" ) if message_id and chat_id: @@ -1014,8 +1055,7 @@ async def mock_answer(text=""): success_text = ( f"โœ… *Token Updated Successfully*\n\n" - f"*{symbol_escaped}* updated on {network_escaped}\n\n" - "โš ๏ธ _Restart Gateway for changes to take effect\\._" + f"*{symbol_escaped}* updated on {network_escaped}" ) if message_id and chat_id: diff --git a/handlers/config/gateway/wallets.py b/handlers/config/gateway/wallets.py index f245f6f2..856762ba 100644 --- a/handlers/config/gateway/wallets.py +++ b/handlers/config/gateway/wallets.py @@ -93,8 +93,11 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: for wallet_group in wallets_data: chain = wallet_group.get("chain", "unknown") addresses = wallet_group.get("walletAddresses", []) + # Check if this wallet group has a default address + default_address = wallet_group.get("default_address", "") for address in addresses: - wallet_list.append({"chain": chain, "address": address}) + is_default = address == default_address + wallet_list.append({"chain": chain, "address": address, "is_default": is_default}) context.user_data["wallet_list"] = wallet_list total_wallets = len(wallet_list) @@ -110,6 +113,7 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: for idx, wallet in enumerate(wallet_list): chain = wallet["chain"] address = wallet["address"] + is_default = wallet.get("is_default", False) # Truncate address for display display_addr = ( address[:6] + "..." + address[-4:] if len(address) > 14 else address @@ -117,7 +121,8 @@ async def show_wallets_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: chain_icon = ( "๐ŸŸฃ" if chain == "solana" else "๐Ÿ”ต" ) # Solana purple, Ethereum blue - button_text = f"{chain_icon} {chain.title()}: {display_addr}" + default_indicator = " โญ๏ธ" if is_default else "" + button_text = f"{chain_icon} {chain.title()}: {display_addr}{default_indicator}" wallet_buttons.append( [ InlineKeyboardButton( @@ -230,6 +235,19 @@ async def handle_wallet_action(query, context: ContextTypes.DEFAULT_TYPE) -> Non await query.answer("โŒ Wallet not found") except ValueError: await query.answer("โŒ Invalid wallet index") + elif action_data.startswith("setdefault_"): + # Set wallet as default: setdefault_{idx} + idx_str = action_data.replace("setdefault_", "") + try: + idx = int(idx_str) + wallet_list = context.user_data.get("wallet_list", []) + if 0 <= idx < len(wallet_list): + wallet = wallet_list[idx] + await set_default_wallet(query, context, wallet["chain"], wallet["address"]) + else: + await query.answer("โŒ Wallet not found") + except ValueError: + await query.answer("โŒ Invalid wallet index") elif action_data.startswith("networks_"): # Edit networks for wallet: networks_{idx} idx_str = action_data.replace("networks_", "") @@ -482,6 +500,8 @@ async def show_wallet_details( ) -> None: """Show details for a specific wallet with edit options""" try: + from config_manager import get_config_manager + chat_id = query.message.chat_id header, server_online, gateway_running = await build_config_message_header( "๐Ÿ”‘ Wallet Details", @@ -493,37 +513,33 @@ async def show_wallet_details( chain_escaped = escape_markdown_v2(chain.title()) chain_icon = "๐ŸŸฃ" if chain == "solana" else "๐Ÿ”ต" - # Get configured networks for this wallet - enabled_networks = get_wallet_networks(context.user_data, address) - if enabled_networks is None: - # Not configured yet - use defaults - enabled_networks = get_default_networks_for_chain(chain) - # Format address display addr_escaped = escape_markdown_v2(address) - # Build networks list - all_networks = get_all_networks_for_chain(chain) - networks_display = [] - for net in all_networks: - is_enabled = net in enabled_networks - status = "โœ…" if is_enabled else "โŒ" - net_escaped = escape_markdown_v2(net) - networks_display.append(f" {status} `{net_escaped}`") - - networks_text = ( - "\n".join(networks_display) - if networks_display - else "_No networks available_" - ) + # Check if this is the default wallet for the chain + is_default = False + try: + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + wallets = await client.accounts.list_gateway_wallets() + for wallet_group in wallets: + if wallet_group.get("chain") == chain: + default_address = wallet_group.get("default_address", "") + if address == default_address: + is_default = True + break + except Exception as e: + logger.warning(f"Failed to check default wallet status: {e}") message_text = ( header + f"{chain_icon} *Chain:* {chain_escaped}\n\n" - f"*Address:*\n`{addr_escaped}`\n\n" - f"*Enabled Networks:*\n{networks_text}\n\n" - "_Only enabled networks will be queried for balances\\._" + f"*Address:*\n`{addr_escaped}`" ) + if is_default: + message_text += "\n\nโญ๏ธ _Default wallet for this chain_" + # Find wallet index in the list wallet_list = context.user_data.get("wallet_list", []) wallet_idx = None @@ -532,34 +548,35 @@ async def show_wallet_details( wallet_idx = idx break + keyboard = [] + if wallet_idx is not None: - keyboard = [ - [ - InlineKeyboardButton( - "๐ŸŒ Edit Networks", - callback_data=f"gateway_wallet_networks_{wallet_idx}", - ) - ], + # Show "Set as Default" button only if not already default + if not is_default: + keyboard.append( + [ + InlineKeyboardButton( + "โญ๏ธ Set as Default", + callback_data=f"gateway_wallet_setdefault_{wallet_idx}", + ) + ] + ) + keyboard.append( [ InlineKeyboardButton( "๐Ÿ—‘๏ธ Delete Wallet", callback_data=f"gateway_wallet_delete_{wallet_idx}", ) - ], - [ - InlineKeyboardButton( - "ยซ Back to Wallets", callback_data="gateway_wallets" - ) - ], - ] - else: - keyboard = [ - [ - InlineKeyboardButton( - "ยซ Back to Wallets", callback_data="gateway_wallets" - ) ] + ) + + keyboard.append( + [ + InlineKeyboardButton( + "ยซ Back to Wallets", callback_data="gateway_wallets" + ) ] + ) reply_markup = InlineKeyboardMarkup(keyboard) @@ -944,6 +961,55 @@ async def remove_wallet( ) +async def set_default_wallet( + query, context: ContextTypes.DEFAULT_TYPE, chain: str, address: str +) -> None: + """Set a wallet as the default for a chain""" + try: + from config_manager import get_config_manager + + await query.answer("Setting as default...") + + chat_id = query.message.chat_id + client = await get_config_manager().get_client_for_chat( + chat_id, preferred_server=get_active_server(context.user_data) + ) + + # Set the wallet as default via API + await client.accounts.set_default_gateway_wallet(chain=chain, address=address) + + # Show success and refresh wallet details + chain_escaped = escape_markdown_v2(chain.replace("-", " ").title()) + display_addr = ( + address[:10] + "..." + address[-8:] if len(address) > 20 else address + ) + addr_escaped = escape_markdown_v2(display_addr) + + success_text = ( + f"โœ… *Default Wallet Set*\n\n" + f"`{addr_escaped}`\n\n" + f"Now the default for {chain_escaped}" + ) + + keyboard = [ + [InlineKeyboardButton("ยซ Back to Wallets", callback_data="gateway_wallets")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.message.edit_text( + success_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error setting default wallet: {e}", exc_info=True) + error_text = f"โŒ Error setting default: {escape_markdown_v2(str(e))}" + keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="gateway_wallets")]] + reply_markup = InlineKeyboardMarkup(keyboard) + await query.message.edit_text( + error_text, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + + async def handle_wallet_input( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py index 928a1c24..8d094d5f 100644 --- a/handlers/dex/geckoterminal.py +++ b/handlers/dex/geckoterminal.py @@ -2314,6 +2314,7 @@ async def show_gecko_liquidity( connector=connector, user_data=context.user_data, chat_id=chat_id, + context=context, ) if error or not bins: @@ -2465,6 +2466,7 @@ async def show_gecko_combined( connector=connector, user_data=context.user_data, chat_id=chat_id, + context=context, ) if not ohlcv_data and not bins: @@ -3156,7 +3158,7 @@ async def handle_gecko_token_add( try: from config_manager import get_config_manager - client = await get_config_manager().get_default_client() + client = await get_config_manager().get_client() await client.gateway.add_token( network_id=gateway_network, address=address, @@ -3707,7 +3709,7 @@ async def add_token_to_gateway(token_address: str) -> str: name = token_data.get("name") # Add to gateway - client = await get_config_manager().get_default_client() + client = await get_config_manager().get_client() await client.gateway.add_token( network_id=gateway_network, address=token_address, @@ -3800,7 +3802,7 @@ async def handle_gecko_restart_gateway( ) try: - client = await get_config_manager().get_default_client() + client = await get_config_manager().get_client() await client.gateway.restart() # Wait a moment for restart diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index e35e84cb..12c70fab 100644 --- a/handlers/dex/liquidity.py +++ b/handlers/dex/liquidity.py @@ -666,6 +666,7 @@ def get_closed_time(pos): help_text += r"๐ŸฆŽ Gecko \- Trending, top, new pools" + "\n" help_text += r"๐Ÿ” Pool Info \- Look up pool by address" + "\n" help_text += r"๐Ÿ“‹ Meteora \- Search Meteora DLMM pools" + "\n" + help_text += r"๐ŸŒ€ Orca \- Search Orca Whirlpools" + "\n" except Exception as e: logger.warning(f"Could not fetch data: {e}") @@ -736,7 +737,12 @@ def get_closed_time(pos): [ InlineKeyboardButton("๐ŸฆŽ Gecko", callback_data="dex:gecko_explore"), InlineKeyboardButton("๐Ÿ” Pool Info", callback_data="dex:pool_info"), + ] + ) + keyboard.append( + [ InlineKeyboardButton("๐Ÿ“‹ Meteora", callback_data="dex:pool_list"), + InlineKeyboardButton("๐ŸŒ€ Orca", callback_data="dex:pool_list_orca"), ] ) diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py index e8bed9e3..5a3f205d 100644 --- a/handlers/dex/pool_data.py +++ b/handlers/dex/pool_data.py @@ -23,6 +23,8 @@ "meteora": "solana", "raydium": "solana", "orca": "solana", + "uniswap": "ethereum", + "pancakeswap": "bsc", } # GeckoTerminal network mapping @@ -50,6 +52,8 @@ "orca": "orca", "uniswap": "uniswap", "uniswap_v3": "uniswap_v3", + "pancakeswap": "pancakeswap", + "pancakeswap_v3": "pancakeswap_v3", "sushiswap": "sushiswap", } @@ -215,6 +219,7 @@ async def fetch_liquidity_bins( network: str = "solana-mainnet-beta", user_data: dict = None, chat_id: int = None, + context=None, ) -> Tuple[Optional[List], Optional[Dict], Optional[str]]: """Fetch liquidity bin data for CLMM pools via gateway diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 8bf26995..4f289e37 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -99,19 +99,21 @@ def format_pair_from_addresses( return f"{base_symbol}-{quote_symbol}" -def get_dex_pool_url(connector: str, pool_address: str) -> str: +def get_dex_pool_url(connector: str, pool_address: str, network: str = "") -> str: """ Generate the DEX web app URL for a pool. Args: - connector: DEX connector name (meteora, raydium, orca, etc.) + connector: DEX connector name (meteora, raydium, orca, uniswap, pancakeswap) pool_address: Pool address + network: Network for EVM connectors (ethereum, arbitrum, base, etc.) Returns: URL to the pool on the DEX web app, or empty string if unknown """ connector_lower = connector.lower() + # Solana DEXes if connector_lower == "meteora": return f"https://app.meteora.ag/dlmm/{pool_address}?referrer=hummingbot" elif connector_lower == "raydium": @@ -119,11 +121,37 @@ def get_dex_pool_url(connector: str, pool_address: str) -> str: elif connector_lower == "orca": return f"https://www.orca.so/pools/{pool_address}" + # EVM DEXes + elif connector_lower == "uniswap": + # Uniswap V3 pool URLs vary by network + network_lower = network.lower() if network else "ethereum" + if "arbitrum" in network_lower: + return f"https://app.uniswap.org/explore/pools/arbitrum/{pool_address}" + elif "base" in network_lower: + return f"https://app.uniswap.org/explore/pools/base/{pool_address}" + elif "optimism" in network_lower: + return f"https://app.uniswap.org/explore/pools/optimism/{pool_address}" + elif "polygon" in network_lower: + return f"https://app.uniswap.org/explore/pools/polygon/{pool_address}" + else: + return f"https://app.uniswap.org/explore/pools/ethereum/{pool_address}" + elif connector_lower == "pancakeswap": + # PancakeSwap V3 pool URLs + network_lower = network.lower() if network else "bsc" + if "ethereum" in network_lower: + return f"https://pancakeswap.finance/liquidity/{pool_address}?chain=eth" + elif "arbitrum" in network_lower: + return f"https://pancakeswap.finance/liquidity/{pool_address}?chain=arb" + elif "base" in network_lower: + return f"https://pancakeswap.finance/liquidity/{pool_address}?chain=base" + else: + return f"https://pancakeswap.finance/liquidity/{pool_address}?chain=bsc" + return "" # ============================================ -# POOL INFO (by address - supports meteora, raydium, orca) +# POOL INFO (by address - supports meteora, raydium, orca, uniswap, pancakeswap) # ============================================ @@ -132,11 +160,14 @@ async def handle_pool_info(update: Update, context: ContextTypes.DEFAULT_TYPE) - help_text = ( r"๐Ÿ” *Pool Info*" + "\n\n" r"Reply with:" + "\n\n" - r"`connector pool_address`" + "\n\n" - r"*Examples:*" + "\n" + r"`connector pool_address [network]`" + "\n\n" + r"*Solana \(network optional\):*" + "\n" r"`meteora 5Q5...abc`" + "\n" r"`raydium 7Xy...def`" + "\n" - r"`orca 3Ab...ghi`" + r"`orca 3Ab...ghi`" + "\n\n" + r"*EVM \(network required\):*" + "\n" + r"`uniswap 0x1...abc ethereum`" + "\n" + r"`pancakeswap 0x2...def bsc`" ) keyboard = [[InlineKeyboardButton("ยซ Cancel", callback_data="dex:liquidity")]] @@ -164,10 +195,11 @@ def _format_pool_info(pool: dict) -> str: lines.append(f"๐ŸŠ Pool: {pair}") lines.append("") - # Basic info + # Basic info - show full address for copy/paste if pool.get("pool_address") or pool.get("address"): addr = pool.get("pool_address") or pool.get("address") - lines.append(f"๐Ÿ“ Address: {addr[:12]}...{addr[-8:]}") + lines.append(f"๐Ÿ“ Address:") + lines.append(f"`{addr}`") if pool.get("bin_step"): lines.append(f"๐Ÿ“Š Bin Step: {pool.get('bin_step')}") @@ -226,19 +258,46 @@ async def process_pool_info( parts = user_input.split() if len(parts) < 2: raise ValueError( - "Need: connector pool_address\n\nExample: meteora 5Q5...abc" + "Need: connector pool_address [network]\n\nExample: meteora 5Q5...abc" ) connector = parts[0].lower() pool_address = parts[1] # Validate connector - must be a supported CLMM connector - supported_connectors = ["meteora", "raydium", "orca"] + solana_connectors = ["meteora", "raydium", "orca"] + evm_connectors = ["uniswap", "pancakeswap"] + supported_connectors = solana_connectors + evm_connectors + if connector not in supported_connectors: raise ValueError( f"Unsupported connector '{connector}'. Use: {', '.join(supported_connectors)}" ) + # Determine network based on connector type + if connector in solana_connectors: + network = "solana-mainnet-beta" + else: + # EVM connectors require network specification + if len(parts) < 3: + raise ValueError( + f"EVM connector '{connector}' requires network.\n\n" + f"Example: {connector} {pool_address} ethereum" + ) + network = parts[2].lower() + # Normalize network names + network_mapping = { + "ethereum": "ethereum-mainnet", + "eth": "ethereum-mainnet", + "arbitrum": "arbitrum-one", + "arb": "arbitrum-one", + "base": "base-mainnet", + "bsc": "binance-smart-chain", + "polygon": "polygon-mainnet", + "optimism": "optimism-mainnet", + } + network = network_mapping.get(network, network) + chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) @@ -251,7 +310,7 @@ async def process_pool_info( # Fetch pool info result = await client.gateway_clmm.get_pool_info( connector=connector, - network="solana-mainnet-beta", + network=network, pool_address=pool_address, ) @@ -361,25 +420,28 @@ def _build_balance_table_compact(gateway_data: dict) -> str: return "\n".join(lines) -async def handle_pool_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle CLMM pool list""" +async def handle_pool_list( + update: Update, context: ContextTypes.DEFAULT_TYPE, connector: str = "meteora" +) -> None: + """Handle CLMM pool list for a specific connector""" # Get cached gateway balances (cached from /lp menu) gateway_data = get_cached(context.user_data, "gateway_balances", ttl=120) balance_table = _build_balance_table_compact(gateway_data) + connector_name = connector.capitalize() help_text = ( - r"๐Ÿ“‹ *List CLMM Pools*" + "\n\n" + balance_table + r"Reply with:" + "\n\n" + rf"๐Ÿ“‹ *List {connector_name} Pools*" + "\n\n" + balance_table + r"Reply with:" + "\n\n" r"`[search_term] [limit]`" + "\n\n" r"*Examples:*" + "\n" r"`SOL 10`" + "\n" - r"`USDC 5`" + "\n\n" - r"_\(Uses Meteora connector\)_" + r"`USDC 5`" + "\n" ) keyboard = [[InlineKeyboardButton("ยซ Cancel", callback_data="dex:liquidity")]] reply_markup = InlineKeyboardMarkup(keyboard) context.user_data["dex_state"] = "pool_list" + context.user_data["pool_list_connector"] = connector # Store message for later editing with results context.user_data["pool_list_message_id"] = update.callback_query.message.message_id context.user_data["pool_list_chat_id"] = update.callback_query.message.chat_id @@ -389,6 +451,13 @@ async def handle_pool_list(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) +async def handle_pool_list_orca( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle Orca CLMM pool list""" + await handle_pool_list(update, context, connector="orca") + + def _format_number(value, decimals: int = 2) -> str: """Format number with K/M suffix for readability""" if value is None: @@ -448,8 +517,16 @@ def _format_pool_table(pools: list) -> str: for i, pool in enumerate(pools): idx = str(i + 1) - # Truncate pair to 11 chars - pair = pool.get("trading_pair", "N/A")[:11] + # Format pair to fit 11 chars - abbreviate base token if needed, keep quote + full_pair = pool.get("trading_pair", "N/A") + if "-" in full_pair and len(full_pair) > 11: + base, quote = full_pair.rsplit("-", 1) + max_base_len = 11 - len(quote) - 1 # -1 for the dash + if max_base_len > 2: + base = base[:max_base_len-1] + "โ€ฆ" + pair = f"{base}-{quote}"[:11] + else: + pair = full_pair[:11] # Get TVL value tvl_val = 0 @@ -624,8 +701,8 @@ async def process_pool_list( # Otherwise, search for pools parts = user_input.split() - # Always use meteora - only connector that supports pool listing - connector = "meteora" + # Use connector from context (set by handle_pool_list), default to meteora + connector = context.user_data.get("pool_list_connector", "meteora") search_term = parts[0] if len(parts) > 0 and parts[0] != "_" else None # Parse limit from user input (default 15, max 30 for display) requested_limit = int(parts[1]) if len(parts) > 1 else 15 @@ -682,6 +759,10 @@ async def process_pool_list( pools = result.get("pools", []) + # Add connector to each pool for later use + for pool in pools: + pool["connector"] = connector + if not pools: message = escape_markdown_v2("๐Ÿ“‹ No pools found") context.user_data["pool_list_cache"] = [] @@ -713,9 +794,10 @@ async def process_pool_list( # Use actual pool count (active_pools or pools), not API total which may be inaccurate actual_total = len(active_pools) if active_pools else len(pools) search_info = f" for '{search_term}'" if search_term else "" + connector_name = connector.capitalize() header = ( - rf"๐Ÿ“‹ *CLMM Pools*{escape_markdown_v2(search_info)} \({len(display_pools)} of {actual_total}\)" + rf"๐Ÿ“‹ *{connector_name} Pools*{escape_markdown_v2(search_info)} \({len(display_pools)} of {actual_total}\)" + "\n\n" ) @@ -1226,12 +1308,7 @@ async def fetch_ohlcv_task(): # Pool header - compact info message += f"๐ŸŠ *Pool:* `{escape_markdown_v2(pair)}`\n" - addr_short = ( - f"{pool_address[:6]}...{pool_address[-4:]}" - if len(pool_address) > 12 - else pool_address - ) - message += f"๐Ÿ“ *Address:* `{escape_markdown_v2(addr_short)}`\n" + message += f"๐Ÿ“ *Address:*\n`{escape_markdown_v2(pool_address)}`\n" if current_price: message += f"๐Ÿ’ฑ *Price:* `{escape_markdown_v2(str(current_price)[:10])}`\n" if bin_step: @@ -1774,7 +1851,7 @@ async def add_token_to_gateway(token_address: str) -> bool: name = attrs.get("name") # Add to gateway - client = await get_config_manager().get_default_client() + client = await get_config_manager().get_client() await client.gateway.add_token( network_id=network_id, address=token_address, @@ -2183,6 +2260,7 @@ async def handle_pool_combined_chart( connector=connector, user_data=context.user_data, chat_id=chat_id, + context=context, ) if not ohlcv_data and not bins: @@ -3113,11 +3191,21 @@ async def handle_pos_collect_fees( # Edit message to show collecting status await query.answer() - await query.message.edit_text( - f"โณ Collecting fees from {escape_markdown_v2(pair)}\\.\\.\\.", - parse_mode="MarkdownV2", - reply_markup=None, - ) + try: + await query.message.edit_text( + f"โณ Collecting fees from {escape_markdown_v2(pair)}\\.\\.\\.", + parse_mode="MarkdownV2", + reply_markup=None, + ) + except Exception as edit_err: + if "no text in the message" in str(edit_err).lower(): + # Message is likely a photo/media - send a new message instead + await query.message.reply_text( + f"โณ Collecting fees from {escape_markdown_v2(pair)}\\.\\.\\.", + parse_mode="MarkdownV2", + ) + else: + raise chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) @@ -3168,15 +3256,27 @@ async def handle_pos_collect_fees( if tx_hash: success_msg += f"\n\nTx: `{tx_hash[:30]}...`" - await query.message.edit_text( - success_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard - ) + try: + await query.message.edit_text( + success_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard + ) + except Exception: + await query.message.reply_text( + success_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard + ) else: - await query.message.edit_text( - f"โ„น๏ธ No fees to collect from {escape_markdown_v2(pair)}", - parse_mode="MarkdownV2", - reply_markup=back_keyboard, - ) + try: + await query.message.edit_text( + f"โ„น๏ธ No fees to collect from {escape_markdown_v2(pair)}", + parse_mode="MarkdownV2", + reply_markup=back_keyboard, + ) + except Exception: + await query.message.reply_text( + f"โ„น๏ธ No fees to collect from {escape_markdown_v2(pair)}", + parse_mode="MarkdownV2", + reply_markup=back_keyboard, + ) except Exception as e: logger.error(f"Error collecting fees: {e}", exc_info=True) @@ -3207,6 +3307,14 @@ async def handle_pos_collect_fees( ) except Exception as edit_error: logger.warning(f"Could not edit message: {edit_error}") + try: + await query.message.reply_text( + f"โŒ *Failed to collect fees*\n\n{display_error}", + parse_mode="MarkdownV2", + reply_markup=back_keyboard, + ) + except Exception: + pass # Last resort - at least don't crash async def handle_pos_close_confirm( @@ -3242,9 +3350,19 @@ async def handle_pos_close_confirm( ] reply_markup = InlineKeyboardMarkup(keyboard) - await update.callback_query.message.edit_text( - message, parse_mode="MarkdownV2", reply_markup=reply_markup - ) + # Try to edit the message, fallback to sending new message if it's a media message + try: + await update.callback_query.message.edit_text( + message, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + except Exception as edit_err: + if "no text in the message" in str(edit_err).lower(): + # Message is likely a photo/media - send a new message instead + await update.callback_query.message.reply_text( + message, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + else: + raise except Exception as e: logger.error(f"Error showing close confirmation: {e}", exc_info=True) @@ -3276,9 +3394,14 @@ async def handle_pos_close_execute( closing_msg += r"_Please wait, this may take a moment\._" await update.callback_query.answer() - await update.callback_query.message.edit_text( - closing_msg, parse_mode="MarkdownV2" - ) + try: + await update.callback_query.message.edit_text( + closing_msg, parse_mode="MarkdownV2" + ) + except Exception: + await update.callback_query.message.reply_text( + closing_msg, parse_mode="MarkdownV2" + ) chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) @@ -3318,9 +3441,14 @@ async def handle_pos_close_execute( keyboard = [[InlineKeyboardButton("ยซ Back", callback_data="dex:liquidity")]] reply_markup = InlineKeyboardMarkup(keyboard) - await update.callback_query.message.edit_text( - success_msg, parse_mode="MarkdownV2", reply_markup=reply_markup - ) + try: + await update.callback_query.message.edit_text( + success_msg, parse_mode="MarkdownV2", reply_markup=reply_markup + ) + except Exception: + await update.callback_query.message.reply_text( + success_msg, parse_mode="MarkdownV2", reply_markup=reply_markup + ) else: await update.callback_query.answer("Failed to close position") @@ -4018,12 +4146,7 @@ async def show_add_position_menu( # Pool info header - show pair and address help_text += f"๐ŸŠ *Pool:* `{escape_markdown_v2(pair)}`\n" if pool_address: - addr_short = ( - f"{pool_address[:6]}...{pool_address[-4:]}" - if len(pool_address) > 12 - else pool_address - ) - help_text += f"๐Ÿ“ *Address:* `{escape_markdown_v2(addr_short)}`\n" + help_text += f"๐Ÿ“ *Address:*\n`{escape_markdown_v2(pool_address)}`\n" if current_price: help_text += ( f"๐Ÿ’ฑ *Price:* `{escape_markdown_v2(str(current_price)[:10])}`\n" diff --git a/handlers/dex/router.py b/handlers/dex/router.py index 2fc2a01d..42f32576 100644 --- a/handlers/dex/router.py +++ b/handlers/dex/router.py @@ -86,6 +86,7 @@ handle_pool_info, handle_pool_list, handle_pool_list_back, + handle_pool_list_orca, handle_pool_ohlcv, handle_pool_select, handle_pos_add_confirm, @@ -272,6 +273,7 @@ def _is_slow_action(action: str) -> bool: # Pool "pool_info": handle_pool_info, "pool_list": handle_pool_list, + "pool_list_orca": handle_pool_list_orca, "pool_list_back": handle_pool_list_back, "pool_detail_refresh": handle_pool_detail_refresh, "add_to_gateway": handle_add_to_gateway, diff --git a/handlers/dex/visualizations.py b/handlers/dex/visualizations.py index c8880038..b2050534 100644 --- a/handlers/dex/visualizations.py +++ b/handlers/dex/visualizations.py @@ -972,12 +972,55 @@ def generate_combined_chart( xaxis_config["rangebreaks"] = [dict(dvalue=gap_threshold)] fig.update_xaxes(**xaxis_config) + + # Calculate Y-axis range from OHLCV data with padding + ohlcv_min = min(lows) if lows else 0 + ohlcv_max = max(highs) if highs else 0 + + # Include range bounds if provided + if lower_price and lower_price < ohlcv_min: + ohlcv_min = lower_price + if upper_price and upper_price > ohlcv_max: + ohlcv_max = upper_price + if current_price: + ohlcv_min = min(ohlcv_min, current_price) + ohlcv_max = max(ohlcv_max, current_price) + + # Add 5% padding + price_range = ohlcv_max - ohlcv_min + if price_range > 0: + padding = price_range * 0.05 + y_min = ohlcv_min - padding + y_max = ohlcv_max + padding + else: + y_min = ohlcv_min * 0.95 + y_max = ohlcv_max * 1.05 + + fig.update_yaxes( + gridcolor=DARK_THEME["grid_color"], + color=DARK_THEME["axis_color"], + showgrid=True, + zeroline=False, + range=[y_min, y_max], # Set explicit range based on OHLCV data + row=1, + col=1, + ) + # Volume axis doesn't need explicit range fig.update_yaxes( gridcolor=DARK_THEME["grid_color"], color=DARK_THEME["axis_color"], showgrid=True, zeroline=False, + row=2, + col=1, ) + if has_liquidity: + # Liquidity panel shares Y-axis with OHLCV + fig.update_yaxes( + range=[y_min, y_max], + row=1, + col=2, + ) # Axis titles fig.update_yaxes(title_text="Price", row=1, col=1, side="left") diff --git a/mcp_servers/hummingbot_api/executor_preferences.py b/mcp_servers/hummingbot_api/executor_preferences.py index cd838bfb..7c22df57 100644 --- a/mcp_servers/hummingbot_api/executor_preferences.py +++ b/mcp_servers/hummingbot_api/executor_preferences.py @@ -103,6 +103,8 @@ lp_executor: # Set your preferred defaults here (all optional, ask user if not set): # connector_name: meteora/clmm # Must include /clmm suffix + # Solana: meteora/clmm, raydium/clmm, orca/clmm + # EVM: uniswap/clmm, pancakeswap/clmm # trading_pair: SOL-USDC # extra_params: # strategyType: 0 # Meteora only: 0=Spot, 1=Curve, 2=Bid-Ask diff --git a/mcp_servers/hummingbot_api/guides/lp_executor.md b/mcp_servers/hummingbot_api/guides/lp_executor.md index 78be87f8..00032da3 100644 --- a/mcp_servers/hummingbot_api/guides/lp_executor.md +++ b/mcp_servers/hummingbot_api/guides/lp_executor.md @@ -1,11 +1,15 @@ ### LP Executor **This is the standard way to manage LP positions on CLMM DEXs.** -Manages liquidity provider positions on CLMM DEXs (Meteora, Raydium). +Manages liquidity provider positions on CLMM DEXs. Opens positions within price bounds, monitors range status, tracks fees. +**Supported DEXs:** +- **Solana:** Meteora (DLMM), Raydium (CLMM), Orca (Whirlpools) +- **EVM:** Uniswap V3 (Ethereum, Arbitrum, Base, etc.), PancakeSwap V3 (BSC, Ethereum) + **Use when:** -- Providing liquidity on Solana DEXs +- Providing liquidity on Solana or EVM DEXs - Want automated position monitoring and fee tracking - Earning trading fees from LP positions @@ -14,6 +18,33 @@ Opens positions within price bounds, monitors range status, tracks fees. - Want directional exposure only - Not familiar with impermanent loss risks +#### Setup Workflow + +**If user provides pool_address:** Skip to step 3 (get pool info directly) + +1. **Find pools** (skip if pool_address provided): + - Use `explore_dex_pools` with `action="list_pools"` and `connector` + - Solana connectors: `meteora`, `raydium`, `orca` + - EVM connectors: `uniswap`, `pancakeswap` + - Filter by `search_term` (e.g., "SOL", "ETH", "USDC") to find relevant pools + - Sort by `volume` or `tvl` to find active pools + +2. **Select pool**: User picks from list or provides address directly + +3. **Get pool info**: + - Use `explore_dex_pools` with `action="get_pool_info"`, `connector`, `network`, and `pool_address` + - **Networks:** `solana-mainnet-beta` (Solana), `ethereum-mainnet`, `arbitrum-one`, `base-mainnet`, `binance-smart-chain` (EVM) + - Get current price, bin_step, trading_pair for position setup + +4. **Determine position parameters**: + - Ask user for amount(s) and range preference + - Calculate `lower_price` / `upper_price` based on current price and user preference + - Set `side` based on which tokens user is providing (0=both, 1=quote-only, 2=base-only) + +5. **Create executor**: + - Use `manage_executors` with `action="create"`, `executor_type="lp_executor"` + - Include all required params: `connector_name`, `trading_pair`, `pool_address`, `lower_price`, `upper_price`, amounts + #### State Machine ``` @@ -30,7 +61,9 @@ NOT_ACTIVE โ†’ OPENING โ†’ IN_RANGE โ†” OUT_OF_RANGE โ†’ CLOSING โ†’ COMPLETE #### Key Parameters **Required:** -- `connector_name`: CLMM connector in `connector/clmm` format (e.g., `meteora/clmm`, `raydium/clmm`) +- `connector_name`: CLMM connector in `connector/clmm` format + - **Solana:** `meteora/clmm`, `raydium/clmm`, `orca/clmm` + - **EVM:** `uniswap/clmm`, `pancakeswap/clmm` - **IMPORTANT:** Must include the `/clmm` suffix โ€” using just `meteora` will fail - `trading_pair`: Token pair (e.g., `SOL-USDC`) - `pool_address`: Pool contract address diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index f7ebbfa6..3cc51613 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -354,24 +354,25 @@ class GatewayConfigRequest(BaseModel): Resource Types: - chains: Blockchain chains (get all chains) - networks: Network configurations (list, get, update) - format: 'chain-network' - - tokens: Token configurations (list, add, delete) per network + - tokens: Token configurations (list, add, delete, save) per network - connectors: DEX connector configurations (list, get, update) - - pools: Liquidity pools (list, add) per connector/network + - pools: Liquidity pools (list, add, delete, save) per connector/network - wallets: Wallet management (add, delete) for blockchain chains Actions: - list: List available resources - get: Get specific resource configuration - update: Update resource configuration - - add: Add new resource (tokens, pools, wallets) - - delete: Delete resource (tokens, wallets) + - add: Add new resource (tokens, pools, wallets) - requires full details + - delete: Delete resource (tokens, pools, wallets) + - save: Save resource by address only (tokens, pools) - auto-fetches details """ resource_type: Literal["chains", "networks", "tokens", "connectors", "pools", "wallets"] = Field( description="Type of resource to manage" ) - action: Literal["list", "get", "update", "add", "delete"] = Field( + action: Literal["list", "get", "update", "add", "delete", "save"] = Field( description="Action to perform on the resource" ) @@ -385,9 +386,9 @@ class GatewayConfigRequest(BaseModel): connector_name: str | None = Field( default=None, - description="DEX connector name (e.g., 'meteora', 'raydium', 'uniswap'). " + description="DEX connector name (e.g., 'meteora', 'raydium', 'orca', 'uniswap'). " "Required for connector operations and pool list operations", - examples=["meteora", "raydium", "uniswap", "pancakeswap"] + examples=["meteora", "raydium", "orca", "uniswap", "pancakeswap"] ) # Configuration data @@ -634,7 +635,7 @@ class GatewayCLMMRequest(BaseModel): # Common parameters connector: str | None = Field( default=None, - description="CLMM connector name (required). Examples: 'meteora', 'raydium', 'uniswap'" + description="CLMM connector name (required). Examples: 'meteora', 'raydium', 'orca', 'uniswap', 'pancakeswap'" ) network: str | None = Field( diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index d1a2e61a..0628e510 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -801,7 +801,7 @@ async def explore_dex_pools( Args: action: Action to perform on CLMM pools. - connector: CLMM connector name (e.g., 'meteora', 'raydium', 'uniswap'). Required. + connector: CLMM connector name (e.g., 'meteora', 'raydium', 'orca', 'uniswap', 'pancakeswap'). Required. network: Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta'). Required for get_pool_info. pool_address: Pool contract address (required for get_pool_info). page: Page number for list_pools (default: 0). diff --git a/mcp_servers/hummingbot_api/tools/gateway.py b/mcp_servers/hummingbot_api/tools/gateway.py index 1d8ffb84..c4ecdf17 100644 --- a/mcp_servers/hummingbot_api/tools/gateway.py +++ b/mcp_servers/hummingbot_api/tools/gateway.py @@ -210,10 +210,32 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "result": result } + elif request.action == "save": + # Simplified token addition - auto-fetches info from GeckoTerminal + if not request.network_id: + raise ToolError( + "network_id is required for 'save' token action. " + "Format: 'chain-network' (e.g., 'solana-mainnet-beta')" + ) + if not request.token_address: + raise ToolError("token_address is required for 'save' token action") + + result = await client.gateway.save_network_token( + network_id=request.network_id, + token_address=request.token_address + ) + return { + "resource_type": "tokens", + "action": "save", + "network_id": request.network_id, + "token_address": request.token_address, + "result": result + } + else: raise ToolError( f"Action '{request.action}' not supported for tokens. " - f"Supported: list, add, delete" + f"Supported: list, add, delete, save" ) # ============================================ @@ -268,50 +290,52 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d # ============================================ elif request.resource_type == "pools": if request.action == "list": - if not request.connector_name: - raise ToolError("connector_name is required for 'list' pools action") - if not request.network: - raise ToolError("network is required for 'list' pools action") + if not request.network_id: + raise ToolError( + "network_id is required for 'list' pools action. " + "Format: 'chain-network' (e.g., 'solana-mainnet-beta')" + ) - result = await client.gateway.list_pools( - request.connector_name, - request.network + result = await client.gateway.get_network_pools( + network_id=request.network_id, + connector=request.connector_name, # Optional filter + pool_type=request.pool_type, # Optional filter + search=request.search # Optional search ) return { "resource_type": "pools", "action": "list", - "connector_name": request.connector_name, - "network": request.network, + "network_id": request.network_id, + "connector": request.connector_name, "result": result } elif request.action == "add": + if not request.network_id: + raise ToolError( + "network_id is required for 'add' pool action. " + "Format: 'chain-network' (e.g., 'solana-mainnet-beta')" + ) if not request.connector_name: raise ToolError("connector_name is required for 'add' pool action") if not request.pool_type: raise ToolError("pool_type is required for 'add' pool action") - if not request.network: - raise ToolError("network is required for 'add' pool action") - if not request.pool_base: - raise ToolError("pool_base is required for 'add' pool action") - if not request.pool_quote: - raise ToolError("pool_quote is required for 'add' pool action") if not request.pool_address: raise ToolError("pool_address is required for 'add' pool action") - result = await client.gateway.add_pool( + result = await client.gateway.add_network_pool( + network_id=request.network_id, connector_name=request.connector_name, pool_type=request.pool_type, - network=request.network, + address=request.pool_address, base=request.pool_base, - quote=request.pool_quote, - address=request.pool_address + quote=request.pool_quote ) return { "resource_type": "pools", "action": "add", + "network_id": request.network_id, "connector_name": request.connector_name, - "network": request.network, "pool": { "type": request.pool_type, "base": request.pool_base, @@ -321,10 +345,54 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "result": result } + elif request.action == "delete": + if not request.network_id: + raise ToolError( + "network_id is required for 'delete' pool action. " + "Format: 'chain-network' (e.g., 'solana-mainnet-beta')" + ) + if not request.pool_address: + raise ToolError("pool_address is required for 'delete' pool action") + + result = await client.gateway.delete_network_pool( + network_id=request.network_id, + address=request.pool_address, + pool_type=request.pool_type + ) + return { + "resource_type": "pools", + "action": "delete", + "network_id": request.network_id, + "pool_address": request.pool_address, + "result": result + } + + elif request.action == "save": + # Simplified pool addition - auto-fetches info from blockchain + if not request.network_id: + raise ToolError( + "network_id is required for 'save' pool action. " + "Format: 'chain-network' (e.g., 'solana-mainnet-beta')" + ) + if not request.pool_address: + raise ToolError("pool_address is required for 'save' pool action") + + result = await client.gateway.save_network_pool( + network_id=request.network_id, + pool_address=request.pool_address + ) + return { + "resource_type": "pools", + "action": "save", + "network_id": request.network_id, + "pool_address": request.pool_address, + "result": result + } + else: raise ToolError( f"Action '{request.action}' not supported for pools. " - f"Supported: list, add" + f"Supported: list, add, delete, save" ) # ============================================ diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index c379e3e7..ed332927 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -121,7 +121,9 @@ async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) - Supported CLMM Connectors: - meteora (Solana): DLMM pools - raydium (Solana): CLMM pools + - orca (Solana): Whirlpools - uniswap (Ethereum/EVM): V3 pools + - pancakeswap (BSC/EVM): V3 pools """ # ============================================ # LIST POOLS - Browse available pools