diff --git a/.gitignore b/.gitignore index b542c6444..3cf59ccdc 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,11 @@ celerybeat.pid # Environments .env +# Every .env variant, not just the bare name. A `.env.bak-telegram` written by a +# config switcher carried a live OpenRouter key into a commit, and GitHub push +# protection is what caught it — the bare pattern below did not. +.env.* +*.bak-telegram .venv env/ venv/ @@ -195,3 +200,9 @@ agents/**/learnings.md agents/**/sessions/ agents/**/dry_runs/ agents/**/delegations/ + +# Working notes on Gateway defects, edited by more than one session at a time. +# Tracking it made every `git add -A` sweep whichever session was mid-edit into the other +# one's commit — which is how 1529d04 came to carry a message describing work it does not +# contain. Kept on disk, out of the index. +GATEWAY_ISSUES.md diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index dfb781a95..fede53c04 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -53,6 +53,28 @@ async def execute( ) running = [e for e in perf.executors if e["status"] == "RUNNING"] + # A terminal executor that still reports an on-chain position address has + # stranded live exposure with no automated owner: an involuntary hold + # (POSITION_HOLD with hold_reason set — an LP close that exhausted its + # retries) or a legacy FAILED-with-position. It would otherwise vanish + # from this RUNNING-only summary — surface it until it is recovered. + orphaned = [ + e + for e in perf.executors + if e["status"] != "RUNNING" + and not (e.get("custom_info") or {}).get("orphan_resolved") + and ( + (e.get("custom_info") or {}).get("orphaned_position") + or ( + (e.get("custom_info") or {}).get("hold_reason") + and (e.get("custom_info") or {}).get("position_address") + ) + or ( + str(e.get("close_type") or "").upper() == "FAILED" + and (e.get("custom_info") or {}).get("position_address") + ) + ) + ] lines = [ ( f"Active Executors ({len(running)}) [agent: {agent_id}]:" @@ -65,6 +87,20 @@ async def execute( lines.append( f" {r['pair']} {side} ${r['pnl']:+.2f} (V:${r['volume']:,.0f})" ) + for o in orphaned: + pos = (o.get("custom_info") or {}).get("position_address") + reason = (o.get("custom_info") or {}).get("hold_reason") or "close failed" + lines.append( + f" 🚨 ORPHANED POSITION: executor {o['id']} ({o['pair']}) terminated " + f"({reason}) with position {pos} still open on-chain. Stopping the executor " + "will NOT close it — it has already terminated. Close it with " + 'manage_clmm(action="close", position_address=..., pool_address=...); ' + 'manage_executors(action="orphaned") reports the dex, pool and network for ' + "the call, and pool_address is required. A new lp_executor CANNOT adopt the " + "position and would mint a second one. Then mark it recovered with " + f"manage_executors(action=\"resolve_orphan\", executor_id=\"{o['id']}\"). " + "Do not open new positions on these funds first." + ) if perf.bot_names: lines.append(f" Bots operated: {', '.join(perf.bot_names)}") lines.append( @@ -90,6 +126,7 @@ async def execute( name=self.name, data={ "executors": running, + "orphaned_executors": orphaned, "all_executors": perf.executors, "total_pnl": perf.total_pnl, "realized_pnl": perf.realized_pnl, diff --git a/condor/agents/risk.py b/condor/agents/risk.py index c7d941b55..2e77f1ad6 100644 --- a/condor/agents/risk.py +++ b/condor/agents/risk.py @@ -263,25 +263,25 @@ async def callback(tool_call: dict, options: list[dict]) -> dict: log.warning("Blocked %s: tool arguments could not be read", tool_name) return {"outcome": {"outcome": "cancelled"}} - # Dry-run mode: block ALL mutating actions + # Dry-run mode: block ALL mutating actions. + # + # Everything here is already inside `is_dangerous_tool_call`, which + # did the per-action matching against DANGEROUS_*_ACTIONS. Repeating + # it per tool was not just redundant, it reopened SEC-093: that gate + # fails CLOSED on an unreadable action, so `manage_clmm` with a + # missing, null or non-string `action` arrives here as dangerous — + # and the re-check then read it as "" , matched no set, fell through + # every branch and hit the auto-approve tail. A malformed write + # executed for real in the one mode whose whole promise is that + # nothing does. Reaching this line is the decision; blocking is + # unconditional. if execution_mode == "dry_run": - if tool_name == "manage_executors": - action = input_data.get("action", "") - if action in ("create", "stop"): - log.info("Dry-run mode: blocked manage_executors(%s)", action) - return {"outcome": {"outcome": "cancelled"}} - elif tool_name == "manage_bots": - action = input_data.get("action", "") - if action in DANGEROUS_BOT_ACTIONS: - log.info("Dry-run mode: blocked manage_bots(%s)", action) - return {"outcome": {"outcome": "cancelled"}} - elif tool_name in ( - "place_order", - "manage_gateway_swaps", - "manage_gateway_clmm", - ): - log.info("Dry-run mode: blocked %s", tool_name) - return {"outcome": {"outcome": "cancelled"}} + log.info( + "Dry-run mode: blocked %s(%s)", + tool_name, + input_data.get("action", "") or "?", + ) + return {"outcome": {"outcome": "cancelled"}} # For executor actions, run risk check if tool_name == "manage_executors": diff --git a/condor/preferences.py b/condor/preferences.py index f11cb425c..9e08d276f 100644 --- a/condor/preferences.py +++ b/condor/preferences.py @@ -117,7 +117,8 @@ def _load_from_cm(user_data: Dict) -> None: DEFAULT_DEX_NETWORK = "solana-mainnet-beta" DEFAULT_DEX_CONNECTOR = "jupiter" DEFAULT_DEX_PAIR = "SOL-USDC" -DEFAULT_DEX_SLIPPAGE = "1.0" +# No slippage default on purpose: an unset slippage is OMITTED from the Gateway request so the +# connector's own configured slippage applies. An explicit value (including "0") is a real value. DEFAULT_DEX_SIDE = "BUY" DEFAULT_DEX_AMOUNT = "1.0" @@ -342,7 +343,6 @@ def _get_default_preferences() -> UserPreferences: "dex": { "default_network": DEFAULT_DEX_NETWORK, "default_connector": DEFAULT_DEX_CONNECTOR, - "default_slippage": DEFAULT_DEX_SLIPPAGE, "last_swap": {}, "last_pool": {}, }, @@ -635,6 +635,19 @@ def get_dex_connector(user_data: Dict, network: Optional[str] = None) -> str: return get_dex_prefs(user_data).get("default_connector", DEFAULT_DEX_CONNECTOR) +def get_dex_slippage(user_data: Dict) -> Optional[str]: + """Get the user's DEX slippage override, or None to use the connector's own setting""" + return get_dex_prefs(user_data).get("default_slippage") + + +def set_dex_slippage(user_data: Dict, slippage: str) -> None: + """Set default DEX slippage percentage""" + prefs = _ensure_preferences(user_data) + prefs["dex"]["default_slippage"] = slippage + _sync_section_to_cm(user_data, "dex") + logger.info(f"Set DEX slippage to {slippage}%") + + def get_dex_last_swap(user_data: Dict) -> DEXSwapParams: """Get last DEX swap parameters""" return deepcopy(get_dex_prefs(user_data).get("last_swap", {})) @@ -667,6 +680,9 @@ def get_dex_swap_defaults(user_data: Dict) -> DEXSwapParams: 1. System defaults 2. User's configured defaults 3. Last swap params (highest priority) + + "slippage" is present ONLY when the user set one. Absent means "omit slippage_pct from the + Gateway request" so the connector's configured slippage applies. """ prefs = get_dex_prefs(user_data) last_swap = prefs.get("last_swap", {}) @@ -675,17 +691,20 @@ def get_dex_swap_defaults(user_data: Dict) -> DEXSwapParams: "network", prefs.get("default_network", DEFAULT_DEX_NETWORK) ) - return { + defaults: DEXSwapParams = { "connector": last_swap.get("connector", get_dex_connector(user_data, network)), "network": network, "trading_pair": last_swap.get("trading_pair", DEFAULT_DEX_PAIR), "side": DEFAULT_DEX_SIDE, "amount": DEFAULT_DEX_AMOUNT, - "slippage": last_swap.get( - "slippage", prefs.get("default_slippage", DEFAULT_DEX_SLIPPAGE) - ), } + slippage = last_swap.get("slippage", prefs.get("default_slippage")) + if slippage is not None: + defaults["slippage"] = slippage + + return defaults + # ============================================ # PUBLIC API - GENERAL diff --git a/condor/runtime/danger.py b/condor/runtime/danger.py index fc2a7540a..840de9635 100644 --- a/condor/runtime/danger.py +++ b/condor/runtime/danger.py @@ -21,7 +21,9 @@ DANGEROUS_TOOLS = { "place_order", "manage_gateway_swaps", # execute action - "manage_gateway_clmm", # open/close position + "manage_clmm", # every action that moves liquidity + "manage_amm", # every action that moves liquidity + "manage_gateway_config", # only the wallets resource; see below } # Tools that are always blocked (RBAC bypass prevention) @@ -45,8 +47,29 @@ # Actions within manage_gateway_swaps that require confirmation DANGEROUS_SWAP_ACTIONS = {"execute"} -# Actions within manage_gateway_clmm that require confirmation -DANGEROUS_CLMM_ACTIONS = {"open_position", "close_position"} +# Actions within manage_clmm that require confirmation. These are the tool's +# own action literals — a name that does not match one lets the call through +# ungated, so they are asserted against the registered tool in the tests. +DANGEROUS_CLMM_ACTIONS = { + "open", + "close", + "add_liquidity", + "remove_liquidity", + "collect_fees", + "create_pool", +} + +# Actions within manage_amm that require confirmation +DANGEROUS_AMM_ACTIONS = {"add_liquidity", "remove_liquidity", "create_pool"} + +# Resource types within manage_gateway_config that require confirmation. This tool +# is gated on `resource_type`, not `action`, because what it edits matters and how +# it edits does not: `wallets` + `add` takes a PRIVATE KEY, and `delete` removes a +# signing wallet. Everything else it touches — tokens, pools, connectors, networks — +# is Gateway's own symbol/address mapping. Deleting a token there moves no funds and +# changes nothing on-chain, so gating it would put a human in front of a config edit +# while the trades that edit enables stay where they are. +DANGEROUS_CONFIG_RESOURCES = {"wallets"} def tool_call_name(tool_call: dict[str, Any]) -> str: @@ -104,6 +127,24 @@ def _has_dangerous_action( return action in dangerous_actions +def _has_dangerous_resource( + tool_call: dict[str, Any], dangerous_resources: set[str] +) -> bool: + """Whether a resource-gated tool call selects one of its dangerous resources. + + The resource-typed twin of :func:`_has_dangerous_action`, and it fails closed the + same way (SEC-093): unreadable arguments, or a missing/non-string ``resource_type``, + count as dangerous. + """ + input_data = tool_call_input(tool_call) + if input_data is None: + return True + resource = input_data.get("resource_type") + if not isinstance(resource, str) or not resource: + return True + return resource in dangerous_resources + + def is_dangerous_tool_call(tool_call: dict[str, Any]) -> bool: """Check if a tool call requires user confirmation.""" tool_name = tool_call_name(tool_call) @@ -114,10 +155,16 @@ def is_dangerous_tool_call(tool_call: dict[str, Any]) -> bool: if tool_name == "manage_gateway_swaps": return _has_dangerous_action(tool_call, DANGEROUS_SWAP_ACTIONS) - # For manage_gateway_clmm, only open/close are dangerous - if tool_name == "manage_gateway_clmm": + # For the LP tools, only the actions that move liquidity are dangerous + if tool_name == "manage_clmm": return _has_dangerous_action(tool_call, DANGEROUS_CLMM_ACTIONS) + if tool_name == "manage_amm": + return _has_dangerous_action(tool_call, DANGEROUS_AMM_ACTIONS) + + if tool_name == "manage_gateway_config": + return _has_dangerous_resource(tool_call, DANGEROUS_CONFIG_RESOURCES) + return True # manage_executors with create/stop actions @@ -186,13 +233,47 @@ def format_tool_summary(tool_call: dict[str, Any]) -> str: amount = input_data.get("amount", "?") return f"Swap {side} {amount} {pair}" - if tool_name == "manage_gateway_clmm": + if tool_name == "manage_gateway_config": + resource = input_data.get("resource_type", "?") + action = input_data.get("action", "?") + if resource == "wallets": + if action == "add": + chain = input_data.get("chain", "?") + return f"Import a {chain} wallet into Gateway (private key)" + if action == "delete": + addr = str(input_data.get("wallet_address") or "?") + return f"Remove wallet {addr[:12]}... from Gateway" + return f"Gateway config: {action} {resource}" + + if tool_name in ("manage_clmm", "manage_amm"): action = input_data.get("action", "?") - if action == "open_position": - return "Open LP position" - if action == "close_position": - return "Close LP position" - return f"CLMM: {action}" + kind = "CLMM" if tool_name == "manage_clmm" else "AMM" + connector = input_data.get("connector", "?") + pool = ( + input_data.get("pool_address") or input_data.get("position_address") or "?" + ) + if action == "open": + lower = input_data.get("lower_price", "?") + upper = input_data.get("upper_price", "?") + return ( + f"Open {kind} position on {connector} pool {pool} over {lower}-{upper}" + ) + if action == "close": + return f"Close {kind} position {pool} on {connector}" + if action == "add_liquidity": + base = input_data.get("base_token_amount", "?") + quote = input_data.get("quote_token_amount", "?") + return f"Add {base} base / {quote} quote to {kind} {pool} on {connector}" + if action == "remove_liquidity": + pct = input_data.get("percentage_to_remove", "?") + return f"Remove {pct}% from {kind} position {pool} on {connector}" + if action == "collect_fees": + return f"Collect fees from {kind} position {pool} on {connector}" + if action == "create_pool": + base = input_data.get("base_token", "?") + quote = input_data.get("quote_token", "?") + return f"Create {kind} pool {base}-{quote} on {connector}" + return f"{kind}: {action}" # Generic fallback return tool_name diff --git a/frontend/index.html b/frontend/index.html index f0b623304..d4ddeefcd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,8 +8,6 @@
- - diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index 90a38f846..de4efe37d 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -32,8 +32,10 @@ ) from condor.runtime.danger import ( # noqa: F401 BLOCKED_TOOLS, + DANGEROUS_AMM_ACTIONS, DANGEROUS_BOT_ACTIONS, DANGEROUS_CLMM_ACTIONS, + DANGEROUS_CONFIG_RESOURCES, DANGEROUS_EXECUTOR_ACTIONS, DANGEROUS_SWAP_ACTIONS, DANGEROUS_TOOLS, diff --git a/handlers/dex/_shared.py b/handlers/dex/_shared.py index ff8762f0b..70ac77247 100644 --- a/handlers/dex/_shared.py +++ b/handlers/dex/_shared.py @@ -129,6 +129,47 @@ def invalidates(*groups: str): } +async def resolve_network_id(client, user_input: str) -> str: + """Resolve a user-typed network name to Gateway's '{chain}-{network}' id. + + Gateway builds its network ids from the yml basenames under + conf/chains//, so 'base' is 'ethereum-base' and 'bsc' is + 'ethereum-bsc' — nothing a static table can be trusted to keep in sync. + The API is the source of truth: GET /gateway/networks returns every + network id together with its chain and bare network name. + + Accepts either the full id ('ethereum-base') or the bare network name + ('base'). Raises ValueError for unknown or ambiguous input, listing what + Gateway actually offers. + """ + text = user_input.strip().lower() + result = await client.gateway.list_networks() + networks = (result or {}).get("networks") or [] + if not networks: + raise ValueError("Gateway returned no networks") + + for entry in networks: + if str(entry.get("network_id", "")).lower() == text: + return entry["network_id"] + + matches = [ + entry["network_id"] + for entry in networks + if str(entry.get("network", "")).lower() == text + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError( + f"Ambiguous network '{user_input}'. Use one of: {', '.join(sorted(matches))}" + ) + + available = sorted(str(entry.get("network_id")) for entry in networks) + raise ValueError( + f"Unknown network '{user_input}'. Available: {', '.join(available)}" + ) + + def get_explorer_url(tx_hash: str, network: str) -> Optional[str]: """Generate explorer URL for a transaction @@ -313,14 +354,14 @@ def get_status_emoji(status: str) -> str: """Get emoji for swap status Args: - status: Status string (CONFIRMED, PENDING, FAILED, etc.) + status: Status string (CONFIRMED, SUBMITTED, FAILED, etc.) Returns: Emoji character """ status_emojis = { "CONFIRMED": "✅", - "PENDING": "âŗ", + "SUBMITTED": "âŗ", "FAILED": "❌", "REJECTED": "đŸšĢ", "UNKNOWN": "❓", @@ -462,7 +503,7 @@ def clear_dex_state(context) -> None: "swap": { "trading_pair": ["All", "SOL-USDC", "SOL-ORE", "ORE-USDC", "ETH-USDC"], "connector": ["All", "jupiter", "uniswap"], - "status": ["All", "CONFIRMED", "PENDING", "FAILED"], + "status": ["All", "CONFIRMED", "SUBMITTED", "FAILED"], }, "position": { "trading_pair": ["All", "SOL-USDC", "ORE-SOL", "METv-SOL"], @@ -485,7 +526,10 @@ class HistoryFilters: network: Optional[str] = None # None = All offset: int = 0 limit: int = DEFAULT_PAGE_SIZE - total_count: int = 0 + # The API reports total_count only on the LAST page; while more rows remain + # it sends null and only has_more is meaningful. + total_count: Optional[int] = None + has_more: bool = False def to_dict(self) -> Dict[str, Any]: return { @@ -497,6 +541,7 @@ def to_dict(self) -> Dict[str, Any]: "offset": self.offset, "limit": self.limit, "total_count": self.total_count, + "has_more": self.has_more, } @classmethod @@ -509,7 +554,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "HistoryFilters": network=data.get("network"), offset=data.get("offset", 0), limit=data.get("limit", DEFAULT_PAGE_SIZE), - total_count=data.get("total_count", 0), + total_count=data.get("total_count"), + has_more=data.get("has_more", False), ) def reset_pagination(self) -> None: @@ -522,13 +568,16 @@ def current_page(self) -> int: @property def total_pages(self) -> int: + if self.total_count is None: + # Unknown total: at least this page, plus one more if there is one. + return self.current_page + (1 if self.has_more else 0) if self.total_count == 0: return 1 return (self.total_count + self.limit - 1) // self.limit @property def has_next(self) -> bool: - return self.offset + self.limit < self.total_count + return self.has_more @property def has_prev(self) -> bool: @@ -618,8 +667,11 @@ def build_pagination_buttons( else: buttons.append(InlineKeyboardButton(" ", callback_data="dex:noop")) - # Page indicator - page_text = f"{filters.current_page}/{filters.total_pages}" + # Page indicator — the total is unknown until the last page + if filters.total_count is None: + page_text = f"Page {filters.current_page}" + else: + page_text = f"{filters.current_page}/{filters.total_pages}" buttons.append(InlineKeyboardButton(page_text, callback_data="dex:noop")) # Next button diff --git a/handlers/dex/geckoterminal.py b/handlers/dex/geckoterminal.py index 7e0a389b3..2cbf9c138 100644 --- a/handlers/dex/geckoterminal.py +++ b/handlers/dex/geckoterminal.py @@ -3344,7 +3344,7 @@ async def handle_gecko_swap(update: Update, context: ContextTypes.DEFAULT_TYPE) "trading_pair": trading_pair, "side": "BUY", "amount": "1.0", - "slippage": "1.0", + # No slippage: the connector's configured slippage applies until the user overrides it } context.user_data["dex_state"] = "swap" diff --git a/handlers/dex/liquidity.py b/handlers/dex/liquidity.py index 2ab85ee8a..42d69be94 100644 --- a/handlers/dex/liquidity.py +++ b/handlers/dex/liquidity.py @@ -172,21 +172,11 @@ def is_active_with_liquidity(pos): # Must not be closed if pos.get("status") == "CLOSED": return False - # Check liquidity - liq = pos.get("liquidity") or pos.get("current_liquidity") - if liq is not None: - try: - return float(liq) > 0 - except (ValueError, TypeError): - pass - base = pos.get("base_token_amount") or pos.get("amount_base") - quote = pos.get("quote_token_amount") or pos.get("amount_quote") - if base is not None or quote is not None: - try: - return float(base or 0) > 0 or float(quote or 0) > 0 - except (ValueError, TypeError): - pass - return True + # hapi's position rows carry the amounts as base_token_amount / + # quote_token_amount; there is no 'liquidity' field to read. + base = float(pos.get("base_token_amount") or 0) + quote = float(pos.get("quote_token_amount") or 0) + return base > 0 or quote > 0 positions = [p for p in positions if is_active_with_liquidity(p)] @@ -309,7 +299,7 @@ def _format_compact_position_line( ) # Get position value from pnl_summary - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} position_value_quote = pnl_summary.get("current_total_value_quote") # Get values from pnl_summary (all values are in quote token units) @@ -439,7 +429,7 @@ def _format_closed_position_line( pass # Get PnL data - use pre-calculated total_pnl_quote - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} total_pnl_quote = pnl_summary.get("total_pnl_quote", 0) or 0 total_fees_value = pnl_summary.get("total_fees_value_quote", 0) or 0 @@ -895,15 +885,15 @@ def _format_detailed_position_line(pos: dict, token_cache: dict = None) -> str: pass # Get PnL summary - pnl = pos.get("pnl_summary", {}) + pnl = pos.get("pnl_summary") or {} initial_base = pnl.get("initial_base", pos.get("initial_base_token_amount", 0)) or 0 initial_quote = ( pnl.get("initial_quote", pos.get("initial_quote_token_amount", 0)) or 0 ) - final_base = pnl.get("current_base_total", pos.get("base_token_amount", 0)) or 0 - final_quote = pnl.get("current_quote_total", pos.get("quote_token_amount", 0)) or 0 - base_pnl = pnl.get("base_pnl", 0) or 0 - quote_pnl = pnl.get("quote_pnl", 0) or 0 + # hapi's pnl_summary names the live amounts current_base / current_quote; + # it reports no per-token PnL, so derive it from initial vs final. + final_base = pnl.get("current_base", pos.get("base_token_amount", 0)) or 0 + final_quote = pnl.get("current_quote", pos.get("quote_token_amount", 0)) or 0 # Fees collected base_fee = pos.get("base_fee_collected", 0) or 0 @@ -960,8 +950,8 @@ def _format_detailed_position_line(pos: dict, token_cache: dict = None) -> str: try: final_base_f = float(final_base) final_quote_f = float(final_quote) - base_pnl_f = float(base_pnl) - quote_pnl_f = float(quote_pnl) + base_pnl_f = final_base_f - float(initial_base) + quote_pnl_f = final_quote_f - float(initial_quote) # Format PnL with +/- signs base_pnl_str = ( @@ -1048,10 +1038,14 @@ async def handle_lp_history( positions = result.get("data", []) if result else [] pagination = result.get("pagination", {}) if result else {} - total_count = pagination.get("total_count", len(positions)) + # total_count is null while more rows remain; has_more is what tells us + # whether there is a next page. + total_count = pagination.get("total_count") + has_more = bool(pagination.get("has_more")) - # Update filters with total count + # Update filters with pagination state filters.total_count = total_count + filters.has_more = has_more set_history_filters(context.user_data, filters) if not positions and filters.offset == 0: @@ -1113,7 +1107,10 @@ def get_closed_time(pos): filter_summary = "" message = rf"📜 *Position History*{filter_summary}" + "\n" - message += rf"_Showing {len(positions)} of {total_count}_" + "\n\n" + if total_count is None: + message += rf"_Showing {len(positions)}, more available_" + "\n\n" + else: + message += rf"_Showing {len(positions)} of {total_count}_" + "\n\n" for pos in positions: line = _format_detailed_position_line(pos, token_cache) @@ -1123,7 +1120,7 @@ def get_closed_time(pos): keyboard = build_filter_buttons(filters, "dex:lp_hist") # Pagination row - if total_count > filters.limit: + if filters.has_next or filters.has_prev: keyboard.append(build_pagination_buttons(filters, "dex:lp_hist")) # Action buttons - use lp_refresh to ensure fresh data when going back diff --git a/handlers/dex/lp_monitor_handlers.py b/handlers/dex/lp_monitor_handlers.py index 535773eb7..2bc01c60e 100644 --- a/handlers/dex/lp_monitor_handlers.py +++ b/handlers/dex/lp_monitor_handlers.py @@ -88,7 +88,7 @@ def format_position_detail_view( quote_price = _get_price(quote_sym, token_prices, 1.0) base_price = _get_price(base_sym, token_prices, 0) - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} pnl_usd = float(pnl_summary.get("total_pnl_quote", 0) or 0) * quote_price value_usd = float(pnl_summary.get("current_lp_value_quote", 0) or 0) * quote_price @@ -230,7 +230,7 @@ async def handle_lpm_navigation( current_str = f"Current: {current}" # Format value - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} value = pnl_summary.get("current_lp_value_quote", 0) value_str = "" if value: @@ -484,7 +484,7 @@ async def handle_lpm_collect_fees( ) if result: - tx_hash = (result.get("tx_hash", "") or "N/A")[:16] + tx_hash = (result.get("transaction_hash", "") or "N/A")[:16] await query.message.reply_text( f"✅ *Fees collected*\nTx: `{escape_markdown_v2(tx_hash)}...`", parse_mode="MarkdownV2", @@ -609,19 +609,27 @@ async def handle_lpm_rebalance_execute( logger.info(f"Close position result: {close_result}") - # Extract tx hash from various possible field names - close_tx = None - if isinstance(close_result, dict): - close_tx = ( - close_result.get("tx_hash") - or close_result.get("txHash") - or close_result.get("signature") - or close_result.get("txSignature") - ) + close_tx = close_result.get("transaction_hash") close_tx_display = ( f"`{escape_markdown_v2(close_tx[:20])}...`" if close_tx else "_pending_" ) + # Re-opening spends the tokens the close returned, so it may only run + # once the close is CONFIRMED on-chain. hapi reports the Gateway + # transaction status as 'confirmed' | 'submitted' | 'failed'; on the + # latter two the tokens are not in the wallet yet (or never will be). + close_status = close_result.get("status") + if close_status != "confirmed": + await query.message.edit_text( + f"âš ī¸ *Rebalance stopped*\n\n" + f"The close is `{escape_markdown_v2(str(close_status))}`, not confirmed\\.\n" + f"Close Tx: {close_tx_display}\n\n" + f"No new position was opened — the withdrawn tokens are not in " + f"your wallet yet\\. Check the position and retry\\.", + parse_mode="MarkdownV2", + ) + return + # Update progress await query.message.edit_text( f"🔄 *Rebalancing\\.\\.\\.*\n\n" @@ -631,19 +639,21 @@ async def handle_lpm_rebalance_execute( parse_mode="MarkdownV2", ) - # Get the withdrawn amounts from the close result - base_withdrawn = close_result.get( - "base_amount", close_result.get("amount_base", 0) - ) - quote_withdrawn = close_result.get( - "quote_amount", close_result.get("amount_quote", 0) - ) + # What the close actually returned on-chain (hapi's + # CLMMClosePositionResponse). Never substitute the pre-close cached + # amounts: those are off by fees, rent and slippage. + base_withdrawn = close_result.get("base_token_amount_removed") + quote_withdrawn = close_result.get("quote_token_amount_removed") - # Fallback to original position amounts if not in close result - if not base_withdrawn: - base_withdrawn = pos.get("base_token_amount", pos.get("amount_a", 0)) - if not quote_withdrawn: - quote_withdrawn = pos.get("quote_token_amount", pos.get("amount_b", 0)) + if not base_withdrawn and not quote_withdrawn: + await query.message.edit_text( + f"âš ī¸ *Rebalance stopped*\n\n" + f"✅ Position closed \\(Tx: {close_tx_display}\\)\n" + f"❌ The close reported no withdrawn amounts\n\n" + f"No new position was opened\\. Your funds are in your wallet\\.", + parse_mode="MarkdownV2", + ) + return # Update progress await query.message.edit_text( @@ -654,17 +664,25 @@ async def handle_lpm_rebalance_execute( parse_mode="MarkdownV2", ) - # Step 3: Open new position with same range using bid-ask strategy (type 2) - extra_params = {"strategyType": 2} # Bid-Ask strategy + # Step 3: Open new position with same range using bid-ask strategy (type 2). + # strategyType is Meteora-only; hapi rejects it for other connectors. + extra_params = {"strategyType": 2} if connector == "meteora" else None + # Decimal, not float: the SDK str()s whatever it is given, and a float + # puts "1e-08" / "0.30000000000000004" on the wire. An empty side is + # None (omitted), not 0. open_result = await client.gateway_clmm.open_position( connector=connector, network=network, pool_address=pool_address, lower_price=Decimal(str(lower_price)), upper_price=Decimal(str(upper_price)), - base_token_amount=float(base_withdrawn) if base_withdrawn else 0, - quote_token_amount=float(quote_withdrawn) if quote_withdrawn else 0, + base_token_amount=( + Decimal(str(base_withdrawn)) if base_withdrawn else None + ), + quote_token_amount=( + Decimal(str(quote_withdrawn)) if quote_withdrawn else None + ), extra_params=extra_params, ) @@ -680,18 +698,14 @@ async def handle_lpm_rebalance_execute( logger.info(f"Open position result: {open_result}") - # Extract tx hash - open_tx = None - if isinstance(open_result, dict): - open_tx = ( - open_result.get("tx_hash") - or open_result.get("txHash") - or open_result.get("signature") - or open_result.get("txSignature") - ) + # hapi returns transaction_hash (None while pending) + open_tx = open_result.get("transaction_hash") open_tx_display = ( f"`{escape_markdown_v2(open_tx[:20])}...`" if open_tx else "_pending_" ) + # position_address is null with status 'submitted' when the open tx has + # not landed yet — hapi's poller records the address once it does. + open_position_address = open_result.get("position_address") # Get token symbols for display _, quote_symbol, pair = _get_position_tokens(pos, token_cache) @@ -708,13 +722,23 @@ async def handle_lpm_rebalance_execute( range_display = f"{escape_markdown_v2(str(lower_price))} \\- {escape_markdown_v2(str(upper_price))}" # Build success message + if open_position_address: + opened_line = ( + f"✅ New position opened: " + f"`{escape_markdown_v2(open_position_address[:20])}...`" + ) + else: + opened_line = ( + "âŗ New position submitted — address known once the tx confirms" + ) + lines = [ f"✅ *Rebalance Complete*", f"━━━━━━━━━━━━━━━━━━━━━", f"*{escape_markdown_v2(pair)}*", "", f"✅ Old position closed", - f"✅ New position opened", + opened_line, "", f"Range: {range_display}", f"Strategy: Bid\\-Ask", diff --git a/handlers/dex/menu.py b/handlers/dex/menu.py index a89f7f8b5..fd92615b4 100644 --- a/handlers/dex/menu.py +++ b/handlers/dex/menu.py @@ -303,22 +303,13 @@ async def _fetch_lp_positions(client) -> dict: open_positions = [p for p in positions if p.get("status") == "OPEN"] logger.info(f"After OPEN filter: {len(open_positions)} positions") - # Filter out positions with 0 liquidity + # Filter out positions with 0 liquidity. hapi's position rows carry the + # amounts as base_token_amount / quote_token_amount (always present, + # always floats) — there is no 'liquidity' field to read. def has_liquidity(pos): - liq = pos.get("liquidity") or pos.get("current_liquidity") - if liq is not None: - try: - return float(liq) > 0 - except (ValueError, TypeError): - pass - base = pos.get("base_amount") or pos.get("amount_base") - quote = pos.get("quote_amount") or pos.get("amount_quote") - if base is not None and quote is not None: - try: - return float(base) > 0 or float(quote) > 0 - except (ValueError, TypeError): - pass - return True + base = float(pos.get("base_token_amount") or 0) + quote = float(pos.get("quote_token_amount") or 0) + return base > 0 or quote > 0 active_positions = [p for p in open_positions if has_liquidity(p)] if len(active_positions) < len(open_positions): diff --git a/handlers/dex/pools.py b/handlers/dex/pools.py index 0c7b7c336..e96ca53bc 100644 --- a/handlers/dex/pools.py +++ b/handlers/dex/pools.py @@ -31,7 +31,13 @@ resolve_token_symbol, ) -from ._shared import DEFAULT_CACHE_TTL, get_cached, invalidate_cache, set_cached +from ._shared import ( + DEFAULT_CACHE_TTL, + get_cached, + invalidate_cache, + resolve_network_id, + set_cached, +) from .visualizations import ( generate_aggregated_liquidity_chart, generate_combined_chart, @@ -41,6 +47,27 @@ logger = logging.getLogger(__name__) +# hapi's CLMM pool LISTING (/gateway/clmm/pools) is Solana-only — it accepts a +# bare Solana network name and rejects every non-Solana connector — so pools +# discovered through it live on this network. Anything reached by address +# carries its own network instead. +SOLANA_CLMM_NETWORK = "solana-mainnet-beta" + + +def _require_network(record: dict, what: str) -> str: + """Read the network a pool/position lives on, failing loudly if it is absent. + + Everything that reaches these screens is stamped with its own network — a + missing one means a stale cached record, and guessing 'solana-mainnet-beta' + is how EVM pools silently became unreachable. + """ + network = record.get("network") + if not network: + raise ValueError( + f"{what} has no network recorded — reopen it from the menu to refresh it" + ) + return network + # ============================================ # TOKEN CACHE HELPERS @@ -186,76 +213,6 @@ async def handle_pool_info(update: Update, context: ContextTypes.DEFAULT_TYPE) - ) -def _format_pool_info(pool: dict) -> str: - """Format detailed pool information - - Args: - pool: Pool data dictionary - - Returns: - Formatted pool info string (not escaped) - """ - lines = [] - - pair = pool.get("trading_pair", pool.get("name", "N/A")) - lines.append(f"🏊 Pool: {pair}") - lines.append("") - - # 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:") - lines.append(f"`{addr}`") - - if pool.get("bin_step"): - lines.append(f"📊 Bin Step: {pool.get('bin_step')}") - - if pool.get("fee") is not None: - fee_pct = ( - float(pool.get("fee", 0)) * 100 - if float(pool.get("fee", 0)) < 1 - else pool.get("fee") - ) - lines.append(f"💸 Fee: {fee_pct:.2f}%") - - lines.append("") - - # TVL and volume - tvl = pool.get("liquidity") or pool.get("tvl") - if tvl is not None: - lines.append(f"💰 TVL: ${format_compact_number(tvl)}") - - vol_24h = pool.get("volume_24h") - if vol_24h is not None: - lines.append(f"📈 Volume 24h: ${format_compact_number(vol_24h)}") - - # APR/Fees - apr = pool.get("apr") - if apr is not None: - lines.append(f"📊 APR: {_format_percent(apr)}") - - fee_tvl = pool.get("fee_tvl_ratio", {}) - if isinstance(fee_tvl, dict) and fee_tvl.get("hour_24"): - lines.append(f"đŸ’ĩ Fee/TVL 24h: {_format_percent(fee_tvl.get('hour_24'))}") - - lines.append("") - - # Prices - current_price = pool.get("current_price") or pool.get("price") - if current_price is not None: - lines.append(f"💱 Current Price: {current_price}") - - # Token info - base_token = pool.get("base_token") or pool.get("token_a") - quote_token = pool.get("quote_token") or pool.get("token_b") - if base_token: - lines.append(f"đŸĒ™ Base: {base_token}") - if quote_token: - lines.append(f"đŸ’ĩ Quote: {quote_token}") - - return "\n".join(lines) - - async def process_pool_info( update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str ) -> None: @@ -280,9 +237,15 @@ async def process_pool_info( f"Unsupported connector '{connector}'. Use: {', '.join(supported_connectors)}" ) + chat_id = update.effective_chat.id + client = await get_client(chat_id, context=context) + + if not hasattr(client, "gateway_clmm"): + raise ValueError("Gateway CLMM not available") + # Determine network based on connector type if connector in solana_connectors: - network = "solana-mainnet-beta" + network = SOLANA_CLMM_NETWORK else: # EVM connectors require network specification if len(parts) < 3: @@ -290,25 +253,8 @@ async def process_pool_info( 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) - - if not hasattr(client, "gateway_clmm"): - raise ValueError("Gateway CLMM not available") + # Gateway owns the network ids; ask it rather than guessing. + network = await resolve_network_id(client, parts[2]) # Send loading message loading_msg = await update.message.reply_text("🔄 Loading pool details...") @@ -335,29 +281,18 @@ async def process_pool_info( ) return - # Construct pool dict with connector info for _show_pool_detail + # Construct pool dict for _show_pool_detail. Only the fields hapi's + # CLMMPoolInfoResponse actually carries: TVL/volume/APR are pool-LIST + # fields and simply do not exist on pool-info. pool = { "pool_address": pool_address, "address": pool_address, "connector": connector, - "trading_pair": result.get("trading_pair", result.get("name", "N/A")), - # Copy over any available data from result - "liquidity": result.get("liquidity") or result.get("tvl"), - "volume_24h": result.get("volume_24h"), - "fees_24h": result.get("fees_24h"), - "base_fee_percentage": result.get("base_fee_percentage") - or result.get("fee"), - "max_fee_percentage": result.get("max_fee_percentage"), - "apr": result.get("apr"), - "apy": result.get("apy"), + "network": network, "bin_step": result.get("bin_step"), - "current_price": result.get("current_price") or result.get("price"), - "mint_x": result.get("mint_x") - or result.get("base_token") - or result.get("token_a"), - "mint_y": result.get("mint_y") - or result.get("quote_token") - or result.get("token_b"), + "current_price": result.get("price"), + "mint_x": result.get("base_token_address"), + "mint_y": result.get("quote_token_address"), } # Use the rich pool detail display with chart and add liquidity button @@ -748,9 +683,10 @@ async def process_pool_list( pools = result.get("pools", []) - # Add connector to each pool for later use + # Add connector/network to each pool for later use for pool in pools: pool["connector"] = connector + pool["network"] = SOLANA_CLMM_NETWORK if not pools: message = escape_markdown_v2("📋 No pools found") @@ -896,7 +832,12 @@ async def fetch_pool_with_info(pool): connector = pool.get("connector", "meteora") try: pool_info = await asyncio.wait_for( - _fetch_pool_info(client, pool_address, connector), + _fetch_pool_info( + client, + pool_address, + connector, + _require_network(pool, "This pool"), + ), timeout=POOL_FETCH_TIMEOUT, ) bins = pool_info.get("bins", []) @@ -1039,14 +980,15 @@ async def fetch_pool_with_info(pool): async def _fetch_pool_info( - client, pool_address: str, connector: str = "meteora" + client, pool_address: str, connector: str, network: str ) -> dict: """Fetch detailed pool info including bins Args: client: Gateway client pool_address: Pool address to fetch - connector: Connector name (meteora, raydium) + connector: Connector name (meteora, raydium, uniswap, ...) + network: Network ID in '{chain}-{network}' form the pool lives on Returns: Pool info dict with bins data @@ -1055,7 +997,7 @@ async def _fetch_pool_info( if hasattr(client, "gateway_clmm"): result = await client.gateway_clmm.get_pool_info( connector=connector, - network="solana-mainnet-beta", + network=network, pool_address=pool_address, ) return result or {} @@ -1111,7 +1053,9 @@ async def _show_pool_detail( pool_address = pool.get("pool_address", pool.get("address", "N/A")) connector = pool.get("connector", "meteora") - network = "solana-mainnet-beta" + # Every pool dict reaching here is stamped with the network it was found on + # (pool list, pool-info lookup, position, or GeckoTerminal). + network = _require_network(pool, "This pool") chat_id = update.effective_chat.id # Parallel fetch: pool_info, token_cache, and OHLCV data @@ -1125,7 +1069,7 @@ async def fetch_pool_info_task(): if pool_info is not None: return pool_info client = await get_client(chat_id, context=context) - return await _fetch_pool_info(client, pool_address, connector) + return await _fetch_pool_info(client, pool_address, connector, network) async def fetch_token_cache_task(): if token_cache is not None: @@ -1159,22 +1103,14 @@ async def fetch_ohlcv_task(): if token_cache: context.user_data["token_cache"] = token_cache - # Try to get trading pair name from multiple sources + # Pair name comes from the pool LIST (CLMMPoolListItem.trading_pair/name); + # pool-info carries only token ADDRESSES, so resolve symbols from those. pair = pool.get("trading_pair") or pool.get("name") - mint_x = ( - pool.get("mint_x") or pool_info.get("mint_x") or pool_info.get("token_x_mint") - ) - mint_y = ( - pool.get("mint_y") or pool_info.get("mint_y") or pool_info.get("token_y_mint") - ) + mint_x = pool.get("mint_x") or pool_info.get("base_token_address") + mint_y = pool.get("mint_y") or pool_info.get("quote_token_address") if not pair or pair == "N/A": - # Try to construct from pool_info token symbols - token_x = pool_info.get("token_x_symbol") or pool_info.get("base_symbol") - token_y = pool_info.get("token_y_symbol") or pool_info.get("quote_symbol") - if token_x and token_y: - pair = f"{token_x}/{token_y}" - elif mint_x and mint_y: + if mint_x and mint_y: base_symbol = resolve_token_symbol(mint_x, token_cache) quote_symbol = resolve_token_symbol(mint_y, token_cache) pair = f"{base_symbol}/{quote_symbol}" @@ -1204,9 +1140,7 @@ async def fetch_ohlcv_task(): quote_symbol = resolve_token_symbol(mint_y, token_cache) if mint_y else "QUOTE" # Get current price and bin step - current_price = ( - pool_info.get("price") or pool.get("current_price") or pool.get("price") - ) + current_price = pool_info.get("price") or pool.get("current_price") bin_step = pool.get("bin_step") or pool_info.get("bin_step") bins = pool_info.get("bins", []) active_bin = pool_info.get("active_bin_id") @@ -1279,17 +1213,15 @@ async def fetch_ohlcv_task(): quote_in_gateway = mint_y and mint_y in token_cache tokens_in_gateway = base_in_gateway and quote_in_gateway - # Get pool metrics - tvl = ( - pool.get("liquidity") - or pool.get("tvl") - or pool_info.get("liquidity") - or pool_info.get("tvl") - ) - vol_24h = pool.get("volume_24h") or pool_info.get("volume_24h") - fees_24h = pool.get("fees_24h") or pool_info.get("fees_24h") - base_fee = pool.get("base_fee_percentage") or pool_info.get("base_fee_percentage") - apr = pool.get("apr") or pool_info.get("apr") + # Pool metrics. TVL/volume/fees/APR exist only on the pool LIST item (or a + # GeckoTerminal pool); pool-info has none of them, so a pool reached by + # address simply shows no metrics row. The fee percentage does come from + # pool-info, as fee_pct. + tvl = pool.get("liquidity") + vol_24h = pool.get("volume_24h") + fees_24h = pool.get("fees_24h") + base_fee = pool.get("base_fee_percentage") or pool_info.get("fee_pct") + apr = pool.get("apr") # Build message - matching show_add_position_menu format message = r"➕ *Add CLMM Position*" + "\n\n" @@ -1783,24 +1715,16 @@ async def handle_add_to_gateway( await query.answer("No pool selected") return - # Get token addresses - mint_x = ( - selected_pool.get("mint_x") - or pool_info.get("mint_x") - or pool_info.get("token_x_mint") - ) - mint_y = ( - selected_pool.get("mint_y") - or pool_info.get("mint_y") - or pool_info.get("token_y_mint") - ) + # Get token addresses (pool list: mint_x/mint_y; pool-info: *_token_address) + mint_x = selected_pool.get("mint_x") or pool_info.get("base_token_address") + mint_y = selected_pool.get("mint_y") or pool_info.get("quote_token_address") if not mint_x or not mint_y: await query.answer("Token addresses not available", show_alert=True) return - network_id = "solana-mainnet-beta" - gecko_network = "solana" + network_id = _require_network(selected_pool, "This pool") + gecko_network = get_gecko_network(network_id) # Show loading await query.answer("Adding tokens to Gateway...") @@ -2414,9 +2338,9 @@ def _format_position_detail( ) # Get PNL data - pnl_summary = pos.get("pnl_summary", {}) - base_pnl = pnl_summary.get("base_pnl") - quote_pnl = pnl_summary.get("quote_pnl") + # hapi emits pnl_summary: null when entry/current price or initial amounts + # are missing, so a {} default on a present key never applies. + pnl_summary = pos.get("pnl_summary") or {} # Get pending fees base_fee = pos.get( @@ -2629,14 +2553,11 @@ def get_price(symbol, default=0): except (ValueError, TypeError): lines.append(f" {range_emoji} Range: {range_str}") - # Compact PNL - if base_pnl is not None or quote_pnl is not None: - pnl_parts = [] - if quote_pnl is not None: - sign = "+" if quote_pnl >= 0 else "" - pnl_parts.append(f"{sign}{format_amount(quote_pnl)} {quote_symbol}") - if pnl_parts: - lines.append(f" 📊 {' '.join(pnl_parts)}") + # Compact PNL (quote-denominated, as hapi calculates it) + compact_pnl = pnl_summary.get("total_pnl_quote") + if compact_pnl is not None: + sign = "+" if compact_pnl >= 0 else "" + lines.append(f" 📊 {sign}{format_amount(compact_pnl)} {quote_symbol}") # Compact Fees - show if any fees are pending try: @@ -2885,12 +2806,12 @@ async def handle_pos_view( chat_id = update.effective_chat.id connector = pos.get("connector", "meteora") pool_address = pos.get("pool_address", "") - network = "solana-mainnet-beta" + network = _require_network(pos, "This position") # Get position price data lower_price = pos.get("lower_price", pos.get("price_lower")) upper_price = pos.get("upper_price", pos.get("price_upper")) - pnl_summary = pos.get("pnl_summary", {}) + pnl_summary = pos.get("pnl_summary") or {} entry_price = pnl_summary.get("entry_price") current_price = pnl_summary.get("current_price") @@ -2911,7 +2832,7 @@ async def fetch_pool_info_task(): if cached: return cached client = await get_client(chat_id, context=context) - info = await _fetch_pool_info(client, pool_address, connector) + info = await _fetch_pool_info(client, pool_address, connector, network) if info: set_cached(context.user_data, cache_key, info) return info or {} @@ -3133,6 +3054,7 @@ async def handle_pos_view_pool( "pool_address": pool_address, "address": pool_address, "connector": connector, + "network": _require_network(pos, "This position"), "trading_pair": pos.get("trading_pair"), "mint_x": pos.get("base_token"), "mint_y": pos.get("quote_token"), @@ -3232,11 +3154,7 @@ async def handle_pos_collect_fees( if result: success_msg = f"✅ *Fees collected from {escape_markdown_v2(pair)}\\!*" if isinstance(result, dict): - tx_hash = ( - result.get("tx_hash") - or result.get("txHash") - or result.get("signature") - ) + tx_hash = result.get("transaction_hash") if tx_hash: success_msg += f"\n\nTx: `{tx_hash[:30]}...`" @@ -3249,15 +3167,22 @@ async def handle_pos_collect_fees( success_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard ) else: + # A successful collect always returns a populated + # CLMMCollectFeesResponse, so an empty result is an API failure — + # not a zero-fee collect. + failure_msg = ( + f"❌ No response from the API collecting fees on " + f"{escape_markdown_v2(pair)}" + ) try: await query.message.edit_text( - f"â„šī¸ No fees to collect from {escape_markdown_v2(pair)}", + failure_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard, ) except Exception: await query.message.reply_text( - f"â„šī¸ No fees to collect from {escape_markdown_v2(pair)}", + failure_msg, parse_mode="MarkdownV2", reply_markup=back_keyboard, ) @@ -3417,10 +3342,8 @@ async def handle_pos_close_execute( pair = pos.get("trading_pair", "Unknown") success_msg = escape_markdown_v2(f"✅ Position closed: {pair}") - if isinstance(result, dict) and result.get("tx_hash"): - success_msg += ( - f"\n\nTx: `{escape_markdown_v2(result['tx_hash'][:20])}...`" - ) + if isinstance(result, dict) and result.get("transaction_hash"): + success_msg += f"\n\nTx: `{escape_markdown_v2(result['transaction_hash'][:20])}...`" keyboard = [[InlineKeyboardButton("ÂĢ Back", callback_data="dex:liquidity")]] reply_markup = InlineKeyboardMarkup(keyboard) @@ -3490,9 +3413,14 @@ async def process_position_list( if not hasattr(client, "gateway_clmm"): raise ValueError("Gateway CLMM not available") - positions = await client.gateway_clmm.get_positions_owned( - connector=connector, network=network, pool_address=pool_address + # positions-owned has no pool filter: it lists every CLMM position the + # wallet owns on the connector, each row carrying its own pool_address. + all_positions = await client.gateway_clmm.get_positions_owned( + connector=connector, network=network ) + positions = [ + p for p in (all_positions or []) if p.get("pool_address") == pool_address + ] # Save params set_dex_last_pool( @@ -3933,35 +3861,25 @@ async def show_add_position_menu( # Get current price and bin step for range calculation # Prefer pool_info (fetched data) over selected_pool (list data) - current_price = ( - pool_info.get("price") - or selected_pool.get("current_price") - or selected_pool.get("price") - ) + current_price = pool_info.get("price") or selected_pool.get("current_price") bin_step = pool_info.get("bin_step") or selected_pool.get("bin_step") bins = pool_info.get("bins", []) - network = params.get("network", "solana-mainnet-beta") + network = params.get("network") or _require_network(selected_pool, "This pool") - # Extract token addresses/symbols from pool info - base_token = selected_pool.get("base_token") or pool_info.get("base_token") - quote_token = selected_pool.get("quote_token") or pool_info.get("quote_token") + # Extract token addresses. pool-info names them base_token_address / + # quote_token_address; the pool list names them mint_x / mint_y. + base_token = selected_pool.get("base_token") + quote_token = selected_pool.get("quote_token") - # Try mint addresses if token not set if not base_token: - base_token = ( - selected_pool.get("mint_x") - or pool_info.get("mint_x") - or pool_info.get("token_x_mint") - ) + base_token = selected_pool.get("mint_x") or pool_info.get("base_token_address") if not quote_token: - quote_token = ( - selected_pool.get("mint_y") - or pool_info.get("mint_y") - or pool_info.get("token_y_mint") + quote_token = selected_pool.get("mint_y") or pool_info.get( + "quote_token_address" ) - # Get pair name, with fallback to resolving from token addresses - pair = selected_pool.get("trading_pair") or pool_info.get("trading_pair") + # Pair name is a pool-LIST field; pool-info has none. + pair = selected_pool.get("trading_pair") if not pair or pair in ("Pool", "N/A", "Unknown Pair"): # Try to resolve from token addresses using token_cache if base_token and quote_token: @@ -4137,10 +4055,8 @@ async def show_add_position_menu( ) if bin_step: help_text += f"📊 *Bin Step:* `{escape_markdown_v2(str(bin_step))}` _\\(default 20 bins each side\\)_\n" - # Add fee if available - base_fee = pool_info.get("base_fee_percentage") or selected_pool.get( - "base_fee_percentage" - ) + # Add fee if available (pool-info: fee_pct; pool list: base_fee_percentage) + base_fee = pool_info.get("fee_pct") or selected_pool.get("base_fee_percentage") if base_fee: help_text += f"💸 *Fee:* `{escape_markdown_v2(str(base_fee))}%`\n" @@ -4554,7 +4470,7 @@ async def handle_pos_refresh( pool_address = params.get("pool_address") or selected_pool.get("pool_address", "") connector = params.get("connector") or selected_pool.get("connector", "meteora") - network = params.get("network", "solana-mainnet-beta") + network = params.get("network") or _require_network(selected_pool, "This pool") base_token = selected_pool.get("base_token", "") quote_token = selected_pool.get("quote_token", "") @@ -4574,7 +4490,7 @@ async def handle_pos_refresh( if pool_address: chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) - pool_info = await _fetch_pool_info(client, pool_address, connector) + pool_info = await _fetch_pool_info(client, pool_address, connector, network) set_cached(context.user_data, pool_cache_key, pool_info) context.user_data["selected_pool_info"] = pool_info @@ -4977,8 +4893,10 @@ async def handle_pos_add_confirm( "Both amounts are 0. Need at least one token to add liquidity." ) - # Build extra_params for strategy type - extra_params = {"strategyType": strategy_type} + # strategyType is Meteora-only; hapi rejects it for other connectors + extra_params = ( + {"strategyType": strategy_type} if connector == "meteora" else None + ) result = await client.gateway_clmm.open_position( connector=connector, @@ -5022,12 +4940,21 @@ async def handle_pos_add_confirm( pos_info += escape_markdown_v2(f"Quote: {float(amount_quote):.6f}\n") if isinstance(result, dict): - if "tx_hash" in result: - pos_info += escape_markdown_v2(f"\nTx: {result['tx_hash'][:16]}...") - if "position_address" in result: + # transaction_hash is None while the open tx is still pending + if result.get("transaction_hash"): + pos_info += escape_markdown_v2( + f"\nTx: {result['transaction_hash'][:16]}..." + ) + # position_address is null when the tx was submitted but not yet + # confirmed — hapi's poller records the position once it lands + if result.get("position_address"): pos_info += escape_markdown_v2( f"\nPosition: {result['position_address'][:16]}..." ) + elif result.get("status") == "submitted": + pos_info += escape_markdown_v2( + "\nPosition: pending confirmation (address known once the tx lands)" + ) keyboard = [ [InlineKeyboardButton("ÂĢ Back to Liquidity", callback_data="dex:liquidity")] @@ -5267,7 +5194,8 @@ async def process_add_position( # Now execute connector = params.get("connector", "meteora") - network = params.get("network", "solana-mainnet-beta") + network = params.get("network") or SOLANA_CLMM_NETWORK + strategy_type = int(params.get("strategy_type", "0")) chat_id = update.effective_chat.id client = await get_client(chat_id, context=context) @@ -5275,6 +5203,11 @@ async def process_add_position( if not hasattr(client, "gateway_clmm"): raise ValueError("Gateway CLMM not available") + # strategyType is Meteora-only; hapi rejects it for other connectors + extra_params = ( + {"strategyType": strategy_type} if connector == "meteora" else None + ) + result = await client.gateway_clmm.open_position( connector=connector, network=network, @@ -5287,6 +5220,7 @@ async def process_add_position( quote_token_amount=( Decimal(params["amount_quote"]) if params["amount_quote"] else None ), + extra_params=extra_params, ) if result is None: @@ -5309,8 +5243,22 @@ async def process_add_position( f"Quote: {params['amount_quote']}\n" ) - if isinstance(result, dict) and "tx_hash" in result: - pos_info += escape_markdown_v2(f"\nTx: {result['tx_hash'][:16]}...") + if isinstance(result, dict): + # transaction_hash is None while the open tx is still pending + if result.get("transaction_hash"): + pos_info += escape_markdown_v2( + f"\nTx: {result['transaction_hash'][:16]}..." + ) + # position_address is null when the tx was submitted but not yet + # confirmed — hapi's poller records the position once it lands + if result.get("position_address"): + pos_info += escape_markdown_v2( + f"\nPosition: {result['position_address'][:16]}..." + ) + elif result.get("status") == "submitted": + pos_info += escape_markdown_v2( + "\nPosition: pending confirmation (address known once the tx lands)" + ) keyboard = [ [InlineKeyboardButton("ÂĢ Back to Liquidity", callback_data="dex:liquidity")] diff --git a/handlers/dex/swap.py b/handlers/dex/swap.py index 280fbc649..0ba5bfdfb 100644 --- a/handlers/dex/swap.py +++ b/handlers/dex/swap.py @@ -11,6 +11,7 @@ import logging from decimal import Decimal +import aiohttp from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes @@ -52,18 +53,114 @@ # HELPER FUNCTIONS # ============================================ +# Shown on the slippage button when the user has not set one: the request omits +# slippage_pct entirely and the connector's own configured slippage applies. +SLIPPAGE_DEFAULT_LABEL = "default" + + +def describe_api_error(exc: Exception) -> str: + """Human-readable cause of an SDK failure. + + The SDK raises aiohttp.ClientResponseError carrying the HTTP status and the API's own + detail message, which is what separates a 404 "No pool found" from a 400 "Unsupported + swap provider" from a genuine no-route. Anything else renders as its type + message. + """ + if isinstance(exc, aiohttp.ClientResponseError): + return f"HTTP {exc.status}: {exc.message}" + detail = str(exc).strip() + return detail or type(exc).__name__ + + +def get_slippage_pct(params: dict) -> Decimal | None: + """The user's slippage override as a Decimal, or None to use the connector's setting. + + None makes the SDK omit slippage_pct from the request. An explicit "0" is a real 0. + """ + slippage = params.get("slippage") + if slippage is None: + return None + return Decimal(str(slippage).rstrip("%").strip()) + + +def _slippage_button_label(params: dict) -> str: + """Slippage button text — the connector's own setting until the user overrides it.""" + slippage = params.get("slippage") + if slippage is None: + return f"📊 Slippage: {SLIPPAGE_DEFAULT_LABEL}" + return f"📊 {slippage}%" + + +def _last_swap_params( + connector: str, network: str, trading_pair: str, side: str, params: dict +) -> dict: + """Params to remember for next time — slippage only when the user actually set one.""" + remembered = { + "connector": connector, + "network": network, + "trading_pair": trading_pair, + "side": side, + } + if params.get("slippage") is not None: + remembered["slippage"] = params["slippage"] + return remembered + + +def _quote_errors(buy_result, sell_result) -> list[str]: + """One 'SIDE: cause' line for each of the two gathered quotes that raised.""" + return [ + f"{side}: {describe_api_error(result)}" + for side, result in (("BUY", buy_result), ("SELL", sell_result)) + if isinstance(result, BaseException) + ] + + +async def resolve_quote_denominated_amount( + client, + amount: Decimal, + connector: str, + network: str, + trading_pair: str, + slippage_pct: Decimal | None, +) -> Decimal: + """Convert a quote-denominated ($) amount into base token units. + + Prices off a 1-unit BUY quote. Raises with the real cause rather than falling back to the + raw number — treating "$100" as 100 base tokens would size the trade catastrophically wrong. + """ + try: + price_quote = await client.gateway_swap.get_swap_quote( + connector=connector, + network=network, + trading_pair=trading_pair, + side="BUY", + amount=Decimal("1"), + slippage_pct=slippage_pct, + ) + except Exception as e: + raise ValueError( + f"Cannot price {trading_pair} on {connector} to convert the $ amount " + f"({describe_api_error(e)})" + ) from e + + if not isinstance(price_quote, dict) or not price_quote.get("price"): + raise ValueError(f"Quote for {trading_pair} on {connector} carried no price") + + price = Decimal(str(price_quote["price"])) + if price <= 0: + raise ValueError( + f"Quote for {trading_pair} on {connector} priced it at {price}" + ) + + return amount / price + async def _fetch_recent_swaps(client, limit: int = 5) -> list: """Fetch recent swaps for display""" - try: - if not hasattr(client, "gateway_swap"): - return [] + if not hasattr(client, "gateway_swap"): + raise ValueError("Gateway swap not available on this server") - result = await client.gateway_swap.search_swaps(limit=limit) - return result.get("data", []) - except Exception as e: - logger.warning(f"Error fetching recent swaps: {e}") - return [] + result = await client.gateway_swap.search_swaps(limit=limit) + return result.get("data", []) def _format_compact_swap_line(swap: dict) -> str: @@ -72,7 +169,8 @@ def _format_compact_swap_line(swap: dict) -> str: Returns: "✅ SOL-USDC BUY @126.00 1.26 USDC 1d jup 🔗" """ pair = swap.get("trading_pair", "N/A") - side = swap.get("side", "?") + # Legacy rows written before hapi normalized casing may hold "buy"/"sell" + side = str(swap.get("side", "?")).upper() status = swap.get("status", "UNKNOWN") network = swap.get("network", "") tx_hash = swap.get("transaction_hash", "") @@ -85,16 +183,10 @@ def _format_compact_swap_line(swap: dict) -> str: price = swap.get("price") quote_token = swap.get("quote_token", "") - # Calculate display price (price of base in terms of quote) - # BUY: input=quote, output=base, price=base/quote -> display = 1/price - # SELL: input=base, output=quote, price=quote/base -> display = price + # Display price: hapi records price as quote-per-base for both sides price_display = "—" - if price is not None and price > 0: - if side == "BUY": - display_price = 1 / price - else: - display_price = price - price_display = f"@{format_compact_number(display_price)}" + if price is not None: + price_display = f"@{format_compact_number(price)}" # Quote amount (what was spent/received in quote token) # BUY: quote amount = input_amount (what we paid) @@ -108,9 +200,12 @@ def _format_compact_swap_line(swap: dict) -> str: # Relative time age = format_relative_time(timestamp) - # Status emoji (compact) + # Status emoji (compact) - hapi stores SUBMITTED for in-flight swaps + status_upper = str(status).upper() status_char = ( - "✅" if status == "CONFIRMED" else "âŗ" if status == "PENDING" else "❌" + "✅" + if status_upper == "CONFIRMED" + else "âŗ" if status_upper == "SUBMITTED" else "❌" ) # Build line: "✅ SOL-USDC BUY @126.00 1.26 USDC 1d jup 🔗" @@ -220,7 +315,7 @@ async def _fetch_quotes_background( network = params.get("network") trading_pair = params.get("trading_pair") amount = params.get("amount") - slippage = params.get("slippage", "1.0") + slippage_pct = get_slippage_pct(params) if not all([connector, network, trading_pair, amount]): return @@ -242,50 +337,41 @@ async def fetch_balances_safe(): # Handle $ prefix (quote-denominated amount) amount_str = str(amount) - is_quote_amount = amount_str.startswith("$") numeric_amount = Decimal(amount_str.lstrip("$").strip()) + if amount_str.startswith("$"): + numeric_amount = await resolve_quote_denominated_amount( + client, + numeric_amount, + connector, + network, + trading_pair, + slippage_pct, + ) - # If quote-denominated, first get price to convert - if is_quote_amount: - try: - # Get a quote for 1 unit to determine price - price_quote = await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side="BUY", - amount=Decimal("1"), - slippage_pct=Decimal(slippage), - ) - if price_quote and isinstance(price_quote, dict): - # Price is quote per base (e.g., USDC per SOL) - price = Decimal(str(price_quote.get("price", 1))) - if price > 0: - numeric_amount = numeric_amount / price - except Exception as e: - logger.warning(f"Could not get price for $ conversion: {e}") - # Fall back to using numeric value as-is - - async def get_quote_safe(side: str): - try: - return await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side=side, - amount=numeric_amount, - slippage_pct=Decimal(slippage), - ) - except Exception as e: - logger.warning(f"Background quote failed for {side}: {e}") - return None + async def get_quote(side: str): + return await client.gateway_swap.get_swap_quote( + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=numeric_amount, + slippage_pct=slippage_pct, + ) - # Fetch quotes and balances in parallel + # Fetch quotes and balances in parallel. return_exceptions keeps one side's failure + # from cancelling the other, and preserves the real cause for the caller. buy_result, sell_result, _ = await asyncio.gather( - get_quote_safe("BUY"), get_quote_safe("SELL"), fetch_balances_safe() + get_quote("BUY"), + get_quote("SELL"), + fetch_balances_safe(), + return_exceptions=True, ) - if buy_result is None and sell_result is None: + errors = _quote_errors(buy_result, sell_result) + for line in errors: + logger.warning(f"Background quote failed — {line}") + + if len(errors) == 2: return quote_data = { @@ -293,6 +379,7 @@ async def get_quote_safe(side: str): "amount": amount, "buy": buy_result if isinstance(buy_result, dict) else None, "sell": sell_result if isinstance(sell_result, dict) else None, + "errors": errors, } # Store quote in cache for display @@ -347,7 +434,7 @@ def _build_swap_keyboard(params: dict) -> list: f"💰 {params.get('amount', '1.0')}", callback_data="dex:swap_set_amount" ), InlineKeyboardButton( - f"📊 {params.get('slippage', '1.0')}%", + _slippage_button_label(params), callback_data="dex:swap_set_slippage", ), InlineKeyboardButton( @@ -457,6 +544,10 @@ def _build_swap_menu_text( spread_str = f"{spread_pct:.2f}%" help_text += f"📊 Spread: `{escape_markdown_v2(spread_str)}`\n" + # A side that failed says why, instead of silently missing from the quote + for line in quote_result.get("errors") or []: + help_text += "âš ī¸ " + escape_markdown_v2(line) + "\n" + help_text += "\n" # Type directly hint @@ -467,16 +558,15 @@ def _build_swap_menu_text( example_amount = escape_markdown_v2(str(params.get("amount", "1.0"))) help_text += f"*Ex:* `{example_pair} {example_side} {example_amount}`\n\n" - # Fetch and show recent swaps (compact format) - try: - swaps = get_cached(user_data, "recent_swaps", ttl=60) - if swaps: - help_text += r"━━━ Recent ━━━" + "\n" - for swap in swaps[:5]: - line = _format_compact_swap_line(swap) - help_text += line + "\n" - except Exception as e: - logger.warning(f"Could not get cached swaps: {e}") + # Show recent swaps (compact format), or why they could not be loaded + swaps = get_cached(user_data, "recent_swaps", ttl=60) + if swaps: + help_text += r"━━━ Recent ━━━" + "\n" + for swap in swaps[:5]: + help_text += _format_compact_swap_line(swap) + "\n" + elif user_data.get("recent_swaps_error"): + help_text += r"━━━ Recent ━━━" + "\n" + help_text += "âš ī¸ " + escape_markdown_v2(user_data["recent_swaps_error"]) + "\n" return help_text @@ -507,8 +597,11 @@ async def show_swap_menu( client = await get_client(chat_id, context=context) swaps = await _fetch_recent_swaps(client, limit=5) set_cached(context.user_data, "recent_swaps", swaps) + context.user_data.pop("recent_swaps_error", None) except Exception as e: - logger.warning(f"Could not fetch recent swaps: {e}") + cause = describe_api_error(e) + logger.warning(f"Could not fetch recent swaps: {cause}") + context.user_data["recent_swaps_error"] = cause # Use cached quote if available and no explicit quote_result provided if quote_result is None: @@ -923,7 +1016,8 @@ async def handle_swap_set_slippage( r"Enter slippage %:" + "\n\n" r"*Examples:*" + "\n" r"`1\.0` \- 1%" + "\n" - r"`2\.5` \- 2\.5%" + r"`2\.5` \- 2\.5%" + "\n" + r"`default` \- use the connector's own setting" ) keyboard = [[InlineKeyboardButton("ÂĢ Back", callback_data="dex:swap")]] @@ -954,7 +1048,7 @@ async def handle_swap_get_quote( network = params.get("network") trading_pair = params.get("trading_pair") amount = params.get("amount") - slippage = params.get("slippage", "1.0") + slippage_pct = get_slippage_pct(params) if not all([connector, network, trading_pair, amount]): raise ValueError("Missing required parameters") @@ -976,50 +1070,45 @@ async def fetch_balances_safe(): # Handle $ prefix (quote-denominated amount) amount_str = str(amount) - is_quote_amount = amount_str.startswith("$") numeric_amount = Decimal(amount_str.lstrip("$").strip()) - - # If quote-denominated, first get price to convert - if is_quote_amount: - try: - # Get a quote for 1 unit to determine price - price_quote = await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side="BUY", - amount=Decimal("1"), - slippage_pct=Decimal(slippage), - ) - if price_quote and isinstance(price_quote, dict): - price = Decimal(str(price_quote.get("price", 1))) - if price > 0: - numeric_amount = numeric_amount / price - except Exception as e: - logger.warning(f"Could not get price for $ conversion: {e}") + if amount_str.startswith("$"): + numeric_amount = await resolve_quote_denominated_amount( + client, + numeric_amount, + connector, + network, + trading_pair, + slippage_pct, + ) # Fetch BUY and SELL quotes in parallel - async def get_quote_safe(side: str): - try: - return await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side=side, - amount=numeric_amount, - slippage_pct=Decimal(slippage), - ) - except Exception as e: - logger.warning(f"Quote failed for {side}: {e}") - return None + async def get_quote(side: str): + return await client.gateway_swap.get_swap_quote( + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=numeric_amount, + slippage_pct=slippage_pct, + ) - # Fetch quotes and balances in parallel + # Fetch quotes and balances in parallel. return_exceptions keeps one side's failure + # from cancelling the other, and preserves the real cause for the caller. buy_result, sell_result, _ = await asyncio.gather( - get_quote_safe("BUY"), get_quote_safe("SELL"), fetch_balances_safe() + get_quote("BUY"), + get_quote("SELL"), + fetch_balances_safe(), + return_exceptions=True, ) - if buy_result is None and sell_result is None: - raise ValueError("No quotes available for this pair") + errors = _quote_errors(buy_result, sell_result) + if len(errors) == 2: + # Both sides failed — report what Gateway actually said, not "no quotes" + raise ValueError( + f"No quote for {trading_pair} on {connector} — " + "; ".join(errors) + ) + for line in errors: + logger.warning(f"Quote failed — {line}") # Build combined quote result quote_data = { @@ -1027,6 +1116,7 @@ async def get_quote_safe(side: str): "amount": amount, "buy": buy_result if isinstance(buy_result, dict) else None, "sell": sell_result if isinstance(sell_result, dict) else None, + "errors": errors, } # Cache the quote @@ -1035,13 +1125,9 @@ async def get_quote_safe(side: str): # Save params set_dex_last_swap( context.user_data, - { - "connector": connector, - "network": network, - "trading_pair": trading_pair, - "side": params.get("side", "BUY"), - "slippage": slippage, - }, + _last_swap_params( + connector, network, trading_pair, params.get("side", "BUY"), params + ), ) # Show menu with quote result inline (disable auto_quote since we already have it) @@ -1049,7 +1135,7 @@ async def get_quote_safe(side: str): except Exception as e: logger.error(f"Error getting quote: {e}", exc_info=True) - error_message = format_error_message(f"Quote failed: {str(e)}") + error_message = format_error_message(f"Quote failed: {describe_api_error(e)}") await update.callback_query.message.edit_text( error_message, parse_mode="MarkdownV2" ) @@ -1067,7 +1153,7 @@ async def handle_swap_execute_confirm( trading_pair = params.get("trading_pair") side = params.get("side") amount = params.get("amount") - slippage = params.get("slippage", "1.0") + slippage_pct = get_slippage_pct(params) if not all([connector, network, trading_pair, side, amount]): raise ValueError("Missing required parameters") @@ -1081,11 +1167,14 @@ async def handle_swap_execute_confirm( if numeric_amount <= 0: raise ValueError("Amount must be greater than 0") - # Validate slippage > 0 - slippage_str = str(slippage).rstrip("%").strip() - slippage_val = Decimal(slippage_str) - if slippage_val <= 0: - raise ValueError("Slippage must be greater than 0%") + # An unset slippage is legitimate (the connector's own setting applies); an explicit + # 0 is a real 0. Only a negative value is nonsense. + if slippage_pct is not None and slippage_pct < 0: + raise ValueError("Slippage cannot be negative") + + slippage_display = ( + SLIPPAGE_DEFAULT_LABEL if slippage_pct is None else f"{slippage_pct}%" + ) # Show loading state immediately to prevent duplicate actions loading_text = escape_markdown_v2( @@ -1093,7 +1182,7 @@ async def handle_swap_execute_confirm( f"Pair: {trading_pair}\n" f"Side: {side}\n" f"Amount: {amount}\n" - f"Slippage: {slippage}%\n\n" + f"Slippage: {slippage_display}\n\n" f"Please wait..." ) await update.callback_query.message.edit_text( @@ -1108,31 +1197,28 @@ async def handle_swap_execute_confirm( # If quote-denominated, convert to base amount if is_quote_amount: - try: - # Get a quote for 1 unit to determine price - price_quote = await client.gateway_swap.get_swap_quote( - connector=connector, - network=network, - trading_pair=trading_pair, - side="BUY", - amount=Decimal("1"), - slippage_pct=Decimal(slippage_str), - ) - if price_quote and isinstance(price_quote, dict): - price = Decimal(str(price_quote.get("price", 1))) - if price > 0: - numeric_amount = numeric_amount / price - except Exception as e: - logger.warning(f"Could not get price for $ conversion: {e}") + numeric_amount = await resolve_quote_denominated_amount( + client, + numeric_amount, + connector, + network, + trading_pair, + slippage_pct, + ) - result = await client.gateway_swap.execute_swap( - connector=connector, - network=network, - trading_pair=trading_pair, - side=side, - amount=numeric_amount, - slippage_pct=Decimal(slippage_str), - ) + try: + result = await client.gateway_swap.execute_swap( + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=numeric_amount, + slippage_pct=slippage_pct, + ) + except Exception as e: + raise ValueError( + f"{side} {trading_pair} on {connector} rejected — {describe_api_error(e)}" + ) from e if result is None: raise ValueError("Swap execution failed") @@ -1146,13 +1232,7 @@ async def handle_swap_execute_confirm( # Save params set_dex_last_swap( context.user_data, - { - "connector": connector, - "network": network, - "trading_pair": trading_pair, - "side": side, - "slippage": slippage, - }, + _last_swap_params(connector, network, trading_pair, side, params), ) # Build success message @@ -1162,12 +1242,12 @@ async def handle_swap_execute_confirm( f"Pair: {trading_pair}\n" f"Side: {side}\n" f"Amount: {display_amount}\n" - f"Slippage: {slippage}%" + f"Slippage: {slippage_display}" ) if isinstance(result, dict): - if "tx_hash" in result: - tx_short = result["tx_hash"][:16] + "..." + if result.get("transaction_hash"): + tx_short = result["transaction_hash"][:16] + "..." swap_info += escape_markdown_v2(f"\nTx: {tx_short}") if "status" in result: swap_info += escape_markdown_v2(f"\nStatus: {result['status']}") @@ -1181,7 +1261,7 @@ async def handle_swap_execute_confirm( except Exception as e: logger.error(f"Error executing swap: {e}", exc_info=True) - error_message = format_error_message(f"Swap failed: {str(e)}") + error_message = format_error_message(f"Swap failed: {describe_api_error(e)}") keyboard = [[InlineKeyboardButton("ÂĢ Back to Swap", callback_data="dex:swap")]] reply_markup = InlineKeyboardMarkup(keyboard) await update.callback_query.message.edit_text( @@ -1243,7 +1323,9 @@ async def process_swap_status( except Exception as e: logger.error(f"Error getting status: {e}", exc_info=True) - error_message = format_error_message(f"Failed to get status: {str(e)}") + error_message = format_error_message( + f"Failed to get status: {describe_api_error(e)}" + ) await update.message.reply_text(error_message, parse_mode="MarkdownV2") @@ -1260,16 +1342,20 @@ def _format_detailed_swap_line(swap: dict) -> str: Returns formatted line (not escaped) """ pair = swap.get("trading_pair", "N/A") - side = swap.get("side", "?") + # Legacy rows written before hapi normalized casing may hold "buy"/"sell" + side = str(swap.get("side", "?")).upper() status = swap.get("status", "UNKNOWN") network = swap.get("network", "") tx_hash = swap.get("transaction_hash", "") connector = swap.get("connector", "") timestamp = swap.get("timestamp", "") - # Status emoji + # Status emoji - hapi stores SUBMITTED for in-flight swaps + status_upper = str(status).upper() status_emoji = ( - "✅" if status == "CONFIRMED" else "âŗ" if status == "PENDING" else "❌" + "✅" + if status_upper == "CONFIRMED" + else "âŗ" if status_upper == "SUBMITTED" else "❌" ) # Get amounts and tokens @@ -1296,14 +1382,10 @@ def _format_detailed_swap_line(swap: dict) -> str: f" Out: {format_compact_number(output_amount)} {quote_token}" ) - # Price - if price is not None and price > 0: - if side == "BUY": - display_price = 1 / price - else: - display_price = price + # Price: hapi records price as quote-per-base for both sides + if price is not None: lines.append( - f" Price: {format_compact_number(display_price, 4)} {quote_token}/{base_token}" + f" Price: {format_compact_number(price, 4)} {quote_token}/{base_token}" ) # Metadata @@ -1355,10 +1437,13 @@ async def handle_swap_history( result = await client.gateway_swap.search_swaps(**search_params) swaps = result.get("data", []) pagination = result.get("pagination", {}) - total_count = pagination.get("total_count", len(swaps)) + # total_count is null while more rows remain; has_more is what tells us + # whether there is a next page. + total_count = pagination.get("total_count") - # Update filters with total count + # Update filters with pagination state filters.total_count = total_count + filters.has_more = bool(pagination.get("has_more")) set_history_filters(context.user_data, filters) if not swaps and filters.offset == 0: @@ -1402,7 +1487,10 @@ async def handle_swap_history( filter_summary = "" message = rf"🔍 *Swap History*{filter_summary}" + "\n" - message += rf"_Showing {len(swaps)} of {total_count}_" + "\n\n" + if total_count is None: + message += rf"_Showing {len(swaps)}, more available_" + "\n\n" + else: + message += rf"_Showing {len(swaps)} of {total_count}_" + "\n\n" for swap in swaps: line = _format_detailed_swap_line(swap) @@ -1425,7 +1513,7 @@ async def handle_swap_history( keyboard = build_filter_buttons(filters, "dex:swap_hist") # Pagination row - if total_count > filters.limit: + if filters.has_next or filters.has_prev: keyboard.append(build_pagination_buttons(filters, "dex:swap_hist")) # Action buttons - use swap_refresh to ensure fresh data when going back @@ -1449,7 +1537,9 @@ async def handle_swap_history( except Exception as e: logger.error(f"Error fetching history: {e}", exc_info=True) - error_message = format_error_message(f"Failed to fetch history: {str(e)}") + error_message = format_error_message( + f"Failed to fetch history: {describe_api_error(e)}" + ) await update.callback_query.message.edit_text( error_message, parse_mode="MarkdownV2" ) @@ -1576,21 +1666,22 @@ async def process_swap( trading_pair = parts[0] side = parts[1].upper() amount = parts[2] - slippage = parts[3] if len(parts) > 3 else "1.0" # Get connector/network from defaults network = DEFAULT_DEX_NETWORK connector = get_dex_connector(context.user_data, network) - # Update params - context.user_data["swap_params"] = { + # Update params. Slippage stays unset unless typed — the connector's setting applies. + swap_params = { "connector": connector, "network": network, "trading_pair": trading_pair, "side": side, "amount": amount, - "slippage": slippage, } + if len(parts) > 3: + swap_params["slippage"] = parts[3].rstrip("%").strip() + context.user_data["swap_params"] = swap_params context.user_data["dex_state"] = "swap" @@ -1657,17 +1748,18 @@ async def process_swap_set_amount( async def process_swap_set_slippage( update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str ) -> None: - """Process slippage input""" + """Process slippage input. `default` clears the override so the connector's setting applies.""" try: + params = context.user_data.get("swap_params", {}) slippage_str = user_input.strip().rstrip("%").strip() - # Validate - slippage_val = Decimal(slippage_str) - if slippage_val <= 0: - raise ValueError("Slippage must be > 0%") - - params = context.user_data.get("swap_params", {}) - params["slippage"] = slippage_str + if slippage_str.lower() == SLIPPAGE_DEFAULT_LABEL: + params.pop("slippage", None) + else: + # An explicit 0 is a real 0 (no tolerance), not "use the default" + if Decimal(slippage_str) < 0: + raise ValueError("Slippage cannot be negative") + params["slippage"] = slippage_str context.user_data["dex_state"] = "swap" diff --git a/handlers/executors/menu.py b/handlers/executors/menu.py index 7fc43f432..a28fa373b 100644 --- a/handlers/executors/menu.py +++ b/handlers/executors/menu.py @@ -892,7 +892,7 @@ async def handle_confirm_stop_executor( full_id = ex_id break try: - await stop_executor(client, full_id, keep_position=False) + stop_result = await stop_executor(client, full_id, keep_position=False) finally: # Either way the cached view is stale: a stop that failed to answer # may still have gone through upstream. @@ -908,8 +908,25 @@ async def handle_confirm_stop_executor( keyboard = [ [InlineKeyboardButton("📋 Back to List", callback_data="executors:menu")] ] + if (stop_result or {}).get("status") == "already_terminated": + # No-op stop: the executor was already terminal. A generic "stopped" + # would hide the one payload that matters — an orphaned position. + text = ( + f"â„šī¸ *Executor Already Terminated*\n\n" + f"🆔 `{escape_markdown_v2(full_id[:30])}`\n" + f"Close type: `{escape_markdown_v2(str(stop_result.get('close_type')))}`" + ) + if stop_result.get("orphaned_position"): + text += ( + f"\n\n🚨 *Orphaned position still open on\\-chain:*\n" + f"`{escape_markdown_v2(str(stop_result.get('position_address')))}`\n" + "Stopping did not close it — the executor had already terminated\\. " + "Ask the agent to close it with `manage_clmm`, then mark it recovered\\." + ) + else: + text = f"✅ *Executor Stopped*\n\n🆔 `{escape_markdown_v2(full_id[:30])}`" await query.message.edit_text( - f"✅ *Executor Stopped*\n\n🆔 `{escape_markdown_v2(full_id[:30])}`", + text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard), ) diff --git a/main.py b/main.py index 22d6a609e..a1acdedb2 100644 --- a/main.py +++ b/main.py @@ -213,61 +213,72 @@ async def start_callback_handler( await _show_admin_menu(query, context) +# Modules the handlers import from that do not live under ``handlers/``, and +# that are safe to re-execute. Everything under ``handlers/`` itself is +# discovered by :func:`_discover_handler_modules`. +# +# ``routines/`` is deliberately absent: routines have their own mtime-aware +# discovery in ``routines.base.discover_routines(force_reload=True)``, which +# owns reimporting individual routine modules. Only the base module is listed, +# so that machinery itself stays fresh. +_EXTRA_RELOAD_MODULES = ( + "config_manager", + "utils.auth", + "utils.telegram_formatters", + "routines.base", +) + + +def _discover_handler_modules() -> list[str]: + """Every module under ``handlers/``, children before parents. + + Derived rather than hand-listed. A hand-maintained list drifts, and it + drifts *silently*: ``reload_handlers`` skips any name not in + ``sys.modules``, so a stale entry is a no-op and a missing entry means the + watcher logs a successful reload while the running bot keeps the old code. + That is worse than no hot-reload, because it reports success. The list this + replaced named three ``handlers.dex.swap_*`` modules that no longer exist + and omitted six that do, including ``handlers.dex.router``. + + Naming every module matters because ``importlib.reload`` is NOT recursive: + reloading ``handlers.dex`` re-executes its ``__init__``, but its + ``from .router import ...`` re-binds the *cached* ``handlers.dex.router``, + so an edit there is missed unless that module is reloaded in its own right. + + For the same reason children sort before parents: by the time a package's + ``__init__`` re-executes, the submodules it imports from have already been + refreshed. + """ + root = Path(__file__).parent / "handlers" + modules: set[str] = set() + for path in root.rglob("*.py"): + parts = path.relative_to(root.parent).with_suffix("").parts + # Per-agent runtime stores can sit under a watched tree (FEAT-003) and + # are data, not code — the file watcher skips them for the same reason. + if "__pycache__" in parts or "store" in parts: + continue + if parts[-1] == "__init__": + parts = parts[:-1] + if parts: + modules.add(".".join(parts)) + return sorted(modules, key=lambda name: (-name.count("."), name)) + + def reload_handlers(): """Reload all handler modules.""" - modules_to_reload = [ - "handlers.portfolio", - "handlers.bots", - "handlers.bots.menu", - "handlers.bots.controllers", - "handlers.bots._shared", - "handlers.executors", - "handlers.executors.menu", - "handlers.executors.grid", - "handlers.executors.position", - "handlers.executors._shared", - "handlers.trading", - "handlers.trading.router", - "handlers.cex", - "handlers.cex.menu", - "handlers.cex.trade", - "handlers.cex.orders", - "handlers.cex.positions", - "handlers.cex._shared", - "handlers.dex", - "handlers.dex.menu", - "handlers.dex.swap_quote", - "handlers.dex.swap_execute", - "handlers.dex.swap_history", - "handlers.dex.pools", - "handlers.dex._shared", - "handlers.config", - "handlers.config.servers", - "handlers.config.api_keys", - "handlers.config.gateway", - "handlers.config.user_preferences", - "routines.base", - "handlers.routines", - "handlers.agents", - "handlers.agents.menu", - # NOTE: no "condor.runtime.*" module belongs in this list. The runtime - # holds live subprocess handles (agent sessions); re-executing those - # modules resets the registry and silently orphans every running agent. - "handlers.agents.stream", - "handlers.agents.confirmation", - "handlers.agents._shared", - "handlers.memory", - "handlers.admin", - "handlers.admin.update", - "utils.auth", - "utils.telegram_formatters", - "config_manager", - ] + # NOTE: no "condor.runtime.*" module belongs here. The runtime holds live + # subprocess handles (agent sessions); re-executing those modules resets + # the registry and silently orphans every running agent. The discovery + # above only walks handlers/, so it cannot pull them in. + modules_to_reload = [*_EXTRA_RELOAD_MODULES, *_discover_handler_modules()] + reloaded = 0 for module_name in modules_to_reload: if module_name in sys.modules: importlib.reload(sys.modules[module_name]) - logger.info(f"Reloaded module: {module_name}") + logger.debug(f"Reloaded module: {module_name}") + reloaded += 1 + logger.info(f"Reloaded {reloaded} modules") # Re-register fetch functions after reload (preserves in-memory cache) try: diff --git a/mcp_servers/hummingbot_api/formatters/__init__.py b/mcp_servers/hummingbot_api/formatters/__init__.py index 1a83936f5..582fd3bb4 100644 --- a/mcp_servers/hummingbot_api/formatters/__init__.py +++ b/mcp_servers/hummingbot_api/formatters/__init__.py @@ -49,6 +49,7 @@ # Gateway formatters from .gateway import ( format_amm_result, + format_clmm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index 230e30507..03662a24b 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -196,19 +196,184 @@ def format_amm_result(action: str, result: dict[str, Any]) -> str: ) return "\n".join(lines) - if action in ("quote_swap", "quote_liquidity") and isinstance(payload, dict): + if action == "quote_liquidity" and isinstance(payload, dict): return f"{header}\n{payload}" if action in ( - "execute_swap", "add_liquidity", "remove_liquidity", "create_pool", ) and isinstance(payload, dict): - sig = payload.get("signature") - extra = "" + # AMM write responses are chain-neutral: the tx identifier is + # `signature` (AMMTransactionResponse / AMMCreatePoolResponse), not + # `transaction_hash` as on the CLMM surface. + lines = [ + header, + f"Tx: {payload.get('signature')} Status: {payload.get('status')}", + ] if action == "create_pool": - extra = f"\nPool: {payload.get('pool_address')} Seed price: {payload.get('price')}" - return f"{header}\nSignature/Tx: {sig} Status: {payload.get('status')}{extra}" + lines.append( + f"Pool: {payload.get('pool_address')} Seed price: {payload.get('price')}" + ) + return "\n".join(lines) + + # What the transaction actually moved. hummingbot-api passes Gateway's `data` + # through untyped on this surface, so these keys are camelCase — the CLMM + # branches read snake_case because those responses are typed models. Printing + # neither, which is what this did, told the caller a transaction confirmed and + # nothing about the money: a close moving 3188 base and 0.0219 quote reported + # both as nothing. + added = action == "add_liquidity" + data = payload.get("data") or {} + # Only when there is one: a fungible-LP pool has no position address, and + # "Position: None" reads as a missing value rather than an absent concept. + if result.get("position_address"): + lines.insert(1, f"Position: {result['position_address']}") + base = data.get("baseTokenAmountAdded" if added else "baseTokenAmountRemoved") + quote = data.get( + "quoteTokenAmountAdded" if added else "quoteTokenAmountRemoved" + ) + if base is not None or quote is not None: + lines.append( + f"Liquidity {'deposited' if added else 'withdrawn'} — base: {base} quote: {quote}" + ) + # Rent is a third kind of money, as on the CLMM close: locked when the position + # account is created, returned when it closes, never income and never principal. + # A partial removal closes nothing, so its absence there is a fact, not a gap. + rent = data.get("positionRent" if added else "positionRentRefunded") + if rent is not None: + lines.append(f"Position rent {'locked' if added else 'refunded'}: {rent}") + fee = data.get("fee") + if fee is not None: + lines.append(f"Gas: {fee}") + return "\n".join(lines) + + return f"{header}\n{payload}" + + +def format_clmm_result(action: str, result: dict[str, Any]) -> str: + """Format manage_clmm results into a human-readable string.""" + # Progressive disclosure: the guide is returned directly. + if action is None or result.get("action") is None: + return result.get("formatted_output", str(result)) + + connector = result.get("connector", "") + network = result.get("network", "") + payload = result.get("result", {}) + header = f"CLMM {action} [{connector} ¡ {network}]" + + if action == "position_info" and isinstance(payload, list): + if not payload: + return f"{header}\nNo open positions for this wallet on this connector." + # Say which question was answered: one named position, or the whole wallet. + asked_for_one = bool(result.get("position_address")) + lines = [ + f"{header}", + ( + "1 position" + if asked_for_one + else f"{len(payload)} position(s) owned by this wallet" + ), + ] + for p in payload: + lines.append( + f" â€ĸ {p.get('position_address')} — Pool: {p.get('pool_address')}" + ) + # in_range is why a position earns or does not: outside its range it holds + # one token and accrues nothing, which is what the fees below will show. + in_range = p.get("in_range") + lines.append( + f" Base: {p.get('base_token_amount')} " + f"Quote: {p.get('quote_token_amount')} " + f"Range: {p.get('lower_price')}–{p.get('upper_price')} " + f"Price: {p.get('current_price')}" + + ( + "" + if in_range is None + else f" [{'in range' if in_range else 'OUT OF RANGE'}]" + ) + ) + lines.append( + f" Uncollected fees — base: {p.get('base_fee_amount')} " + f"quote: {p.get('quote_fee_amount')}" + ) + return "\n".join(lines) + + if action == "open" and isinstance(payload, dict): + # A submitted-not-confirmed open has position_address=None: the address + # is unknowable until the tx lands, so say that instead of "None". + position = ( + payload.get("position_address") + or "pending — known once the transaction confirms" + ) + return ( + f"{header}\n" + f"Position: {position}\n" + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}\n" + f"Range: {payload.get('lower_price')}–{payload.get('upper_price')}\n" + "This position is NOT tracked by any executor — it will not be range-monitored, " + "rebalanced, or auto-closed." + ) + + if action in ("close", "collect_fees") and isinstance(payload, dict): + lines = [ + header, + f"Position: {payload.get('position_address')}", + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}", + f"Fees collected — base: {payload.get('base_fee_collected')} " + f"quote: {payload.get('quote_fee_collected')}", + ] + # A close returns the liquidity and the account's rent as well as the fees, and + # the three are different kinds of money: fees are income, the removed amounts + # are principal coming back, and the rent was never either — it was locked at + # open and is returned when the account closes. Showing only the fees made a + # close look like it recovered almost nothing. + removed_base = payload.get("base_token_amount_removed") + removed_quote = payload.get("quote_token_amount_removed") + if removed_base is not None or removed_quote is not None: + lines.append( + f"Liquidity withdrawn — base: {removed_base} quote: {removed_quote}" + ) + rent = payload.get("position_rent_refunded") + if rent is not None: + lines.append(f"Position rent refunded: {rent}") + return "\n".join(lines) + + if action in ("add_liquidity", "remove_liquidity") and isinstance(payload, dict): + added = action == "add_liquidity" + lines = [header] + if payload.get("position_address"): + lines.append(f"Position: {payload['position_address']}") + lines.append( + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}" + ) + # The amounts are the point of the call and were never printed: a removal that + # moved thousands of base tokens reported only that a transaction confirmed. + # These are flat and snake_case here — /clmm/add and /clmm/remove return a plain + # dict, unlike the AMM writes above, which pass Gateway's camelCase `data` + # through untouched. + base = payload.get( + "base_token_amount_added" if added else "base_token_amount_removed" + ) + quote = payload.get( + "quote_token_amount_added" if added else "quote_token_amount_removed" + ) + if base is not None or quote is not None: + lines.append( + f"Liquidity {'deposited' if added else 'withdrawn'} — base: {base} quote: {quote}" + ) + gas = payload.get("gas_fee") + if gas is not None: + lines.append(f"Gas: {gas}") + return "\n".join(lines) + + if action == "create_pool" and isinstance(payload, dict): + return ( + f"{header}\n" + f"Pool: {payload.get('pool_address')}\n" + f"Tx: {payload.get('transaction_hash')} Status: {payload.get('status')}\n" + "The pool is created empty — add liquidity by opening a position " + "(action='open' or manage_executors lp_executor)." + ) return f"{header}\n{payload}" diff --git a/mcp_servers/hummingbot_api/guides/gateway_amm.md b/mcp_servers/hummingbot_api/guides/gateway_amm.md index 6200c6a11..1e49842f7 100644 --- a/mcp_servers/hummingbot_api/guides/gateway_amm.md +++ b/mcp_servers/hummingbot_api/guides/gateway_amm.md @@ -19,15 +19,13 @@ through `manage_executors(order_executor)`; CLMM LP goes through `manage_executo | `pool_info` | read | `pool_address` | | `position_info` | read | `pool_address` (returns aggregate + `positions[]`) | | `positions_owned` | read | — (**meteora only**; fungible-LP → error) | -| `quote_swap` | read | `pool_address`, `base_token`, `side`, `amount` | -| `execute_swap` | write | `pool_address`, `base_token`, `side`, `amount` | | `quote_liquidity` | read | `pool_address`, `base_token_amount`, `quote_token_amount` | | `add_liquidity` | write | `pool_address`, `base_token_amount`, `quote_token_amount` (+ optional `position_address`) | | `remove_liquidity` | write | `pool_address`, `percentage_to_remove` (+ **`position_address` required for meteora**) | | `create_pool` | write | `base_token`, `quote_token`, `base_token_amount` (+ connector extra) | -`quote_swap`/`execute_swap` are **pool-scoped** (they trade against the specific `pool_address`), not -a router. Use `explore_dex_pools`/`pool_info` to find a pool. +Swaps are not part of this tool: the unified swap route handles every connector type +(pass connector as `name/type`, e.g. `raydium/amm`) — via the order executor for trading. ## Meteora DAMM v2 position model (important) DAMM v2 positions are **NFTs**: a wallet may hold **several positions in one pool**, each an @@ -51,12 +49,14 @@ Seed price priority: `initial_price` (quote per base) → `quote_token_amount` r price** (fetched from the swap router so the pool opens on-market and bots can't arb your seed). Only `base_token_amount` is required. -Per-connector `create_pool` extras (only the owning connector consumes them): -- **meteora**: `config_address` (**required**) — the DAMM v2 config account that fixes the fee - schedule. Many configs are token-launch configs whose base fee starts near **99%** and decays; - pick a static-fee config you actually want. Token order: `base_token` → token A, `quote_token` → token B. -- **raydium**: `fee_config_index` (optional; defaults to the first available config). -- **uniswap**: `gas_price` / `max_gas` (optional; fee is fixed at 0.30%). +Per-connector `create_pool` extras ride `extra_params` under Gateway's own names (only the +owning connector consumes them; unknown keys are rejected): +- **meteora**: `extra_params={"configAddress": ...}` (**required**) — the DAMM v2 config account + that fixes the fee schedule. Many configs are token-launch configs whose base fee starts near + **99%** and decays; pick a static-fee config you actually want. Token order: `base_token` → + token A, `quote_token` → token B. +- **raydium**: `ammConfigIndex` (optional; defaults to the first available config). +- **uniswap** (EVM): no extra params (fee is fixed at 0.30%). ## Examples - Load this guide: `manage_amm()` @@ -65,4 +65,4 @@ Per-connector `create_pool` extras (only the owning connector consumes them): - Open a new Meteora position: `manage_amm(action="add_liquidity", connector="meteora", network="solana-mainnet-beta", pool_address="â€Ļ", base_token_amount="1", quote_token_amount="2")` (omit `position_address`) - Exit a specific position: `manage_amm(action="remove_liquidity", connector="meteora", network="solana-mainnet-beta", pool_address="â€Ļ", position_address="â€Ļ", percentage_to_remove="100")` - Create a Raydium CPMM pool (market-seeded): `manage_amm(action="create_pool", connector="raydium", network="solana-mainnet-beta", base_token="SOL", quote_token="USDC", base_token_amount="1")` -- Create a Meteora DAMM v2 pool: `manage_amm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="â€Ļ", quote_token="USDC", base_token_amount="1", config_address="â€Ļ")` +- Create a Meteora DAMM v2 pool: `manage_amm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="â€Ļ", quote_token="USDC", base_token_amount="1", extra_params={"configAddress": "â€Ļ"})` diff --git a/mcp_servers/hummingbot_api/guides/gateway_clmm.md b/mcp_servers/hummingbot_api/guides/gateway_clmm.md new file mode 100644 index 000000000..7a4c8956b --- /dev/null +++ b/mcp_servers/hummingbot_api/guides/gateway_clmm.md @@ -0,0 +1,81 @@ +# manage_clmm — direct concentrated-liquidity positions + +Direct, chain- and DEX-agnostic CLMM position control. Stateless: you hold position state in your +journal. + +**This is the unmanaged path.** For normal LP work use `manage_executors` with `lp_executor` — it +owns range monitoring, rebalancing, and bounded close retries. Reach for `manage_clmm` when there is +no executor to do that for you: + +- **Recovering an orphaned position** — the main reason this tool exists. Once an executor has + terminated it cannot be told to close anything; `manage_executors(action="stop")` on it correctly + returns a no-op. The position has to be closed by address. +- Inspecting a position's live amounts and uncollected fees. +- Collecting fees without disturbing the position. + +## Connectors + +| Connector | Chain | Pools | +|---|---|---| +| `meteora` | Solana | DLMM | +| `raydium` | Solana | CLMM | +| `orca` | Solana | Whirlpools | +| `pancakeswap-sol` | Solana | CLMM | +| `uniswap` | EVM | V3 | +| `pancakeswap` | EVM | V3 | + +A `/clmm` form is accepted too, so an orphan record's `lp_provider` (e.g. `orca/clmm`) can be +passed straight through. + +## Actions + +| Action | Required | Notes | +|---|---|---| +| `position_info` | — | Lists ALL the wallet's positions on the connector (each row names its pool) with amounts, range, and uncollected fees | +| `open` | `pool_address`, `lower_price`, `upper_price`, and at least one amount | Creates a position no executor tracks | +| `add_liquidity` | `position_address`, at least one amount | Keeps the existing range | +| `remove_liquidity` | `position_address`, `percentage_to_remove` | Partial withdrawal; the position account survives even at 100 | +| `close` | `position_address` | Withdraws everything, collects fees, closes the account | +| `collect_fees` | `position_address` | Fees only; position untouched | +| `create_pool` | `base_token`, `quote_token` | Creates a new EMPTY pool; `initial_price` optional (market price fetched when omitted) | + +`remove_liquidity` at 100% and `close` are not the same thing: the former leaves an empty position +open, the latter closes the account. To recover an orphan, use `close`. + +## create_pool + +Creates a new (empty) CLMM pool — liquidity is added afterwards by opening positions. `initial_price` +is quote per base and optional: when omitted, the API fetches the live market price. Connector-specific +params ride `extra_params` under Gateway's own names (unknown keys are rejected with a 400): + +- **meteora / orca**: `binStep` +- **meteora / uniswap / pancakeswap**: `feeBps` +- **raydium / pancakeswap-sol**: `ammConfigIndex` + +Example: `manage_clmm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="SOL", quote_token="USDC", extra_params={"binStep": 20, "feeBps": 20})` + +## pool_address on close and collect_fees + +`pool_address` is optional and informational on both. Gateway needs only `position_address` to +close or collect, and the API's pre-close fee snapshot (positions-owned, which takes no pool +filter) does not use it either — so a position the API never recorded, such as every +`lp_executor` position, closes fine without one. + +## Recovering an orphaned position + +1. `manage_executors(action="orphaned")` — each entry carries `lp_provider`, `pool_address`, + `position_address`, and `connector_name` (which for an LP executor holds the *network*, e.g. + `solana-mainnet-beta`, not the DEX). +2. Confirm it is really still on-chain (lists every position the wallet owns; match by + `position_address` / `pool_address`): + `manage_clmm(action="position_info", connector=, network=)` +3. Close it: + `manage_clmm(action="close", connector=, network=, position_address=)` +4. `manage_executors(action="resolve_orphan", executor_id="...")` so it stops being reported. + +If an entry has `needs_onchain_reconciliation: true` its position address was never persisted (an +API restart). Find it with `get_portfolio_overview(include_lp_positions=True)` or `position_info` +before closing anything. + +Do not open a replacement position until the orphan is closed — a fresh `lp_executor` always mints a +new position and cannot adopt an existing one, so you would end up funding two. diff --git a/mcp_servers/hummingbot_api/guides/lp_executor.md b/mcp_servers/hummingbot_api/guides/lp_executor.md index ed2064c57..9ceff30b5 100644 --- a/mcp_servers/hummingbot_api/guides/lp_executor.md +++ b/mcp_servers/hummingbot_api/guides/lp_executor.md @@ -51,7 +51,7 @@ NOT_ACTIVE → OPENING → IN_RANGE ↔ OUT_OF_RANGE → CLOSING → COMPLETE ↘ SWAPPING → COMPLETE ``` -Any state may transition to `FAILED` if max retries are exhausted (open / close / swap) or if `early_stop` is called while still `OPENING`. +Any state may transition to `FAILED` if max retries are exhausted with nothing left on-chain (open / swap) or if `early_stop` is called while still `OPENING`. An exhausted **close** instead terminates as an involuntary `POSITION_HOLD` (`hold_reason: "close_retries_exhausted"`) because the position is still live on-chain. - **NOT_ACTIVE**: Initial state, no position yet - **OPENING**: `add_liquidity` submitted, waiting for confirmation @@ -60,7 +60,7 @@ Any state may transition to `FAILED` if max retries are exhausted (open / close - **CLOSING**: `remove_liquidity` submitted, waiting for confirmation - **SWAPPING**: Close-out swap in progress (only when `keep_position=False`, to return to the original quote asset) - **COMPLETE**: Position closed permanently -- **FAILED**: Max retries reached or invalid config; manual intervention required +- **FAILED**: Max retries reached with nothing left on-chain, or invalid config. A failed close retries in `CLOSING` with fresh on-chain state (bounded by `max_retries`); if the retries run out with the position still live, the executor terminates as an involuntary `POSITION_HOLD` with `hold_reason: "close_retries_exhausted"` and the record is flagged `orphaned_position` in `custom_info` — recover it (see below) before opening new positions #### Key Parameters @@ -82,6 +82,16 @@ Any state may transition to `FAILED` if max retries are exhausted (open / close - `extra_params`: Connector-specific params, e.g., `{"strategyType": 0}` for Meteora - `keep_position`: Default `True` — keep the net token change as a held spot position when closed. Set `False` to swap back to the original quote asset on close. +**Slippage (a ramp, not a single value):** +- `slippage_pct`: What the *first* attempt asks for. Default `0.05` — deliberately tight, so a position that can be opened or closed at near-spot is. +- `slippage_multiplier`: How much wider each retry goes. Default `5`. +- `max_slippage_pct`: The ceiling. Default `5`. +- Together the defaults give the ramp **0.05 → 0.25 → 1.25 → 5**. It widens **only** on a failure Gateway attributed to slippage (`SLIPPAGE_EXCEEDED`); a wrong tick, an insufficient balance or a transport error retries at the same tolerance, because loosening one that was never too tight just pays more for the same trade. +- The ramp resets at each phase boundary — open, close, close-out swap — so a close starts tight again rather than inheriting however wide the open had to go. +- At the ceiling an **exit keeps trying** (the position is real and has to come out) and an **entry stops** (nothing is stranded by abandoning it). +- Enforcement is per connector on the close: orca, uniswap and pancakeswap apply a minimum-amount check; meteora, raydium and pancakeswap-sol currently close without one, so the ramp changes nothing there. +- `current_retries` and the live `slippage_pct` are both in the executor's `custom_info`, so a position that took several widenings says so. + **Limit Prices (auto-close triggers, grid-executor style):** - `upper_limit_price`: Close when current price â‰Ĩ this value (default `None` = no upper limit) - `lower_limit_price`: Close when current price ≤ this value (default `None` = no lower limit) @@ -177,7 +187,15 @@ manage_executors( - If position is closed on-chain but executor still shows `RUNNING`, manually update executor status in database to `TERMINATED` - If position is open on-chain but executor still shows `OPENING`, the executor should eventually sync — if stuck, check API logs for errors -**Exception: Executor not found in API (404 error):** -- If API was restarted, executors may no longer exist in memory but positions remain on-chain -- In this case, close the on-chain position directly via the DEX UI or gateway API -- Then manually update the executor status in the database to `TERMINATED` +**Orphaned positions (executor terminated with a live on-chain position):** +- `manage_executors(action="orphaned")` lists terminated executors that may still own an on-chain position: involuntary holds (`POSITION_HOLD` with `hold_reason` set — a close that exhausted its retries), legacy `FAILED` executors whose final state carries a `position_address`, and `SYSTEM_CLEANUP` LP executors from an API restart (position address unknown — reconcile against `get_portfolio_overview(include_lp_positions=True)` or the gateway positions-owned endpoints) +- Each entry reports the `lp_provider` (the DEX, e.g. `orca/clmm`) and `pool_address` needed to close it. Note `connector_name` holds the **network** (e.g. `solana-mainnet-beta`), not the DEX +- Recover with `manage_clmm(action="close", connector=, network=, position_address=..., pool_address=...)`. `pool_address` is REQUIRED here: LP-executor positions are opened by the bot straight against Gateway, so the API database has no row to read the pool from and the close returns 400 without it +- Stopping the executor will NOT close the position — it has already terminated, so `manage_executors(action="stop")` is correctly a no-op. A fresh `lp_executor` CANNOT adopt an existing position either; it always mints a new one, which would stack a second funded position on top of the orphan +- After the position is closed on-chain, mark it recovered with `manage_executors(action="resolve_orphan", executor_id="...")` so it stops appearing in orphan listings and warnings +- If an `lp_rebalancer` controller was managing the executor, restart the controller (or its bot) after resolving: the controller's orphan halt is held in memory and only clears on restart. `resolve_orphan` updates the API database, not the running controller +- Do NOT open new positions on the same funds until the orphan is recovered + +**Stopping an already-terminated executor is a no-op, not an error:** +- `manage_executors(action="stop")` on a terminated executor returns `status="already_terminated"` with its final `close_type`, `position_address`, and `orphaned_position` flag instead of a 404 +- A 404 means the executor ID is unknown to the API's database (never existed, or the database was unavailable at that moment) diff --git a/mcp_servers/hummingbot_api/guides/order_executor.md b/mcp_servers/hummingbot_api/guides/order_executor.md index cfd6136ed..ed5162561 100644 --- a/mcp_servers/hummingbot_api/guides/order_executor.md +++ b/mcp_servers/hummingbot_api/guides/order_executor.md @@ -4,8 +4,16 @@ Simple order execution with retry logic and multiple execution strategies. Closest executor to a plain BUY/SELL order but with strategy options. +**This is also how you swap on a DEX.** Set `connector_name` to a NETWORK +(`"solana-mainnet-beta"`, `"ethereum-mainnet"`) with `execution_strategy="MARKET"` and the +order routes through Gateway's unified swap route — the same one `manage_gateway_swaps` +uses, but with the slippage ramp and an executor record attached. The router is not +selectable per order: it comes from the network's configured `swapProvider`. Prefer this +over `manage_gateway_swaps(action="execute")` for any swap that is part of a strategy. + **Use when:** - You want a one-off buy or sell with reliable execution +- You want to swap one token for another on a DEX (see above) - You need a specific execution strategy (MARKET, LIMIT, LIMIT_MAKER, or LIMIT_CHASER) - You want simple order placement without multi-level complexity @@ -25,7 +33,11 @@ Closest executor to a plain BUY/SELL order but with strategy options. **Key Parameters:** - `connector_name`: Exchange to execute on -- `trading_pair`: Trading pair (e.g., 'USDT-BRL') +- `trading_pair`: Trading pair (e.g., 'USDT-BRL'). On a DEX, either side may be a raw + token address instead of a symbol — `BANKJmvh...-USDC` is valid. The token does NOT + need to be registered with Gateway first; it resolves the mint and reads decimals + on-chain. Never add a token to Gateway as a prerequisite for trading it, and never + guess decimals in order to do so - `side`: 1 (BUY) or 2 (SELL) - `amount`: Order amount (base currency, or '$100' for USD value) - `execution_strategy`: LIMIT, MARKET, LIMIT_MAKER, or LIMIT_CHASER @@ -35,8 +47,25 @@ Closest executor to a plain BUY/SELL order but with strategy options. - `position_action`: 'OPEN' or 'CLOSE' (default: 'OPEN', useful for perpetuals in HEDGE mode) - `level_id`: Optional identifier tag -**Solana / Jupiter: `executed_amount_base` is the amount REQUESTED, not received.** -Slippage and fees mean the wallet gets slightly less (observed −0.06% to −0.44%). -Never feed it straight into a downstream call that must spend those tokens (e.g. an -LP open's `base_amount`) — apply a haircut (`× 0.995`) or read the true post-swap -wallet balance. +**Solana: whether `executed_amount_base` is what you received depends on the route.** + +On an EXACT fill it is exact. A SOL-USDC round trip through `order_executor` on +2026-08-21 moved gross Âą0.010000000 SOL in both directions, matching the request to the +lamport, and cost 0.0166% for the pair. A blanket `× 0.995` haircut there under-spends by +half a percent. + +It is short when the BUY was APPROXIMATED. A BUY is an ExactOut order, and a thin token +with no ExactOut route is quoted by pricing the sell leg and quoting that input forward — +roughly 2.5%, and up to 4.83% observed. The order is silently resized rather than +overcharged, which is what makes it dangerous to a caller who asked for a quantity. + +So the check is conditional, not a constant: the quote's `approximation` flag says which +case you are in. When it is true, read the true post-swap wallet balance before feeding +the amount into a call that must spend those tokens (e.g. an LP open's `base_amount`); +when it is false, use the figure. Passing `extra_params={'approximateIfNoExactOut': False}` +requires an exact route instead of accepting the resize. + +**Observability.** `custom_info` carries `transaction_hash` (the on-chain signature — +`order_id` is internal and appears nowhere on chain), plus `slippage_pct` and +`max_slippage_pct`. `slippage_pct` is the LIVE tolerance: a value above the configured +start means earlier attempts failed on slippage and this one is paying to get through. diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 15b35df9f..ec4255d34 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -30,6 +30,8 @@ class ManageExecutorsRequest(BaseModel): 9. action="reset_preferences" -> Reset preferences to defaults 10. action="positions_summary" -> Get positions (or specific if connector_name+trading_pair given) 11. action="clear_position" + connector_name + trading_pair -> Clear position + 12. action="orphaned" -> List terminated executors that may still own an on-chain position + 13. action="resolve_orphan" + executor_id -> Mark an orphaned position as recovered """ action: ( @@ -44,6 +46,8 @@ class ManageExecutorsRequest(BaseModel): "positions_summary", "clear_position", "performance_report", + "orphaned", + "resolve_orphan", ] | None ) = Field( @@ -188,6 +192,10 @@ def get_flow_stage(self) -> str: return "clear_position" elif self.action == "performance_report": return "performance_report" + elif self.action == "orphaned": + return "orphaned" + elif self.action == "resolve_orphan": + return "resolve_orphan" elif self.executor_type is not None: return "show_schema" else: @@ -394,9 +402,13 @@ class GatewaySwapRequest(BaseModel): 4. action="search" + filters -> Query swap history """ - action: Literal["quote", "execute", "search", "get_status"] = Field( - description="Action to perform: 'quote' (get price), 'execute' (perform swap), " - "'search' (query history), 'get_status' (check tx status)" + action: Literal["quote", "execute", "execute_quote", "search", "get_status"] = ( + Field( + description="Action to perform: 'quote' (get price), 'execute' (perform swap at the " + "price found at execution), 'execute_quote' (execute a quote you already have, by its " + "quote_id — the price you were shown is the price you get), 'search' (query history), " + "'get_status' (check tx status)" + ) ) # Common swap parameters (required for quote/execute) @@ -433,9 +445,17 @@ class GatewaySwapRequest(BaseModel): ) slippage_pct: str | None = Field( - default="1.0", - description="Maximum slippage percentage (optional, default: 1.0). " - "Example: '1.5' for 1.5% slippage tolerance", + default=None, + description="Maximum slippage percentage (optional). " + "Omit to use the connector's configured slippage. " + "Example: '1.5' for 1.5% slippage tolerance ('0' is a real value, not 'use default')", + ) + + extra_params: dict[str, Any] | None = Field( + default=None, + description="Connector-specific parameters for quote/execute (optional). " + "Supported key: 'approximateIfNoExactOut' (bool, jupiter/dflow/okx/titan routers only) - " + "allow an approximate quote when no exact-out route exists", ) # Execute-specific parameter @@ -444,6 +464,14 @@ class GatewaySwapRequest(BaseModel): description="Wallet address for execute action (optional, uses default wallet if not provided)", ) + # Execute-quote parameter + quote_id: str | None = Field( + default=None, + description="quote_id from a prior quote action (required for execute_quote). Only the " + "router connectors return one — jupiter, dflow, okx, titan, 0x — because only they hold " + "a price; a pool-scoped connector prices against the pool at execution and has none.", + ) + # Get status parameter transaction_hash: str | None = Field( default=None, description="Transaction hash (required for get_status action)" @@ -531,6 +559,18 @@ class GatewayCLMMRequest(BaseModel): default=None, description="Pool contract address (required for get_pool_info)" ) + bin_count: int = Field( + default=0, + ge=0, + le=401, + description=( + "For get_pool_info: if > 0, include the per-tick liquidity distribution " + "(`bins`) around the active price. Meteora always returns its bins; " + "orca, raydium, uniswap and pancakeswap compute them on request. " + "Default 0 skips the extra on-chain reads." + ), + ) + # Pool listing parameters page: int = Field( default=0, ge=0, description="Page number for list_pools (default: 0)" @@ -549,7 +589,11 @@ class GatewayCLMMRequest(BaseModel): ) sort_key: str | None = Field( - default="volume", description="Sort by field (volume, tvl, feetvlratio, etc.)" + default="tvl", + description="Sort by field. Defaults to tvl — depth is the LP question, and " + "volume ties at zero across every pool on a quiet pair. " + "meteora: tvl, volume, feetvlratio. " + "orca: tvl, volume, fees, rewards, yieldovertvl.", ) order_by: str | None = Field( @@ -590,8 +634,6 @@ class AMMRequest(BaseModel): "pool_info", "position_info", "positions_owned", - "quote_swap", - "execute_swap", "quote_liquidity", "add_liquidity", "remove_liquidity", @@ -609,7 +651,8 @@ class AMMRequest(BaseModel): ) network: str | None = Field( default=None, - description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'base-mainnet'", + description="Network ID in 'chain-network' format, built from Gateway's chain/network " + "config names. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'ethereum-base'", ) wallet_address: str | None = Field( default=None, @@ -621,21 +664,14 @@ class AMMRequest(BaseModel): description="Meteora NFT position: REQUIRED for remove_liquidity, optional for add_liquidity (omit = open a new position). Ignored by fungible-LP AMMs.", ) - # Swap params + # Token params base_token: str | None = Field( - default=None, - description="Base token symbol or address (swap direction / pool base)", + default=None, description="Base token symbol or address (create_pool)" ) quote_token: str | None = Field( default=None, description="Quote token symbol or address (pool quote, for create_pool)", ) - amount: str | None = Field( - default=None, description="Swap amount (as string; parsed to Decimal)" - ) - side: Literal["BUY", "SELL"] | None = Field( - default=None, description="Swap direction" - ) slippage_pct: str | None = Field( default=None, description="Maximum slippage percentage (as string)" ) @@ -659,18 +695,113 @@ class AMMRequest(BaseModel): default=None, description="Initial price as quote per base (create_pool; overrides quote_token_amount)", ) - config_address: str | None = Field( + extra_params: dict[str, Any] | None = Field( + default=None, + description="Connector-specific create_pool params under Gateway's own names: " + "configAddress (meteora DAMM v2, required there), ammConfigIndex (raydium CPMM). " + "uniswap/pancakeswap (EVM) AMM take no extra params. Unknown keys are " + "rejected by the API with a 400.", + ) + + +class CLMMRequest(BaseModel): + """Request model for the direct, chain- & DEX-agnostic CLMM tool (manage_clmm). + + Drives concentrated-liquidity position operations across Meteora DLMM, Raydium CLMM, Orca + Whirlpools (Solana), and Uniswap V3 / PancakeSwap V3 (EVM). + + Progressive disclosure: action=None returns the CLMM guide. + + This is the direct, unmanaged path. The managed path for normal LP work is + manage_executors(lp_executor), which owns range monitoring, rebalancing and close retries. + Use manage_clmm to inspect positions and to recover orphaned ones that no executor owns + any more — a terminated executor cannot be told to close its position. + """ + + action: ( + Literal[ + "position_info", + "open", + "add_liquidity", + "remove_liquidity", + "close", + "collect_fees", + "create_pool", + ] + | None + ) = Field( + default=None, description="CLMM action. Leave empty to load the CLMM guide." + ) + + connector: str | None = Field( + default=None, + description="CLMM connector (required for any action): 'meteora', 'raydium', 'orca', " + "'pancakeswap-sol' (Solana), 'uniswap', 'pancakeswap' (EVM). A '/clmm' form is " + "accepted, so an orphan record's lp_provider (e.g. 'orca/clmm') can be passed through " + "unchanged.", + ) + network: str | None = Field( default=None, - description="Meteora DAMM v2 config account address (required for meteora create_pool)", + description="Network ID in 'chain-network' format, built from Gateway's chain/network " + "config names. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'ethereum-base', " + "'ethereum-bsc'", ) - fee_config_index: int | None = Field( + wallet_address: str | None = Field( + default=None, + description="Wallet address (optional, uses default if not provided)", + ) + pool_address: str | None = Field( default=None, - description="Raydium CPMM fee config index (optional, create_pool)", + description="Pool contract address. Required for open. Optional and informational " + "everywhere else: the API never reads it on close or collect_fees (Gateway needs only " + "position_address, and the pre-close fee snapshot does not use it), and position_info " + "lists every position the wallet owns without a pool filter.", ) - gas_price: str | None = Field( + position_address: str | None = Field( default=None, - description="Uniswap (EVM) gas price in gwei (optional, create_pool)", + description="Position NFT address. Required for add_liquidity, remove_liquidity, close, collect_fees.", + ) + + # Range params (open) + lower_price: str | None = Field( + default=None, description="Lower price bound of the position range (open)" ) - max_gas: int | None = Field( - default=None, description="Uniswap (EVM) max gas limit (optional, create_pool)" + upper_price: str | None = Field( + default=None, description="Upper price bound of the position range (open)" + ) + + # Liquidity params + base_token_amount: str | None = Field( + default=None, description="Base token amount (open / add_liquidity)" + ) + quote_token_amount: str | None = Field( + default=None, description="Quote token amount (open / add_liquidity)" + ) + percentage_to_remove: str | None = Field( + default=None, + description="Percentage of liquidity to remove, 0-100 (remove_liquidity). 100 empties the position but " + "leaves the account open — use action='close' to withdraw and close it.", + ) + slippage_pct: str | None = Field( + default=None, description="Maximum slippage percentage (as string)" + ) + + # create_pool params + base_token: str | None = Field( + default=None, description="Base token symbol or address (create_pool)" + ) + quote_token: str | None = Field( + default=None, description="Quote token symbol or address (create_pool)" + ) + initial_price: str | None = Field( + default=None, + description="Initial pool price as quote per base (create_pool, optional — " + "the API fetches the market price when omitted)", + ) + extra_params: dict[str, Any] | None = Field( + default=None, + description="Connector-specific parameters under Gateway's own names. " + "open/add_liquidity: strategyType (meteora DLMM, e.g. {'strategyType': 0}). " + "create_pool: binStep (meteora/orca), feeBps (meteora/uniswap/pancakeswap), " + "ammConfigIndex (raydium/pancakeswap-sol). Unknown keys are rejected by the API with a 400.", ) diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index d55c97de0..7c1cc9222 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -14,6 +14,7 @@ format_active_bots_as_table, format_amm_result, format_bot_logs_as_table, + format_clmm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, @@ -24,6 +25,7 @@ from mcp_servers.hummingbot_api.middleware import GATEWAY_LOG_HINT, handle_errors from mcp_servers.hummingbot_api.schemas import ( AMMRequest, + CLMMRequest, GatewayCLMMRequest, GatewayConfigRequest, GatewayContainerRequest, @@ -50,6 +52,9 @@ from mcp_servers.hummingbot_api.tools.gateway_clmm import ( explore_gateway_clmm_pools as explore_gateway_clmm_pools_impl, ) +from mcp_servers.hummingbot_api.tools.gateway_clmm import ( + manage_clmm_impl, +) from mcp_servers.hummingbot_api.tools.gateway_swap import ( manage_gateway_swaps as manage_gateway_swaps_impl, ) @@ -702,6 +707,8 @@ async def manage_executors( "positions_summary", "clear_position", "performance_report", + "orphaned", + "resolve_orphan", ] | None ) = None, @@ -747,6 +754,10 @@ async def manage_executors( - positions_summary → View all positions (add connector_name + trading_pair to filter) - clear_position + connector_name + trading_pair → Clear externally-closed position - performance_report → Get executor performance report (optionally filter by controller_id) + - orphaned → List terminated executors that may still own an on-chain LP position (recover before opening new ones). + Reports the dex, pool and network needed to close each one with `manage_clmm(action="close")` + - resolve_orphan + executor_id → Mark an orphaned position as recovered, after closing it with `manage_clmm`. + Stopping a terminated executor does NOT close its position Args: action: Action to perform. Leave empty to see executor types or config schema. @@ -810,7 +821,7 @@ async def explore_dex_pools( page: int = 0, limit: int = 50, search_term: str | None = None, - sort_key: str | None = "volume", + sort_key: str | None = "tvl", order_by: str | None = "desc", include_unknown: bool = True, detailed: bool = False, @@ -833,7 +844,13 @@ async def explore_dex_pools( page: Page number for list_pools (default: 0). limit: Results per page for list_pools (default: 50, max: 100). search_term: Search term to filter pools by token symbols (e.g., 'SOL', 'USDC'). - sort_key: Sort by field for list_pools (volume, tvl, feetvlratio, etc.). + sort_key: Sort by field for list_pools. Defaults to 'tvl', which is the LP + question — how much depth is there. 'volume' ranks by how much OTHERS traded, + and on a quiet pair every pool ties at zero, making the order arbitrary: on + UMBRA-USDC that put a pool holding $1.07 above one holding $15.34K, and buried + the deep one at row 68 of 73. meteora: tvl, volume, feetvlratio. + orca: tvl, volume, fees, rewards, yieldovertvl. Anything else is rejected with + the legal list rather than failing upstream. order_by: Sort order for list_pools ('asc' or 'desc'). include_unknown: Include pools with unverified tokens (default: True). detailed: Return detailed table with more columns for list_pools (default: False). @@ -857,6 +874,119 @@ async def explore_dex_pools( return format_gateway_clmm_pool_result(action, result) +@mcp.tool() +@handle_errors("manage Gateway config", GATEWAY_LOG_HINT) +async def manage_gateway_config( + resource_type: Literal[ + "chains", "networks", "tokens", "connectors", "pools", "wallets" + ], + action: Literal["list", "get", "update", "add", "delete", "save"], + network_id: str | None = None, + connector_name: str | None = None, + config_updates: dict[str, Any] | None = None, + token_address: str | None = None, + token_symbol: str | None = None, + token_decimals: int | None = None, + token_name: str | None = None, + pool_type: str | None = None, + pool_base: str | None = None, + pool_quote: str | None = None, + pool_address: str | None = None, + search: str | None = None, + network: str | None = None, + chain: str | None = None, + private_key: str | None = None, + wallet_address: str | None = None, +) -> str: + """Read and edit Gateway's own configuration — chains, networks, tokens, connectors, pools, wallets. + + This is Gateway's config, not the chain. Adding or deleting a token here changes the + symbol -> address mapping Gateway resolves against; it moves no funds and touches + nothing on-chain. + + Use `tokens` + `list` (with `search`) to answer "does Gateway know this symbol", + which is the check to run BEFORE quoting or swapping an unfamiliar token: a symbol + Gateway cannot resolve fails at the route, not at the trade. + + Resource types: + - chains: every blockchain Gateway knows + - networks: network config, ids in 'chain-network' form ('solana-mainnet-beta') + - tokens: the per-network symbol/address/decimals mapping (list, add, delete) + - connectors: DEX connector config + - pools: the named pool registry (list, add) + - wallets: wallet add/delete. GATED — needs confirmation, and `add` takes a private + key. Prefer importing a wallet outside the agent. + + Args: + resource_type: Which part of Gateway's config to act on. + action: list | get | update | add | delete | save. + network_id: Network id in 'chain-network' form. Required for token and pool actions. + connector_name: DEX connector name ('meteora', 'raydium', 'uniswap'). + config_updates: Key-value updates for 'update'/'save'. + token_address: Token contract address. Required to add or delete a token. + token_symbol: Token symbol. Required to add a token. + token_decimals: Token decimals (6 for USDC, 18 for WETH). Required to add a token. + token_name: Token name. Optional on add; defaults to the symbol. + pool_type: 'CLMM' or 'AMM'. Required to add a pool. + pool_base: Base token symbol. Required to add a pool. + pool_quote: Quote token symbol. Required to add a pool. + pool_address: Pool contract address. Required to add a pool. + search: Filter tokens by symbol or name when listing. + network: Bare network name ('mainnet-beta'). Required to list pools. + chain: Chain for a wallet action ('solana', 'ethereum'). + private_key: Private key for 'add' wallet. Gated; avoid where possible. + wallet_address: Wallet address for 'delete' wallet. + """ + request = GatewayConfigRequest( + resource_type=resource_type, + action=action, + network_id=network_id, + connector_name=connector_name, + config_updates=config_updates, + token_address=token_address, + token_symbol=token_symbol, + token_decimals=token_decimals, + token_name=token_name, + pool_type=pool_type, + pool_base=pool_base, + pool_quote=pool_quote, + pool_address=pool_address, + search=search, + network=network, + chain=chain, + private_key=private_key, + wallet_address=wallet_address, + ) + + client = await hummingbot_client.get_client() + result = await manage_gateway_config_impl(client, request) + return format_gateway_config_result(result) + + +@mcp.tool() +@handle_errors("manage Gateway container") +async def manage_gateway_container( + action: Literal["get_status", "start", "stop", "restart", "get_logs"], + config: dict[str, Any] | None = None, + tail: int = 100, +) -> str: + """Gateway container lifecycle — status, start, stop, restart, and logs. + + `get_logs` is what the hint on a failed Gateway call points at: when a swap or an LP + action fails with an error that does not say why, the container log usually does. + + Args: + action: get_status | start | stop | restart | get_logs. + config: Gateway configuration. Used by 'start', optional for 'restart'. + tail: Log lines to retrieve for 'get_logs' (1-200, default 100). + """ + request = GatewayContainerRequest(action=action, config=config, tail=tail) + + client = await hummingbot_client.get_client() + result = await manage_gateway_container_impl(client, request) + return format_gateway_container_result(result) + + @mcp.tool() @handle_errors("manage AMM", GATEWAY_LOG_HINT) async def manage_amm( @@ -865,8 +995,6 @@ async def manage_amm( "pool_info", "position_info", "positions_owned", - "quote_swap", - "execute_swap", "quote_liquidity", "add_liquidity", "remove_liquidity", @@ -881,17 +1009,12 @@ async def manage_amm( position_address: str | None = None, base_token: str | None = None, quote_token: str | None = None, - amount: str | None = None, - side: Literal["BUY", "SELL"] | None = None, slippage_pct: str | None = None, base_token_amount: str | None = None, quote_token_amount: str | None = None, percentage_to_remove: str | None = None, initial_price: str | None = None, - config_address: str | None = None, - fee_config_index: int | None = None, - gas_price: str | None = None, - max_gas: int | None = None, + extra_params: dict[str, Any] | None = None, ) -> str: """Direct AMM pool operations + pool creation, chain- & DEX-agnostic (Meteora / Raydium / Uniswap). @@ -901,7 +1024,6 @@ async def manage_amm( Actions: - pool_info / position_info → read pool reserves/price/fee; read your position (aggregate + positions[]) - positions_owned → list ALL your positions across pools (meteora only) - - quote_swap / execute_swap → quote/execute a swap AGAINST a specific AMM pool (pool-scoped, not router) - quote_liquidity / add_liquidity / remove_liquidity → two-sided LP in/out - create_pool → create + seed a new pool (market-price seeded by default; anti-snipe) @@ -912,8 +1034,9 @@ async def manage_amm( returns a positions[] breakdown, positions_owned lists all your positions. Fungible-LP AMMs (raydium, uniswap) ignore position_address and have no enumerable positions. - create_pool extras by connector: meteora→config_address (required); raydium→fee_config_index - (optional); uniswap→gas_price/max_gas (optional, 0.30% fixed fee). + create_pool extras ride extra_params under Gateway's own names: meteora→configAddress + (required); raydium→ammConfigIndex (optional); uniswap/pancakeswap (EVM)→none + (0.30% fixed fee). Unknown keys are rejected with a 400. Scope: AMM only. Router/one-shot swaps → manage_executors(order_executor); CLMM LP → manage_executors(lp_executor). @@ -925,19 +1048,16 @@ async def manage_amm( wallet_address: Wallet address (optional, uses default if not provided). pool_address: Pool contract address. position_address: Meteora NFT position — required for remove_liquidity, optional for add_liquidity (omit = new position). - base_token: Base token symbol or address (swap direction / pool base). + base_token: Base token symbol or address (create_pool). quote_token: Quote token symbol or address (pool quote, for create_pool). - amount: Swap amount (string). - side: Swap direction (BUY or SELL). slippage_pct: Maximum slippage percentage (string). base_token_amount: Base token amount (add_liquidity / quote_liquidity / create_pool). quote_token_amount: Quote token amount (add_liquidity / quote_liquidity / create_pool). percentage_to_remove: Percentage of liquidity to remove, 0-100 (remove_liquidity). initial_price: Initial price as quote per base (create_pool; overrides quote_token_amount). - config_address: Meteora DAMM v2 config account address (required for meteora create_pool). - fee_config_index: Raydium CPMM fee config index (optional, create_pool). - gas_price: Uniswap (EVM) gas price in gwei (optional, create_pool). - max_gas: Uniswap (EVM) max gas limit (optional, create_pool). + extra_params: Connector-specific create_pool params under Gateway's own names: + configAddress (meteora, required), ammConfigIndex (raydium). Unknown keys + are rejected with a 400. """ request = AMMRequest( action=action, @@ -948,17 +1068,12 @@ async def manage_amm( position_address=position_address, base_token=base_token, quote_token=quote_token, - amount=amount, - side=side, slippage_pct=slippage_pct, base_token_amount=base_token_amount, quote_token_amount=quote_token_amount, percentage_to_remove=percentage_to_remove, initial_price=initial_price, - config_address=config_address, - fee_config_index=fee_config_index, - gas_price=gas_price, - max_gas=max_gas, + extra_params=extra_params, ) client = await hummingbot_client.get_client() @@ -966,6 +1081,241 @@ async def manage_amm( return format_amm_result(action, result) +@mcp.tool() +@handle_errors("manage CLMM", GATEWAY_LOG_HINT) +async def manage_clmm( + action: ( + Literal[ + "position_info", + "open", + "add_liquidity", + "remove_liquidity", + "close", + "collect_fees", + "create_pool", + ] + | None + ) = None, + connector: str | None = None, + network: str | None = None, + wallet_address: str | None = None, + pool_address: str | None = None, + position_address: str | None = None, + lower_price: str | None = None, + upper_price: str | None = None, + base_token_amount: str | None = None, + quote_token_amount: str | None = None, + percentage_to_remove: str | None = None, + slippage_pct: str | None = None, + base_token: str | None = None, + quote_token: str | None = None, + initial_price: str | None = None, + extra_params: dict | None = None, +) -> str: + """Direct CLMM position operations, chain- & DEX-agnostic (Meteora / Raydium / Orca / Uniswap / PancakeSwap). + + Stateless — you hold position state in your journal. Progressive disclosure: call with NO + `action` to load the CLMM guide. + + THE UNMANAGED PATH. For normal LP work use `manage_executors` with `lp_executor`, which owns + range monitoring, rebalancing and bounded close retries. Use this tool when no executor can do + that for you — above all to RECOVER AN ORPHANED POSITION: a terminated executor cannot be told + to close anything (`manage_executors(action="stop")` on one is correctly a no-op), so the + position must be closed by address here, then marked with + `manage_executors(action="resolve_orphan")`. + + Actions: + - position_info → your positions in a pool with amounts, range, and uncollected fees + - open → create a position NO executor tracks (not range-monitored or auto-closed) + - add_liquidity / remove_liquidity → resize an existing position, keeping its range + - close → withdraw everything, collect fees, close the account + - collect_fees → fees only, position untouched + - create_pool → create a new (empty) CLMM pool; liquidity is added by opening positions + + remove_liquidity at 100% leaves an EMPTY POSITION OPEN; only `close` closes the account. To + recover an orphan, use `close`. + + Positions opened by an lp_executor are not in the API database (the bot opens them straight + against Gateway), and they close fine anyway: the API never reads `pool_address` on + close/collect_fees. + + Pool discovery lives in `explore_dex_pools`. + + Args: + action: CLMM action. Leave empty to load the CLMM guide. + connector: CLMM connector: meteora | raydium | orca | pancakeswap-sol (Solana), + uniswap | pancakeswap (EVM). A '/clmm' form is accepted, so an orphan's + lp_provider passes through unchanged. + network: Network ID in 'chain-network' format (e.g. 'solana-mainnet-beta', 'ethereum-mainnet'). + For an orphan record this is the `connector_name` field. + wallet_address: Wallet address (optional, uses default if not provided). + pool_address: Pool contract address. Required for open; informational elsewhere — + position_info takes no pool filter and close/collect_fees never read it. + position_address: Position NFT address (add_liquidity, remove_liquidity, close, collect_fees). + lower_price: Lower price bound of the range (open). + upper_price: Upper price bound of the range (open). + base_token_amount: Base token amount (open / add_liquidity). + quote_token_amount: Quote token amount (open / add_liquidity). + percentage_to_remove: Percentage of liquidity to remove, 0-100 (remove_liquidity). + slippage_pct: Maximum slippage percentage. + base_token: Base token symbol or address (create_pool). + quote_token: Quote token symbol or address (create_pool). + initial_price: Initial pool price as quote per base (create_pool, optional — + the API fetches the market price when omitted). + extra_params: Connector-specific params under Gateway's own names. + open/add_liquidity: strategyType (meteora DLMM, e.g. {"strategyType": 0}). + create_pool: binStep (meteora/orca), feeBps (meteora/uniswap/pancakeswap), + ammConfigIndex (raydium/pancakeswap-sol). Unknown keys are rejected with a 400. + """ + request = CLMMRequest( + action=action, + connector=connector, + network=network, + wallet_address=wallet_address, + pool_address=pool_address, + position_address=position_address, + lower_price=lower_price, + upper_price=upper_price, + base_token_amount=base_token_amount, + quote_token_amount=quote_token_amount, + percentage_to_remove=percentage_to_remove, + slippage_pct=slippage_pct, + base_token=base_token, + quote_token=quote_token, + initial_price=initial_price, + extra_params=extra_params, + ) + + client = await hummingbot_client.get_client() + result = await manage_clmm_impl(client, request) + return format_clmm_result(action, result) + + +@mcp.tool() +@handle_errors("manage Gateway swaps", GATEWAY_LOG_HINT) +async def manage_gateway_swaps( + action: Literal["quote", "execute", "search", "get_status"], + connector: str | None = None, + network: str | None = None, + trading_pair: str | None = None, + side: Literal["BUY", "SELL"] | None = None, + amount: str | None = None, + slippage_pct: str | None = None, + wallet_address: str | None = None, + transaction_hash: str | None = None, + extra_params: dict[str, Any] | None = None, + search_connector: str | None = None, + search_network: str | None = None, + search_wallet_address: str | None = None, + search_trading_pair: str | None = None, + status: Literal["SUBMITTED", "CONFIRMED", "FAILED"] | None = None, + start_time: int | None = None, + end_time: int | None = None, + limit: int = 50, + offset: int = 0, +) -> str: + """One-shot DEX swaps through Gateway's unified swap route — quote, execute, and track. + + PREFER order_executor FOR SWAPS. `manage_executors(action="create", + executor_type="order_executor", ...)` with `connector_name=` (e.g. + "solana-mainnet-beta") and `execution_strategy="MARKET"` swaps through this same + Gateway route, and adds what a one-shot call cannot: + - the slippage ramp (`slippage_pct` / `slippage_multiplier` / `max_slippage_pct`), + which starts tight and widens only on a failure Gateway attributes to slippage. + A swap here carries ONE fixed tolerance and never retries at a wider one. + - an executor record, so the fill is tagged with `controller_id` and reaches PnL + attribution. A swap here is written to swap history only, so an entry executed + this way and the position it funds land in different ledgers. + + Use this tool when order_executor cannot express what you need: a quote without an + execution, resolving or searching swap history, or a pool-scoped connector the + executor does not route to. + + "Unified" is about Gateway's routes, not about tool choice. Router aggregators + (jupiter, 0x) and pool-scoped AMM/CLMM swaps all resolve to one route here; the + pool-scoped `/trading/amm/*-swap` routes no longer exist. + + Actions: + - quote → price, expected output, and price impact BEFORE committing (free, always do this first) + - execute → submit the swap on-chain; returns a transaction hash + - get_status → resolve a submitted swap by transaction_hash + - search → query swap history with filters + + CONNECTOR FORMAT. `connector` is "name/type": + - "jupiter/router", "0x/router" — aggregator routing across every pool it knows + - "meteora/amm", "raydium/amm", "uniswap/amm" — swap against an AMM pool + - "meteora/clmm", "raydium/clmm", "uniswap/clmm" — swap against a CLMM pool + + POOL RESOLUTION (read this before trusting a failure). For the pool-scoped forms + (`/amm`, `/clmm`) Gateway resolves the pool itself from ITS OWN CONFIGURED POOL LIST, + matched BY TOKEN SYMBOL from `trading_pair` — you cannot pass a pool address here. A token + Gateway does not know (a fresh launch mint, a pool created moments ago via + `manage_amm(create_pool)`) will NOT resolve, and creating a pool does not register it. Such a + call fails with "No pool found", which means UNKNOWN, not "bad token" — do not read it as a + honeypot or a failed safety check. Route those through an aggregator ("jupiter/router"), + which quotes off live on-chain routes rather than a config file. + + LP work is elsewhere: `manage_amm` (AMM pools), `manage_clmm` (CLMM positions), + `manage_executors(lp_executor)` (managed LP). + + Args: + action: Swap action to perform. + connector: Connector in "name/type" form (required for quote/execute), e.g. + 'jupiter/router', 'meteora/amm', 'raydium/clmm'. + network: Network ID in 'chain-network' format (required for quote/execute), e.g. + 'solana-mainnet-beta', 'ethereum-mainnet'. + trading_pair: Trading pair as 'BASE-QUOTE' (required for quote/execute). Either + side may be a SYMBOL or a raw TOKEN ADDRESS, and the address does NOT have to + be registered with Gateway first — Gateway resolves an unknown mint on the + spot and reads its decimals on-chain. Do not add a token to Gateway's list as + a prerequisite for trading it: that write is a symbol/address mapping, it is + not required here, and guessing decimals to satisfy it corrupts the mapping. + Pool-scoped connectors match Gateway's pool list by SYMBOL. + side: 'BUY' (buy base with quote) or 'SELL' (sell base for quote). Required for quote/execute. + amount: Base token amount to buy or sell (required for quote/execute). + slippage_pct: Maximum slippage percentage. OMIT to use the connector's configured + slippage; '0' is a real value, not "use the default". + wallet_address: Wallet for execute (optional, uses the default wallet). + transaction_hash: Transaction hash (required for get_status). + extra_params: Connector-specific params under Gateway's own names. Supported: + 'approximateIfNoExactOut' (bool, jupiter/dflow/okx/titan routers). + search_connector: Filter history by connector (search). + search_network: Filter history by network (search). + search_wallet_address: Filter history by wallet address (search). + search_trading_pair: Filter history by trading pair (search). + status: Filter history by status: SUBMITTED | CONFIRMED | FAILED (search). + start_time: Start timestamp in unix seconds (search). + end_time: End timestamp in unix seconds (search). + limit: Max results for search (default 50, max 1000). + offset: Pagination offset for search (default 0). + """ + request = GatewaySwapRequest( + action=action, + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=amount, + slippage_pct=slippage_pct, + wallet_address=wallet_address, + transaction_hash=transaction_hash, + extra_params=extra_params, + search_connector=search_connector, + search_network=search_network, + search_wallet_address=search_wallet_address, + search_trading_pair=search_trading_pair, + status=status, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + client = await hummingbot_client.get_client() + result = await manage_gateway_swaps_impl(client, request) + return format_gateway_swap_result(action, result) + + # GeckoTerminal Tools diff --git a/mcp_servers/hummingbot_api/tools/executors.py b/mcp_servers/hummingbot_api/tools/executors.py index fc43d1353..47938f39b 100644 --- a/mcp_servers/hummingbot_api/tools/executors.py +++ b/mcp_servers/hummingbot_api/tools/executors.py @@ -306,9 +306,29 @@ async def manage_executors( keep_position=request.keep_position, ) - formatted = f"Executor stopped successfully!\n\n" - formatted += f"Executor ID: {request.executor_id}\n" - formatted += f"Keep Position: {request.keep_position}\n" + if result.get("status") == "already_terminated": + # No-op: the executor was already terminal. Say so — a generic + # "stopped successfully" would hide the one payload that matters + # (an orphaned on-chain position needing recovery). + formatted = f"Executor was ALREADY terminated (stop was a no-op).\n\n" + formatted += f"Executor ID: {request.executor_id}\n" + formatted += f"Final close_type: {result.get('close_type')}\n" + if result.get("orphaned_position"): + formatted += ( + f"\n🚨 ORPHANED POSITION: {result.get('position_address')} is still " + "open on-chain with no automated owner. Stopping the executor does not " + "close it — it has already terminated. Close it with " + 'manage_clmm(action="close", position_address=..., pool_address=...), ' + 'then mark it recovered with action="resolve_orphan", ' + f'executor_id="{request.executor_id}".\n' + 'Run action="orphaned" to get the dex, pool and network for the call.\n' + ) + elif result.get("position_address"): + formatted += f"Position address (final state): {result.get('position_address')}\n" + else: + formatted = f"Executor stopped successfully!\n\n" + formatted += f"Executor ID: {request.executor_id}\n" + formatted += f"Keep Position: {request.keep_position}\n" return { "action": "stop", @@ -325,6 +345,104 @@ async def manage_executors( "formatted_output": f"Error stopping executor {request.executor_id}: {e}", } + elif flow_stage == "orphaned": + # List terminated executors that may still own an on-chain position + try: + resp = await client.executors.session.get( + f"{client.executors.base_url}/executors/positions/orphaned", + ) + resp.raise_for_status() + result = await resp.json() + + orphans = result.get("orphans", []) + formatted = ( + f"Orphaned position candidates: {result.get('count', len(orphans))}\n\n" + ) + if not orphans: + formatted += ( + "No orphaned positions. All terminated executors closed cleanly." + ) + else: + for o in orphans: + formatted += ( + f"- {o.get('executor_id')} ({o.get('executor_type')}, " + f"{o.get('trading_pair')} on {o.get('connector_name')}, " + f"close_type={o.get('close_type')}, closed_at={o.get('closed_at')})\n" + ) + if o.get("position_address"): + formatted += f" position: {o['position_address']}\n" + if o.get("lp_provider") or o.get("pool_address"): + formatted += f" dex: {o.get('lp_provider')} pool: {o.get('pool_address')}\n" + if o.get("needs_onchain_reconciliation"): + formatted += ( + " position address unknown (API restart) - reconcile against " + "on-chain positions (get_portfolio_overview include_lp_positions=True)\n" + ) + elif o.get("lp_provider") and o.get("pool_address"): + # Spell the recovery call out: the executor is terminated, so stopping it is + # a no-op and the position can only be closed by address. + formatted += ( + ' close with: manage_clmm(action="close", ' + f"connector=\"{o.get('lp_provider')}\", " + f"network=\"{o.get('connector_name')}\", " + f"position_address=\"{o.get('position_address')}\", " + f"pool_address=\"{o.get('pool_address')}\")\n" + ) + formatted += ( + '\nRecover each by closing the position with manage_clmm(action="close") - ' + "pool_address is required because LP-executor positions are not in the API " + "database. Stopping the executor will NOT close it; it has already terminated. " + 'Then mark it recovered with action="resolve_orphan", executor_id="...".' + ) + + return { + "action": "orphaned", + "result": result, + "formatted_output": formatted, + } + + except Exception as e: + return { + "action": "orphaned", + "error": str(e), + "formatted_output": f"Error listing orphaned positions: {e}", + } + + elif flow_stage == "resolve_orphan": + # Mark an orphaned position as recovered (after closing it externally) + if not request.executor_id: + return { + "action": "resolve_orphan", + "error": "executor_id is required", + "formatted_output": ( + "resolve_orphan requires executor_id. " + 'Run manage_executors(action="orphaned") to list candidates.' + ), + } + try: + resp = await client.executors.session.post( + f"{client.executors.base_url}/executors/{request.executor_id}/resolve-orphan", + ) + resp.raise_for_status() + result = await resp.json() + + return { + "action": "resolve_orphan", + "executor_id": request.executor_id, + "result": result, + "formatted_output": ( + f"Orphaned position for executor {request.executor_id} marked recovered. " + "It will no longer appear in orphan listings or warnings." + ), + } + + except Exception as e: + return { + "action": "resolve_orphan", + "error": str(e), + "formatted_output": f"Error resolving orphan for executor {request.executor_id}: {e}", + } + elif flow_stage == "get_logs": # Get executor logs via direct API call (not yet in client library) try: diff --git a/mcp_servers/hummingbot_api/tools/gateway_amm.py b/mcp_servers/hummingbot_api/tools/gateway_amm.py index 6a9dc7a9b..b9d55d122 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_amm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_amm.py @@ -101,31 +101,6 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: connector=connector, network=net, wallet_address=request.wallet_address ) - elif action == "quote_swap": - _require(request, "pool_address", "base_token", "side", "amount") - result = await ga.get_swap_quote( - connector=connector, - network=net, - pool_address=request.pool_address, - base_token=request.base_token, - side=request.side, - amount=_dec(request.amount, "amount"), - slippage_pct=_opt_dec(request.slippage_pct), - ) - - elif action == "execute_swap": - _require(request, "pool_address", "base_token", "side", "amount") - result = await ga.execute_swap( - connector=connector, - network=net, - pool_address=request.pool_address, - base_token=request.base_token, - side=request.side, - amount=_dec(request.amount, "amount"), - slippage_pct=_opt_dec(request.slippage_pct), - wallet_address=request.wallet_address, - ) - elif action == "quote_liquidity": _require(request, "pool_address", "base_token_amount", "quote_token_amount") result = await ga.get_liquidity_quote( @@ -173,11 +148,13 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: elif action == "create_pool": _require(request, "base_token", "quote_token", "base_token_amount") - if connector == "meteora" and not request.config_address: + if connector == "meteora" and not (request.extra_params or {}).get( + "configAddress" + ): raise ToolError( - "config_address is required for meteora create_pool (DAMM v2 pools are created against a " - "config account that fixes the fee schedule). Discover configs via the Meteora app/SDK; " - "avoid token-launch configs whose base fee starts near 99%." + 'extra_params={"configAddress": ...} is required for meteora create_pool (DAMM v2 pools ' + "are created against a config account that fixes the fee schedule). Discover configs via " + "the Meteora app/SDK; avoid token-launch configs whose base fee starts near 99%." ) result = await ga.create_pool( connector=connector, @@ -187,11 +164,8 @@ async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: base_token_amount=_dec(request.base_token_amount, "base_token_amount"), quote_token_amount=_opt_dec(request.quote_token_amount), initial_price=_opt_dec(request.initial_price), - config_address=request.config_address, - fee_config_index=request.fee_config_index, - gas_price=_opt_dec(request.gas_price), - max_gas=request.max_gas, wallet_address=request.wallet_address, + extra_params=request.extra_params, ) else: diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index 189f4fce9..35c7f3f88 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -1,22 +1,37 @@ """ Gateway CLMM tools for Hummingbot MCP Server -Handles DEX CLMM read-only operations via Hummingbot Gateway: -- Pool exploration (list pools, get pool info) -- Position queries (get positions) +Two tools live here: +- explore_gateway_clmm_pools: read-only pool discovery (list pools, get pool info) +- manage_clmm: direct position operations (open, add, remove, close, collect fees, create pool) -For opening/closing LP positions, use `manage_executors` with `lp_executor` type. +The managed path for normal LP work is `manage_executors` with `lp_executor`, which owns range +monitoring, rebalancing and close retries. manage_clmm is the direct path, and the only way to +recover an orphaned position: once an executor has terminated it cannot be told to close anything, +so the position has to be closed by address. """ import logging +from decimal import Decimal +from pathlib import Path from typing import Any from mcp_servers.hummingbot_api.exceptions import ToolError from mcp_servers.hummingbot_api.formatters.base import format_number, get_field -from mcp_servers.hummingbot_api.schemas import GatewayCLMMRequest +from mcp_servers.hummingbot_api.schemas import CLMMRequest, GatewayCLMMRequest logger = logging.getLogger("hummingbot-mcp") +# Gateway routes CLMM calls on the bare connector name (see trading/clmm close/open routes). +SUPPORTED_CLMM_CONNECTORS = { + "meteora", + "raydium", + "orca", + "uniswap", + "pancakeswap", + "pancakeswap-sol", +} + def format_pools_as_table(pools: list[dict[str, Any]]) -> str: """ @@ -52,42 +67,32 @@ def format_pools_as_table(pools: list[dict[str, Any]]) -> str: def format_pools_as_detailed_table(pools: list[dict[str, Any]]) -> str: """ - Format pool data as a detailed table string with exploded volume and fee_tvl_ratio fields. + Format pool data as a detailed table string. + + Every column here is a field of hapi's CLMMPoolListItem. The old exploded + volume / fee_tvl_ratio columns, plus max_fee_percentage and + protocol_fee_percentage, were dropped: the API never sends them, so they + rendered as a permanent wall of N/A. - Columns: address | trading_pair | mint_x | mint_y | bin_step | current_price | liquidity | - base_fee_percentage | max_fee_percentage | protocol_fee_percentage | apr | apy | - volume_hour_1 | volume_hour_12 | volume_hour_24 | - fee_tvl_ratio_hour_1 | fee_tvl_ratio_hour_12 | fee_tvl_ratio_hour_24 + Columns: address | name | trading_pair | mint_x | mint_y | bin_step | current_price | + liquidity | base_fee_percentage | apr | apy | volume_24h | fees_24h """ if not pools: return "No pools found." # Header - detailed columns header = ( - "address | trading_pair | mint_x | mint_y | bin_step | current_price | liquidity | " - "base_fee_percentage | max_fee_percentage | protocol_fee_percentage | apr | apy | " - "volume_hour_1 | volume_hour_12 | volume_hour_24 | " - "fee_tvl_ratio_hour_1 | fee_tvl_ratio_hour_12 | fee_tvl_ratio_hour_24" + "address | name | trading_pair | mint_x | mint_y | bin_step | current_price | " + "liquidity | base_fee_percentage | apr | apy | volume_24h | fees_24h" ) separator = "-" * 300 # Format each pool as a row rows = [] for pool in pools: - # Extract nested volume fields - volume = pool.get("volume", {}) - volume_hour_1 = volume.get("hour_1", "N/A") - volume_hour_12 = volume.get("hour_12", "N/A") - volume_hour_24 = volume.get("hour_24", "N/A") - - # Extract nested fee_tvl_ratio fields - fee_tvl_ratio = pool.get("fee_tvl_ratio", {}) - fee_tvl_ratio_hour_1 = fee_tvl_ratio.get("hour_1", "N/A") - fee_tvl_ratio_hour_12 = fee_tvl_ratio.get("hour_12", "N/A") - fee_tvl_ratio_hour_24 = fee_tvl_ratio.get("hour_24", "N/A") - row = ( f"{get_field(pool, 'address', default='N/A')} | " + f"{get_field(pool, 'name', default='N/A')} | " f"{get_field(pool, 'trading_pair', default='N/A')} | " f"{get_field(pool, 'mint_x', default='N/A')} | " f"{get_field(pool, 'mint_y', default='N/A')} | " @@ -95,16 +100,10 @@ def format_pools_as_detailed_table(pools: list[dict[str, Any]]) -> str: f"{format_number(get_field(pool, 'current_price', default=None))} | " f"{format_number(get_field(pool, 'liquidity', default=None))} | " f"{format_number(get_field(pool, 'base_fee_percentage', default=None))} | " - f"{format_number(get_field(pool, 'max_fee_percentage', default=None))} | " - f"{format_number(get_field(pool, 'protocol_fee_percentage', default=None))} | " f"{format_number(get_field(pool, 'apr', default=None))} | " f"{format_number(get_field(pool, 'apy', default=None))} | " - f"{format_number(volume_hour_1)} | " - f"{format_number(volume_hour_12)} | " - f"{format_number(volume_hour_24)} | " - f"{format_number(fee_tvl_ratio_hour_1)} | " - f"{format_number(fee_tvl_ratio_hour_12)} | " - f"{format_number(fee_tvl_ratio_hour_24)}" + f"{format_number(get_field(pool, 'volume_24h', default=None))} | " + f"{format_number(get_field(pool, 'fees_24h', default=None))}" ) rows.append(row) @@ -127,6 +126,7 @@ async def explore_gateway_clmm_pools( - orca (Solana): Whirlpools - uniswap (Ethereum/EVM): V3 pools - pancakeswap (BSC/EVM): V3 pools + - pancakeswap-sol (Solana): CLMM pools """ # ============================================ # LIST POOLS - Browse available pools @@ -182,6 +182,7 @@ async def explore_gateway_clmm_pools( connector=request.connector, network=request.network, pool_address=request.pool_address, + bin_count=request.bin_count, ) return { @@ -194,3 +195,181 @@ async def explore_gateway_clmm_pools( else: raise ToolError(f"Unknown action: {request.action}") + + +def _clmm_guide() -> str: + guide_file = Path(__file__).parent.parent / "guides" / "gateway_clmm.md" + if guide_file.exists(): + return guide_file.read_text().strip() + raise ToolError("CLMM guide not found (guides/gateway_clmm.md)") + + +def _dec(value: str | None, name: str) -> Decimal: + if value is None: + raise ToolError(f"{name} is required for this action") + try: + return Decimal(str(value)) + except Exception as exc: + raise ToolError(f"{name} must be a number, got {value!r}") from exc + + +def _opt_dec(value: str | None) -> Decimal | None: + return None if value is None else Decimal(str(value)) + + +def _require_clmm(request: CLMMRequest, *fields: str) -> None: + for f in fields: + if getattr(request, f) is None: + raise ToolError(f"{f} is required for the '{request.action}' action") + + +def _normalize_connector(connector: str) -> str: + """Accept both 'orca' and the 'orca/clmm' form an lp_executor config records as lp_provider.""" + name = connector.lower().strip() + if name.endswith("/clmm"): + name = name[: -len("/clmm")] + if name not in SUPPORTED_CLMM_CONNECTORS: + raise ToolError( + f"Unsupported CLMM connector '{connector}'. Supported: {', '.join(sorted(SUPPORTED_CLMM_CONNECTORS))}" + ) + return name + + +async def manage_clmm_impl(client: Any, request: CLMMRequest) -> dict[str, Any]: + """Validate per-action and dispatch to client.gateway_clmm.*.""" + # Progressive disclosure: no action → load the guide. + if request.action is None: + return {"action": None, "formatted_output": _clmm_guide()} + + if not request.connector: + raise ToolError( + "connector is required (meteora | raydium | orca | pancakeswap-sol | uniswap | " + "pancakeswap). " + "Call manage_clmm with no action to load the guide." + ) + connector = _normalize_connector(request.connector) + if not request.network: + raise ToolError( + "network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')" + ) + + net = request.network + action = request.action + # Dereferenced only after per-action validation so guard errors never depend on the client. + gc = None if client is None else client.gateway_clmm + + if action == "position_info": + if request.position_address: + # Asked about one position, so read that one. positions_owned would answer + # with every position the wallet holds on this connector — not wrong data, + # but not the question, and it silently grows into a list once a wallet + # holds more than one. + result = [ + await gc.get_position_info( + connector=connector, + network=net, + position_address=request.position_address, + ) + ] + else: + # No pool filter: Gateway's positions-owned lists every CLMM position the + # wallet owns on the connector, each row carrying its own pool_address. + result = await gc.get_positions_owned( + connector=connector, + network=net, + wallet_address=request.wallet_address, + ) + + elif action == "open": + _require_clmm(request, "pool_address", "lower_price", "upper_price") + if request.base_token_amount is None and request.quote_token_amount is None: + raise ToolError( + "open requires base_token_amount, quote_token_amount, or both" + ) + result = await gc.open_position( + connector=connector, + network=net, + pool_address=request.pool_address, + lower_price=_dec(request.lower_price, "lower_price"), + upper_price=_dec(request.upper_price, "upper_price"), + base_token_amount=_opt_dec(request.base_token_amount), + quote_token_amount=_opt_dec(request.quote_token_amount), + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, + extra_params=request.extra_params, + ) + + elif action == "add_liquidity": + _require_clmm(request, "position_address") + if request.base_token_amount is None and request.quote_token_amount is None: + raise ToolError( + "add_liquidity requires base_token_amount, quote_token_amount, or both" + ) + result = await gc.add_liquidity( + connector=connector, + network=net, + position_address=request.position_address, + base_token_amount=_opt_dec(request.base_token_amount), + quote_token_amount=_opt_dec(request.quote_token_amount), + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, + extra_params=request.extra_params, + ) + + elif action == "remove_liquidity": + _require_clmm(request, "position_address", "percentage_to_remove") + result = await gc.remove_liquidity( + connector=connector, + network=net, + position_address=request.position_address, + percentage_to_remove=_dec( + request.percentage_to_remove, "percentage_to_remove" + ), + slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, + ) + + elif action == "close": + _require_clmm(request, "position_address") + result = await gc.close_position( + connector=connector, + network=net, + position_address=request.position_address, + pool_address=request.pool_address, + wallet_address=request.wallet_address, + ) + + elif action == "collect_fees": + _require_clmm(request, "position_address") + result = await gc.collect_fees( + connector=connector, + network=net, + position_address=request.position_address, + pool_address=request.pool_address, + wallet_address=request.wallet_address, + ) + + elif action == "create_pool": + _require_clmm(request, "base_token", "quote_token") + result = await gc.create_pool( + connector=connector, + network=net, + base_token=request.base_token, + quote_token=request.quote_token, + # None -> hapi/gateway fetch the market price + initial_price=_opt_dec(request.initial_price), + wallet_address=request.wallet_address, + extra_params=request.extra_params, + ) + + else: + raise ToolError(f"Unknown action: {action}") + + return { + "action": action, + "connector": connector, + "network": net, + "pool_address": request.pool_address, + "position_address": request.position_address, + "result": result, + } diff --git a/mcp_servers/hummingbot_api/tools/gateway_swap.py b/mcp_servers/hummingbot_api/tools/gateway_swap.py index 3843ef8a8..a6f5591a4 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_swap.py +++ b/mcp_servers/hummingbot_api/tools/gateway_swap.py @@ -24,13 +24,21 @@ async def manage_gateway_swaps( Actions: - quote: Get price quote for a swap before executing - - execute: Execute a swap transaction on DEX + - execute: Execute a swap transaction on DEX, priced at execution + - execute_quote: Execute a quote already taken, by its quote_id (routers only) - search: Search swap history with various filters - get_status: Get status of a specific swap by transaction hash Supported DEX Connectors: - jupiter (Solana): Router for Solana swaps - 0x (Ethereum): Aggregator for EVM chains + + A quote carrying `approximation: true` reports an ESTIMATED amount_out rather than + the exact-out amount asked for. A BUY is an ExactOut order, and a thin token with no + ExactOut route is quoted by pricing the sell leg and quoting that input forward, + which costs roughly 2.5%. Nobody is overcharged — the order is silently resized — so + say so whenever the quantity is what the user cares about, and pass + extra_params={'approximateIfNoExactOut': False} to require an exact route instead. """ # ============================================ # QUOTE - Get swap price quote @@ -60,7 +68,13 @@ async def manage_gateway_swaps( trading_pair=request.trading_pair, side=request.side, amount=Decimal(request.amount), - slippage_pct=Decimal(request.slippage_pct or "1.0"), + # None -> SDK omits it and the connector's configured slippage applies + slippage_pct=( + Decimal(request.slippage_pct) + if request.slippage_pct is not None + else None + ), + extra_params=request.extra_params, ) return { @@ -99,8 +113,14 @@ async def manage_gateway_swaps( trading_pair=request.trading_pair, side=request.side, amount=Decimal(request.amount), - slippage_pct=Decimal(request.slippage_pct or "1.0"), + # None -> SDK omits it and the connector's configured slippage applies + slippage_pct=( + Decimal(request.slippage_pct) + if request.slippage_pct is not None + else None + ), wallet_address=request.wallet_address, + extra_params=request.extra_params, ) return { @@ -112,6 +132,47 @@ async def manage_gateway_swaps( "result": result, } + # ============================================ + # EXECUTE_QUOTE - Commit to a quote already taken + # ============================================ + elif request.action == "execute_quote": + if not request.connector: + raise ToolError("connector is required for execute_quote action") + if not request.network: + raise ToolError("network is required for execute_quote action") + if not request.quote_id: + raise ToolError( + "quote_id is required for execute_quote action: take a quote first " + "(action='quote') and pass the quote_id it returns. Only router connectors " + "return one." + ) + if not request.trading_pair or not request.side or not request.amount: + raise ToolError( + "trading_pair, side and amount are required for execute_quote: Gateway " + "identifies the swap by quote_id alone, but the recorded trade has to be " + "filed under the pair and size it was for." + ) + + result = await client.gateway_swap.execute_quote( + connector=request.connector, + network=request.network, + quote_id=request.quote_id, + trading_pair=request.trading_pair, + side=request.side, + amount=Decimal(request.amount), + wallet_address=request.wallet_address, + ) + + return { + "action": "execute_quote", + "trading_pair": request.trading_pair, + "side": request.side, + "amount": request.amount, + "quote_id": request.quote_id, + "wallet_address": request.wallet_address or "(default)", + "result": result, + } + # ============================================ # GET STATUS - Get swap status by tx hash # ============================================ diff --git a/mcp_servers/hummingbot_api/tools/portfolio.py b/mcp_servers/hummingbot_api/tools/portfolio.py index 88ff5778f..b981feb3f 100644 --- a/mcp_servers/hummingbot_api/tools/portfolio.py +++ b/mcp_servers/hummingbot_api/tools/portfolio.py @@ -18,12 +18,12 @@ logger = logging.getLogger("hummingbot-mcp") -# Upper bound on concurrent real-time CLMM pool reads through Gateway. These are -# on-chain queries — the slowest calls in the overview — so they are fanned out -# instead of walked one at a time, but bounded so an LP-heavy account never +# Upper bound on concurrent real-time CLMM position reads through Gateway. These +# are on-chain queries — the slowest calls in the overview — so they are fanned +# out instead of walked one at a time, but bounded so an LP-heavy account never # bursts unlimited simultaneous Gateway/RPC requests (same bound the other # fan-outs in this repo use). -MAX_CONCURRENT_POOL_FETCHES = 10 +MAX_CONCURRENT_POSITION_FETCHES = 10 async def get_portfolio_overview( @@ -43,8 +43,8 @@ async def get_portfolio_overview( 1. Token Balances - Real-time holdings across CEX/DEX exchanges 2. Perpetual Positions - Active perp futures positions from CEX 3. LP Positions (CLMM) - Real-time concentrated liquidity positions from blockchain DEXs - - Queries database to find all pools user has interacted with - - Calls get_positions() for each pool to fetch real-time blockchain data + - Queries database to find the connector/network venues the user has used + - Calls positions-owned once per venue to fetch real-time blockchain data - Includes real-time fees and token amounts 4. Active Orders - Currently open orders across all exchanges @@ -123,48 +123,48 @@ async def get_lp_positions(): if not db_positions: return [] - # Step 2: Get unique pool addresses and their networks/connectors - pools_map = {} # {(connector, network, pool_address): True} + # Step 2: Get the unique connector/network venues to query. + # Gateway's positions-owned takes no pool filter — one call + # per venue returns every position the wallet owns there, + # each row carrying its own pool_address — so querying per + # pool would just repeat the same list N times. + venues = [] for pos in db_positions: connector = pos.get("connector") network = pos.get("network") - pool_address = pos.get("pool_address") - if connector and network and pool_address: - pools_map[(connector, network, pool_address)] = True - - # Step 3: Fetch real-time data for each pool, concurrently. - # Total latency is one pool's round-trip (per semaphore slot) - # instead of the sum over every pool. Each call keeps its own - # try/except so one bad pool is logged and skipped exactly as - # in the serial version, and results stay positionally aligned - # with `pools` so every position is stamped with its own pool's - # connector/network. - pools = list(pools_map.keys()) - semaphore = asyncio.Semaphore(MAX_CONCURRENT_POOL_FETCHES) - - async def fetch_pool(connector, network, pool_address): + if connector and network and (connector, network) not in venues: + venues.append((connector, network)) + + # Step 3: Fetch real-time data for each venue, concurrently. + # Total latency is one venue's round-trip (per semaphore slot) + # instead of the sum over every venue. Each call keeps its own + # try/except so one bad venue is logged and skipped, and + # results stay positionally aligned with `venues` so every + # position is stamped with its own venue's connector/network. + semaphore = asyncio.Semaphore(MAX_CONCURRENT_POSITION_FETCHES) + + async def fetch_venue(connector, network): async with semaphore: try: return await client.gateway_clmm.get_positions_owned( connector=connector, network=network, - pool_address=pool_address, wallet_address=None, # Uses default wallet ) except Exception as e: logger.warning( - f"Failed to get positions for pool {pool_address}: {str(e)}" + f"Failed to get positions for {connector} on " + f"{network}: {str(e)}" ) return None - pool_results = await asyncio.gather( - *(fetch_pool(*pool) for pool in pools), return_exceptions=True + venue_results = await asyncio.gather( + *(fetch_venue(*venue) for venue in venues), + return_exceptions=True, ) real_time_positions = [] - for (connector, network, _pool_address), positions in zip( - pools, pool_results - ): + for (connector, network), positions in zip(venues, venue_results): if isinstance(positions, BaseException): continue if positions and isinstance(positions, list): diff --git a/pyproject.toml b/pyproject.toml index a0d3be286..b3284c2fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Telegram bot for Hummingbot trading via Backend API" requires-python = ">=3.12" dependencies = [ "python-telegram-bot[job-queue]", - "hummingbot-api-client==1.5.7", + "hummingbot-api-client==1.5.8", "python-dotenv", "PyYAML", "aiohttp", @@ -33,6 +33,20 @@ dependencies = [ # ``black --check`` with no black on PATH and failed to spawn it. dev = ["pytest", "pytest-asyncio", "black", "isort", "pre-commit"] +[tool.uv.sources] +# 1.5.8 is not on PyPI yet (hummingbot/hummingbot-api-client#26). Until that +# releases, resolve the ==1.5.8 pin from the PR branch so ``uv sync`` works in +# CI and locally. Drop this whole block and re-run ``uv lock`` once 1.5.8 ships. +# +# A branch MOVES, and the lockfile pins a commit on it. Re-run +# ``uv lock --upgrade-package hummingbot-api-client`` after every push to that +# branch: the lock once sat four commits behind the method condor calls, and only +# tests/test_client_surface.py notices, because every other test stubs the client. +# +# Merge order: this PR merges LAST — client PR merged and 1.5.8 published to PyPI +# first, then drop this block here. +hummingbot-api-client = { git = "https://github.com/hummingbot/hummingbot-api-client.git", branch = "feat/clmm-bin-count" } + [tool.isort] profile = "black" diff --git a/tests/test_agents.py b/tests/test_agents.py index 4d61c8485..14fa42203 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -736,6 +736,9 @@ class _NumericPasswordServer: def get_accessible_servers(self, user_id): return ["local"] + def has_server_access(self, user_id, name, permission=None): + return True + def get_server(self, name): return { "host": "localhost", diff --git a/tests/test_amm_write_formatting.py b/tests/test_amm_write_formatting.py new file mode 100644 index 000000000..8f53d2f83 --- /dev/null +++ b/tests/test_amm_write_formatting.py @@ -0,0 +1,152 @@ +"""An AMM add or remove has to say what it moved. + +The formatter's add_liquidity/remove_liquidity branch printed three lines — position, tx, +status — and nothing else, so a close that moved 3188 DOGE-1 and 0.0219 SOL reported +neither. hummingbot-api returns those amounts (they reach its database, which is how a +4x rent-inflation bug was found), and the CLMM branch twenty lines earlier renders the +same three kinds of money properly. AMM was the odd one out. + +The tx line was wrong too: it read `transaction_hash`, a field of the CLMM models. +AMMTransactionResponse carries `signature`, so every AMM write printed "Tx: None". +""" + +from mcp_servers.hummingbot_api.formatters.gateway import format_amm_result + + +def _result(action, data): + return { + "action": action, + "connector": "meteora", + "network": "solana-mainnet-beta", + "pool_address": "pool-addr", + "position_address": "F1YcTMd6BEKAjECLSvZJasXiWTwMbNRTx5fmDwfcm6N5", + "result": {"signature": "67ZzMAHv", "status": "CONFIRMED", "data": data}, + } + + +REMOVED = { + "fee": 0.000013214, + "baseTokenAmountRemoved": 3188.755913808, + "quoteTokenAmountRemoved": 0.005299128, + "positionRentRefunded": 0.0119364, +} + + +def test_a_removal_reports_the_amounts_it_withdrew(): + out = format_amm_result("remove_liquidity", _result("remove_liquidity", REMOVED)) + + assert "3188.755913808" in out + assert "0.005299128" in out + + +def test_a_removal_separates_rent_from_liquidity(): + # Two different kinds of money: the rent was locked at open and is not a withdrawal. + out = format_amm_result("remove_liquidity", _result("remove_liquidity", REMOVED)) + + assert "0.0119364" in out + assert "rent" in out.lower() + + +def test_the_transaction_is_identified_by_its_signature(): + out = format_amm_result("remove_liquidity", _result("remove_liquidity", REMOVED)) + + assert "67ZzMAHv" in out + assert "None" not in out + + +def test_an_add_reports_what_it_deposited(): + out = format_amm_result( + "add_liquidity", + _result( + "add_liquidity", + { + "fee": 0.00001, + "baseTokenAmountAdded": 3000.0, + "quoteTokenAmountAdded": 0.01522083, + "positionRent": 0.0099, + }, + ), + ) + + assert "3000.0" in out + assert "0.01522083" in out + assert "0.0099" in out + + +def test_a_partial_removal_omits_a_rent_refund_it_did_not_get(): + # Removing less than everything leaves the position account open, so no rent comes + # back. Printing "Position rent refunded: None" would read as a missing number + # rather than an absent event. + out = format_amm_result( + "remove_liquidity", + _result( + "remove_liquidity", + {"baseTokenAmountRemoved": 1.0, "quoteTokenAmountRemoved": 2.0}, + ), + ) + + assert "rent" not in out.lower() + assert "1.0" in out + + +def test_a_submitted_write_still_formats(): + # No data at all until the transaction confirms. + out = format_amm_result("add_liquidity", _result("add_liquidity", None)) + + assert "SUBMITTED" in out or "CONFIRMED" in out + + +# The CLMM surface has the same gap and a different shape: /clmm/add and /clmm/remove +# return a flat snake_case dict with `transaction_hash`, not an AMMTransactionResponse +# wrapping Gateway's camelCase `data`. Both branches now print the amounts; neither may +# be "corrected" into the other's key convention. +from mcp_servers.hummingbot_api.formatters.gateway import format_clmm_result + + +def _clmm(action, payload): + return { + "action": action, + "connector": "orca", + "network": "solana-mainnet-beta", + "result": payload, + } + + +def test_a_clmm_removal_reports_the_amounts_it_withdrew(): + out = format_clmm_result( + "remove_liquidity", + _clmm( + "remove_liquidity", + { + "transaction_hash": "4Rg7buez", + "position_address": "4G5GyCPi", + "base_token_amount_removed": 0.0101, + "quote_token_amount_removed": 6.095924, + "gas_fee": 0.00001, + "status": "confirmed", + }, + ), + ) + + assert "0.0101" in out + assert "6.095924" in out + assert "4Rg7buez" in out + + +def test_a_clmm_add_reports_what_it_deposited(): + out = format_clmm_result( + "add_liquidity", + _clmm( + "add_liquidity", + { + "transaction_hash": "5aMZatkF", + "position_address": "4G5GyCPi", + "base_token_amount_added": 3000.0, + "quote_token_amount_added": 0.01522083, + "status": "confirmed", + }, + ), + ) + + assert "3000.0" in out + assert "0.01522083" in out diff --git a/tests/test_client_surface.py b/tests/test_client_surface.py new file mode 100644 index 000000000..71ead977d --- /dev/null +++ b/tests/test_client_surface.py @@ -0,0 +1,57 @@ +"""The installed client must actually carry the methods condor calls. + +condor's pyproject resolves ``hummingbot-api-client==1.5.8`` from a PR branch until +1.5.8 ships on PyPI, and a branch moves. The lockfile pinned a commit from before +``execute_quote`` was added while ``mcp_servers/hummingbot_api/tools/gateway_swap.py`` +already called it — an AttributeError on the first real invocation. Nothing caught it: +every other test in this suite hands the tools a stub client that defines whatever it +is asked for, so the stubs answered for a method the real package did not have. + +These assertions run against the resolved dependency, so a lockfile that drifts behind +the surface condor uses fails here instead of in front of a user. +""" + +import inspect + +import pytest + +pytest.importorskip("hummingbot_api_client") + +from hummingbot_api_client import HummingbotAPIClient # noqa: E402 + +# Method per router, taken from what condor actually calls. +REQUIRED_SURFACE = { + "gateway_swap": [ + "execute_quote", + "execute_swap", + "get_swap_quote", + "get_swap_status", + "search_swaps", + ], +} + + +@pytest.mark.parametrize( + "router,method", + [ + (router, method) + for router, methods in REQUIRED_SURFACE.items() + for method in methods + ], +) +def test_the_installed_client_exposes_what_condor_calls(router, method): + client = HummingbotAPIClient.__new__(HummingbotAPIClient) + router_type = type(client).__annotations__.get(router) + if router_type is None: + # Routers are attributes built in __init__ rather than annotations on some + # versions; fall back to the class the package exports for this router. + module = __import__(f"hummingbot_api_client.routers.{router}", fromlist=["*"]) + router_type = next( + obj + for name, obj in vars(module).items() + if inspect.isclass(obj) and obj.__module__ == module.__name__ + ) + assert hasattr(router_type, method), ( + f"The installed hummingbot-api-client has no {router}.{method}. condor calls it; " + "the lockfile is resolving a commit from before it was added." + ) diff --git a/tests/test_confirmation.py b/tests/test_confirmation.py index 3285fef52..e149cb15e 100644 --- a/tests/test_confirmation.py +++ b/tests/test_confirmation.py @@ -1,6 +1,6 @@ """Unit tests for the human-readable confirmation-prompt formatter.""" -from handlers.agents.confirmation import _format_tool_summary +from handlers.agents.confirmation import format_tool_summary def _bot_call(action: str, **extra) -> dict: @@ -8,7 +8,7 @@ def _bot_call(action: str, **extra) -> dict: def test_format_manage_bots_deploy(): - summary = _format_tool_summary( + summary = format_tool_summary( _bot_call("deploy", bot_name="mm-bot", controllers_config=["cfg_a"]) ) assert "mm-bot" in summary @@ -16,7 +16,7 @@ def test_format_manage_bots_deploy(): def test_format_manage_bots_update_config(): - summary = _format_tool_summary( + summary = format_tool_summary( _bot_call("update_config", bot_name="mm-bot", config_name="cfg_a") ) assert "mm-bot" in summary @@ -24,6 +24,6 @@ def test_format_manage_bots_update_config(): def test_format_manage_bots_stop_bot(): - summary = _format_tool_summary(_bot_call("stop_bot", bot_name="mm-bot")) + summary = format_tool_summary(_bot_call("stop_bot", bot_name="mm-bot")) assert "mm-bot" in summary assert "stop_bot" in summary diff --git a/tests/test_dangerous_gate_names_resolve.py b/tests/test_dangerous_gate_names_resolve.py new file mode 100644 index 000000000..18d8b90b9 --- /dev/null +++ b/tests/test_dangerous_gate_names_resolve.py @@ -0,0 +1,175 @@ +"""Every safety-gate name must resolve to a real MCP tool and a real action. + +The gates are string sets matched against tool names and action literals. A name +that no longer matches anything does not fail loudly — it silently stops gating, +so the call executes unconfirmed and, in dry-run mode, executes for real. That is +exactly what happened: the lists named ``manage_gateway_clmm`` (registered as +``manage_clmm``) with actions ``open_position``/``close_position`` (registered as +``open``/``close``), and omitted ``manage_amm`` altogether, so every AMM and CLMM +write bypassed both gates. + +These assert the gate strings against the tool registry itself, so a rename on +either side fails here instead of in production. +""" + +import inspect +import typing + +from handlers.agents._shared import ( + DANGEROUS_AMM_ACTIONS, + DANGEROUS_BOT_ACTIONS, + DANGEROUS_CLMM_ACTIONS, + DANGEROUS_CONFIG_RESOURCES, + DANGEROUS_EXECUTOR_ACTIONS, + DANGEROUS_SWAP_ACTIONS, + DANGEROUS_TOOLS, + is_dangerous_tool_call, +) +from mcp_servers.hummingbot_api import server as mcp_server + +# Gate names that belong to a different MCP server than hummingbot_api. +_FOREIGN_TOOLS = {"place_order"} + + +def _registered_tools() -> dict: + """Every function the hummingbot_api MCP server registers as a tool.""" + return { + name: obj.fn if hasattr(obj, "fn") else obj + for name, obj in vars(mcp_server).items() + if callable(obj) and not name.startswith("_") + } + + +def _action_literals(tool_name: str) -> set[str]: + """The action literals a registered tool actually accepts.""" + fn = _registered_tools()[tool_name] + annotation = inspect.signature(fn).parameters["action"].annotation + # `action` is either a bare Literal[...] or `Literal[...] | None`. + literals = {str(v) for v in typing.get_args(annotation) if isinstance(v, str)} + for arg in typing.get_args(annotation): + literals.update(str(v) for v in typing.get_args(arg) if isinstance(v, str)) + return literals + + +def test_every_gated_tool_name_is_registered(): + for tool_name in DANGEROUS_TOOLS - _FOREIGN_TOOLS: + assert ( + tool_name in _registered_tools() + ), f"{tool_name} is gated but not registered — the gate matches nothing" + + +def test_gated_actions_exist_on_their_tools(): + for tool_name, actions in ( + ("manage_clmm", DANGEROUS_CLMM_ACTIONS), + ("manage_amm", DANGEROUS_AMM_ACTIONS), + ("manage_gateway_swaps", DANGEROUS_SWAP_ACTIONS), + ("manage_executors", DANGEROUS_EXECUTOR_ACTIONS), + ("manage_bots", DANGEROUS_BOT_ACTIONS), + ): + available = _action_literals(tool_name) + unknown = actions - available + assert not unknown, f"{tool_name} has no such action(s): {sorted(unknown)}" + + +def test_every_liquidity_moving_action_is_gated(): + """Read-only actions stay ungated; anything that moves funds must be gated.""" + read_only = {"pool_info", "position_info", "positions_owned", "quote_liquidity"} + + for tool_name, gated in ( + ("manage_clmm", DANGEROUS_CLMM_ACTIONS), + ("manage_amm", DANGEROUS_AMM_ACTIONS), + ): + writes = _action_literals(tool_name) - read_only + assert ( + writes <= gated + ), f"{tool_name} write action(s) ungated: {sorted(writes - gated)}" + + +def test_lp_writes_require_confirmation(): + for tool_name, action in ( + ("manage_clmm", "open"), + ("manage_clmm", "close"), + ("manage_clmm", "remove_liquidity"), + ("manage_amm", "add_liquidity"), + ("manage_amm", "create_pool"), + ): + call = {"tool": tool_name, "input": {"action": action}} + assert is_dangerous_tool_call(call), f"{tool_name}({action}) was not gated" + + +def test_lp_reads_do_not_require_confirmation(): + for tool_name, action in ( + ("manage_clmm", "position_info"), + ("manage_amm", "pool_info"), + ("manage_amm", "quote_liquidity"), + ): + call = {"tool": tool_name, "input": {"action": action}} + assert not is_dangerous_tool_call(call), f"{tool_name}({action}) gated a read" + + +def test_gated_calls_render_a_specific_confirmation_summary(): + """A gated call must describe itself in the approval prompt. + + The summary renderer branched on ``manage_gateway_clmm`` too, so once the + gate started matching, the prompt fell through to the generic branch and + asked the user to approve the bare string "manage_clmm" — a confirmation + that shows nothing to confirm. + """ + from handlers.agents.confirmation import format_tool_summary + + for tool_name, action, expected in ( + ("manage_clmm", "open", "Open CLMM position"), + ("manage_clmm", "close", "Close CLMM position"), + ("manage_clmm", "remove_liquidity", "Remove"), + ("manage_amm", "add_liquidity", "Add"), + ("manage_amm", "create_pool", "Create AMM pool"), + ): + summary = format_tool_summary( + {"tool": tool_name, "input": {"action": action, "connector": "meteora"}} + ) + assert expected in summary, f"{tool_name}({action}) rendered {summary!r}" + assert summary != tool_name + + +def test_gateway_config_gates_wallets_and_only_wallets(): + """manage_gateway_config is gated on resource_type, not action. + + `wallets` + `add` takes a private key, so it needs a human. Everything else the + tool edits is Gateway's own symbol/address mapping — deleting a token moves no + funds and changes nothing on-chain, so gating it would stop a config edit while + leaving the trades it enables ungated. + """ + fn = _registered_tools()["manage_gateway_config"] + resources = { + str(v) + for v in typing.get_args( + inspect.signature(fn).parameters["resource_type"].annotation + ) + if isinstance(v, str) + } + assert DANGEROUS_CONFIG_RESOURCES <= resources, ( + f"gated resource(s) the tool has no such value for: " + f"{sorted(DANGEROUS_CONFIG_RESOURCES - resources)}" + ) + assert DANGEROUS_CONFIG_RESOURCES == {"wallets"} + + assert is_dangerous_tool_call( + { + "tool": "manage_gateway_config", + "input": {"resource_type": "wallets", "action": "add"}, + } + ) + for resource in resources - DANGEROUS_CONFIG_RESOURCES: + for action in ("list", "add", "delete"): + assert not is_dangerous_tool_call( + { + "tool": "manage_gateway_config", + "input": {"resource_type": resource, "action": action}, + } + ), f"{resource}/{action} should not need confirmation" + + +def test_gateway_config_fails_closed_on_an_unreadable_resource(): + """SEC-093: a call whose resource_type cannot be read is treated as dangerous.""" + for bad in ({}, {"resource_type": None}, {"resource_type": 7}, {"action": "add"}): + assert is_dangerous_tool_call({"tool": "manage_gateway_config", "input": bad}) diff --git a/tests/test_dex_swap_slippage_and_errors.py b/tests/test_dex_swap_slippage_and_errors.py new file mode 100644 index 000000000..3f94d5fda --- /dev/null +++ b/tests/test_dex_swap_slippage_and_errors.py @@ -0,0 +1,188 @@ +"""The DEX swap surface must not invent a slippage, and must not hide Gateway's reason. + +Two contracts pinned here: + +1. **Slippage.** Stack-wide, an OMITTED slippage means "the connector's configured slippage + applies"; an explicit 0 is a real value. The Telegram swap UI used to substitute "1.0" for an + unset slippage and forward it unconditionally, silently overriding every connector's own + setting on the money path. + +2. **Error surfacing.** Quote/execute failures used to be caught bare, logged at warning, and + rendered to the user as "No quotes available for this pair" — a 404 "No pool found", a 400 + "Unsupported swap provider", and a genuine no-route were indistinguishable. The SDK raises + aiohttp.ClientResponseError carrying the HTTP status and the API's own detail; that has to + reach the user. + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" + +import asyncio +from decimal import Decimal + +import aiohttp +import pytest + +from condor.preferences import get_dex_swap_defaults, set_dex_slippage +from handlers.dex.swap import ( + _last_swap_params, + _quote_errors, + _slippage_button_label, + describe_api_error, + get_slippage_pct, + resolve_quote_denominated_amount, +) + + +def _response_error(status: int, message: str) -> aiohttp.ClientResponseError: + return aiohttp.ClientResponseError( + request_info=None, history=(), status=status, message=message + ) + + +# ---------------------------------------------------------------- slippage + + +def test_unset_slippage_is_omitted_not_defaulted_to_one_percent(): + """No slippage in params -> None, which makes the SDK omit slippage_pct entirely.""" + assert get_slippage_pct({}) is None + assert get_slippage_pct({"connector": "jupiter", "slippage": None}) is None + + +def test_explicit_zero_slippage_is_a_real_zero(): + """'0' means zero tolerance, NOT 'use the default' — it must survive as Decimal(0).""" + assert get_slippage_pct({"slippage": "0"}) == Decimal("0") + + +def test_explicit_slippage_is_parsed_with_or_without_a_percent_sign(): + assert get_slippage_pct({"slippage": "2.5"}) == Decimal("2.5") + assert get_slippage_pct({"slippage": "2.5%"}) == Decimal("2.5") + + +def test_the_button_reads_as_the_connector_default_until_the_user_overrides(): + assert _slippage_button_label({}) == "📊 Slippage: default" + assert _slippage_button_label({"slippage": "0"}) == "📊 0%" + assert _slippage_button_label({"slippage": "2.5"}) == "📊 2.5%" + + +def test_swap_defaults_carry_no_slippage_for_a_fresh_user(): + """A user who never set one must not get a fabricated 1.0 out of preferences.""" + defaults = get_dex_swap_defaults({}) + assert "slippage" not in defaults + assert defaults["connector"] and defaults["network"] and defaults["trading_pair"] + + +def test_swap_defaults_return_a_slippage_the_user_did_set(): + user_data = {} + set_dex_slippage(user_data, "3.0") + assert get_dex_swap_defaults(user_data)["slippage"] == "3.0" + + +def test_last_swap_remembers_a_slippage_only_when_one_was_set(): + """Persisting a fabricated slippage would resurrect the override on the next swap.""" + without = _last_swap_params("jupiter", "solana-mainnet-beta", "SOL-USDC", "BUY", {}) + assert "slippage" not in without + + with_override = _last_swap_params( + "jupiter", "solana-mainnet-beta", "SOL-USDC", "BUY", {"slippage": "0.5"} + ) + assert with_override["slippage"] == "0.5" + + +# ---------------------------------------------------------------- error surfacing + + +def test_an_http_error_keeps_its_status_and_the_apis_own_message(): + """404 'No pool found' must not read the same as 400 'Unsupported swap provider'.""" + assert ( + describe_api_error(_response_error(404, "No pool found")) + == "HTTP 404: No pool found" + ) + assert ( + describe_api_error(_response_error(400, "Unsupported swap provider")) + == "HTTP 400: Unsupported swap provider" + ) + + +def test_a_non_http_error_still_names_its_cause(): + assert describe_api_error(ValueError("bad pair")) == "bad pair" + assert describe_api_error(TimeoutError()) == "TimeoutError" + + +def test_only_the_sides_that_raised_are_reported_as_errors(): + failure = _response_error(404, "No pool found") + assert _quote_errors({"price": "1"}, failure) == ["SELL: HTTP 404: No pool found"] + assert _quote_errors(failure, failure) == [ + "BUY: HTTP 404: No pool found", + "SELL: HTTP 404: No pool found", + ] + assert _quote_errors({"price": "1"}, {"price": "1"}) == [] + + +# ---------------------------------------------------------------- $ amount conversion + + +class _FakeSwapRouter: + def __init__(self, quote=None, error=None): + self.quote = quote + self.error = error + self.calls = [] + + async def get_swap_quote(self, **kwargs): + self.calls.append(kwargs) + if self.error: + raise self.error + return self.quote + + +class _FakeClient: + def __init__(self, router): + self.gateway_swap = router + + +def test_a_dollar_amount_converts_at_the_quoted_price(): + router = _FakeSwapRouter(quote={"price": "200"}) + amount = asyncio.run( + resolve_quote_denominated_amount( + _FakeClient(router), + Decimal("100"), + "jupiter/router", + "solana-mainnet-beta", + "SOL-USDC", + None, + ) + ) + assert amount == Decimal("0.5") + # The pricing call must itself omit slippage_pct when the user set none + assert router.calls[0]["slippage_pct"] is None + + +def test_a_failed_pricing_call_raises_with_the_real_cause_instead_of_using_the_raw_number(): + """Treating '$100' as 100 base tokens would size the trade catastrophically wrong.""" + router = _FakeSwapRouter(error=_response_error(404, "No pool found")) + with pytest.raises(ValueError) as excinfo: + asyncio.run( + resolve_quote_denominated_amount( + _FakeClient(router), + Decimal("100"), + "meteora/amm", + "solana-mainnet-beta", + "NEW-SOL", + None, + ) + ) + assert "HTTP 404: No pool found" in str(excinfo.value) + + +def test_a_priceless_quote_raises_rather_than_defaulting_the_price_to_one(): + router = _FakeSwapRouter(quote={"amount_out": "3"}) + with pytest.raises(ValueError, match="no price"): + asyncio.run( + resolve_quote_denominated_amount( + _FakeClient(router), + Decimal("100"), + "jupiter/router", + "solana-mainnet-beta", + "SOL-USDC", + None, + ) + ) diff --git a/tests/test_execute_quote_action.py b/tests/test_execute_quote_action.py new file mode 100644 index 000000000..47dbe2a35 --- /dev/null +++ b/tests/test_execute_quote_action.py @@ -0,0 +1,74 @@ +"""The two-step swap flow reaches the agent, or it may as well not exist. + +Gateway has had /trading/router/execute-quote all along and no layer above it exposed +the route: not hummingbot-api, not the client SDK, not this tool. So every swap on +record went through the one-step execute, which re-prices at execution and throws away +the quote the caller was shown — the whole value of a held quote on dflow, titan and 0x. +""" + +import asyncio +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from mcp_servers.hummingbot_api.exceptions import ToolError +from mcp_servers.hummingbot_api.schemas import GatewaySwapRequest +from mcp_servers.hummingbot_api.tools.gateway_swap import manage_gateway_swaps + + +class _Recorder: + def __init__(self): + self.calls = [] + + async def execute_quote(self, **kwargs): + self.calls.append(kwargs) + return {"transaction_hash": "sig", "status": "confirmed"} + + +def _client(recorder): + return SimpleNamespace(gateway_swap=recorder) + + +def _request(**overrides): + fields = dict( + action="execute_quote", + connector="jupiter", + network="solana-mainnet-beta", + quote_id="q-123", + trading_pair="SOL-USDC", + side="SELL", + amount="0.01", + ) + fields.update(overrides) + return GatewaySwapRequest(**fields) + + +def test_the_action_exists_and_passes_the_quote_id_through(): + recorder = _Recorder() + + result = asyncio.run(manage_gateway_swaps(_client(recorder), _request())) + + assert recorder.calls[0]["quote_id"] == "q-123" + assert recorder.calls[0]["amount"] == Decimal("0.01") + assert result["action"] == "execute_quote" + + +def test_a_missing_quote_id_says_how_to_get_one(): + recorder = _Recorder() + + with pytest.raises(ToolError, match="take a quote first"): + asyncio.run(manage_gateway_swaps(_client(recorder), _request(quote_id=None))) + + assert recorder.calls == [] + + +def test_the_pair_and_size_are_required_because_the_record_needs_them(): + # Gateway identifies the swap by quote_id alone, but the stored trade is filed under + # the pair and size — omitting them would write a row that says nothing. + recorder = _Recorder() + + with pytest.raises(ToolError, match="filed under the pair"): + asyncio.run( + manage_gateway_swaps(_client(recorder), _request(trading_pair=None)) + ) diff --git a/tests/test_executor_orphan_flow.py b/tests/test_executor_orphan_flow.py new file mode 100644 index 000000000..f8b5102a7 --- /dev/null +++ b/tests/test_executor_orphan_flow.py @@ -0,0 +1,55 @@ +"""Regression tests for the orphaned-position recovery actions. + +An orphan recovery request that goes unanswered is worse than one that errors: +the agent is told about a stranded on-chain position, asks to resolve it, and +`resolve_orphan` without an executor_id used to fall through get_flow_stage() to +show_schema/list_types — so the caller got a list of executor types back and the +position stayed flagged. These tests pin the routing and the required-input error. + +The repo has no async test setup, so the coroutine is driven with asyncio.run() +instead of a pytest-asyncio marker. +""" + +import asyncio + +import pytest + +from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest +from mcp_servers.hummingbot_api.tools.executors import manage_executors + + +def test_resolve_orphan_without_id_routes_to_resolve_orphan(): + """A missing executor_id must not silently reroute to executor discovery.""" + request = ManageExecutorsRequest(action="resolve_orphan") + + assert request.get_flow_stage() == "resolve_orphan" + + +def test_resolve_orphan_without_id_reports_the_missing_input(): + """The caller gets an actionable error, not unrelated informational output.""" + request = ManageExecutorsRequest(action="resolve_orphan") + + result = asyncio.run(manage_executors(client=None, request=request)) + + assert result["action"] == "resolve_orphan" + assert "executor_id" in result["error"] + assert "orphaned" in result["formatted_output"] + + +def test_resolve_orphan_without_id_ignores_executor_type(): + """executor_type present must not divert the recovery request to show_schema.""" + request = ManageExecutorsRequest( + action="resolve_orphan", executor_type="lp_executor" + ) + + assert request.get_flow_stage() == "resolve_orphan" + + +def test_resolve_orphan_with_id_still_routes_to_the_api_call(): + request = ManageExecutorsRequest(action="resolve_orphan", executor_id="abc123") + + assert request.get_flow_stage() == "resolve_orphan" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_executor_resolve_orphan.py b/tests/test_executor_resolve_orphan.py new file mode 100644 index 000000000..f82bae9a6 --- /dev/null +++ b/tests/test_executor_resolve_orphan.py @@ -0,0 +1,31 @@ +"""resolve_orphan without executor_id must fail loudly, not fall through. + +``get_flow_stage()`` originally required ``executor_id`` for the resolve_orphan +action, so a call missing the id silently dispatched to show_schema/list_types: +the agent's recovery request was ignored and it received unrelated executor-type +listings instead of a required-input error. The action now always routes to the +resolve_orphan stage, and the tool returns an explicit error when the id is +missing (the API client is never touched on that path). + +The repo has no async test setup, so the coroutine is driven with asyncio.run(). +""" + +import asyncio + +from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest +from mcp_servers.hummingbot_api.tools.executors import manage_executors + + +def test_resolve_orphan_without_id_still_routes_to_resolve_orphan(): + request = ManageExecutorsRequest(action="resolve_orphan") + assert request.get_flow_stage() == "resolve_orphan" + + +def test_resolve_orphan_without_id_returns_required_input_error(): + request = ManageExecutorsRequest(action="resolve_orphan") + + result = asyncio.run(manage_executors(client=None, request=request)) + + assert result["action"] == "resolve_orphan" + assert result["error"] == "executor_id is required" + assert 'action="orphaned"' in result["formatted_output"] diff --git a/tests/test_hot_reload_module_discovery.py b/tests/test_hot_reload_module_discovery.py new file mode 100644 index 000000000..5241a4f9b --- /dev/null +++ b/tests/test_hot_reload_module_discovery.py @@ -0,0 +1,95 @@ +"""The hot-reload list is derived, so it cannot drift out of sync with the tree. + +The list this replaced was hand-maintained, and it had drifted both ways: it +named three ``handlers.dex.swap_*`` modules that do not exist and omitted six +that do, including ``handlers.dex.router``. Both failures are silent — +``reload_handlers`` only touches names already in ``sys.modules``, so a stale +entry is a no-op and a missing entry means the watcher logs a successful reload +while the running bot goes on executing the old code. + +These pin the two properties the derivation has to keep: it covers every module +under ``handlers/``, and it orders children before parents. +""" + +import importlib.util +from pathlib import Path + +import main + +HANDLERS_ROOT = Path(main.__file__).parent / "handlers" + + +def test_every_handler_module_on_disk_is_discovered(): + """No module under handlers/ is left out of the reload list.""" + discovered = set(main._discover_handler_modules()) + + on_disk = set() + for path in HANDLERS_ROOT.rglob("*.py"): + parts = path.relative_to(HANDLERS_ROOT.parent).with_suffix("").parts + if "__pycache__" in parts or "store" in parts: + continue + if parts[-1] == "__init__": + parts = parts[:-1] + if parts: + on_disk.add(".".join(parts)) + + assert on_disk, "found no handler modules — the walk is broken" + assert not on_disk - discovered, f"never reloaded: {sorted(on_disk - discovered)}" + + +def test_the_dex_modules_the_old_list_missed_are_covered(): + """Regression: these six exist and were absent from the hand-written list.""" + discovered = set(main._discover_handler_modules()) + for name in ( + "handlers.dex.router", + "handlers.dex.swap", + "handlers.dex.liquidity", + "handlers.dex.lp_monitor_handlers", + "handlers.dex.geckoterminal", + "handlers.dex.visualizations", + ): + assert name in discovered, f"{name} would not hot-reload" + + +def test_no_module_in_the_reload_set_is_a_dead_name(): + """Every name resolves to something importable. + + The old list carried handlers.dex.swap_quote / swap_execute / swap_history, + none of which exist. A dead name never raises — it is simply skipped. + """ + names = [*main._EXTRA_RELOAD_MODULES, *main._discover_handler_modules()] + dead = [n for n in names if importlib.util.find_spec(n) is None] + assert not dead, f"names that resolve to nothing: {dead}" + + +def test_children_reload_before_their_parents(): + """A package's __init__ re-binds from cache, so submodules must go first. + + ``importlib.reload`` is not recursive: reloading ``handlers.dex`` + re-executes ``from .router import ...`` against whatever + ``handlers.dex.router`` is already in ``sys.modules``. Unless the submodule + was refreshed first, the package re-binds the stale object and the edit is + lost. + """ + order = main._discover_handler_modules() + position = {name: i for i, name in enumerate(order)} + + for name, index in position.items(): + parent = name.rpartition(".")[0] + while parent: + if parent in position: + assert index < position[parent], ( + f"{name} reloads after its parent {parent}; " + "the parent would re-bind the stale submodule" + ) + parent = parent.rpartition(".")[0] + + +def test_the_runtime_is_never_reloaded(): + """condor.runtime holds live agent subprocess handles. + + Re-executing those modules resets the registry and silently orphans every + running agent, so the derivation must not be able to reach them. + """ + names = [*main._EXTRA_RELOAD_MODULES, *main._discover_handler_modules()] + assert not [n for n in names if n.startswith("condor.runtime")] diff --git a/tests/test_hummingbot_mcp_tools.py b/tests/test_hummingbot_mcp_tools.py index 07b963a1b..0e9ac3b6c 100644 --- a/tests/test_hummingbot_mcp_tools.py +++ b/tests/test_hummingbot_mcp_tools.py @@ -22,7 +22,7 @@ from mcp_servers.hummingbot_api.tools.controllers import modify_controllers from mcp_servers.hummingbot_api.tools.portfolio import ( - MAX_CONCURRENT_POOL_FETCHES, + MAX_CONCURRENT_POSITION_FETCHES, get_portfolio_overview, ) @@ -81,15 +81,20 @@ def test_the_backtesting_tools_are_gone(): class FakeGatewayClmm: - """CLMM surface where each pool read costs POOL_LATENCY, as an on-chain read does. + """CLMM surface where each venue read costs POOL_LATENCY, as an on-chain read does. - ``failing_pool`` raises the way a dead RPC does, so the skip-and-continue - behaviour of the serial loop stays pinned. + ``get_positions_owned`` mirrors the real SDK signature exactly — connector, + network, wallet_address, and NO pool_address. Gateway's positions-owned has + no pool filter; every returned row carries its own ``pool_address``. A fake + that accepted one hid a live TypeError at two call sites. + + ``failing_venue`` raises the way a dead RPC does, so the skip-and-continue + behaviour stays pinned. """ - def __init__(self, pools, failing_pool=None): + def __init__(self, pools, failing_venue=None): self.pools = pools - self.failing_pool = failing_pool + self.failing_venue = failing_venue self.max_in_flight = 0 self._in_flight = 0 @@ -101,21 +106,22 @@ async def search_positions(self, limit, offset, status): ] } - async def get_positions_owned( - self, connector, network, pool_address, wallet_address=None - ): + async def get_positions_owned(self, connector, network, wallet_address=None): self._in_flight += 1 self.max_in_flight = max(self.max_in_flight, self._in_flight) try: await asyncio.sleep(POOL_LATENCY) - if pool_address == self.failing_pool: + if (connector, network) == self.failing_venue: raise RuntimeError("gateway RPC down") - # No connector/network of its own: the tool stamps them per pool. + # No connector/network of its own: the tool stamps them per venue. return [ { "trading_pair": f"PAIR-{pool_address}", "position_address": pool_address, + "pool_address": pool_address, } + for (c, n, pool_address) in self.pools + if (c, n) == (connector, network) ] finally: self._in_flight -= 1 @@ -138,9 +144,9 @@ def _lp_overview(client): ) -def test_clmm_pools_are_fetched_concurrently_not_one_at_a_time(): - """K on-chain pool reads must cost ~1 round-trip, not K of them stacked.""" - pools = [("meteora", "mainnet-beta", f"pool{i}") for i in range(8)] +def test_clmm_venues_are_fetched_concurrently_not_one_at_a_time(): + """K on-chain venue reads must cost ~1 round-trip, not K of them stacked.""" + pools = [("meteora", f"net{i}", f"pool{i}") for i in range(8)] clmm = FakeGatewayClmm(pools) start = time.monotonic() @@ -150,16 +156,16 @@ def test_clmm_pools_are_fetched_concurrently_not_one_at_a_time(): serial = len(pools) * POOL_LATENCY assert ( elapsed < serial / 2 - ), f"{len(pools)} pools took {elapsed:.3f}s; serial would be ~{serial:.3f}s" + ), f"{len(pools)} venues took {elapsed:.3f}s; serial would be ~{serial:.3f}s" section = next(s for s in result["sections"] if s["title"] == "LP Positions (CLMM)") assert section["total_positions"] == len(pools) -def test_one_failing_pool_is_skipped_and_the_rest_still_return(): - """A dead pool degrades exactly as it did serially: logged, skipped, no propagation.""" - pools = [("meteora", "mainnet-beta", f"pool{i}") for i in range(4)] - clmm = FakeGatewayClmm(pools, failing_pool="pool2") +def test_one_failing_venue_is_skipped_and_the_rest_still_return(): + """A dead venue degrades gracefully: logged, skipped, no propagation.""" + pools = [("meteora", f"net{i}", f"pool{i}") for i in range(4)] + clmm = FakeGatewayClmm(pools, failing_venue=("meteora", "net2")) result = _lp_overview(FakeGatewayClient(clmm)) @@ -168,6 +174,21 @@ def test_one_failing_pool_is_skipped_and_the_rest_still_return(): assert "pool2" not in section["content"] +def test_one_venue_with_many_pools_is_read_once_and_not_duplicated(): + """positions-owned takes no pool filter: querying per pool would repeat the + same list once per pool. One call per venue, each position exactly once.""" + pools = [("meteora", "mainnet-beta", f"pool{i}") for i in range(5)] + clmm = FakeGatewayClmm(pools) + + result = _lp_overview(FakeGatewayClient(clmm)) + + assert clmm.max_in_flight == 1, "five pools on one venue is one Gateway read" + section = next(s for s in result["sections"] if s["title"] == "LP Positions (CLMM)") + assert section["total_positions"] == len(pools) + for _, _, pool_address in pools: + assert section["content"].count(f"PAIR-{pool_address}") == 1 + + def test_each_position_keeps_its_own_pools_connector_and_network(): """The fan-out must not cross-stamp: position N carries pool N's connector.""" pools = [ @@ -189,17 +210,17 @@ def test_each_position_keeps_its_own_pools_connector_and_network(): ), f"{pool_address} was stamped with the wrong connector" -def test_pool_fan_out_is_bounded_by_an_explicit_constant(): - """A many-pool account must not burst unlimited simultaneous Gateway requests.""" +def test_position_fan_out_is_bounded_by_an_explicit_constant(): + """A many-venue account must not burst unlimited simultaneous Gateway requests.""" pools = [ - ("meteora", "mainnet-beta", f"pool{i}") - for i in range(MAX_CONCURRENT_POOL_FETCHES * 3) + ("meteora", f"net{i}", f"pool{i}") + for i in range(MAX_CONCURRENT_POSITION_FETCHES * 3) ] clmm = FakeGatewayClmm(pools) _lp_overview(FakeGatewayClient(clmm)) - assert clmm.max_in_flight <= MAX_CONCURRENT_POOL_FETCHES + assert clmm.max_in_flight <= MAX_CONCURRENT_POSITION_FETCHES assert clmm.max_in_flight > 1, "the reads should still overlap" diff --git a/tests/test_lp_contract_shape_fixes.py b/tests/test_lp_contract_shape_fixes.py new file mode 100644 index 000000000..c3ab43b3d --- /dev/null +++ b/tests/test_lp_contract_shape_fixes.py @@ -0,0 +1,475 @@ +"""Regression tests for condor reading shapes the Hummingbot API never sends. + +Every case here failed silently or crashed a whole screen because condor read a +key the API does not have — the kind of bug that survives manual use because the +field simply renders blank: + +- ``pnl_summary`` is emitted as ``null`` (not omitted), so ``.get(k, {})`` hands + back None and the next ``.get`` raises inside a per-position loop. +- the zero-liquidity filters read ``liquidity`` / ``base_amount``; the API sends + ``base_token_amount`` / ``quote_token_amount``, so the filters were no-ops. +- a close returns ``base_token_amount_removed``; condor read ``base_amount``, + missed, and re-opened with PRE-CLOSE amounts. +- an open that was submitted but not confirmed has ``position_address: None``; + paths that did not branch on it printed a literal "None". +""" + +import asyncio +from decimal import Decimal +from types import SimpleNamespace + +from handlers.dex import liquidity as liquidity_mod +from handlers.dex import lp_monitor_handlers as lpm +from handlers.dex import menu as menu_mod +from handlers.dex import pools as pools_mod +from handlers.dex._shared import resolve_network_id +from mcp_servers.hummingbot_api.formatters.gateway import ( + format_amm_result, + format_clmm_result, +) + + +def _position(**overrides): + """A position row shaped like hapi's GatewayCLMMRepository.position_to_dict.""" + row = { + "position_address": "Pos1", + "pool_address": "Pool1", + "network": "solana-mainnet-beta", + "connector": "meteora", + "trading_pair": "SOL-USDC", + "base_token": "SOL", + "quote_token": "USDC", + "created_at": "2026-08-01T00:00:00", + "closed_at": None, + "status": "OPEN", + "lower_price": 100.0, + "upper_price": 140.0, + "lower_bin_id": None, + "upper_bin_id": None, + "entry_price": None, + "current_price": None, + "percentage": None, + "initial_base_token_amount": None, + "initial_quote_token_amount": None, + "position_rent": None, + "base_token_amount": 1.5, + "quote_token_amount": 200.0, + "in_range": True, + "base_fee_collected": 0.0, + "quote_fee_collected": 0.0, + "base_fee_pending": 0.0, + "quote_fee_pending": 0.0, + # The bug: the key is always PRESENT and null when hapi cannot compute + # PnL (no entry/current price, or no initial amounts). + "pnl_summary": None, + "last_updated": "2026-08-01T00:00:00", + } + row.update(overrides) + return row + + +# ============================================ +# pnl_summary: null, not {} +# ============================================ + + +def test_every_position_formatter_survives_a_null_pnl_summary(): + """One AttributeError here took down the whole /lp screen.""" + pos = _position() + + assert pools_mod._format_position_detail(pos, detailed=True) + assert pools_mod._format_position_detail(pos, detailed=False) + assert liquidity_mod._format_compact_position_line(pos) + assert liquidity_mod._format_closed_position_line(pos) + assert liquidity_mod._format_detailed_position_line(pos) + text, _keyboard = lpm.format_position_detail_view( + pos, token_cache={}, token_prices={}, index=0, total=1, instance_id="i1" + ) + assert text + + +def test_a_populated_pnl_summary_still_renders_its_real_keys(): + """The real key names, straight from position_to_dict's pnl_summary.""" + pos = _position( + entry_price=120.0, + current_price=130.0, + pnl_summary={ + "entry_price": 120.0, + "current_price": 130.0, + "price_change_pct": 8.33, + "initial_base": 2.0, + "initial_quote": 100.0, + "initial_value_quote": 340.0, + "current_base": 1.5, + "current_quote": 200.0, + "current_lp_value_quote": 395.0, + "total_fees_base": 0.0, + "total_fees_quote": 3.0, + "total_fees_value_quote": 3.0, + "hodl_value_quote": 360.0, + "impermanent_loss_quote": 35.0, + "current_total_value_quote": 398.0, + "total_pnl_quote": 58.0, + "total_pnl_pct": 17.06, + "duration_hours": 12.0, + "fee_apr_estimate": 6.4, + }, + ) + + detailed = liquidity_mod._format_detailed_position_line(pos) + # current_base / current_quote, not the invented current_base_total. + assert "1.5" in detailed and "200" in detailed + # Per-token PnL is derived (1.5 - 2.0 = -0.5), since hapi reports none. + assert "-0.5" in detailed + + compact = pools_mod._format_position_detail(pos, detailed=False) + assert "58" in compact, "the compact line must show total_pnl_quote" + + +# ============================================ +# zero-liquidity filters +# ============================================ + + +class _FakeClmmSearch: + def __init__(self, positions): + self.positions = positions + + async def search_positions(self, **kwargs): + return {"data": self.positions} + + +class _FakeClient: + """No `gateway` attribute: the token top-up branch is skipped.""" + + def __init__(self, positions): + self.gateway_clmm = _FakeClmmSearch(positions) + + +def test_the_dex_menu_drops_positions_whose_liquidity_is_gone(): + positions = [ + _position(position_address="Live", base_token_amount=1.5), + _position( + position_address="Empty", base_token_amount=0.0, quote_token_amount=0.0 + ), + ] + + data = asyncio.run(menu_mod._fetch_lp_positions(_FakeClient(positions))) + + addresses = [p["position_address"] for p in data["lp_positions"]] + assert addresses == ["Live"], "the drained position must be filtered out" + + +def test_the_lp_screen_drops_positions_whose_liquidity_is_gone(): + positions = [ + _position(position_address="Live", quote_token_amount=200.0), + _position( + position_address="Empty", base_token_amount=0.0, quote_token_amount=0.0 + ), + ] + + data = asyncio.run(liquidity_mod._fetch_lp_positions(_FakeClient(positions))) + + addresses = [p["position_address"] for p in data["positions"]] + assert addresses == ["Live"] + + +# ============================================ +# rebalance: close result keys + confirmation gate +# ============================================ + + +class _FakeMessage: + def __init__(self): + self.texts = [] + + async def edit_text(self, text, **kwargs): + self.texts.append(text) + + async def reply_text(self, text, **kwargs): + self.texts.append(text) + + +class _FakeQuery: + def __init__(self): + self.message = _FakeMessage() + + async def answer(self, *a, **kw): + return None + + +class _FakeRebalanceClmm: + def __init__(self, close_result): + self.close_result = close_result + self.open_calls = [] + + async def close_position(self, **kwargs): + return self.close_result + + async def open_position(self, **kwargs): + self.open_calls.append(kwargs) + return { + "transaction_hash": "OpenTx", + "position_address": None, + "status": "submitted", + } + + +def _run_rebalance(monkeypatch, close_result): + clmm = _FakeRebalanceClmm(close_result) + client = SimpleNamespace(gateway_clmm=clmm) + + async def _fake_get_client(*a, **kw): + return client + + import config_manager + + monkeypatch.setattr(config_manager, "get_client", _fake_get_client) + + query = _FakeQuery() + update = SimpleNamespace(callback_query=query, effective_chat=SimpleNamespace(id=1)) + pos = _position(base_token_amount=1.5, quote_token_amount=200.0) + context = SimpleNamespace( + user_data={"positions_cache": {"k": pos}, "token_cache": {}} + ) + + asyncio.run(lpm.handle_lpm_rebalance_execute(update, context, "k")) + return clmm, query + + +def test_rebalance_reopens_with_the_amounts_the_close_actually_returned(monkeypatch): + """hapi sends base_token_amount_removed; the old keys always missed and the + re-open silently used stale pre-close amounts.""" + clmm, _ = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "confirmed", + "base_token_amount_removed": "1.4321", + "quote_token_amount_removed": "198.77", + }, + ) + + assert len(clmm.open_calls) == 1 + call = clmm.open_calls[0] + # Decimal, not float: a float puts "0.30000000000000004"/"1e-08" on the wire. + assert call["base_token_amount"] == Decimal("1.4321") + assert call["quote_token_amount"] == Decimal("198.77") + assert isinstance(call["base_token_amount"], Decimal) + # Never the pre-close cached amounts. + assert call["base_token_amount"] != Decimal("1.5") + + +def test_rebalance_passes_none_for_a_side_the_close_returned_nothing_for(monkeypatch): + clmm, _ = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "confirmed", + "base_token_amount_removed": "1.4321", + "quote_token_amount_removed": None, + }, + ) + + assert clmm.open_calls[0]["quote_token_amount"] is None + + +def test_rebalance_refuses_to_reopen_before_the_close_confirms(monkeypatch): + """A submitted-not-confirmed close means the tokens are not in the wallet.""" + clmm, query = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "submitted", + "base_token_amount_removed": None, + "quote_token_amount_removed": None, + }, + ) + + assert clmm.open_calls == [], "no re-open until the close is confirmed" + assert any("submitted" in t for t in query.message.texts) + + +def test_rebalance_reports_a_pending_open_instead_of_claiming_an_address(monkeypatch): + _, query = _run_rebalance( + monkeypatch, + { + "transaction_hash": "CloseTx", + "position_address": "Pos1", + "status": "confirmed", + "base_token_amount_removed": "1.4321", + "quote_token_amount_removed": "198.77", + }, + ) + + final = query.message.texts[-1] + assert "None" not in final + assert "submitted" in final or "confirm" in final + + +# ============================================ +# network ids come from Gateway, not a static table +# ============================================ + + +class _FakeNetworksGateway: + """Gateway's real network ids: '{chain}-{network}' from conf/chains/.""" + + async def list_networks(self): + return { + "networks": [ + { + "network_id": "solana-mainnet-beta", + "chain": "solana", + "network": "mainnet-beta", + }, + {"network_id": "solana-devnet", "chain": "solana", "network": "devnet"}, + { + "network_id": "ethereum-mainnet", + "chain": "ethereum", + "network": "mainnet", + }, + {"network_id": "ethereum-base", "chain": "ethereum", "network": "base"}, + {"network_id": "ethereum-bsc", "chain": "ethereum", "network": "bsc"}, + { + "network_id": "ethereum-arbitrum", + "chain": "ethereum", + "network": "arbitrum", + }, + ], + "count": 6, + } + + +def _resolve(text): + client = SimpleNamespace(gateway=_FakeNetworksGateway()) + return asyncio.run(resolve_network_id(client, text)) + + +def test_evm_network_names_resolve_to_gatewaysown_ids(): + """'base' is ethereum-base, NOT base-mainnet: Gateway builds the id from the + chain directory plus the network yml basename.""" + assert _resolve("base") == "ethereum-base" + assert _resolve("bsc") == "ethereum-bsc" + assert _resolve("arbitrum") == "ethereum-arbitrum" + assert _resolve("mainnet") == "ethereum-mainnet" + + +def test_a_full_network_id_passes_through(): + assert _resolve("ethereum-base") == "ethereum-base" + assert _resolve("solana-mainnet-beta") == "solana-mainnet-beta" + + +def test_an_unknown_network_fails_loudly_listing_what_gateway_offers(): + import pytest + + with pytest.raises(ValueError) as excinfo: + _resolve("base-mainnet") + assert "ethereum-base" in str(excinfo.value) + + +# ============================================ +# formatters: never print a literal "None" +# ============================================ + + +def test_a_pending_open_never_renders_the_word_none(): + out = format_clmm_result( + "open", + { + "action": "open", + "connector": "meteora", + "network": "solana-mainnet-beta", + "result": { + "transaction_hash": "Tx1", + "position_address": None, + "trading_pair": "SOL-USDC", + "pool_address": "Pool1", + "lower_price": 100.0, + "upper_price": 140.0, + "status": "submitted", + }, + }, + ) + assert "None" not in out + assert "submitted" in out + + +def test_amm_write_results_read_the_signature_the_api_sends(): + """AMM write responses carry `signature`, not CLMM's `transaction_hash`.""" + out = format_amm_result( + "add_liquidity", + { + "action": "add_liquidity", + "connector": "meteora", + "network": "solana-mainnet-beta", + "result": {"signature": "Sig123", "status": 1, "data": None}, + }, + ) + assert "Sig123" in out + assert "None" not in out + + +def test_amm_create_pool_result_reads_the_signature_too(): + out = format_amm_result( + "create_pool", + { + "action": "create_pool", + "connector": "meteora", + "network": "solana-mainnet-beta", + "result": { + "signature": "Sig456", + "status": 1, + "pool_address": "Pool9", + "price": 1.25, + }, + }, + ) + assert "Sig456" in out and "Pool9" in out + assert "None" not in out + + +# ============================================ +# pool-info field names +# ============================================ + + +def test_the_mcp_detailed_pool_table_only_has_columns_the_api_sends(): + """volume / fee_tvl_ratio / max_fee_percentage / protocol_fee_percentage are + not CLMMPoolListItem fields — they rendered as a permanent wall of N/A.""" + from mcp_servers.hummingbot_api.tools.gateway_clmm import ( + format_pools_as_detailed_table, + ) + + table = format_pools_as_detailed_table( + [ + { + "address": "Pool1", + "name": "SOL-USDC", + "trading_pair": "SOL-USDC", + "mint_x": "MintX", + "mint_y": "MintY", + "bin_step": 20, + "current_price": 130.0, + "liquidity": "1000000", + "base_fee_percentage": "0.2", + "apr": 12.0, + "apy": 12.7, + "volume_24h": 500000.0, + "fees_24h": 1000.0, + } + ] + ) + + header = table.splitlines()[0] + for gone in ( + "volume_hour_1", + "fee_tvl_ratio", + "max_fee_percentage", + "protocol_fee_percentage", + ): + assert gone not in header, f"{gone} is not a field the API sends" + assert "N/A" not in table.splitlines()[2] diff --git a/tests/test_manage_clmm.py b/tests/test_manage_clmm.py new file mode 100644 index 000000000..faaeaa75b --- /dev/null +++ b/tests/test_manage_clmm.py @@ -0,0 +1,279 @@ +"""manage_clmm — the tool that makes an orphaned CLMM position recoverable. + +Before this tool existed the orphan warning told the agent to "close the position via the gateway +tools", but nothing exposed a CLMM close: ``manage_executors(action="stop")`` is correctly a no-op +on an already-terminated executor, ``manage_amm`` covers AMMs only, and ``explore_dex_pools`` is +read-only. The recovery instruction had no implementing tool. + +Two details carry the recovery path and are pinned here: +- an orphan record reports ``lp_provider`` as ``"orca/clmm"`` while Gateway routes on the bare + ``"orca"``, so the tool must normalise it +- an lp_executor's position is opened by the bot straight against Gateway, so the API database has + no row to read the pool from and ``pool_address`` must be forwarded on close + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" + +import asyncio + +import pytest + +from mcp_servers.hummingbot_api.exceptions import ToolError +from mcp_servers.hummingbot_api.schemas import CLMMRequest +from mcp_servers.hummingbot_api.tools.gateway_clmm import manage_clmm_impl + + +class _RecordingCLMM: + """Captures the kwargs each client.gateway_clmm.* call receives.""" + + def __init__(self): + self.calls = [] + + def _record(self, name): + async def call(**kwargs): + self.calls.append((name, kwargs)) + return {"transaction_hash": "sig123", "status": "CONFIRMED"} + + return call + + def __getattr__(self, name): + return self._record(name) + + +class _Client: + def __init__(self): + self.gateway_clmm = _RecordingCLMM() + + +def _run(request, client=None): + return asyncio.run(manage_clmm_impl(client, request)) + + +# --- connector normalisation ------------------------------------------------- + + +def test_lp_provider_form_is_normalised_to_the_bare_connector(): + """An orphan's lp_provider ('orca/clmm') must reach Gateway as 'orca'.""" + client = _Client() + + result = _run( + CLMMRequest( + action="close", + connector="orca/clmm", + network="solana-mainnet-beta", + position_address="POS", + pool_address="POOL", + ), + client, + ) + + assert result["connector"] == "orca" + _, kwargs = client.gateway_clmm.calls[0] + assert kwargs["connector"] == "orca" + + +def test_unsupported_connector_is_rejected_by_name(): + with pytest.raises(ToolError, match="Unsupported CLMM connector"): + _run( + CLMMRequest( + action="close", + connector="sushiswap", + network="ethereum-mainnet", + position_address="POS", + ) + ) + + +# --- the orphan recovery call ------------------------------------------------ + + +def test_close_forwards_pool_address(): + """Without pool_address the API 400s: an lp_executor position is never in its database.""" + client = _Client() + + _run( + CLMMRequest( + action="close", + connector="orca", + network="solana-mainnet-beta", + position_address="H4vD69DsraHjHyKvRwRPHVGe2aJkvAUaNK5tMif2CiNw", + pool_address="Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", + ), + client, + ) + + name, kwargs = client.gateway_clmm.calls[0] + assert name == "close_position" + assert kwargs["pool_address"] == "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" + assert kwargs["position_address"] == "H4vD69DsraHjHyKvRwRPHVGe2aJkvAUaNK5tMif2CiNw" + + +def test_collect_fees_forwards_pool_address(): + client = _Client() + + _run( + CLMMRequest( + action="collect_fees", + connector="meteora", + network="solana-mainnet-beta", + position_address="POS", + pool_address="POOL", + ), + client, + ) + + name, kwargs = client.gateway_clmm.calls[0] + assert name == "collect_fees" + assert kwargs["pool_address"] == "POOL" + + +# --- validation guards ------------------------------------------------------- + + +def test_no_action_returns_the_guide_without_touching_the_client(): + result = _run(CLMMRequest()) + + assert result["action"] is None + assert "manage_clmm" in result["formatted_output"] + assert "orphan" in result["formatted_output"].lower() + + +def test_connector_is_required(): + with pytest.raises(ToolError, match="connector is required"): + _run( + CLMMRequest( + action="close", network="solana-mainnet-beta", position_address="POS" + ) + ) + + +def test_network_is_required(): + with pytest.raises(ToolError, match="network is required"): + _run(CLMMRequest(action="close", connector="orca", position_address="POS")) + + +def test_close_requires_position_address(): + with pytest.raises(ToolError, match="position_address is required"): + _run( + CLMMRequest(action="close", connector="orca", network="solana-mainnet-beta") + ) + + +def test_remove_liquidity_requires_a_percentage(): + with pytest.raises(ToolError, match="percentage_to_remove is required"): + _run( + CLMMRequest( + action="remove_liquidity", + connector="orca", + network="solana-mainnet-beta", + position_address="POS", + ) + ) + + +def test_open_requires_at_least_one_amount(): + """Both amounts optional individually, but an empty position is not a position.""" + with pytest.raises(ToolError, match="requires base_token_amount"): + _run( + CLMMRequest( + action="open", + connector="orca", + network="solana-mainnet-beta", + pool_address="POOL", + lower_price="75", + upper_price="76", + ) + ) + + +def test_guards_run_before_the_client_is_dereferenced(): + """A validation failure must not depend on having a client at all.""" + request = CLMMRequest( + action="close", connector="orca", network="solana-mainnet-beta" + ) + + with pytest.raises(ToolError, match="position_address is required"): + _run(request, client=None) + + +# --- amounts are passed through as Decimals --------------------------------- + + +def test_remove_liquidity_passes_percentage_as_decimal(): + from decimal import Decimal + + client = _Client() + + _run( + CLMMRequest( + action="remove_liquidity", + connector="raydium", + network="solana-mainnet-beta", + position_address="POS", + percentage_to_remove="50", + ), + client, + ) + + _, kwargs = client.gateway_clmm.calls[0] + assert kwargs["percentage_to_remove"] == Decimal("50") + + +# --- position_info: one position, or the wallet's ----------------------------- +# position_address is declared on the request, so pydantic accepts it. It used to be +# dropped: the action always called positions_owned, which answers with every position +# the wallet holds on the connector. That reads as correct while a wallet holds one +# position and silently becomes the wrong answer the moment it holds two. + + +def test_position_info_with_an_address_reads_that_position(): + client = _Client() + _run( + CLMMRequest( + action="position_info", + connector="orca", + network="solana-mainnet-beta", + position_address="POS1", + ), + client, + ) + + names = [name for name, _ in client.gateway_clmm.calls] + assert names == ["get_position_info"], ( + f"asked about one position, called {names} — positions_owned answers with every " + "position the wallet holds, which is a different question" + ) + assert client.gateway_clmm.calls[0][1]["position_address"] == "POS1" + + +def test_position_info_without_an_address_lists_the_wallet(): + client = _Client() + _run( + CLMMRequest( + action="position_info", + connector="orca", + network="solana-mainnet-beta", + wallet_address="WALLET", + ), + client, + ) + + names = [name for name, _ in client.gateway_clmm.calls] + assert names == ["get_positions_owned"] + assert client.gateway_clmm.calls[0][1]["wallet_address"] == "WALLET" + + +def test_a_single_position_is_returned_as_a_list(): + """The formatter renders position_info as rows either way, so the shapes must agree.""" + client = _Client() + result = _run( + CLMMRequest( + action="position_info", + connector="orca", + network="solana-mainnet-beta", + position_address="POS1", + ), + client, + ) + + assert isinstance(result["result"], list) and len(result["result"]) == 1 diff --git a/tests/test_mcp_gateway_swap_tool.py b/tests/test_mcp_gateway_swap_tool.py new file mode 100644 index 000000000..112be43ca --- /dev/null +++ b/tests/test_mcp_gateway_swap_tool.py @@ -0,0 +1,86 @@ +"""The swap implementation must be REGISTERED as an MCP tool, not just importable. + +`tools/gateway_swap.py`, `schemas.GatewaySwapRequest`, `formatters.format_gateway_swap_result`, +the agent danger-gate entry (`DANGEROUS_TOOLS`) and the ACP preload list all referenced +`manage_gateway_swaps` — but `server.py` imported the implementation and never wrapped it in +`@mcp.tool()`. Nothing on the wire exposed it, so agent playbooks routed swaps through +`manage_amm(quote_swap / execute_swap)` instead — actions that raise ToolError. + +Gateway folded pool-scoped AMM swaps into the unified swap route, so this is now the ONLY swap +surface: its docstring has to carry the "name/type" connector form and the pool-resolution +caveat that makes "No pool found" readable as "unknown token", not "honeypot". + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" + +import asyncio +import inspect + +import pytest + +from mcp_servers.hummingbot_api.exceptions import ToolError +from mcp_servers.hummingbot_api.schemas import GatewaySwapRequest +from mcp_servers.hummingbot_api.tools.gateway_amm import manage_amm_impl + + +def test_the_swap_tool_is_registered_on_the_mcp_server(): + import mcp_servers.hummingbot_api.server as server + + assert hasattr(server, "manage_gateway_swaps"), ( + "the swap implementation exists but no @mcp.tool() exposes it — " + "agents have no swap surface at all" + ) + + +def test_the_registered_tool_covers_every_action_the_implementation_supports(): + import mcp_servers.hummingbot_api.server as server + + params = inspect.signature(server.manage_gateway_swaps).parameters + action = params["action"].annotation + assert set(action.__args__) == {"quote", "execute", "search", "get_status"} + + +def test_the_tool_documents_the_name_slash_type_connector_form(): + """A bare 'meteora' is not routable — the unified route takes 'meteora/amm'.""" + import mcp_servers.hummingbot_api.server as server + + doc = server.manage_gateway_swaps.__doc__ + assert "meteora/amm" in doc + assert "jupiter/router" in doc + + +def test_the_tool_warns_that_an_unresolvable_pool_is_not_a_bad_token(): + """Gateway matches its configured pool list by SYMBOL; a fresh mint is simply unknown.""" + import mcp_servers.hummingbot_api.server as server + + doc = server.manage_gateway_swaps.__doc__ + assert "No pool found" in doc + assert "honeypot" in doc + + +def test_omitting_slippage_is_documented_as_the_connectors_own_setting(): + import mcp_servers.hummingbot_api.server as server + + doc = " ".join(server.manage_gateway_swaps.__doc__.split()) + assert "OMIT to use the connector's configured slippage" in doc + assert "'0' is a real value" in doc + + +def test_manage_amm_has_no_swap_actions_left(): + """Gateway deleted /trading/amm/{quote,execute}-swap; manage_amm is LP-only now.""" + from mcp_servers.hummingbot_api.schemas import AMMRequest + + with pytest.raises(Exception): + AMMRequest(action="quote_swap") + + request = AMMRequest.model_construct( + action="execute_swap", connector="meteora", network="solana-mainnet-beta" + ) + with pytest.raises(ToolError, match="[Uu]nknown action"): + asyncio.run(manage_amm_impl(None, request)) + + +def test_an_unset_slippage_stays_unset_on_the_request_model(): + """None is the wire signal for 'omit slippage_pct'; '0' is a real value.""" + assert GatewaySwapRequest(action="quote").slippage_pct is None + assert GatewaySwapRequest(action="quote", slippage_pct="0").slippage_pct == "0" diff --git a/tests/test_orphan_recovery_instructions.py b/tests/test_orphan_recovery_instructions.py new file mode 100644 index 000000000..11c46575c --- /dev/null +++ b/tests/test_orphan_recovery_instructions.py @@ -0,0 +1,130 @@ +"""The orphan listing must hand the agent a call it can actually make. + +The listing used to say "close the position via the gateway tools (remove liquidity by position +address)" — an instruction with no implementing tool. An agent following it found that +``manage_executors(action="stop")`` was a no-op (the executor is already terminated), ``manage_amm`` +does not handle CLMM positions, and ``explore_dex_pools`` is read-only, so the position stayed open. + +The listing now emits a concrete ``manage_clmm(action="close", ...)`` call built from the record's +own fields. Two are easy to get wrong and are pinned here: +- ``connector_name`` is the NETWORK for an lp_executor ("solana-mainnet-beta"), so it must land in + ``network=``, never ``connector=`` +- ``lp_provider`` is the DEX, and ``pool_address`` must be forwarded or the close 400s + +The repo has no async test setup, so coroutines are driven with asyncio.run(). +""" + +import asyncio + +from mcp_servers.hummingbot_api.schemas import ManageExecutorsRequest +from mcp_servers.hummingbot_api.tools.executors import manage_executors + +ORPHAN = { + "executor_id": "HJJUFaSZdThCH6rRv5agcVCZ4widziThm1aRdh2LtcTW", + "executor_type": "lp_executor", + "connector_name": "solana-mainnet-beta", + "trading_pair": "SOL-USDC", + "lp_provider": "orca/clmm", + "pool_address": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", + "close_type": "POSITION_HOLD", + "closed_at": "2026-08-13T18:04:11+00:00", + "position_address": "H4vD69DsraHjHyKvRwRPHVGe2aJkvAUaNK5tMif2CiNw", + "hold_reason": "close_retries_exhausted", + "needs_onchain_reconciliation": False, +} + + +class _Response: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + async def json(self): + return self._payload + + +class _Session: + def __init__(self, payload): + self._payload = payload + + async def get(self, _url): + return _Response(self._payload) + + +class _Executors: + def __init__(self, payload): + self.session = _Session(payload) + self.base_url = "http://api" + + +class _Client: + def __init__(self, payload): + self.executors = _Executors(payload) + + +def _list_orphans(orphans): + client = _Client({"count": len(orphans), "orphans": orphans}) + request = ManageExecutorsRequest(action="orphaned") + return asyncio.run(manage_executors(client=client, request=request))[ + "formatted_output" + ] + + +def test_listing_emits_a_runnable_close_call(): + output = _list_orphans([ORPHAN]) + + assert 'manage_clmm(action="close"' in output + assert 'connector="orca/clmm"' in output + assert 'network="solana-mainnet-beta"' in output + assert f'position_address="{ORPHAN["position_address"]}"' in output + assert f'pool_address="{ORPHAN["pool_address"]}"' in output + + +def test_network_is_not_passed_as_the_connector(): + """connector_name holds the network for an lp_executor — mixing them up produces a dead call.""" + output = _list_orphans([ORPHAN]) + + assert 'connector="solana-mainnet-beta"' not in output + + +def test_listing_reports_the_dex_and_pool(): + output = _list_orphans([ORPHAN]) + + assert "dex: orca/clmm" in output + assert f"pool: {ORPHAN['pool_address']}" in output + + +def test_listing_says_stopping_will_not_close_it(): + """The failure mode was an agent trying to stop an already-terminated executor.""" + output = _list_orphans([ORPHAN]) + + assert "Stopping the executor will NOT close it" in output + assert "resolve_orphan" in output + + +def test_no_close_call_when_the_position_address_is_unknown(): + """A SYSTEM_CLEANUP orphan needs reconciliation first — never a close call with None in it.""" + unknown = { + **ORPHAN, + "close_type": "SYSTEM_CLEANUP", + "position_address": None, + "hold_reason": None, + "needs_onchain_reconciliation": True, + } + + output = _list_orphans([unknown]) + + # The trailing summary always names the recovery call; what must not appear is a + # per-record "close with:" line, which would carry position_address=None. + assert "close with:" not in output + assert 'position_address="None"' not in output + assert "position address unknown" in output + + +def test_empty_listing_stays_clean(): + output = _list_orphans([]) + + assert "No orphaned positions" in output + assert "close with:" not in output diff --git a/tests/test_orphan_visibility_fixes.py b/tests/test_orphan_visibility_fixes.py new file mode 100644 index 000000000..c191cc8fe --- /dev/null +++ b/tests/test_orphan_visibility_fixes.py @@ -0,0 +1,103 @@ +"""Adversarial-review fixes: orphan-recovery honesty and signal grading. + +Two ways an agent was misled about what it was looking at: + +- format_clmm_result's position_info branch read camelCase keys against the + API's snake_case CLMMPositionInfo fields, so every field — including the + position address — rendered as None and "the position is gone" was a + reasonable (wrong) conclusion. +- options_flow confidence graded agreement against the dominant sub-signal vote + instead of the emitted direction, stamping MEDIUM on entries the majority of + signals disagreed with. +""" + +import asyncio + +from condor.agents.performance import AgentPerformance +from condor.agents.providers.executors import ExecutorsProvider + + +def _run_provider(executors): + import condor.agents.performance as perf_mod + + perf = AgentPerformance(agent_id="agent.strat_1", bot_names=["bot_1"]) + perf.executors = executors + + async def _fake(*a, **kw): + return perf + + original = perf_mod.fetch_agent_performance + perf_mod.fetch_agent_performance = _fake + try: + return asyncio.run( + ExecutorsProvider().execute( + object(), {"bot_name": "bot_1"}, agent_id="agent.strat_1" + ) + ) + finally: + perf_mod.fetch_agent_performance = original + + +def _row(**overrides): + row = { + "id": "exec-1", + "type": "lp_executor", + "pair": "SOL-USDC", + "status": "TERMINATED", + "close_type": "", + "custom_info": {}, + "pnl": 0.0, + "volume": 0.0, + "amount": 0.0, + } + row.update(overrides) + return row + + +def test_address_bearing_orphan_still_emits_the_close_call(): + result = _run_provider( + [ + _row( + close_type="POSITION_HOLD", + custom_info={ + "hold_reason": "close_retries_exhausted", + "position_address": "H4vD69Ds", + }, + ) + ] + ) + assert "🚨 ORPHANED POSITION" in result.summary + assert 'manage_clmm(action="close"' in result.summary + + +def test_position_info_formats_the_apis_snake_case_fields(): + """The formatter must read what /gateway/clmm/positions_owned sends.""" + from mcp_servers.hummingbot_api.formatters.gateway import format_clmm_result + + out = format_clmm_result( + "position_info", + { + "action": "position_info", + "connector": "meteora/clmm", + "network": "solana-mainnet-beta", + "result": [ + { + "position_address": "PosAddr123", + "pool_address": "PoolAddr", + "base_token_amount": 1.5, + "quote_token_amount": 200.0, + "lower_price": 100.0, + "upper_price": 140.0, + "current_price": 120.0, + "base_fee_amount": 0.01, + "quote_fee_amount": 1.2, + } + ], + }, + ) + assert "PosAddr123" in out + assert "1.5" in out and "200.0" in out + assert "100.0–140.0" in out + assert "120.0" in out + assert "0.01" in out and "1.2" in out + assert "None" not in out diff --git a/uv.lock b/uv.lock index a25fe8e03..aa8dd06b9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -639,7 +639,7 @@ requires-dist = [ { name = "fastapi" }, { name = "faster-whisper" }, { name = "geckoterminal-py" }, - { name = "hummingbot-api-client", specifier = "==1.5.7" }, + { name = "hummingbot-api-client", git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count" }, { name = "kaleido" }, { name = "markdown", specifier = ">=3.10.2" }, { name = "matplotlib", specifier = ">=3.10.8" }, @@ -1495,15 +1495,11 @@ wheels = [ [[package]] name = "hummingbot-api-client" -version = "1.5.7" -source = { registry = "https://pypi.org/simple" } +version = "1.5.8" +source = { git = "https://github.com/hummingbot/hummingbot-api-client.git?branch=feat%2Fclmm-bin-count#f5ceeeaffaa1443803e8ce9b265d04930535c700" } dependencies = [ { name = "aiohttp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/92/9bb06b365b8d9c457ad39aa58ffa471ef2668f43433b6fae5b338c57609f/hummingbot_api_client-1.5.7.tar.gz", hash = "sha256:65d9cef4f518fd9ab2dacd4de345e1d46ea30a1a19c01b730936078f9b441679", size = 41107, upload-time = "2026-08-10T14:46:53.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/ce/50e80f122cbc5a80aa9a5c595381d54633fd2e72cfb639e78320f044b66c/hummingbot_api_client-1.5.7-py3-none-any.whl", hash = "sha256:a1a133f2ba7ef9f30ffec99ab61cf10377d8be210e39d85f0e76dc8f63bab5cb", size = 48960, upload-time = "2026-08-10T14:46:52.307Z" }, -] [[package]] name = "identify"