From 429205694f3a02a45ef85744b8aa547e9fbb170d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 09:30:27 -0700 Subject: [PATCH 01/16] feat: update pool handlers to use network-based API Telegram Handlers: - Update show_connector_pools() to use get_network_pools() - Update prompt_remove_pool() to use get_network_pools() - Update remove_pool() to use delete_network_pool() - Update handle_pool_input() to use add_network_pool() - Get chain from connector data for network_id construction MCP Tools: - Update pools list action to use network_id parameter - Update pools add action to use add_network_pool() - Add pools delete action using delete_network_pool() - Update error messages to guide users on network_id format These changes align with hummingbot-api updates that organize pools by network (chain-network format) instead of by connector. Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/pools.py | 54 +++++++++++++---- mcp_servers/hummingbot_api/tools/gateway.py | 66 ++++++++++++++------- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/handlers/config/gateway/pools.py b/handlers/config/gateway/pools.py index 5859be38..8a931ebd 100644 --- a/handlers/config/gateway/pools.py +++ b/handlers/config/gateway/pools.py @@ -299,9 +299,18 @@ 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 + + # Get chain from connector data + connectors_data = context.user_data.get("pool_connectors_data", {}) + connector_info = connectors_data.get(connector_name, {}) + chain = connector_info.get("chain", "solana") # Default to solana for backward compat + + # Use new network-based endpoint + network_id = f"{chain}-{network}" + result = await client.gateway.get_network_pools( + network_id=network_id, connector=connector_name ) + pools = result.get("pools", []) if isinstance(result, dict) else result connector_escaped = escape_markdown_v2(connector_name) network_escaped = escape_markdown_v2(network) @@ -460,9 +469,18 @@ async def prompt_remove_pool( 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 chain from connector data + connectors_data = context.user_data.get("pool_connectors_data", {}) + connector_info = connectors_data.get(connector_name, {}) + chain = connector_info.get("chain", "solana") + + # Use new network-based endpoint + network_id = f"{chain}-{network}" + result = await client.gateway.get_network_pools( + network_id=network_id, connector=connector_name ) + pools = result.get("pools", []) if isinstance(result, dict) else result if not pools: message_text = ( @@ -604,11 +622,18 @@ async def remove_pool( 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, + + # Get chain from connector data + connectors_data = context.user_data.get("pool_connectors_data", {}) + connector_info = connectors_data.get(connector_name, {}) + chain = connector_info.get("chain", "solana") + + # Use new network-based endpoint + network_id = f"{chain}-{network}" + await client.gateway.delete_network_pool( + network_id=network_id, address=pool_address, + pool_type=pool_type, ) connector_escaped = escape_markdown_v2(connector_name) @@ -725,19 +750,26 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) chat_id, preferred_server=get_active_server(context.user_data) ) + # Get chain from connector data + connectors_data = context.user_data.get("pool_connectors_data", {}) + connector_info = connectors_data.get(connector_name, {}) + chain = connector_info.get("chain", "solana") + 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}" ) - await client.gateway.add_pool( + # Use new network-based endpoint + network_id = f"{chain}-{network}" + await client.gateway.add_network_pool( + network_id=network_id, connector_name=connector_name, pool_type=pool_type, - network=network, + address=address, base=base, quote=quote, - address=address, base_address=base_address, quote_address=quote_address, ) diff --git a/mcp_servers/hummingbot_api/tools/gateway.py b/mcp_servers/hummingbot_api/tools/gateway.py index 1d8ffb84..564d9e4f 100644 --- a/mcp_servers/hummingbot_api/tools/gateway.py +++ b/mcp_servers/hummingbot_api/tools/gateway.py @@ -268,50 +268,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 +323,32 @@ 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 + } + else: raise ToolError( f"Action '{request.action}' not supported for pools. " - f"Supported: list, add" + f"Supported: list, add, delete" ) # ============================================ From a58bb03c21c41affe6754dd507e262610599c661 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 13:15:01 -0700 Subject: [PATCH 02/16] Refactor pools management to use network-first approach like tokens - Changed from connector-first to network-first selection - Now shows list of networks (like tokens menu) - Pools displayed with pagination and grid layout - Pool details show connector, type, fee, and address - Add pool now uses format: connector,pool_type,address - Uses network_id directly instead of building from connector chain Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/pools.py | 797 +++++++++++++++---------------- 1 file changed, 383 insertions(+), 414 deletions(-) diff --git a/handlers/config/gateway/pools.py b/handlers/config/gateway/pools.py index 8a931ebd..e9b48a35 100644 --- a/handlers/config/gateway/pools.py +++ b/handlers/config/gateway/pools.py @@ -6,44 +6,34 @@ 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, logger, ) async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: - """Show liquidity pools menu - select connector first""" + """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", []) + response = await client.gateway.list_networks() - # Filter connectors that support liquidity pools (AMM or CLMM trading types) - pool_connectors = filter_pool_connectors(connectors) - - # Store full connector data in context for later use - context.user_data["pool_connectors_data"] = { - c.get("name"): c for c in pool_connectors - } + networks = response.get("networks", []) - if not pool_connectors: + if not 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,28 +43,27 @@ 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:_" - ) + # Limit to first 20 networks + network_buttons = [] + context.user_data["pool_network_list"] = networks[:20] - # 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( + for idx, network_item in enumerate(networks[:20]): + 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 + [ + count_escaped = escape_markdown_v2(str(len(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" @@ -89,8 +78,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 +97,89 @@ 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.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 - ) + # 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") - 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) - 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 - ) - - 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 - ) + """Show pools for a specific network with button grid and pagination""" + POOLS_PER_PAGE = 16 + COLUMNS = 4 - 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 @@ -300,79 +190,113 @@ async def show_connector_pools( chat_id, preferred_server=get_active_server(context.user_data) ) - # Get chain from connector data - connectors_data = context.user_data.get("pool_connectors_data", {}) - connector_info = connectors_data.get(connector_name, {}) - chain = connector_info.get("chain", "solana") # Default to solana for backward compat - - # Use new network-based endpoint - network_id = f"{chain}-{network}" - result = await client.gateway.get_network_pools( - network_id=network_id, connector=connector_name - ) - pools = result.get("pools", []) if isinstance(result, dict) else result + # 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 = [] - connector_escaped = escape_markdown_v2(connector_name) - network_escaped = escape_markdown_v2(network) + 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) @@ -382,64 +306,120 @@ 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\\._\n\n" + "โš ๏ธ _Restart Gateway after adding for changes to take effect\\._" ) keyboard = [ - [InlineKeyboardButton("ยซ Cancel", callback_data="gateway_pool_view")] + [ + InlineKeyboardButton( + "ยซ Cancel", callback_data=f"gateway_pool_view_{network_id}" + ) + ] ] reply_markup = InlineKeyboardMarkup(keyboard) @@ -454,105 +434,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) ) - # Get chain from connector data - connectors_data = context.user_data.get("pool_connectors_data", {}) - connector_info = connectors_data.get(connector_name, {}) - chain = connector_info.get("chain", "solana") + # 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 = [] - # Use new network-based endpoint - network_id = f"{chain}-{network}" - result = await client.gateway.get_network_pools( - network_id=network_id, connector=connector_name - ) - pools = result.get("pools", []) if isinstance(result, dict) else result + 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 @@ -560,20 +539,31 @@ 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" + f"โš ๏ธ This will remove the pool from *{network_escaped}*\\.\n" "You will need to restart the Gateway for changes to take effect\\.\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 = [ [ @@ -581,31 +571,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: @@ -613,31 +600,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) ) - - # Get chain from connector data - connectors_data = context.user_data.get("pool_connectors_data", {}) - connector_info = connectors_data.get(connector_name, {}) - chain = connector_info.get("chain", "solana") - - # Use new network-based endpoint - network_id = f"{chain}-{network}" await client.gateway.delete_network_pool( network_id=network_id, address=pool_address, - pool_type=pool_type, + 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 @@ -648,12 +623,16 @@ 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" + f"Removed from {network_escaped}\n\n" "โš ๏ธ _Restart Gateway for changes to take effect\\._" ) 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) @@ -664,12 +643,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 @@ -677,9 +662,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 @@ -693,55 +683,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 + connector_name, pool_type, 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 - - # 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", ) @@ -750,33 +729,23 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) chat_id, preferred_server=get_active_server(context.user_data) ) - # Get chain from connector data - connectors_data = context.user_data.get("pool_connectors_data", {}) - connector_info = connectors_data.get(connector_name, {}) - chain = connector_info.get("chain", "solana") - 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}" ) # Use new network-based endpoint - network_id = f"{chain}-{network}" await client.gateway.add_network_pool( network_id=network_id, connector_name=connector_name, pool_type=pool_type, address=address, - base=base, - quote=quote, - 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}\n\n" + "โš ๏ธ _Restart Gateway for changes to take effect\\._" ) if message_id and chat_id: @@ -792,6 +761,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, @@ -803,12 +776,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) From bcc4423d6830911dddc17097bf05f31362379f3a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 13:19:18 -0700 Subject: [PATCH 03/16] Remove unnecessary gateway restart messages from pools Adding and removing pools takes effect immediately without requiring a gateway restart. Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/pools.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/handlers/config/gateway/pools.py b/handlers/config/gateway/pools.py index e9b48a35..0f258eac 100644 --- a/handlers/config/gateway/pools.py +++ b/handlers/config/gateway/pools.py @@ -410,8 +410,7 @@ async def prompt_add_pool( "`connector,pool_type,address`\n\n" "*Example:*\n" "`raydium,clmm,8sLbNZoA1cfnvMJLPfp98ZLAnFSYCFApfJKMbiXNLwxj`\n\n" - "_Pool info \\(tokens, fees\\) will be fetched automatically\\._\n\n" - "โš ๏ธ _Restart Gateway after adding for changes to take effect\\._" + "_Pool info \\(tokens, fees\\) will be fetched automatically\\._" ) keyboard = [ @@ -553,8 +552,7 @@ async def show_delete_pool_confirmation( message_text += ( f"Address: `{addr_escaped}`\n\n" - f"โš ๏ธ This will remove the pool from *{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?" ) @@ -623,8 +621,7 @@ async def remove_pool( success_text = ( f"โœ… *Pool 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 = [ @@ -744,8 +741,7 @@ async def handle_pool_input(update: Update, context: ContextTypes.DEFAULT_TYPE) success_text = ( f"โœ… *Pool Added Successfully*\n\n" - f"Added to {connector_escaped} on {network_escaped}\n\n" - "โš ๏ธ _Restart Gateway for changes to take effect\\._" + f"Added to {connector_escaped} on {network_escaped}" ) if message_id and chat_id: From d01bf482d703b671ece604f0a90c4788f0060ca9 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 13:46:12 -0700 Subject: [PATCH 04/16] Add default networks feature for tokens and pools menus - Added get_default_networks() helper to fetch default_networks from solana-mainnet-beta and ethereum-mainnet configs - Tokens menu now shows only default networks with "All Networks" button - Pools menu now shows only default networks with "All Networks" button - Networks detail view shows default status with toggle button - Users can add/remove networks from defaults via the toggle button Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/_shared.py | 40 ++++++++++++ handlers/config/gateway/networks.py | 97 ++++++++++++++++++++++++++++- handlers/config/gateway/pools.py | 84 +++++++++++++++++++------ handlers/config/gateway/tokens.py | 86 +++++++++++++++++++------ 4 files changed, 266 insertions(+), 41 deletions(-) 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/networks.py b/handlers/config/gateway/networks.py index 0f29fea7..0bbd456c 100644 --- a/handlers/config/gateway/networks.py +++ b/handlers/config/gateway/networks.py @@ -112,6 +112,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 +150,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 +167,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 +198,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 +415,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 0f258eac..8f9e28e3 100644 --- a/handlers/config/gateway/pools.py +++ b/handlers/config/gateway/pools.py @@ -10,11 +10,14 @@ from ._shared import ( escape_markdown_v2, extract_network_id, + get_default_networks, logger, ) -async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: +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 @@ -27,9 +30,9 @@ async def show_pools_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 = ( "๐Ÿ’ง *Liquidity Pools*\n\n" "No networks available\\.\n\n" @@ -43,11 +46,29 @@ async def show_pools_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["pool_network_list"] = networks_to_show + context.user_data["pool_all_networks"] = all_networks[:20] + + # Create network buttons network_buttons = [] - context.user_data["pool_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( [ @@ -57,19 +78,39 @@ async def show_pools_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: ] ) - count_escaped = escape_markdown_v2(str(len(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" - ) + 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) @@ -97,6 +138,11 @@ 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 == "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_", "") diff --git a/handlers/config/gateway/tokens.py b/handlers/config/gateway/tokens.py index 1ec544fc..89de2eab 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_", "") From 39aa6812039400d982c5f0cd970d1ffa3e08272b Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 13:52:29 -0700 Subject: [PATCH 05/16] Add checkmark to default networks in networks menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Networks that are in default_networks now show โœ“ prefix - Updated message to show default count and explain checkmark meaning Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/networks.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/handlers/config/gateway/networks.py b/handlers/config/gateway/networks.py index 0bbd456c..e230da67 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 + [ From ee6a5ee0871352732ccc86cb0c0bdf0fdadbc34a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 16:05:23 -0700 Subject: [PATCH 06/16] Use green checkmark emoji for default networks Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/networks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handlers/config/gateway/networks.py b/handlers/config/gateway/networks.py index e230da67..6c595ef2 100644 --- a/handlers/config/gateway/networks.py +++ b/handlers/config/gateway/networks.py @@ -52,7 +52,7 @@ async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: 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}" + label = f"โœ… {network_id}" else: label = network_id # Use index-based callback to avoid exceeding 64-byte limit @@ -75,7 +75,7 @@ async def show_networks_menu(query, context: ContextTypes.DEFAULT_TYPE) -> None: message_text = ( 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\\._" + "_โœ… indicates default networks shown in Tokens/Pools\\._" ) keyboard = network_buttons + [ From 08f1c11fff6712513b44d65b32dde25f0546b97d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 17:29:23 -0700 Subject: [PATCH 07/16] fix(pools): improve pool pair display formatting - Abbreviate long base token names with ellipsis - Always show full quote token symbol - Fix truncation issue where quote asset was cut off Co-Authored-By: Claude Opus 4.5 --- handlers/dex/pools.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 8bf26995..5aac8160 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -448,8 +448,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 From d73a1c108cbbc22d1189b7f4e4b735fa3a70b554 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 17:42:21 -0700 Subject: [PATCH 08/16] feat(lp): add Orca pool listing support - Add Orca to /lp explore pools menu - Support both Meteora and Orca connectors in pool listing - Store connector in context for pool selection - Add connector field to pool dicts for proper handling - Update headers to show connector name (Meteora/Orca) Co-Authored-By: Claude Opus 4.5 --- handlers/dex/liquidity.py | 6 ++++++ handlers/dex/pools.py | 31 +++++++++++++++++++++++-------- handlers/dex/router.py | 2 ++ 3 files changed, 31 insertions(+), 8 deletions(-) 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/pools.py b/handlers/dex/pools.py index 5aac8160..83ecedb4 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -361,25 +361,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 +392,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: @@ -632,8 +642,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 @@ -690,6 +700,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"] = [] @@ -721,9 +735,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" ) 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, From 281c329680325df45b49f13cfc6d91488295e29c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 17:52:31 -0700 Subject: [PATCH 09/16] fix: fix chart not showing and address display issues - Add missing context parameter to fetch_liquidity_bins function - Pass context to all fetch_liquidity_bins calls in pools.py and geckoterminal.py - Show full pool address in code block for easy copy/paste instead of truncated Co-Authored-By: Claude Opus 4.5 --- handlers/dex/geckoterminal.py | 2 ++ handlers/dex/pool_data.py | 1 + handlers/dex/pools.py | 6 ++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py index 928a1c24..e0620ce5 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: diff --git a/handlers/dex/pool_data.py b/handlers/dex/pool_data.py index e8bed9e3..58a30134 100644 --- a/handlers/dex/pool_data.py +++ b/handlers/dex/pool_data.py @@ -215,6 +215,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 83ecedb4..39735b6e 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -164,10 +164,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')}") @@ -2206,6 +2207,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: From a2fe586b5e59e8c9015349163d7bd2acd3dc052d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 20:16:00 -0700 Subject: [PATCH 10/16] fix: show full pool address in detail view for easy copying Co-Authored-By: Claude Opus 4.5 --- handlers/dex/pools.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 39735b6e..494de548 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -1250,12 +1250,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: @@ -4043,12 +4038,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" From ddb7a335782553a131561785e27ddcb3932c5e0a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 20:17:12 -0700 Subject: [PATCH 11/16] fix: set explicit Y-axis range for OHLCV chart based on price data The Y-axis was starting from 0, squishing candles at the top when prices were high (e.g., 50). Now calculates range from OHLCV lows/highs with 5% padding for proper candle visibility. Co-Authored-By: Claude Opus 4.5 --- handlers/dex/visualizations.py | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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") From e43d2a380640d7803c12518c50150cd8673d20f0 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 6 May 2026 16:52:08 -0700 Subject: [PATCH 12/16] Add Orca/EVM DEX support, save endpoints, and wallet default management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Orca and PancakeSwap to supported CLMM connectors - Add save_network_token and save_network_pool actions to MCP tools - Update lp_executor guide with setup workflow and EVM support - Add default wallet indicator (โญ๏ธ) to wallet list and details - Add "Set as Default" wallet functionality - Fix token pagination "Next" button not working - Remove outdated "restart gateway" messages from token handlers - Add Uniswap/PancakeSwap support to pool URL generation Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/tokens.py | 20 +-- handlers/config/gateway/wallets.py | 156 +++++++++++++----- handlers/dex/pool_data.py | 4 + handlers/dex/pools.py | 76 ++++++++- .../hummingbot_api/executor_preferences.py | 2 + .../hummingbot_api/guides/lp_executor.md | 39 ++++- mcp_servers/hummingbot_api/schemas.py | 17 +- mcp_servers/hummingbot_api/server.py | 2 +- mcp_servers/hummingbot_api/tools/gateway.py | 48 +++++- .../hummingbot_api/tools/gateway_clmm.py | 2 + 10 files changed, 285 insertions(+), 81 deletions(-) diff --git a/handlers/config/gateway/tokens.py b/handlers/config/gateway/tokens.py index 89de2eab..d37fdf3e 100644 --- a/handlers/config/gateway/tokens.py +++ b/handlers/config/gateway/tokens.py @@ -227,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: @@ -410,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 = [ @@ -622,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 = [ @@ -696,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?" ) @@ -759,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 = [ @@ -922,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: @@ -1060,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/pool_data.py b/handlers/dex/pool_data.py index 58a30134..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", } diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 494de548..b514905a 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")]] @@ -227,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) @@ -252,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, ) 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 564d9e4f..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" ) # ============================================ @@ -345,10 +367,32 @@ async def manage_gateway_config(client: Any, request: GatewayConfigRequest) -> d "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, delete" + 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 From 25b5d9eec395c76104d70d61273dfa1ca0537190 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 7 May 2026 19:32:58 -0700 Subject: [PATCH 13/16] feat(gateway): add RPC Providers menu for API keys and custom URLs - Add new RPC Providers menu to Gateway configuration - Support for Helius (Solana) and Infura (Ethereum) API key management - Custom URL configuration for any network - Two-step process: add API key, then activate as RPC provider - Shows current status (active, has key, not configured) - Network selection follows same pattern as Pools menu (defaults + All) Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/__init__.py | 7 + handlers/config/gateway/menu.py | 7 +- handlers/config/gateway/rpc_providers.py | 771 +++++++++++++++++++++++ 3 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 handlers/config/gateway/rpc_providers.py 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/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/rpc_providers.py b/handlers/config/gateway/rpc_providers.py new file mode 100644 index 00000000..feb52337 --- /dev/null +++ b/handlers/config/gateway/rpc_providers.py @@ -0,0 +1,771 @@ +""" +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 == "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 + masked_key = current_key[:8] + "..." + current_key[-4:] if len(current_key) > 12 else "***" + 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: + return + + # Delete user's input message for security + try: + await update.message.delete() + except: + pass + + # 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""" + try: + api_key = update.message.text.strip() + provider_info = RPC_PROVIDERS.get(provider_key) + + if not provider_info: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text="โŒ Unknown provider" + ) + return + + if not api_key: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text="โŒ API key cannot be empty" + ) + return + + # Clear input state + context.user_data.pop("awaiting_rpc_input", None) + message_id = context.user_data.pop("rpc_message_id", None) + chat_id = context.user_data.pop("rpc_chat_id", None) + + # 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: + pass + + # Save API key + 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) + ) + + # Step 1: Update API key + await client.gateway.update_api_keys({provider_key: api_key}) + + # Step 2: Set rpcProvider on the default network + network_id = provider_info["default_network"] + await client.gateway.update_network_config( + network_id, + {"rpc_provider": provider_key} + ) + + # Show success and return to provider details + success_text = ( + f"โœ… {provider_info['name']} configured\\!\n\n" + f"API key saved and set as RPC provider for {escape_markdown_v2(network_id)}\\.\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: + 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 + ) + else: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error handling API key input: {e}", exc_info=True) + context.user_data.pop("awaiting_rpc_input", None) + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=f"โŒ Error saving API key: {str(e)}" + ) + + +async def _handle_url_input( + update: Update, context: ContextTypes.DEFAULT_TYPE, input_type: str +) -> None: + """Handle node URL input""" + try: + # Extract network_id from input_type (format: "url_{network_id}") + network_id = input_type.replace("url_", "") + node_url = update.message.text.strip() + + 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 + + # Clear input state + context.user_data.pop("awaiting_rpc_input", None) + message_id = context.user_data.pop("rpc_message_id", None) + chat_id = context.user_data.pop("rpc_chat_id", None) + + # 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: + pass + + # 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) + ) + + # Update node_url and set rpc_provider to "url" + await client.gateway.update_network_config( + network_id, + { + "node_url": node_url, + "rpc_provider": "url" + } + ) + + network_escaped = escape_markdown_v2(network_id) + 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: + 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 + ) + else: + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=success_text, + parse_mode="MarkdownV2", + reply_markup=reply_markup + ) + + except Exception as e: + logger.error(f"Error handling URL input: {e}", exc_info=True) + context.user_data.pop("awaiting_rpc_input", None) + await update.get_bot().send_message( + chat_id=update.effective_chat.id, + text=f"โŒ Error saving URL: {str(e)}" + ) + + +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]}") + + +# ============================================ +# 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 + ) From 102a76d256f1fe9df3ea0912f3b58028b371c03c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 8 May 2026 13:47:36 -0700 Subject: [PATCH 14/16] fix(gateway): RPC providers input routing + LP position action fixes RPC Providers (#94 related): - Add awaiting_rpc_input to text input routing in config/__init__.py - Add RPC state cleanup to clear_all_input_states - Improve error handling in rpc_providers.py input handlers GeckoTerminal token add (#94): - Replace get_default_client() with get_client() (4 occurrences) - Fixes: 'ConfigManager' object has no attribute 'get_default_client' LP position actions (#93): - Add fallback to reply_text when edit_text fails on media messages - Fixes: "There is no text in the message to edit" error - Applied to handle_pos_collect_fees, handle_pos_close_confirm, handle_pos_close_execute Co-Authored-By: Claude Opus 4.5 --- handlers/__init__.py | 8 ++ handlers/config/__init__.py | 1 + handlers/config/gateway/rpc_providers.py | 140 +++++++++++++++-------- handlers/dex/geckoterminal.py | 6 +- handlers/dex/pools.py | 96 ++++++++++++---- 5 files changed, 176 insertions(+), 75 deletions(-) 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/rpc_providers.py b/handlers/config/gateway/rpc_providers.py index feb52337..611124d5 100644 --- a/handlers/config/gateway/rpc_providers.py +++ b/handlers/config/gateway/rpc_providers.py @@ -291,13 +291,16 @@ async def handle_rpc_input(update: Update, context: ContextTypes.DEFAULT_TYPE) - """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 - # Delete user's input message for security + 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: - pass + 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_"): @@ -310,14 +313,22 @@ 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: - api_key = update.message.text.strip() + 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="โŒ Unknown provider" + text=f"โŒ Unknown provider: {provider_key}" ) return @@ -328,11 +339,6 @@ async def _handle_api_key_input( ) return - # Clear input state - context.user_data.pop("awaiting_rpc_input", None) - message_id = context.user_data.pop("rpc_message_id", None) - chat_id = context.user_data.pop("rpc_chat_id", None) - # Show saving message if message_id and chat_id: try: @@ -341,31 +347,37 @@ async def _handle_api_key_input( message_id=message_id, text=f"๐Ÿ’พ Saving {provider_info['name']} API key..." ) - except: - pass + 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 - await client.gateway.update_api_keys({provider_key: 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"] - await client.gateway.update_network_config( + 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 {escape_markdown_v2(network_id)}\\.\n\n" + f"API key saved and set as RPC provider for `{network_escaped}`\\.\n\n" f"_Restart Gateway for changes to take effect\\._" ) @@ -376,13 +388,22 @@ async def _handle_api_key_input( reply_markup = InlineKeyboardMarkup(keyboard) if message_id and chat_id: - 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 - ) + 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, @@ -391,23 +412,34 @@ async def _handle_api_key_input( 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) - context.user_data.pop("awaiting_rpc_input", None) - await update.get_bot().send_message( - chat_id=update.effective_chat.id, - text=f"โŒ Error saving API key: {str(e)}" - ) + 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() + 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( @@ -424,11 +456,6 @@ async def _handle_url_input( ) return - # Clear input state - context.user_data.pop("awaiting_rpc_input", None) - message_id = context.user_data.pop("rpc_message_id", None) - chat_id = context.user_data.pop("rpc_chat_id", None) - # Show saving message if message_id and chat_id: try: @@ -437,8 +464,8 @@ async def _handle_url_input( message_id=message_id, text=f"๐Ÿ’พ Saving node URL for {network_id}..." ) - except: - pass + except Exception as edit_err: + logger.debug(f"Could not edit message: {edit_err}") # Save URL from config_manager import get_config_manager @@ -449,17 +476,19 @@ async def _handle_url_input( ) # Update node_url and set rpc_provider to "url" - await client.gateway.update_network_config( + logger.info(f"Updating node_url for {network_id}") + result = await client.gateway.update_network_config( network_id, { "node_url": node_url, "rpc_provider": "url" } ) + logger.info(f"Network config update result: {result}") network_escaped = escape_markdown_v2(network_id) success_text = ( - f"โœ… Node URL updated for {network_escaped}\\!\n\n" + f"โœ… Node URL updated for `{network_escaped}`\\!\n\n" f"_Restart Gateway for changes to take effect\\._" ) @@ -470,13 +499,22 @@ async def _handle_url_input( reply_markup = InlineKeyboardMarkup(keyboard) if message_id and chat_id: - 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 - ) + 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, @@ -485,13 +523,17 @@ async def _handle_url_input( 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) - context.user_data.pop("awaiting_rpc_input", None) - await update.get_bot().send_message( - chat_id=update.effective_chat.id, - text=f"โŒ Error saving URL: {str(e)}" - ) + 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( diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py index e0620ce5..8d094d5f 100644 --- a/handlers/dex/geckoterminal.py +++ b/handlers/dex/geckoterminal.py @@ -3158,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, @@ -3709,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, @@ -3802,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/pools.py b/handlers/dex/pools.py index b514905a..4f289e37 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -1851,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, @@ -3191,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) @@ -3246,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) @@ -3285,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( @@ -3320,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) @@ -3354,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) @@ -3396,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") From 8745d861c7f2452f5741d2c370babbbdb39648dc Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 9 May 2026 16:08:56 -0700 Subject: [PATCH 15/16] fix(gateway): improve nodeURL activation prompt wording - Change prompt to "Do you want to use the new nodeURL for this network?" - Change button to "Yes, use new URL" Co-Authored-By: Claude Opus 4.5 --- handlers/config/gateway/rpc_providers.py | 98 ++++++++++++++++++++---- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/handlers/config/gateway/rpc_providers.py b/handlers/config/gateway/rpc_providers.py index 611124d5..f7760a6b 100644 --- a/handlers/config/gateway/rpc_providers.py +++ b/handlers/config/gateway/rpc_providers.py @@ -133,6 +133,9 @@ async def handle_rpc_action(query, context: ContextTypes.DEFAULT_TYPE) -> None: 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: @@ -178,8 +181,8 @@ async def show_provider_details( # API key status if has_key: - # Mask the API key for display - masked_key = current_key[:8] + "..." + current_key[-4:] if len(current_key) > 12 else "***" + # 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" @@ -475,27 +478,51 @@ async def _handle_url_input( preferred_server=get_active_server(context.user_data) ) - # Update node_url and set rpc_provider to "url" + # 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, - "rpc_provider": "url" - } + {"node_url": node_url} ) logger.info(f"Network config update result: {result}") network_escaped = escape_markdown_v2(network_id) - 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")] - ] + # 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: @@ -608,6 +635,47 @@ async def deactivate_provider( 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 # ============================================ From 4fc15c6e376caf844f3c178922e9848e7724c157 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Sat, 9 May 2026 20:34:09 -0700 Subject: [PATCH 16/16] fix(acp): use npx for Gemini and Copilot CLI commands Use npx to invoke Gemini CLI and GitHub Copilot CLI instead of relying on direct binaries in PATH. This ensures the commands work regardless of how Node.js is installed (nvm, system, etc.). - gemini: `npx @google/gemini-cli --acp` (also fixes deprecated --experimental-acp flag) - copilot: `npx @github/copilot --acp --stdio` Co-Authored-By: Claude Opus 4.5 --- condor/acp/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" }